Skip to content

Instantly share code, notes, and snippets.

@insin
Created August 22, 2012 12:33
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save insin/3425118 to your computer and use it in GitHub Desktop.
Save insin/3425118 to your computer and use it in GitHub Desktop.
Set phasers to Redis CRM

Redis CRM

Braining about Redis for a simple CRM.

There will be no admin-style CRUD views, just the frontend.

Users

Redis Keys

strings

users:nextid

incr to generate user ids

username.to.id:#<username>

maps lowercase usernames to ids

hashes

users:#<id>

user details

sets

users

user ids

admins

user ids with admin priveleges

Contacts

A contact is a person or an organisation.

A person can be associated with a single organisation.

Common fields:

id type backgroundInfo phoneNumbers (array of {number, type}) emailAddresses (array of {email, type}) addresses (array of {address, city, county, postal, country, type})

Person fields:

title firstName lastName jobTitle organisation

Organisation fields:

name

Redis Keys

strings

contacts:nextid

incr to generate contact ids

hashes

contacts:#<id>

contact details

sets

org.to.people:#<contactid>

ids of people associated with a given organisation

Categories

Tasks can be assigned a single category.

Redis Keys

strings

categories:nextid

incr to generate category ids

categories:#<id>

category name

categoryname.to.id:#<categoryname>

maps lowercase category names to ids

sets

categories

category ids

Tasks

Tasks are assigned to a user with a due date and may be linked to a contact.

Screens

Dashboard

Tasks for current user betweet given dates (-inf, +7), low-high

  • Categories (assigned by display logic, not query):
    • Overdue
    • Next 7 days

Calendar

Calendar display of incomplete tasks for the date range being displayed, low-high.

  • Filters:
    • User
    • Category

Tasks List

This is the only place completed tasks can be viewed, as completed tasks are viewed elsewhere as updates.

  • Filters:
    • Completion
    • User
    • Category

Contact

  • Incomplete tasks, low-high.

Redis Keys

strings

tasks:nextid

incr to generate task ids

hashes

tasks:#<id>

task details

sorted sets

For vewing and filtering incomplete tasks throughout the app:

tasks:cron

incomplete task ids, by due timestamp

tasks:user:#<userid>

incomplete task ids assigned to a user, by due timestamp

tasks:user:#<userid>:category:#<categoryid>

incomplete task ids assigned to a user by category, by due timestamp

tasks:contact:#<contact>

incomplete tasks linked to a contact, by due timestamp

The following are purely to support viewing completed tasks on the tasks list:

tasks:completed

completed task ids, by completion timestamp

tasks:user:#<userid>:completed

completed task ids assigned to a user, by completion timestamp

tasks:user:#<userid>:category:#<categoryid>:completed

completed task ids assigned to a user by category, by completion timestamp

Updates

Updates record notes and task completion for a contact.

Screens

Dashboard

Updates, high-low

  • Filters
    • Type (notes or tasks)
    • User

Contact

Updates, high-low

  • Filters:
    • Type (notes or tasks)

Redis Keys

strings

updates:nextid

incr to generate update ids

hashes

updates:#<id>

update details

sorted sets

updates:cron

update ids, by created timestamp

updates:<type>

update ids by type, by created timestamp

updates:user:#<userid>

updates by a user, by created timestamp

updates:user:#<userid>:<type>

updates by a user by type, by created timestamp

updates:contact:#<contactid>

updates for a contact, by created timestamp

updates:contact:#<contactid>:<type>

updates for a contact by type, by created timestamp

var forms = require('newforms')
, object = require('isomorph/object')
var choices = require('./choices')
function requirePersonName() {
if (!this.cleanedData.firstName && !this.cleanedData.lastName) {
throw forms.ValidationError('A first name or last name is required.')
}
return this.cleanedData
}
exports.PersonForm = forms.Form.extend({
title : forms.ChoiceField({required: false, choices: choices.TITLE_CHOICES})
, firstName : forms.CharField({required: false, maxLength: 50})
, lastName : forms.CharField({required: false, maxLength: 50})
, jobTitle : forms.CharField({required: false, maxLength: 100})
, organisation : forms.CharField({required: false})
, clean: requirePersonName
})
exports.OrganisationForm = forms.Form.extend({
name : forms.CharField({maxLength: 100})
})
exports.InlinePersonForm = forms.Form.extend({
firstName : forms.CharField({required: false, maxLength: 50})
, lastName : forms.CharField({required: false, maxLength: 50})
, jobTitle : forms.CharField({required: false, maxLength: 100})
, email : forms.EmailField({required: false})
, mobilePhone : forms.CharField({required: false})
, directPhone : forms.CharField({required: false})
, clean: requirePersonName
})
exports.InlinePersonFormSet = forms.formsetFactory(exports.InlinePersonForm)
exports.PhoneNumberForm = forms.Form.extend({
number : forms.CharField({maxLength: 30})
, type : forms.ChoiceField({choices: choices.PHONE_NUMBER_TYPE_CHOICES})
})
exports.PhoneNumberFormSet = forms.formsetFactory(exports.PhoneNumberForm)
exports.EmailAddressForm = forms.Form.extend({
email : forms.EmailField()
, type : forms.ChoiceField({choices: choices.EMAIL_TYPE_CHOICES})
})
exports.EmailAddressFormSet = forms.formsetFactory(exports.EmailAddressForm)
exports.AddressForm = forms.Form.extend({
address : forms.CharField({required: false, widget: forms.Textarea, attrs: {placeholder: 'Address'}})
, type : forms.ChoiceField({required: false, choices: choices.ADDRESS_TYPE_CHOICES})
, city : forms.CharField({required: false, maxLength: 100, attrs: {placeholder: 'City/Town'}})
, county : forms.CharField({required: false, maxLength: 100, attrs: {placeholder: 'County'}})
, postCode : forms.CharField({required: false, maxLength: 8, attrs: {placeholder: 'Postcode'}})
, country : forms.ChoiceField({required: false, choices: choices.COUNTRY_CHOICES})
})
exports.AddressFormSet = forms.formsetFactory(exports.AddressForm)
exports.TaskForm = forms.Form.extend({
description : forms.CharField({maxLength: 50})
, detail : forms.CharField({required: false, widget: forms.Textarea})
, due : forms.DateField()
, time : forms.TimeField({required: false})
, category : forms.ChoiceField({required: false})
, assignedTo : forms.ChoiceField()
, contact : forms.CharField({require: false})
, constructor: function(kwargs) {
kwargs = object.extend({categories: [], users: []}, kwargs)
form.Form.call(this, kwargs)
this.fields.category.setChoices(kwargs.categories)
this.fields.assignedTo.setChoices(kwargs.users)
}
})
var object = require('isomorph/object')
exports.TITLE_CHOICES = [
['Mr', 'Mr']
, ['Master', 'Master']
, ['Mrs', 'Mrs']
, ['Miss', 'Miss']
, ['Ms', 'Ms']
, ['Dr', 'Dr']
, ['Prof', 'Prof']
]
exports.PHONE_NUMBER_TYPE_CHOICES = [
['Home', 'Home']
, ['Work', 'Work']
, ['Mobile', 'Mobile']
, ['Fax', 'Fax']
, ['Direct', 'Direct']
]
exports.EMAIL_TYPE_CHOICES = [
['Home', 'Home']
, ['Work', 'Work']
]
exports.ADDRESS_TYPE_CHOICES = [
['Home', 'Home']
, ['Postal', 'Postal']
, ['Office', 'Office']
]
exports.COUNTRY_CHOICES = [
['AF', 'Afghanistan']
, ['AX', 'Åland Islands ']
, ['AL', 'Albania']
, ['DZ', 'Algeria']
, ['AS', 'American Samoa']
, ['AD', 'Andorra']
, ['AO', 'Angola']
, ['AI', 'Anguilla']
, ['AQ', 'Antarctica']
, ['AG', 'Antigua and Barbuda']
, ['AR', 'Argentina']
, ['AM', 'Armenia']
, ['AW', 'Aruba']
, ['AU', 'Australia']
, ['AT', 'Austria']
, ['AZ', 'Azerbaijan']
, ['BS', 'Bahamas']
, ['BH', 'Bahrain']
, ['BD', 'Bangladesh']
, ['BB', 'Barbados']
, ['BY', 'Belarus']
, ['BE', 'Belgium']
, ['BZ', 'Belize']
, ['BJ', 'Benin']
, ['BM', 'Bermuda']
, ['BT', 'Bhutan']
, ['BO', 'Bolivia, Plurinational State of']
, ['BQ', 'Bonaire, Sint Eustatius and Saba']
, ['BA', 'Bosnia and Herzegovina']
, ['BW', 'Botswana']
, ['BV', 'Bouvet Island']
, ['BR', 'Brazil']
, ['IO', 'British Indian Ocean Territory']
, ['BN', 'Brunei Darussalam']
, ['BG', 'Bulgaria']
, ['BF', 'Burkina Faso']
, ['BI', 'Burundi']
, ['KH', 'Cambodia']
, ['CM', 'Cameroon']
, ['CA', 'Canada']
, ['CV', 'Cape Verde']
, ['KY', 'Cayman Islands']
, ['CF', 'Central African Republic']
, ['TD', 'Chad']
, ['CL', 'Chile']
, ['CN', 'China']
, ['CX', 'Christmas Island']
, ['CC', 'Cocos (Keeling) Islands']
, ['CO', 'Colombia']
, ['KM', 'Comoros']
, ['CG', 'Congo']
, ['CD', 'Congo, Democratic Republic of the']
, ['CK', 'Cook Islands']
, ['CR', 'Costa Rica']
, ['CI', "Côte d'Ivoire"]
, ['HR', 'Croatia']
, ['CU', 'Cuba']
, ['CW', 'Curaçao']
, ['CY', 'Cyprus']
, ['CZ', 'Czech Republic']
, ['DK', 'Denmark']
, ['DJ', 'Djibouti']
, ['DM', 'Dominica']
, ['DO', 'Dominican Republic']
, ['EC', 'Ecuador']
, ['EG', 'Egypt']
, ['SV', 'El Salvador']
, ['GQ', 'Equatorial Guinea']
, ['ER', 'Eritrea']
, ['EE', 'Estonia']
, ['ET', 'Ethiopia']
, ['FK', 'Falkland Islands (Malvinas)']
, ['FO', 'Faroe Islands']
, ['FJ', 'Fiji']
, ['FI', 'Finland']
, ['FR', 'France']
, ['GF', 'French Guiana']
, ['PF', 'French Polynesia']
, ['TF', 'French Southern Territories']
, ['GA', 'Gabon']
, ['GM', 'Gambia']
, ['GE', 'Georgia']
, ['DE', 'Germany']
, ['GH', 'Ghana']
, ['GI', 'Gibraltar']
, ['GR', 'Greece']
, ['GL', 'Greenland']
, ['GD', 'Grenada']
, ['GP', 'Guadeloupe']
, ['GU', 'Guam']
, ['GT', 'Guatemala']
, ['GG', 'Guernsey']
, ['GN', 'Guinea']
, ['GW', 'Guinea-Bissau']
, ['GY', 'Guyana']
, ['HT', 'Haiti']
, ['HM', 'Heard Island and McDonald Islands']
, ['VA', 'Holy See (Vatican City State)']
, ['HN', 'Honduras']
, ['HK', 'Hong Kong']
, ['HU', 'Hungary']
, ['IS', 'Iceland']
, ['IN', 'India']
, ['ID', 'Indonesia']
, ['IR', 'Iran, Islamic Republic of']
, ['IQ', 'Iraq']
, ['IE', 'Ireland']
, ['IM', 'Isle of Man']
, ['IL', 'Israel']
, ['IT', 'Italy']
, ['JM', 'Jamaica']
, ['JP', 'Japan']
, ['JE', 'Jersey']
, ['JO', 'Jordan']
, ['KZ', 'Kazakhstan']
, ['KE', 'Kenya']
, ['KI', 'Kiribati']
, ['KP', "Korea, Democratic People's Republic of"]
, ['KR', 'Korea, Republic of']
, ['KW', 'Kuwait']
, ['KG', 'Kyrgyzstan']
, ['LA', "Lao People's Democratic Republic"]
, ['LV', 'Latvia']
, ['LB', 'Lebanon']
, ['LS', 'Lesotho']
, ['LR', 'Liberia']
, ['LY', 'Libyan Arab Jamahiriya']
, ['LI', 'Liechtenstein']
, ['LT', 'Lithuania']
, ['LU', 'Luxembourg']
, ['MO', 'Macao']
, ['MK', 'Macedonia, the former Yugoslav Republic of']
, ['MG', 'Madagascar']
, ['MW', 'Malawi']
, ['MY', 'Malaysia']
, ['MV', 'Maldives']
, ['ML', 'Mali']
, ['MT', 'Malta']
, ['MH', 'Marshall Islands']
, ['MQ', 'Martinique']
, ['MR', 'Mauritania']
, ['MU', 'Mauritius']
, ['YT', 'Mayotte']
, ['MX', 'Mexico']
, ['FM', 'Micronesia, Federated States of']
, ['MD', 'Moldova']
, ['MC', 'Monaco']
, ['MN', 'Mongolia']
, ['ME', 'Montenegro']
, ['MS', 'Montserrat']
, ['MA', 'Morocco']
, ['MZ', 'Mozambique']
, ['MM', 'Myanmar']
, ['NA', 'Namibia']
, ['NR', 'Nauru']
, ['NP', 'Nepal']
, ['NL', 'Netherlands']
, ['AN', 'Netherlands Antilles']
, ['NC', 'New Caledonia']
, ['NZ', 'New Zealand']
, ['NI', 'Nicaragua']
, ['NE', 'Niger']
, ['NG', 'Nigeria']
, ['NU', 'Niue']
, ['NF', 'Norfolk Island']
, ['MP', 'Northern Mariana Islands']
, ['NO', 'Norway']
, ['OM', 'Oman']
, ['PK', 'Pakistan']
, ['PW', 'Palau']
, ['PS', 'Palestinian Territory, Occupied']
, ['PA', 'Panama']
, ['PG', 'Papua New Guinea']
, ['PY', 'Paraguay']
, ['PE', 'Peru']
, ['PH', 'Philippines']
, ['PN', 'Pitcairn']
, ['PL', 'Poland']
, ['PT', 'Portugal']
, ['PR', 'Puerto Rico']
, ['QA', 'Qatar']
, ['RE', 'Réunion']
, ['RO', 'Romania']
, ['RU', 'Russian Federation']
, ['RW', 'Rwanda']
, ['BL', 'Saint Barthélemy']
, ['SH', 'Saint Helena, Ascension and Tristan da Cunha']
, ['KN', 'Saint Kitts and Nevis']
, ['LC', 'Saint Lucia']
, ['MF', 'Saint Martin']
, ['PM', 'Saint Pierre and Miquelon']
, ['VC', 'Saint Vincent and the Grenadines']
, ['WS', 'Samoa']
, ['SM', 'San Marino']
, ['ST', 'Sao Tome and Principe']
, ['SA', 'Saudi Arabia']
, ['SN', 'Senegal']
, ['RS', 'Serbia']
, ['SC', 'Seychelles']
, ['SL', 'Sierra Leone']
, ['SG', 'Singapore']
, ['SX', 'Sint Maarten (Dutch part)']
, ['SK', 'Slovakia']
, ['SI', 'Slovenia']
, ['SB', 'Solomon Islands']
, ['SO', 'Somalia']
, ['ZA', 'South Africa']
, ['GS', 'South Georgia and the South Sandwich Islands']
, ['SS', 'South Sudan']
, ['ES', 'Spain']
, ['LK', 'Sri Lanka']
, ['SD', 'Sudan']
, ['SR', 'Suriname']
, ['SJ', 'Svalbard and Jan Mayen']
, ['SZ', 'Swaziland']
, ['SE', 'Sweden']
, ['CH', 'Switzerland']
, ['SY', 'Syrian Arab Republic']
, ['TW', 'Taiwan']
, ['TJ', 'Tajikistan']
, ['TZ', 'Tanzania, United Republic of']
, ['TH', 'Thailand']
, ['TL', 'Timor-Leste']
, ['TG', 'Togo']
, ['TK', 'Tokelau']
, ['TO', 'Tonga']
, ['TT', 'Trinidad and Tobago']
, ['TN', 'Tunisia']
, ['TR', 'Turkey']
, ['TM', 'Turkmenistan']
, ['TC', 'Turks and Caicos Islands']
, ['TV', 'Tuvalu']
, ['UG', 'Uganda']
, ['UA', 'Ukraine']
, ['AE', 'United Arab Emirates']
, ['GB', 'United Kingdom']
, ['US', 'United States']
, ['UM', 'United States Minor Outlying Islands']
, ['UY', 'Uruguay']
, ['UZ', 'Uzbekistan']
, ['VU', 'Vanuatu']
, ['VE', 'Venezuela, Bolivarian Republic of']
, ['VN', 'Viet Nam']
, ['VG', 'Virgin Islands, British']
, ['VI', 'Virgin Islands, U.S.']
, ['WF', 'Wallis and Futuna']
, ['EH', 'Western Sahara']
, ['YE', 'Yemen']
, ['ZM', 'Zambia']
, ['ZW', 'Zimbabwe']
]
exports.COUNTRIES = object.fromItems(COUNTRY_CHOICES)
//... Express stuff ...
var allValid = require('newforms').allValid
var forms = require('./forms')
, redis = require('./redis')
/**
* Adds a person whose details were defined inline as part of adding an
* organisation.
* @param organisation the newly-created organisation (with a generated id)
* @param cleanedDate cleaned data from an InlinePersonForm
*/
function addPersonInline(organisation, cleanedData, cb) {
var person = {
title: ''
, firstName: cleanedData.firstName
, lastName: cleanedData.lastName
, jobTitle: cleanedData.jobTitle
, organisation: organisation
, emailAddresses: []
, phoneNumbers: []
, addresses: []
}
if (cleanedData.email) {
person.emailAddresses.push({email: cleanedData.email, type: ''})
}
if (cleanedData.mobilePhone) {
person.phoneNumbers.push({number: cleanedData.mobilePhone, type: 'Mobile'})
}
if (cleanedData.directPhone) {
person.phoneNumbers.push({number: cleanedData.directPhone, type: 'Direct'})
}
redis.people.store(person, cb)
})
app.get('/contacts/add_organisation', function(req, res, next) {
var organisationForm = new forms.OrganisationForm({prefix: 'org'})
, peopleFormSet = new forms.InlinePersonFormSet({prefix: 'people'})
, phoneNumberFormSet = new forms.PhoneNumberFormSet({prefix: 'phone'})
, emailAddressFormSet = new forms.EmailAddressFormSet({prefix: 'email'})
, addressFormSet = new forms.AddressFormSet({prefix: 'address'})
res.render('add_organisation', {
organisationForm: organisationForm
, peopleFormSet: peopleFormSet
, addressFormSet: addressFormSet
, phoneNumberFormSet: phoneNumberFormSet
, emailAddressFormSet: emailAddressFormSet
})
})
app.post('/contacts/add_organisation', function(req, res, next) {
var organisationForm = new forms.OrganisationForm({prefix: 'org', data: req.body})
, peopleFormSet = new forms.InlinePersonFormSet({prefix: 'people', data: req.body})
, phoneNumberFormSet = new forms.PhoneNumberFormSet({prefix: 'phone', data: req.body})
, emailAddressFormSet = new forms.EmailAddressFormSet({prefix: 'email', data: req.body})
, addressFormSet = new forms.AddressFormSet({prefix: 'address', data: req.body})
if (allValid(organisationForm, peopleFormSet,
addressFormSet, phoneNumberFormSet, emailAddressFormSet)) {
var organisation = {
name: organisationForm.cleanedData.name
, phoneNumbers: peopleFormSet.cleanedData()
, emailAddresses: emailAddressFormSet.cleanedData()
, addresses: addressFormSet.cleanedData()
}
redis.organisations.store(organisation, function(err, organisation) {
if (err) return next(err)
var redirect = function() { res.redirect('/contacts/' + organisation.id) }
var peopleData = peopleFormSet.cleanedData()
if (!peopleData.length) return redirect()
var addPerson = addPersonInline.bind(null, organisation)
async.forEach(peopleData, addPerson, function(err) {
if (err) return next(err)
redirect()
})
})
}
else {
res.render('add_organisation', {
organisationForm: organisationForm
, peopleFormSet: peopleFormSet
, addressFormSet: addressFormSet
, phoneNumberFormSet: phoneNumberFormSet
, emailAddressFormSet: emailAddressFormSet
})
}
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment