Appearance
Leads & CRM — architecture fit
How the designed Leads/CRM module fits QRSETU's existing core, and why it is not a marketing add-on. Every repo fact on this page was measured against the live migration set on 2026-08-22; where something is unbuilt it says so in the same sentence.
THE REFRAME THIS PAGE EXISTS TO MAKE
Leads is the missing fulfilment record for an entire archetype. The expertise archetype's registered definition is "Sells knowledge or service delivered after a conversation — the card generates the enquiry, not the sale", and its output is literally recorded as "an enquiry". Five industries sit on it — electrician_plumber · real_estate · car_sales · photographer · direct_seller — and there is no enquiry table and no lead table. orders' own comment concedes the gap: "all six goods industries gain it while real_estate and salon correctly do not." They correctly do not get orders, and nothing was ever built in their place.
So a Goods merchant has orders; a Time merchant will have bookings; an Expertise merchant has nowhere to record the only thing their card produces. Two of the three verticals furthest along in discovery (direct_seller, real_estate) are expertise industries. Treating this as a CRM nice-to-have understates it by an archetype.
It is the Party primitive, and four registered features are waiting on it
The design proposes a Contact registry keyed on E.164. That concept is already in the platform model as the party primitive, seeded in process_primitives with a description that names the use case outright:
('party', 'Party', 'A workspace-scoped counterparty: customer, lead, student, patient. Identity-optional — most are not QRSETU account holders.')
parties is NOT BUILT (measured: 51 tables, none of them). And the dependency chain is already declared in the features registry:
| Feature key | Category | Primitive | Parent | Status |
|---|---|---|---|---|
customers | crm | party | — | registered, unbuilt |
khata | crm | balance | customers | registered, unbuilt |
bookings | ops | schedule | customers | registered, unbuilt |
subscriptions | ops | recurrence | customers | registered, unbuilt |
Three features declare customers as their parent, so building the Party primitive properly unblocks four registered features at once — and khata is "a running credit account per customer", i.e. money. A bespoke contacts table would either be duplicated by customers later or migrated out from under a financial feature. ADR-0024 has also already written the FK: assets(workspace_id, party_id, kind, identifier, attributes jsonb, acquired_at).
Decision this forces: the contact registry is
parties, the Party primitive. Notcontacts, not an admin-local table. The design's reasoning for one shared registry is right; the name and the home are already decided by the platform model. This is the same class as QRS-807's Ledger finding — a primitive with several waiting claimants must be built once.
⚠ Three counterparty identity models exist, and neither design reconciles them
This is the hardest data-model question here, it is un-recoverable if decided wrongly, and it appears in no spec. The platform already identifies "the person on the other side" three different ways:
| Where | Key | Identity |
|---|---|---|
orders | buyer_phone NOT NULL, buyer_user_id nullable | phone-primary, account optional (anonymous-first) |
conversations | consumer_user_id NOT NULL, no phone at all | account-required, and the migration flags this as "THE OPPOSITE OF orders.buyer_user_id" |
designed Contact | phone_e164 unique | phone-only |
The design's premise — "a lead, a contact, a WhatsApp message and a Meta wamid share exactly one identifier and it is the number" — is true for orders and WhatsApp and false for chat. A consumer who signed in with Google has a users row and may have no phone on record at all, so a chat conversation cannot be joined to a phone-keyed contact.
Recommended model, and it costs one nullable column now versus a merge over live data later:
partiescarries bothphone_e164(nullable) anduser_id(nullable), with a constraint that at least one is present. A party may be phone-only (an anonymous buyer), account-only (a chat consumer), or both.- Consent stays keyed on the PHONE, never on the party — as ADR-0029's data model already specifies. WhatsApp permission is a property of a number, so it survives a party merge, a party split, and a phone change.
- Merging two parties is an explicit, audited operation, never an inferred join. The design's own hard-won lesson applies: its first attempt joined leads to contacts by a modulo and its second resolved for 2% of rows. A merge must be a recorded act with a reversible trail, not a matching heuristic.
Naming: four collisions this work walks straight into
The feature-scoped naming rule exists because templates, primitives, cards and plans each had to be swept out after the fact. All four collisions below are live in the incoming designs.
| Word | Meanings now in play | Required |
|---|---|---|
| campaign | three: AdManager ad campaigns · the registered campaigns feature = "Targeted, scheduled offers published across cards" (ADR-0025) · the Communications module's message campaigns | message campaigns get their own feature key and table (communication_campaigns). ⚠ Do not reuse the campaigns feature key — it is taken, it means card offers, and it depends on store |
| messages | public.messages already exists as the in-app chat table, append-mostly with delivered_at/read_at — the same vocabulary a WhatsApp ledger wants | the WhatsApp ledger is communication_messages, per ADR-0029. A naive implementer will reach for messages and find it occupied |
| templates | four: Setu Card manifests · Supabase Auth's 13 email templates · WhatsApp message templates · the admin panel's own Templates.dc.html (card templates) vs the Templates tab inside Communications | table stays whatsapp_message_templates; and the admin nav must label the two surfaces distinctly, or Operations will open the wrong one |
| contact / customer / crm / party | primitive party · feature customers · feature category crm · the design's Contact and its RBAC module key crm | primitive party → table parties → merchant feature customers (already seeded); crm is the feature category, not a new key. Lead is a distinct entity on top of a party |
⚠ RBAC: the specs' biggest reuse claim is true of the prototype and false of QRSETU
The Leads spec's headline reuse is "The permission surface already exists — ['crm','Leads & CRM', …] is already declared, with six actions. There is no screen behind it. The role model is waiting for the module." That is accurate about RBAC.dc.html in the design project. In this repo:
| Artifact | Status |
|---|---|
roles · permissions · role_permissions · user_roles · platform_admins | none exist |
is_admin() | does not exist (QRS-803) |
workspace_members.role_key | exists as text default 'owner' held to five values by an interim CHECK (owner · admin · manager · member · viewer, 20260808210000_v2_production_hardening.sql:210-211), no FK. Its own comment: "FK to roles added in the RBAC layer" |
| ADR-0006 (RBAC model) | 🟢 Accepted — and unimplemented |
apps/*/src/tiers/admin/ | two READMEs, zero code |
So every screen in both modules is gated on an authorization layer that does not exist in any form. This is the QRS-451 class exactly — an absence proves nothing until you establish which project you are looking at — and it is the single largest shared prerequisite. It is also bigger than either module, so it should be scoped and sequenced as its own wave rather than absorbed into a CRM build.
⚠ There is no scheduler, and the only substrate is currently offline
Both designs depend on unattended execution — campaign sends, fan-out batches, retry, and the communications alert sweep. Measured:
- Zero
cron.schedulecalls across the live migrations. public.outboxexists and nothing drains it — no worker, no consumer (the one importer,_shared/cardCache.ts, deliberately purges synchronously because nothing drains it).- The platform runs no recurring job at all. The one that existed (
payments-watchdog.yml, a GitHub Actions cron) was made manual-only on 2026-09-23, together with every other scheduled workflow, to save Actions quota (GitHub Actions usage forensics). A scheduler for communications therefore has to be designed, not borrowed.
The Communications spec names this itself and is right to: "there is no scheduler in the prototype … on lift this is an Edge Function on a cron, and it is the single largest unbuilt dependency in the module." Adding audience import and campaign fan-out makes it larger, not smaller. A campaign's running state with no worker behind it is a record that sits at running forever — the design says so in its own open questions.
Targeting: audiences target PARTIES, the existing vocabulary targets TENANTS
ADR-0025 D3 extracted targeting as a shared vocabulary — "a campaign is not a grant, but it targets identically" — and its axes are subtree · group · explicit set, i.e. it targets workspaces (showrooms, divisions, agents). A communications audience targets parties (people, by predicate over lead and party attributes).
These are different axes and must not be forced together. But the ADR's own conclusion still applies one level down: "share the targeting vocabulary, not the table." So:
communication_audiencesstores a predicate, re-evaluated at send time, never a frozen row set. (The design already insists on this: "A segment that froze its membership is a stale list pretending to be live.")- An imported list is the exception and legitimately stores rows.
- The predicate language should reuse the shape of the grant/campaign targeting vocabulary (operators, groups, explicit sets) so operators learn one mental model, while the subject is a party rather than a workspace.
One table, two views: merchant and admin leads
The mobile console has a designed Leads.dc.html and the admin panel now has its own. Neither is built, and there is no leads route or feature in the repo — measured: (tabs)/ holds dashboard · chats · catalog · more, and tiers/user/features/ has no leads. (⚠ CLAUDE.md still documents the tabs as {dashboard,chats,leads,more} — stale; see QRS-875.)
That is the best possible position: the shared model can be decided once, with no retrofit. The Leads spec's instinct is right — "Merchant-managed leads later is a workspace-scoped grant of an existing permission, not a new architecture" — and the repo agrees, because customers is already a scoped feature. So:
One
leadstable,workspace_idNOT NULL from the first migration, RLS by workspace membership, two presentation surfaces. The admin view is the same rows read with a platform-scope grant; the merchant view is the same rows read with a workspace grant. Building them as two systems is the duplicate-source class (QRS-249) chosen on purpose.
⚠ And note what "admin leads" must not silently become: QRSETU's own sales pipeline for acquiring merchants is a different subject (the party is a prospective merchant, not a merchant's customer). If that is wanted, it is a distinct workspace or a distinct table — not the same rows with a flag.
High-volume posture — where the design is right, and what it still owes
The design's pagination audit is sound and its per-dataset reasoning should be adopted as written: keyset for append-only time-ordered ledgers, offset for small bounded sets, aggregate-never-paginate for time series, and no pager at all where a Meta ceiling already bounds the set. Two additions the repo requires:
- Bulk operations run over a predicate, never a list of ids. The design states this; the schema must make it possible (a saved predicate, a job row, and an idempotency key), because "assign all 40,000 real-estate leads to Priya" as 40,000 requests is an outage.
- Retention is a control, and it is currently unset in both designs. The Communications spec flags it: "Pagination stops a screen falling over; it does not stop a table growing forever." A message ledger and a lead table both grow monotonically, and a dead three-year-old lead is a DPDP liability rather than an asset. Retention belongs in
platform/controls.jswith a default, and the decision is the owner's. partiesandleadsneed masked-by-default phone display with an audited reveal. The design has the pattern; make it a schema and grant concern (ADR-0014's column-level grants), not a UI convention — a CSV export of 40,000 leads is the highest-risk action in the module.