Project 07 · CRM architecture
The CRM I designed for a car dealership, and how I closed its gaps.
Three layers, each saying what it is: how their system worked, what I designed, and how I would build it today.
Plain words first, diagrams you can explore, two interactive tools, and the code behind the redesign.
- Lead sources
- 7
- Departments
- 6
- Lead groups
- 4
- Gaps closed
- 11
By Majed Alandajani · Updated 2 November 2026
On this page
Context
Leads were arriving from seven channels and leaking between six departments.
In 2024 I worked as a marketing analyst at a car dealership. Customers reached it through advertising, the website, social media, SMS, email, outdoor campaigns, events and the call center, and six departments needed those customers: telesales, test drives, aftersales, the call center, fleet sales and marketing.
Nothing connected them. Leads were copied by hand, forwarded by email, tracked in a spreadsheet, and marked lost after a few calls in one afternoon. Nobody could say which campaign sold a car. I mapped how it worked, then designed the CRM end to end. This page shows both, and then the part I care about most: where my own first design fell short, and how I would close every gap today.
Layer 1 · What I found
Two snapshots of how a lead moved before the redesign.
Dashed boxes were done by hand. Select any box to read what happened there. Tool names are generalised.
Advertising
Campaigns sent people to the website, to social media and to the call center, with no way to tell later which campaign a customer came from.
Website
Forms landed with the marketing team, not with the department that needed them.
Social media
Messages and lead forms were collected by hand and passed through the agency.
Call center
Callers were entered into a separate intake tool.
Marketing team, by hand
Extracted every lead, sorted it, and decided by hand which department should get it.
Agency
Passed social media leads to marketing, one more hand-off before anyone called.
Intake tool
Recorded calls, then fed the dealer system by hand.
Email forwarding
Every department received its leads as emails. Nothing tracked whether an email was opened, called or forgotten.
Dealer system
The dealer management system held sales and service, but knew nothing about leads before the sale.
Aftersales
Received service requests by email.
Fleet
Received company inquiries by email.
Telesales
Called each lead, who became either hot or lost. A hot lead went on to the dealer system, a branch or a bank, payment, an appointment and delivery.
Advertising
Now also through events, still without tracking which campaign brought whom.
Events
Booth visitors filled in forms.
Website
Quote, contact, offer and general inquiry forms.
Social media
Lead forms, plus offers that pointed people to the website.
Call center
Still recorded in the intake tool.
Forms, handled by hand
Form entries were moved into the task tool by hand.
Intake tool
Fed telesales directly, outside the task tool.
Task tool
The new hub. It sent leads to fleet and telesales, but it was not connected to aftersales at all, so service requests still travelled by email.
Aftersales
Requests arrived by email as spreadsheets, were confirmed, and were typed into the dealer system.
Fleet
Received leads from the task tool and offers.
Telesales
Worked a waiting list: new, divided among the team, contacted, then interested, not interested or no answer.
Dealer system
Still fed by hand.
Spreadsheet and team chat
Leads left the tool for one shared spreadsheet and a team chat. A customer was called three times in the same day; with no answer, the lead was lost.
Hand-offs: every lead passed through at least one person who copied it somewhere else.
No source: no campaign could be tied to a sale, so budgets were set by feeling.
No single customer: the same person could exist in the task tool, the spreadsheet and the dealer system as three different people.
Lost too early: three calls in one afternoon decided that a customer was gone.
Aftersales outside: service requests never entered the shared tool.
Layer 2 · The design
One intake, one router, six department lists, and nothing lost at the end.
Every channel carries a UTM link, so each lead knows where it came from. Every lead enters one intake, is checked against existing customers, and is routed to the list of the department that needs it. Each department works its list through the same status engine, and every ending feeds a data warehouse, with sales and aftersales feeding each other through up-selling and cross-selling.
Events
Booth visitors register on forms that enter the same intake.
SMS
Offers carry a UTM link to the website, so a visit is traced back to the message. The dashed lines are UTM links.
Outdoor
Billboards point to tracked links too.
Website
Offer, quote and test drive forms. The test drive form lets the branch manager control the date, the time and the car.
Campaign emails link to the website with UTM.
Social media
Lead forms enter the intake directly, and offers link to the website.
Call center
Calls are recorded on the same forms, with the appointment date and time picked from real availability.
One intake
All data lands in one system, with its source.
Customer check
Is this a customer we already know? If yes, the request joins their record; if not, a new record is created.
Router
Each request goes to the list of the department that needs it, with each department’s own requirements.
Telesales
Quotes and offers. See the department flows below.
Test drive
Bookings per branch, handed to telesales after the drive.
Aftersales
Service bookings, parts and service questions.
Call center
General inquiries and complaints.
Fleet
Company inquiries. Its detailed flow was still open in my first design.
Marketing
Engage potential customers, build one customer base, and organise responses to companies. Its detailed flow was still open in my first design.
Up-sell and cross-sell
A sale leads to service, and service leads to the next sale.
Data warehouse
Every ending, sold, served, lost or closed, is kept for analysis, so campaigns and teams are judged on data.
Layer 2 · Department flows
How a lead moves inside each department.
Telesales list
Leads routed to telesales wait here.
New, then assigned
The system marks the lead new and divides the list among the team.
Contact
The agent calls. With no answer, the lead returns to the waiting list and is tried again, up to three times; after the third, it joins the cold group.
Next action
Interested, semi-interested, not interested or no answer. Interested leads are classified by when they plan to buy.
Hot
Ready to buy. The lead stays with the same agent for 15 days while the procedures are completed.
Warm
Buying in the coming months. A suspended hot lead also returns here.
Cold
Later buyers, semi-interested leads, and leads not reached after three attempts.
Lost
Not interested. The group is kept, not deleted.
Sale
Procedures done, the sale goes into the dealer system, then the delivery appointment.
Reason recorded
Why the customer did not buy, to improve service and fix what drives customers away.
Follow-up date
Warm and cold leads come back to contact on their next follow-up date.
Test drive list
Requests from the website or the call center, sorted by branch.
Branch test drive manager
Controls the car, the date and the time. They are linked, so a change updates everywhere at once.
Confirm with the customer
If the customer wants a change, it goes back to the manager; otherwise the booking is confirmed.
Reminder
An SMS reminds the customer before the drive.
Arrival
The customer arrives at the branch and their data is verified.
Done
The drive happened.
Cancelled
Recorded and closed. A requested change goes back to the branch manager.
To telesales, the next day
One day after the drive, the customer moves to telesales automatically and is classified hot, warm or cold.
Aftersales list
Service bookings, parts and service questions.
Book an appointment
Once a slot is booked, it closes everywhere at once.
Reminder
An SMS reminds the customer of the visit.
Service advisor
Has full authority to add or change appointments, and every change updates the booking automatically.
Arrival
The day's bookings are ordered by date and time.
Attended
Confirmed only after the customer has arrived and been registered in the dealer system.
No-show
Marked when the scheduled time passes without the customer. A requested change goes back to the advisor.
Complete
The visit is closed.
Parts and service questions
Handled by an aftersales team inside the call center. Their detailed flow was still open in my first design.
Call center list
Every call lands here first.
Customer check
A station that checks whether the customer exists. If yes, the department type is chosen and the data goes straight there; if not, the customer is registered first. Inquiries and complaints stay with the call center.
To marketing
Moves automatically to the marketing list.
To aftersales
A booking request moves to the aftersales list.
To test drive
Moves to the test drive list.
To telesales
Moves to the telesales list.
General inquiry
Answered by the call center, on the channel the customer used.
Complaint
Followed by the call center until it is solved or explained.
Contact
With no answer, the request waits a day and is tried again, up to three times.
Solved
Closed with a comment.
Not solved
Closed with the reason recorded. In the redesign, this is where escalation was added.
Closed
The journey ends and the result goes to the data warehouse.
These flows are my design as I drew it, with drawing mistakes corrected: a label copied between two departments, and a loop that had no exit. Nothing was added to them.
Honest review
Eleven gaps in my first design.
A design is only as good as its weakest rule. Reading my own diagrams again, these are the gaps a CRM would have hit in its first month.
1. No data model. Lists and statuses, but no single customer record, so one person could still become three leads.
2. Routing by keywords. The script needed every keyword at once, overwrote its output file with each lead, read its dictionary the wrong way round, and dropped any lead its rules could not place.
3. Scoring that contradicted itself. Purchase probabilities with gaps between the bands (71 to 79% belonged nowhere), and timeframes that overlapped.
4. No assignment rule. "Divide to the team" did not say how.
5. No deadlines. No target for the first call, and three attempts without saying how far apart.
6. Complaints could close unsolved. No escalation, and no customer confirmation.
7. Drawing errors. A loop with no exit, a role label copied from another department, and a reminder sent before the booking was confirmed.
8. Open departments. Marketing and fleet were left "under procedure", and parts and service questions had no flow.
9. The dealer system was a box. Not an integration: nothing said what crosses between the CRM and it, or who owns what.
10. No consent. Reminders and re-engagement by SMS and WhatsApp, without recording consent or offering an opt-out.
11. Benefits without measures. Lower advertising cost and more sales were promised, with no baseline and no indicator to prove them.
Layer 3 · Redesign
One customer, many leads, and every touch recorded.
The redesign starts where the first design was thinnest: the data. A customer is one record, keyed by one normalised mobile number. Every request is a lead attached to that customer, every call or visit is an activity, and consent lives on the customer, per channel.
Customer
Identified by one normalised Saudi mobile number, so the same person arriving from three channels is still one customer.
Consent
Marketing by SMS and by WhatsApp is consented to separately, with the date, and an opt-out overrides both.
Lead
One request: its department, its inquiry type from the form, its source and UTM, status, temperature, owner, attempts, and when it was first answered.
Agent
Belongs to a department and a branch, and owns the leads assigned to them.
Activity
Every call, message and visit with its outcome. This is what makes performance measurable.
Booking
A test drive or a service visit, in a slot that closes everywhere once taken.
Complaint
Has an owner, an age and an escalation step, and cannot close itself.
Dealer system
Owns the sale, the invoice and the service visit, and reports each back to the CRM as an event.
// One customer, many leads, and every touch recorded as an activity, so
// response times and performance are measured, not guessed.
export type Department = 'telesales' | 'testdrive' | 'aftersales' | 'callcenter' | 'fleet' | 'marketing';
export type Source = 'website' | 'social' | 'sms' | 'email' | 'outdoor' | 'event' | 'callcenter';
export interface Customer {
id: string;
phone: string; // +9665XXXXXXXX: the one key that detects duplicates
name: string;
consent: { marketingSms: boolean; marketingWhatsApp: boolean; at: string };
optedOutAt?: string;
}
export interface Lead {
id: string;
customerId: string;
department: Department;
inquiryType: string; // chosen in the form, never guessed when present
source: Source;
utm?: { campaign: string; medium: string; content?: string };
status: 'new' | 'assigned' | 'contacting' | 'qualified' | 'won' | 'lost' | 'nurture';
temperature?: 'hot' | 'warm' | 'cold' | 'lost';
ownerId?: string;
attempts: number;
lostReason?: string;
createdAt: string;
firstContactAt?: string; // speed to lead is measured from createdAt to here
}
export interface Activity {
leadId: string;
agentId: string;
kind: 'call' | 'whatsapp' | 'sms' | 'visit' | 'test_drive' | 'note';
outcome: 'answered' | 'no_answer' | 'interested' | 'not_interested' | 'booked';
at: string;
}
// The same person arrives as 0551234567, 966551234567, +966 55 123 4567 or
// 551234567. One normalised number means one customer record, not four.
export function normalizeSaudiMobile(raw: string): string | null {
const d = raw.replace(/\D/g, '');
const local = d.startsWith('966') ? d.slice(3) : d.startsWith('0') ? d.slice(1) : d;
return /^5\d{8}$/.test(local) ? `+966${local}` : null;
}
export async function findOrCreateCustomer(db: Db, input: { phone: string; name: string }) {
const phone = normalizeSaudiMobile(input.phone);
if (!phone) throw new Error('invalid_mobile');
const existing = await db.customers.findByPhone(phone);
if (existing) return { customer: existing, isNew: false };
const customer = await db.customers.insert({ phone, name: input.name.trim() });
return { customer, isNew: true };
}
Layer 3 · Redesign
The form decides where a lead goes. Keywords only suggest.
My first routing script read free text and looked for keywords. It is shown below as I wrote it. It needed every keyword of a department in one message, so a customer who only wrote "buy" went nowhere; it opened its output file in write mode, so each new lead erased the last; and its loop read the dictionary the wrong way round. The redesign asks the customer what they want in the form itself, and keeps keywords for free text only, in Arabic and English, with a person checking anything unclear.
Pick a request type and follow it
Telesales list
Assigned to the telesales agent with the fewest open leads.
First call within 15 minutes in working hours, then up to three attempts on three different days.
Before: forwarded by email, or copied from the task tool into a shared spreadsheet.
def transfer_text_based_on_keywords(input_file, keywords_to_files):
# Read the contents of the original file
with open(input_file, 'r', encoding='utf-8') as file:
text = file.read()
# Check each set of keywords and transfer the text to the corresponding file if the keywords are found
for keywords, output_file in keywords_to_files.items():
if all(keyword in text for keyword in keywords):
with open(output_file, 'w', encoding='utf-8') as file:
file.write(text)
print(f"The text has been successfully transferred to {output_file}!")
# Define the files and the corresponding keywords
keywords_to_files = {
'TeleSales.txt': ('buy', 'cost', 'value'),
'AfterSales.txt': ('part', 'maintenance', 'inspection', 'periodic'),
'Fleet.txt': ('company', 'institution'),
}
# Use the function to transfer texts
transfer_text_based_on_keywords('path/to/input_file.txt', keywords_to_files)
import json
import re
from pathlib import Path
# The form's own "inquiry type" decides. Keywords only suggest a department
# for free text, in Arabic and English, and anything unclear goes to triage.
RULES = {
"telesales": {"buy", "price", "cost", "offer", "شراء", "سعر", "عرض", "تقسيط"},
"aftersales": {"part", "parts", "maintenance", "service", "قطع", "صيانة", "فحص"},
"fleet": {"company", "fleet", "institution", "شركة", "أسطول", "مؤسسة"},
}
def suggest_department(text: str) -> str:
words = set(re.findall(r"\w+", text.lower()))
hits = {dept: len(words & keys) for dept, keys in RULES.items()}
best = max(hits, key=hits.get)
tied = list(hits.values()).count(hits[best]) > 1
return "triage" if hits[best] == 0 or tied else best
def route(lead: dict, queue_dir: Path) -> str:
dept = lead.get("inquiry_type") or suggest_department(lead.get("message", ""))
# Append, never overwrite: every lead stays in its queue until handled.
with open(queue_dir / f"{dept}.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(lead, ensure_ascii=False) + "\n")
return dept
// The form field decides where a lead goes. Free text can only suggest, and a
// lead no rule can place goes to a triage queue that a person checks, so
// nothing is dropped and nothing is overwritten.
const BY_INQUIRY: Record<string, Department> = {
quote: 'telesales',
offer: 'telesales',
test_drive: 'testdrive',
service_booking: 'aftersales',
spare_parts: 'aftersales',
fleet: 'fleet',
complaint: 'callcenter',
general: 'callcenter',
};
export function routeLead(inquiryType: string, message = ''): Department | 'triage' {
return BY_INQUIRY[inquiryType] ?? suggestFromText(message) ?? 'triage';
}
Layer 3 · Redesign
One rule, with no gaps and no overlaps.
My first design had two classifications that disagreed: purchase probabilities with gaps between them, and purchase timeframes that overlapped. Nobody can estimate a probability on a phone call, but a customer can say when they plan to buy. The redesign keeps the four groups and uses three questions an agent can actually ask. Try it.
Classify a lead
When does the customer plan to buy?
Budget or financing confirmed?
Reached within three attempts?
Hot
Call back within 2 days and offer a test drive. The same agent keeps the lead for 15 days, then it is reassessed.
Warm
Follow up every month until the purchase window opens, and help with financing if the budget is what holds it back.
Cold
Follow up every three months, while marketing keeps in touch with offers, only with consent.
Lost
Record the reason. No more sales calls; marketing may re-engage later, only with consent.
// One rule, with no gaps and no overlaps: the purchase timeframe decides the
// temperature, a confirmed budget or financing separates hot from warm, and a
// lead nobody reached after three attempts is nurtured, not thrown away.
export function classifyLead(timeframe: Timeframe, budgetConfirmed: boolean, answered: boolean): Temperature {
if (timeframe === 'no') return 'lost';
if (!answered) return 'cold';
if (timeframe === 'd30') return budgetConfirmed ? 'hot' : 'warm';
if (timeframe === 'm3') return 'warm';
return 'cold';
}
// Days until the next follow-up for each temperature.
export const FOLLOW_UP_DAYS: Record<Temperature, number | null> = {
hot: 2, // and the same agent keeps it for 15 days before it is reassessed
warm: 30,
cold: 90,
lost: null, // the reason is recorded; only marketing may re-engage, with consent
};
Layer 3 · Redesign
A lead in the redesign, from the form to the dealer system.
Captured with its source and its consent
The form asks what the customer wants, records the UTM source, and asks for consent to marketing messages separately from service messages.
// A service message, such as a booking reminder, needs no marketing consent.
// Offers and re-engagement do, per channel, and an opt-out wins over everything.
export function canSend(c: Customer, purpose: 'service' | 'marketing', channel: 'sms' | 'whatsapp'): boolean {
if (purpose === 'service') return true;
if (c.optedOutAt) return false;
return channel === 'sms' ? c.consent.marketingSms : c.consent.marketingWhatsApp;
}
Recognised as a customer we may already know
The mobile number is normalised, so 0551234567 and +966 55 123 4567 are the same person and join the same record.
// The same person arrives as 0551234567, 966551234567, +966 55 123 4567 or
// 551234567. One normalised number means one customer record, not four.
export function normalizeSaudiMobile(raw: string): string | null {
const d = raw.replace(/\D/g, '');
const local = d.startsWith('966') ? d.slice(3) : d.startsWith('0') ? d.slice(1) : d;
return /^5\d{8}$/.test(local) ? `+966${local}` : null;
}
export async function findOrCreateCustomer(db: Db, input: { phone: string; name: string }) {
const phone = normalizeSaudiMobile(input.phone);
if (!phone) throw new Error('invalid_mobile');
const existing = await db.customers.findByPhone(phone);
if (existing) return { customer: existing, isNew: false };
const customer = await db.customers.insert({ phone, name: input.name.trim() });
return { customer, isNew: true };
}
Routed by the form, triaged when unclear
The inquiry type decides the department. Free text only suggests, and anything unclear goes to a person.
// The form field decides where a lead goes. Free text can only suggest, and a
// lead no rule can place goes to a triage queue that a person checks, so
// nothing is dropped and nothing is overwritten.
const BY_INQUIRY: Record<string, Department> = {
quote: 'telesales',
offer: 'telesales',
test_drive: 'testdrive',
service_booking: 'aftersales',
spare_parts: 'aftersales',
fleet: 'fleet',
complaint: 'callcenter',
general: 'callcenter',
};
export function routeLead(inquiryType: string, message = ''): Department | 'triage' {
return BY_INQUIRY[inquiryType] ?? suggestFromText(message) ?? 'triage';
}
Assigned to the least-loaded agent
Within the right department and branch, the agent with the fewest open leads gets the next one.
// Leads go to the agent of the right department and branch who holds the
// fewest open leads, so nobody hoards leads and nobody drowns in them.
export function pickAgent(agents: Agent[], open: Map<string, number>, branch?: string): Agent | null {
const pool = agents.filter((a) => a.active && (!branch || a.branch === branch));
if (!pool.length) return null;
return pool.reduce((best, a) => ((open.get(a.id) ?? 0) < (open.get(best.id) ?? 0) ? a : best));
}
Three attempts on three days, with a deadline
The first call has a deadline, and missing it alerts the team leader. Without an answer, the next attempt is the next working day, up to three, and then the lead is nurtured instead of lost.
// Three attempts on three different days, inside working hours, instead of
// three calls in one afternoon. The first call has a deadline, and missing it
// alerts the team leader. (The hours here are example settings.)
const WORK_START = 9;
const WORK_END = 21;
export const FIRST_CONTACT_SLA_MIN = 15;
export function nextAttempt(lead: Lead, now: Date): Date | 'nurture' {
if (lead.attempts >= 3) return 'nurture'; // the lead turns cold; marketing keeps in touch
const next = new Date(now);
if (lead.attempts > 0) next.setDate(next.getDate() + 1);
if (next.getHours() >= WORK_END) next.setDate(next.getDate() + 1);
if (next.getHours() < WORK_START || next.getHours() >= WORK_END) next.setHours(WORK_START, 0, 0, 0);
return next;
}
export function breachedFirstContact(lead: Lead, now: Date): boolean {
if (lead.firstContactAt) return false;
return now.getTime() - Date.parse(lead.createdAt) > FIRST_CONTACT_SLA_MIN * 60_000;
}
Classified by one rule
Hot, warm, cold or lost, from three questions, with a follow-up date for each. The classifier above runs this exact rule.
Handed to the dealer system as an event
When the lead is won, the CRM sends one event to the dealer system, which owns the sale, the invoice and the service visits from then on and reports each one back.
// The CRM and the dealer management system (DMS) exchange events, not
// spreadsheets. The CRM owns the lead until it is won; the DMS owns the sale,
// the invoice and the service visit, and reports each one back.
export type CrmToDms = {
type: 'lead.won';
leadId: string;
customer: { phone: string; name: string };
model: string;
payment: 'cash' | 'finance';
};
export type DmsToCrm =
| { type: 'sale.delivered'; leadId: string; deliveredAt: string }
| { type: 'service.booked'; customerPhone: string; at: string; branch: string }
| { type: 'service.completed'; customerPhone: string; at: string; nextServiceDue?: string };
Complaints escalate instead of closing
Unsolved after two days, a complaint moves to the branch manager, and it closes only with a resolution and the customer’s confirmation, or a recorded reason.
// A complaint cannot close itself. Unsolved after two days it moves to the
// branch manager, and closing needs a resolution and the customer's
// confirmation, or a recorded reason why it could not be solved.
export function complaintNextStep(c: Complaint, now: Date): 'open' | 'escalate' | 'can_close' {
if (c.resolution && (c.customerConfirmed || c.unresolvableReason)) return 'can_close';
const ageHours = (now.getTime() - Date.parse(c.openedAt)) / 3_600_000;
return ageHours > 48 && !c.escalatedAt ? 'escalate' : 'open';
}
01 / 08
Layer 3 · Redesign
Promises become indicators.
My first design promised lower advertising costs and more sales. The redesign defines how each promise is checked, from the activity and lead records, so a campaign or a team is judged on numbers.
Speed to lead: minutes from the form to the first call.
Contact rate: the share of leads actually reached.
Lead to test drive, and test drive to sale: where the funnel loses people.
Conversion by source and campaign: from the UTM on every lead, so budgets follow real sales.
No-show rate: for test drives and service visits.
Complaint resolution time and escalations: per branch.
Overdue follow-ups per agent: the fair way to evaluate performance.
-- What the design is judged by: how fast a lead is answered, how many are
-- reached, and which source really sells, so the budget follows sales.
SELECT source,
COUNT(*) AS leads,
AVG(CASE WHEN first_contact_at IS NOT NULL THEN 1.0 ELSE 0 END) AS contact_rate,
AVG((julianday(first_contact_at) - julianday(created_at)) * 1440) AS minutes_to_first_contact,
AVG(CASE WHEN status = 'won' THEN 1.0 ELSE 0 END) AS conversion
FROM leads
WHERE created_at >= date('now', '-30 days')
GROUP BY source
ORDER BY conversion DESC;
In plain terms
What I designed, and what is redesign.
I designed this CRM in full while working as a marketing analyst. This page does not claim that the dealership runs it.
Their system is shown as I mapped it then, with the names of their tools generalised.
My design is shown as I drew it, with drawing mistakes corrected and nothing added.
The redesign is how I would build it today. Its code is reference code written for this case study, to show the engineering; the first routing script is the original from my design.
Why I show the gaps too
A design that looks perfect on a slide usually breaks on its first busy day. Reading my own work again, finding where it would have failed, and closing each gap is the part of systems work I trust most.
If your sales or service team is losing customers between tools, I am happy to talk.