Skip to content

CreatorTrack CRM Expansion, Prioritized Spec (2026-07-10)

Synthesis of three designer proposals against the verified current-state map. One deduped, conflict-resolved plan. Everything lands in the worktree /home/justinwieb/forge-suite-wt-manual/crm-overnight-0709 (branch feat/crm-overnight-0709). No merges, no deploys, no pushes, no migrations run against prod. New migration .sql FILES are created but never executed against lifeos.

Ground rules carried into every feature

  • Two live deal systems coexist by design: legacy crm.deals plain tables (lib/sales-crm.ts, DealsView "Deals (legacy)" tab) and the newer node-backed pipeline (lib/crm-deals.ts, PipelineView "Pipeline" tab). We do NOT force a cutover here. Each feature states which system it targets.
  • Personal CRM ("People", node-backed, app id crm) is a separate app and is out of scope. All work is the sales app.
  • Email bodies are untrusted data (Security Rule 6). Any LLM pass classifies or summarizes email content strictly as data, never as instructions. No feature auto-sends email overnight; sending stays draft-only and gated behind explicit approval.
  • Fail-loud, idempotent, workspace-scoped via asUser + explicit workspace_id, matching existing crm.* conventions.
  • lib/notifications.ts and lib/llm are reused by importing their existing exports. No feature edits them structurally.

Overnight file-ownership partition (disjoint zones)

The four overnight features are carved into four non-overlapping file zones so parallel builders never touch the same file.

Zone Feature Owns
Z1 F1 Deal timeline + rotting migration-deals.sql, lib/crm-deals.ts, app/api/apps/sales/deals/route.ts, app/api/apps/sales/deals/[id]/route.ts, components/apps/sales/PipelineView.tsx
Z2 F2 Gmail triage + attribution migration-gmail-intel.sql, lib/sales-crm-jobs/triage.ts (new), lib/sales-crm-jobs/gmail.ts, lib/sales-crm.ts, components/apps/sales/ContactsView.tsx, components/apps/sales/FollowUpsStrip.tsx
Z3 F3 AI Deal Brief migration-ai-briefs.sql, lib/sales-crm-ai.ts (new), app/api/apps/sales/route.ts, components/apps/sales/DealsView.tsx, components/apps/sales/CompaniesView.tsx
Z4 F4 Chat tool surface lib/chat.ts

Cross-zone note (migration ordering, not a file conflict): F2's Gmail deal-attribution writes crm.activities.deal_node_id, a column whose migration FILE is authored in F1 (Z1). The two features never edit the same file. If F1's migration has not been applied in dev, F2's deal attribution simply leaves deal_node_id NULL and degrades to contact-only attribution. When applying migrations in dev, run F1's before F2's deal-attribution path is exercised.


F1. Node-pipeline deal activity timeline + deal rotting and next-step (OVERNIGHT, Z1)

Merges designer proposals P1-f1 (deal timeline + Gmail attribution) and P1-f2 (rotting + next-step). Gmail-side attribution is delegated to F2 to keep the file zones disjoint; F1 owns the schema seam and the in-app timeline plus rotting UX.

Value. The node-backed Pipeline (the strategic deal surface, the future of the CRM per docs/crm-overhaul-design.md) has zero activity, note, or history today, and no staleness signal. This gives every deal card a reverse-chron timeline (manual notes, calls, stage changes) plus a visible rot indicator and a single "next step + due date", the cheapest high-value CRM mechanic in the whole research set. Kills the stalled-deal-hiding-in-Negotiation failure that costs real sponsorship money.

Data. - New additive migration FILE db/migrations/<ts>_feat_crm_deal_node_activities.sql: ALTER TABLE crm.activities ADD COLUMN deal_node_id bigint NULL (FK to app.nodes(id)), plus index (workspace_id, deal_node_id). Additive, RLS unchanged (activities already has workspace_isolation). File only, not executed against prod. - No new node props are strictly required for the timeline. Rotting/next-step adds two node props on the Deals collection via ensureDealsCollection: Next Step (text), Next Step Due (date). No SQL for these (node props live in app.collection_values).

Lib (lib/crm-deals.ts). - addDealActivity(me, ws, dealNodeId, {kind, body, occurredAt}) and listDealActivities(me, ws, dealNodeId): read/write crm.activities filtered by deal_node_id (ownership-checked that dealNodeId belongs to this workspace's Deals collection, same guard as updateDeal). kind reuses the existing CHECK vocabulary (note/email/call/meeting/touch). - Stage-change auto-note: in updateDeal, when the Stage prop changes, write a kind='note' activity "Stage: X -> Y" stamped with deal_node_id. Poor-man's stage-transition history that later unlocks funnel analytics. - ensureDealsCollection seeds Next Step and Next Step Due props. - listDeals returns, per deal: nextStep, nextStepDue, lastActivityAt (MAX(occurred_at) grouped by deal_node_id in one query, createdAt fallback so it works before any activity exists), and computed staleness (none | warm | rotting) from per-stage day thresholds (constants in lib/crm-deals.ts, e.g. Outbound Pitching 5d, Negotiation 7d, Awaiting Payment 14d).

API. - GET /api/apps/sales/deals/[id]?activities=1 returns listDealActivities. Add the GET handler to the existing [id]/route.ts (currently PATCH/DELETE). - POST /api/apps/sales/deals/[id] (or the existing PATCH) accepts a log_activity body and nextStep/nextStepDue patches through the existing prop-by-name path.

UI (PipelineView.tsx). - Right-pane deal editor gains a Timeline section (reuse the expand-to-fetch pattern already in ContactsView's EmailTimelineItem for any email-kind rows that F2 later attributes) plus a log-note composer. - Cards get a colored staleness dot and a next-step line (red when overdue, gray "no next step" badge). Pipeline-tab header gains a "Needs attention" filter toggle (rotting or overdue-next-step or no-next-step). Rotting deals surface ON the board only; F1 does not touch FollowUpsStrip (that file belongs to Z2).

Verification. In dev with WORKSPACE_DEV_AUTH=1: create a deal, log a note (appears newest-first), change stage (auto "Stage: X -> Y" note appears), set Next Step + past-due date (card shows red next-step line and enters the Needs-attention filter), let a deal sit past its stage threshold (staleness dot turns amber then red). Run the new migration against the DEV db first.


F2. Gmail reply triage + email-to-company/deal auto-attribution (OVERNIGHT, Z2)

Merges P2-f2 / P3-f2 (LLM reply triage) and P2-f4 (email->company and email->deal auto-attribution). Both live entirely in the Gmail-ingest zone.

Value. Turns the working-but-passive Gmail ingest into a triaged, self-filling pipeline. Today mail only ever attaches to a known contact; mail from an unknown person at a known company domain vanishes, and no ingested email ever attaches to a deal even though the column exists. Plus every inbound email is logged identically whether it says "send me your rates" or "no thanks". This adds: (1) domain-based auto-creation of contacts under a known company, (2) auto-attribution of an ingested email to the contact's single open deal, (3) one-Claude-call triage of each new inbound email into interested / negotiating / declined / ooo / referral / bounce / other, with hot replies pushed to the in-app notification bell (a documented, currently-unused integration point).

Data. New additive migration FILE db/migrations/<ts>_feat_crm_gmail_intel.sql: - ALTER TABLE crm.activities ADD COLUMN triage text NULL CHECK (triage IN ('interested','negotiating','declined','ooo','referral','bounce','other')), ADD COLUMN triage_detail jsonb NULL (e.g. {oooUntil:'2026-07-21'}), plus partial index (workspace_id) WHERE triage IS NULL AND direction='in' as the work queue. - ALTER TABLE crm.contacts ADD COLUMN source text NULL ('gmail_auto' marks domain-auto-created rows) and ADD COLUMN email_bounced_at timestamptz NULL. - crm.activities.deal_node_id is defined in F1's migration; F2 writes it but does not create it.

Lib. - lib/sales-crm-jobs/gmail.ts (runGmailSync): - Load a domain -> company_id map from crm.companies WHERE domain IS NOT NULL (lowercased, strip www.). Free-mail domains (gmail/yahoo/outlook/icloud/proton) are excluded from domain matching. - In matchContacts, when a sender matches no contact but its domain matches a company, auto-create a minimal contact (name parsed from the From display-name, emails=[addr], company_id, source='gmail_auto', fail-loud on conflict) and treat it as matched. - Deal attribution: preload a contact_id -> open-deal-node map (from crm.deal_contacts joined to the node Deals collection, only when the contact has EXACTLY ONE open deal; ambiguous stays NULL on purpose) and stamp deal_node_id on inserted activities. - Collision surfacing: loadContactEmailMap already detects duplicate addresses; collect them into sync_state.detail jsonb (duplicateEmails:[{email, contactIds}]) instead of dropping silently. - At the end of the sync, call triageNewActivities inside the existing chunkErrors fail-tolerant seam (a triage failure logs into sync_state.detail and never blocks the ingest, with an explicit why-swallowed comment). - lib/sales-crm-jobs/triage.ts (new): triageNewActivities(me, ws, ids?) selects untriaged inbound email activities (triage IS NULL AND gmail_message_id IS NOT NULL), batches 10-20 subject+snippet rows into one structured lib/llm (resolveProviderForUser) call, writes triage + triage_detail. Idempotent on the triage IS NULL transition (safe on the 24h re-scan overlap). Side effects, each idempotent: interested/negotiating -> createNotification (category crm, links to contact); declined -> no send-side effect here (sequence auto-stop belongs to the awaiting-approval outreach feature); ooo with parsed date -> store triage_detail.oooUntil; bounce -> stamp contacts.email_bounced_at. - lib/sales-crm.ts: extend listActivities/getSalesData payloads to include triage/source fields (type passthrough only, no new write path). Add source to the ContactRow type.

UI. - ContactsView.tsx: colored triage badge on EmailTimelineItem rows (green interested, gray declined, amber ooo); "auto-added from Gmail" badge on source='gmail_auto' contacts. - FollowUpsStrip.tsx: a "Hot replies" section pinned above the overdue-cadence list, listing interested/negotiating inbound activities from the last 7 days (dismissible); a dismissible "N duplicate emails across contacts" warning read from lastSync.detail.

Verification. In dev: run the migration against DEV, trigger the in-app gmail_sync op (owner). Confirm an email from an unknown address at a known company domain creates a gmail_auto contact; an email from a contact with exactly one open deal stamps deal_node_id; new inbound emails get a triage label; an "interested" email raises a notification bell entry. Re-run the sync and confirm no duplicate activities and no re-triage (idempotent).


F3. AI Deal Brief (OVERNIGHT, Z3)

Merges P1-f6 (AI deal brief) and P3-f1 (state-of-the-deal panel). Targets the legacy crm.deals system where the activity data actually lives today, with a thin path to reuse the same generator for node deals later.

Value. One Claude call over a deal's full context (fields + crm.activities timeline including Gmail-ingested emails + linked contact cadence state) produces a scannable brief: where it stands, last touch, open commitments both sides, silence-duration risk flag, deadline proximity, one suggested unstick move. Replicates Gong AI Briefer / Einstein deal insight for a solo creator with zero ML infra. Highest perceived daily value because deal truth is currently scattered across the email timeline.

Data. New additive migration FILE db/migrations/<ts>_feat_crm_ai_deal_briefs.sql: optional cache table crm.ai_deal_briefs (id, workspace_id, deal_id, brief jsonb, source_fingerprint text, computed_at, ...) with UNIQUE(workspace_id, deal_id) and RLS workspace_isolation matching existing crm.* tables. source_fingerprint = hash of latest activity id + deal updated_at so recompute is skip-if-unchanged. Code falls back to compute-on-demand when the table is absent (works with zero schema change tonight; cache is a deploy-time upgrade).

Lib (lib/sales-crm-ai.ts, new). generateDealBrief(me, ws, dealId): loads the deal via existing getSalesData/listActivities({dealId}) plus contact last_touch_at, builds a compact prompt, calls resolveProviderForUser(me).streamChat (same pattern as lib/university-ai.ts generateQuizForScope), parses strict JSON {brief_md, risk_flags[], suggested_action}. Email content passed strictly as data. Draft-only: a brief may propose a follow-up body but nothing sends.

API. New op ai_deal_brief in the op-dispatch of app/api/apps/sales/route.ts (POST, workspace-scoped, asUser). Returns the brief JSON; writes the cache row when the table exists.

UI. "AI brief" section at the top of the deal editor modal in DealsView.tsx and on the deal rows in CompaniesView.tsx CompanyPanel, with a Refresh button and a staleness note (from computed_at vs current fingerprint).

Verification. In dev: open a legacy deal with several activities, click Generate; confirm a coherent brief with risk flags; edit an activity and Refresh; confirm the fingerprint changes and a new brief is produced. With the cache table absent, confirm on-demand path still returns a brief.


F4. Chat-agent CRM tool-surface expansion (OVERNIGHT, Z4)

Merges the gap-closing halves of P1-f6, P2-f6, P3-f4. Overnight scope is deliberately limited to tools that call ALREADY-EXPORTED lib/sales-crm.ts functions, so this feature edits only lib/chat.ts and has zero build-order dependency on F1/F2/F3.

Value. The interface multiplier (Twenty/MCP pattern), the cheapest item on the list since the agent runtime and per-tool dispatch already exist. Chat agents can already create companies/contacts/deals and log a note, but cannot read a timeline, edit or archive a company, archive a contact, log a touch, or snooze, so half of natural CRM conversations dead-end. For a single user living partly on Telegram, chat is the automation console.

Design (lib/chat.ts only). Extend TOOL_DEFS + the sales dispatch (~L993-1129) with, each delegating verbatim to existing lib/sales-crm.ts exports under the caller identity and workspace: - list_activities(contactId | dealId | companyId) -> listActivities, snippets truncated, includes any triage label when present. - update_company, archive_company -> existing company writers. - archive_contact -> existing. - log_touch(contactId) -> addActivity kind touch (advances last_touch_at). - snooze_contact(contactId, until) -> updateContact snoozed_until. All name-to-id resolution is workspace-scoped with an ambiguity error rather than guessing (consistent with the existing update_deal stage-by-name resolution). Each tool description marks email content in results as data, not instructions, preserving the untrusted-email boundary at the agent seam. No schema change, no new route, no migration.

Deferred to the awaiting-approval batch (they depend on F3/outreach libs and would create cross-file build order): get_deal_brief, draft_followup, enroll_in_sequence, list_hot_replies, node-pipeline deal tools.

Verification. In dev with WORKSPACE_DEV_AUTH=1, a chat session: "what happened with brand X" (list_activities returns the timeline), "snooze Acme two weeks", "archive the old GoPro contact", "log a touch with Sarah". Confirm each resolves the right id and mutates state.


F5. Payment tracking, Net-terms clock, overdue queue, money dashboard (AWAITING APPROVAL)

From P1-f4. Not overnight: it shares lib/crm-deals.ts, the deals routes, PipelineView.tsx, and FollowUpsStrip.tsx with F1/F2, so it must land sequentially after those to avoid file conflicts.

Value. The sharpest pain in the creator-CRM category: 56% of creators get paid late, terms stretch to Net 60-120, 60% avoid chasing because it is awkward. The node pipeline has an Awaiting Payment stage but no invoice date, terms, due-date clock, or overdue surfacing.

Design. Node props only (no SQL): ensureDealsCollection adds Invoice Sent (date), Payment Terms (select: Due on receipt/Net 15/Net 30/Net 60/Net 90), Invoice Number (text), Paid Date (date). listDeals computes paymentDue (invoiceSent + terms), paymentState (not_invoiced/invoiced/due_soon/overdue+daysOver/paid); moving Stage to Paid auto-stamps Paid Date. GET /api/apps/sales/deals adds a money rollup {outstandingCents, overdueCents, collectedThisYearCents, byCompany:[{companyId, lifetimeCents, avgDaysToPay}]} from listDeals + deal_companies joins. UI: invoice-state chip on cards in In Contract / Content Production / Awaiting Payment; a Payment section in the deal editor; FollowUpsStrip surfaces overdue invoices worst-first with a "Draft chase email" button that opens a prefilled polite dunning draft (mailto/copy tonight, Gmail-draft later, never auto-send); a money header strip (Outstanding / Overdue / Collected YTD). No Stripe, no invoice generation (forge's existing invoice tooling stays the generator); this tracks state only.


F6. Structured deliverables tracker + Deadline Radar + calendar projection (AWAITING APPROVAL)

From P1-f3. Not overnight: shares lib/crm-deals.ts and PipelineView.tsx with F1.

Value. Creator deals extend past "closed won" into content production; the missed deliverable, not the missed pitch, kills a brand relationship. Today the node pipeline has only two bare date props (Draft Deadline, Live Post Deadline). This adds per-deal typed deliverables (format, platform, due date, revision rounds, status, evidence link), a cross-deal Deadline Radar, read-only calendar surfacing, and usage-rights-expiry alerts (a resell/renewal trigger almost no tool has).

Design. Node prop Deliverables (jsonb array of {id, label, format, platform, dueDate, revisions, status, evidenceUrl}) on the Deals collection (mirrors the Timeline-in-a-cell pattern proven in lib/crm.ts). lib/crm-deals.ts gains addDeliverable/updateDeliverable/removeDeliverable (read-modify-write inside the ownership-checked updateDeal path); listDeals returns deliverables + next upcoming due date. New lib/deal-calendar.ts cloned from lib/crm-calendar.ts's synthetic-negative-id pattern, projecting deliverable due dates + Draft/Live deadlines + Usage Rights End as read-only all-day events (account deals, distinct negative-id namespace, gated by a workspace-has-sales-app check exactly like workspaceHasCrmApp), wired into the same seam in lib/calendar.ts where crm-calendar is merged. Deliverable ops ride PATCH /api/apps/sales/deals/[id]. UI: a Deliverables section in the deal editor; a new components/apps/sales/DeadlineRadar.tsx panel (all upcoming deliverables/deadlines/usage-expiries, soonest first, overdue red) mounted as a tab or strip in SalesClient.tsx + SalesSidebar.tsx.


F7. Stop-on-reply outreach sequences + AI-drafted follow-ups (AWAITING APPROVAL, highest caution)

Merges P2-f1 (sequences), P2-f3 / P3-f3 (AI-drafted follow-ups). Explicitly NOT overnight: this is the only feature that SENDS email autonomously, so it stays gated behind Justin's explicit approval despite email-send being technically cleared for the business account. Draft-first is the safe first slice; automated multi-step sending ships only after approval.

Value. Structured follow-up roughly 2-3x's reply rate and the first follow-up alone outperforms the initial pitch. A sent pitch with no reply currently just dies in Gmail. Stop-on-reply is the whole feature: the moment a brand replies (detected via the existing thread-id ingest), all pending steps cancel, so no bot ever bumps someone who already answered. The draft-only follow-up half needs no send scope and could ship first: one Claude call over a contact's real correspondence history produces a short thread-aware follow-up in Justin's voice for review-and-copy.

Design (proposed, for approval). - Phase A (draft-only, shippable first): lib/sales-outreach.ts draftFollowUp(me, ws, contactId) assembles context from listActivities + open deals, one lib/llm call returning {subject, body}; op ai_draft_followup in app/api/apps/sales/route.ts; a "Draft follow-up" button per overdue row in FollowUpsStrip.tsx and in the contact panel, opening an editable compose modal with Copy + mailto fallback + "Log as touch". No send scope touched. - Phase B (auto-send, approval-gated): migration crm.sequences + crm.sequence_enrollments (steps jsonb, gmail_thread_id, status, next_send_at, RLS workspace_isolation); lib/sales-crm-jobs/sequences.ts runSequenceTick (reply detection by thread-id against ingested direction='in' activities, then send due steps as real in-thread replies via lib/gmail.ts sendMessage, jittered, hard daily cap ~20/workspace); triage declined/bounce/ooo (from F2) auto-stop or defer enrollments; wire runSequenceTickAll into the existing token-gated app/api/apps/sales/jobs/route.ts timer; new OutreachView.tsx tab. Rate-limited, draft-reviewed enrollment, stop-on-reply mandatory.

Chat tools for outreach (draft_followup, enroll_in_sequence, list_hot_replies) and the AI-brief chat tool (get_deal_brief) fold in here once F3/F7 libs exist.


Deferred / not this round

  • Rate card + offered-vs-agreed negotiation memory (P1-f5): valuable, but a net-new subsystem (crm.rate_card_items, RateCardView, sidebar) that competes for the same deal-editor and sidebar files; sequence it after F5/F6.
  • AI attributes on contacts/companies (P3-f5) and AI notes ingestion "Log with AI" (P3-f6): both strong, both share lib/sales-crm-ai.ts + app/api/apps/sales/route.ts + ContactsView/DealsView with F2/F3; schedule after the overnight batch lands to avoid file contention.

Build status summary

# Feature Status System targeted
F1 Deal timeline + rotting/next-step Overnight (Z1) Node pipeline (crm-deals)
F2 Gmail triage + auto-attribution Overnight (Z2) Legacy ingest (crm.* + deal_node_id seam)
F3 AI Deal Brief Overnight (Z3) Legacy deals (crm.deals)
F4 Chat CRM tool surface Overnight (Z4) Legacy sales-crm
F5 Payment tracking + money dashboard Awaiting approval Node pipeline
F6 Deliverables + Deadline Radar + calendar Awaiting approval Node pipeline + calendar
F7 Outreach sequences + AI drafts Awaiting approval (auto-send gated) Legacy ingest + Gmail send

URL: https://mkdocs.justinsforge.com/memory/plans/creatortrack-crm-expansion-2026-07-10/