Sales workspace architecture
The Sales workspace is a permanent capability of every organization. See ADR-0023 for the decisions and the operations runbook for onboarding and data upkeep.
Boundary and request flow
The workspace serves studios receiving approximately 150 leads per day. It adds a work queue and explicit sales commitments to the existing CRM. Taikan remains the source of truth for identity, leads, memberships, tasks, messages, payments and automation execution. No CRM vendor, synchronization service or second workflow engine is introduced.
Clerk authenticates API calls; the existing lead_management platform entitlement still applies.
AuthModule provides the Sales access service. SalesWorkspaceModule owns queue, lead operations and reports. ConversationsModule owns the Sales inbox and email delivery endpoint. Existing automation and task modules retain their responsibilities.
Authorization
sales_workspace_access adds a capability to an existing active staff membership, without adding an organization role or changing Clerk organization management. Sales is staff-only: owner/admin hold it implicitly, a coach holds it through a grant, and a member (a customer) never does.
| Actor | Record scope | Management |
|---|---|---|
| Active owner/admin | Team, implicit | Sales management and access grants |
Active coach with own grant | Assigned leads only | No team management |
Active coach with team grant | Organization leads | Manager only if separately granted |
| Active member (customer), even with a leftover grant row | None | Cannot be granted (400) |
| Missing/inactive membership or no grant | None | None |
A manager grant implies team scope. Only owner/admin can change grants. Sales managers can distribute work and use reports/settings; Sales conversion cannot turn their narrow capability into permission to create staff memberships. A Sales-only manager can create member memberships, while staff-role creation retains the organization-role check.
GET …/sales-workspace/access/team (Sales managers) lists every active owner, admin and coach with their resolved access, owners and admins first; Sales → Manage reads it. GET …/access/staff lists only the people who currently hold access, which is the set lead assignment and automation round-robin choose from. Members are redirected off the dashboard exactly as they are without Sales; the sidebar shows a coach the Sales entry only when their grant resolves.
Every Sales service request carries the resolved actor. Organization and owner scope are enforced in queries and checked again when mutating a locked lead. Client filters, hidden navigation and client-side flags are not authorization. Conversation/task IDs are resolved through their lead and organization. Losing ownership cannot expose a newer lead snapshot in a conflict response.
Default assignee and default view
In a small organization the owner works every lead, so their “My leads” view should not start empty. SalesWorkspaceAccessService.defaultAssignee names the organization’s default assignee: its owner, when it has exactly one active owner membership and at most two people who can be assigned Sales leads (the access/staff set: owners and admins implicitly, plus granted coaches). Any larger team, or a second owner, has none.
Only a lead created without an owner gets the default, at creation; existing unassigned leads are not backfilled. Every path that inserts organization_leads applies it: OrganizationLeadsService.createLead (staff create, Spotter/MCP tools, minisite and public forms, Meta lead ads, WhatsApp), the released join-link demotion, both CSV imports and course purchases. The owner is written with the row and recorded in the same transaction as a sales_assignment activity with a null actor and payload.source = 'default_assignee', so history and owner-credited reports attribute it. The new-lead task (auto task or seeded follow-up) is assigned to that owner. An explicit owner wins: Sales intake keeps the creating rep. An automation’s assign step still applies afterwards: assignInternal lets a routing rule replace an owner whose latest assignment is that default one, while an owner a person or rule chose stays (already_owned).
GET …/access/me returns defaultAssignee: true to that owner. With no scope URL parameter, the queue opens on My leads for an own grant or the default assignee and on the team view for everyone else, so larger teams and the second person in a small one keep a non-empty view. An explicit ?scope= always wins for team-scope callers.
My leads (scope=own) is the leads assigned to the caller, except for the default assignee, whose My leads also holds every lead nobody owns: the default never reaches leads that were unowned before it, so without this they would sit in no one’s opening view. salesQueueWhere applies it to every queue read in that scope (the queue and its counts, Needs action, All active, the reason filter and saved views, which read through the queue endpoint), and so do the Action Center row and the urgent prompt, which read the same queue. GET …/queue and GET …/summary resolve the caller through access/me once per request and carry defaultAssignee on the actor; it is computed, never stored. An owner who is not the default assignee, and every own grant, keep My leads to their own leads. Per-lead authorization is unchanged: the default assignee is always an owner, whose access is team-wide. The team Unassigned view still lists the unowned leads, with the same count; the Board, team-wide for an owner, is unchanged.
Data ownership
| Data | Owner and meaning |
|---|---|
leads, organization_leads | Existing contact and organization relationship; the organization lead gains version, pause, attempt/contact facts and sales attribution fields. |
lead_activity_events | Existing ledger gains typed outcome/channel, operation ID and historical actor/owner/commitment facts. Old unstructured contact notes remain unspecified. |
tasks | Existing service mirrors the current contact commitment. Lead-linked tasks show in Tasks within the viewer’s Sales scope and are written only through SalesLeadTasksService (see Tasks). |
lead_trial_appointments | Versioned manual appointments with explicit outcomes; superseded occurrences remain history. An appointment with a class_session_id holds a seat in that class while pending or attended (see Trials); checked_in_at / checked_in_by_user_id record a coach’s check-in. |
lead_loss_reasons, lead_saved_views, sales_workspace_settings | Organization loss taxonomy, per-user saved filters, and Sales settings: working days/hours, which the quick follow-up presets (in 2 hours, next business day, next week) resolve into and in which First response is measured, and the no-answer review threshold, at which logging a no-answer asks the rep to keep trying, pause or close. |
sales_workspace_access | One scoped capability per membership; manager/team consistency is also a database constraint. |
conversations, conversation_messages | Existing message history plus reply watermark, reply obligation, stable client send key and send claim timestamp. |
| Memberships, course entitlements, payment transactions | Existing canonical conversion and financial records; linking a member does not itself prove a paid sale. |
The generated additive migration is 0166_sales_workspace_v2.sql, following main’s 0165_daily_blacklash. It includes the earlier unshipped reply-recovery and review-threshold changes. Review SQL, snapshot ancestry and journal timestamps together.
Queue and result transaction
Queue views select work reasons rather than creating a new pipeline status model: replies, missed commitments, untouched leads, today’s work, missing next action, later work, paused leads and trial outcomes. Reasons can overlap, so summary reason counts are not additive. History is a separate workspace filter. The reason filter narrows the queue, and its summary counts, to leads whose primary reason is the one given; salesQueueWhere applies the same primary-reason expression the queue CTE reports, so ?view=all&reason=untouched_new lists exactly the leads Reports counts as waiting for a first attempt.
The follow-up intake seeds on a new lead under lead-follow-ups (“call this new lead”, next_action_at = leads.created_at) is not a promise. While the lead is untouched it is untouched_new, never missed_commitment or due_today: it counts as waiting in the first-response report, and in neither the Overdue/Today views nor the daily report’s overdue backlog (untouchedIntakeSeed).
A follow-up time someone chose is different. An untouched lead with a chosen follow-up (“call her Thursday”) is a commitment like any other: due_today, scheduled_later, or missed_commitment once the time passes. Until then it is listed in Later, not Needs action, and the first-response report’s Waiting now does not count it. Setting the follow-up is still not an attempt, so its first-attempt facts stay empty and the period cells still report it as not attempted.
Rows are ordered by salesQueueOrder, which follows how fast an uncontacted lead goes cold:
| Band | Rows | Within the band |
|---|---|---|
| 1 | Hot: untouched leads added, and replies received, in the last 24 hours | Newest first (added or replied) |
| 2 | Promises: missed commitments and trial outcomes due | Earliest due first |
| 3 | Cooling: older unanswered replies, untouched leads 1–7 days old | Newest first |
| 4 | Due later today, then no next action | Earliest first |
| 5 | Stale: untouched for more than 7 days | Newest first |
| 6 | Scheduled later, paused | Earliest first |
Lead age is clock time since leads.created_at, the “Added” age the row shows; a reply’s age is its oldest unanswered inbound message. The queue endpoint and the Action Center samples use the same order, so the samples are the queue’s first rows. Counts do not depend on order. The row’s next-step line takes its colour from the row’s reasons (missed commitment → overdue, due today → today), not from the clock. A row whose follow-up is still the intake call (intakeCall on the queue row) reads “Call · now” in the today colour, never a past time.
Existing configurable pipelines remain, shown in Sales as a read-only Board. GET …/sales-workspace/board?pipelineId= uses the same actor and scope as the queue (an own grant sees only assigned leads) and the queue’s active rules (closed statuses and archived leads are History). It returns the org’s pipelines and, for the requested pipeline (else the default, else the first), each active stage with an exact lead count and the newest five leads with their primary queue reason. A lead counts at its effective stage in that pipeline: a secondary pipeline membership’s stage, else its primary stage. Leads on an archived stage are reported as unstaged. A stage opens the queue at ?view=all&stageId=, and the queue’s stage filter resolves memberships the same way, so the two agree. The Board moves nothing: progress is recorded only through queue results, and pipeline/stage editing lives in Sales → Manage for owners and admins.
Queries are organization-scoped and page at 50, 100 or 200 rows. Inbox and trial aggregates are grouped before joining leads, avoiding one subquery per lead. Page totals come from the same requested-view summary, including an empty out-of-range page. Saved views persist validated filters, not executable queries.
For a structured result:
- Validate the shared Zod contract and resolve the actor.
- Open a transaction, lock the organization-lead row and recheck record scope.
- Check the stable
operationId. An identical request replays its saved result; changing the request under the same key is rejected. - Check
expectedVersion. A permitted stale caller receives409 sales_version_conflictwith a limited current owner/commitment snapshot. A caller that lost access is denied instead. - Resolve
keep,replaceorclearfollow-up intent. Explicit future callbacks are preserved unless the caller chooses replacement. Business presets use organization timezone and configured working days/hours. - Write the result, lead projection, mirrored contact task, trial/pause changes and activity in the same transaction, using the same database connection. Increment the lead version.
- Emit eligible domain events and best-effort telemetry after the durable operation.
no_answer can include a future pause and otherwise keeps a pause that is still running; either way the resulting follow-up must exist at or after the pause’s end. Every other outcome ends the pause, so the new commitment is ranked by its own due time. Validation happens before writes. The UI submits this as one result operation, so a failed pause cannot leave a saved result without its commitment. Existing write paths advance lead versions and preserve task consistency.
The UI keeps a draft pinned through filter changes and reassignment. It offers retry against a newly visible version only while the user still has access. Stable operation IDs also protect conversion/linking and versioned trial actions. Bulk assignment reports per-lead outcomes rather than promising an all-or-nothing batch.
Queue row and inline stage move
Each queue row carries its stage’s category (stageCategory) and its own pipeline (pipelineId: the primary placement, else the stage’s pipeline, else the org’s default pipeline), next to the campaign ID, intake time and latest notes. The row shows the campaign name from the queue filters, the latest note (the last result’s note, else the follow-up note) on one line with the full text in a tooltip, the lead’s age, and a stage pill coloured by its category.
The work record drives a lead’s phase (its stage category); the row menu only refines the stage within that phase. When a result changes the phase, the same transaction moves the lead to the first active stage (by position) of the new category in its own pipeline, unless its stage is already in that category: any attempt (answered, no answer, message sent, including an accepted human email from the inbox) makes a new lead contacted, Trial scheduled books the trial stage, and Not relevant (or closing at the no-answer review) the lost stage. Conversion already moves to the converted stage. A pipeline with no stage in the target category keeps the lead’s stage; the status still changes. Each phase change writes leads.status with a status event, a stage_moved activity when the stage moves, and emits LEAD_STATUS_CHANGED after commit, as the legacy stage move does, so automations listening for status changes still run. Trial outcomes and pauses do not change the phase.
On the work queue the stage pill is a menu only when the lead’s pipeline has another stage in the lead’s current phase (the status, for a lead without a stage); otherwise it is a static pill. POST …/sales-workspace/leads/:leadId/stage takes { operationId, expectedVersion, stageId } and follows steps 1 to 4 above: an identical retry replays, a stale version is 409 sales_version_conflict, and an own-scope caller cannot move a lead outside their scope. It refuses (400) archived stages, stages of another pipeline and any stage in another category, naming the flow that makes that move (Trial scheduled, Not relevant, Convert, or logging a result). The move writes the stage and pipeline, a stage_moved activity carrying the operation ID, and increments the version, using the same placement helper as results.
A Sales manager reassigns one lead from its owner chip: on the queue row (from lg up, where the row shows its owner) and in the lead sheet’s header. The chip opens a menu of the access/staff people, the set the bulk bar offers, with the current owner checked and “Unassigned” to clear the owner. A pick saves at once through POST …/sales-workspace/leads/:leadId/assign ({ operationId, expectedVersion, ownerUserId | null }), which follows steps 1 to 4 above, refuses a closed or archived lead (409), moves the open contact task to the new owner and logs a sales_assignment activity. Success runs the queue’s refresh() and names the lead and its new owner in a toast; a stale version refreshes and toasts the conflict copy. Other staff, and any lead in History (primary reason history), see a static chip. A lead assigned out of the view on screen (My leads) leaves it on refresh, and focus moves to the next row’s main action. The chip takes the static chip’s room (its chevron rides on the avatar), so rows wrap as before; the sheet keeps stage, source and owner on one line, truncating the owner’s name first. Bulk assignment stays on the selection bar.
The row’s result editor opens in place from a contact tap or from Log result. A call tap prefills a no-answer by call and a WhatsApp tap a sent message by WhatsApp; Log result leaves the outcome to the rep, and a lead with no phone but an email is worked by email. A no-answer defaults its next step to a follow-up on the next business day, even when the lead already has one. Other outcomes keep a follow-up that is still ahead; a follow-up already past counts as none, so they schedule one with their own preset (two hours after a message, the next business day otherwise). The default follows the outcome, and is re-weighed if the follow-up lapses while the editor is open, until the rep picks a next step themselves. Save & next confirms the save where the lead was, naming what was saved (a result, a trial outcome or a follow-up), and moves focus to the next row’s main action. A saved result cannot be undone: the result API has no revert.
Each row’s main action follows its primary reason. A trial outcome due confirms the trial (attended, no show or cancelled) in an inline editor that reads the pending occurrence from the lead record and saves through POST …/trial-outcomes with its next step in the same request: a follow-up (the next business day by default), or, for an attended trial and a Sales manager, Convert, which keeps the current follow-up and opens the convert dialog. No next action sets a follow-up inline (type, preset or time, optional note). Needs reply opens the lead sheet with focus in the conversation composer. Every other reason logs a result. The result editor stays one tap away on every row as a secondary icon button, because a rep may call anyway. From md up every outcome is a segment; below md the rarer ones are under “More”. Trial scheduled is not offered while a trial is pending (trialPending on the row): its outcome is recorded first. A chosen segment is tinted teal with a check and aria-checked; focus is only a ring. Saving without an outcome marks the whole group invalid (aria-invalid, a rust outline, the message tied by aria-describedby) and focuses the group, not a segment. Esc closes any inline editor and returns focus to what opened it.
POST …/sales-workspace/leads/:leadId/follow-up takes { operationId, expectedVersion, followUp: replace | clear, note? } and follows steps 1 to 4 above. It writes the follow-up through the same path a result uses (presets resolved from Sales hours, the contact task moved with it or cancelled by LeadFollowUpTaskService), logs one follow_up_set or follow_up_cleared activity carrying the operation (a clear reuses the entry the close logs) and increments the version. It is not an attempt: status, stage, attempt and contact times, the last result and the no-answer count stay as they are, so first-attempt reporting is unchanged. A lead still paused stays paused, so its follow-up must fall at or after the pause ends. note is kept on the activity and becomes the follow-up’s note when the replacement brings none.
The queue reads ?reason= (a primary reason, as the first-response report links it) and passes it to the queue request, shown as a removable chip so the view is never narrowed silently; resetting filters clears it.
Attempts outside a result
An attempt keeps first_attempt_at once set and moves last_attempt_at, last_contacted_at and its channel to the attempt time. attemptFacts in sales-placement.ts is that one definition, shared by results, accepted human emails and the two writes below. Two writes that are not Sales results also record an attempt through recordAttempt, inside their own transaction and version bump:
- a touch logged on the legacy lead screens or API (
POST …/leads/:leadId/touches,OrganizationLeadsService.logTouch). Itscall_loggedactivity then carries the attempt time ashappened_at. - completing the follow-up’s own contact task, from the lead sheet or from Tasks. This is the contact
LeadFollowUpTaskService.closeFollowUpFromTaskalready stamps onlast_contacted_at.
As with any attempt, the first one moves a new lead to contacted through enterCategory, and the status change is emitted once, after commit. Neither write carries an outcome, so the first successful contact, the last result and the no-answer count stay as they were, and so do the daily effort counts.
Contact logged before these writers recorded attempts stamped only last_contacted_at. scripts/backfill-sales-attempts.ts turns that history into attempt times (see the runbook). The queue’s untouched_new reason keeps reading last_contacted_at next to first_attempt_at, so a lead contacted before the backfill does not wait as untouched. That fallback is kept on purpose: it covers only that older history, because every writer of last_contacted_at now also records an attempt. Once the backfill has run, the two columns agree, so the queue and the reports (which read first_attempt_at alone) agree too. Until then, the daily report’s legacyResults unknown counts the leads contacted without an attempt time.
Tasks
Lead tasks, including custom work, stay in Tasks, scoped like the queue: staff without Sales access see none, an own grant sees the tasks of the leads it owns, team scope sees all of them. salesLeadTaskScope adds that scope to the Tasks list, summary and urgent-prompt queries as a semi-join on the lead’s organization record, so pages and counts stay exact. A lead task outside the caller’s scope reads as not found. The list’s leadId filter narrows Tasks to one lead.
Every lead-task write goes through SalesLeadTasksService, from the lead sheet and from Tasks alike: create, edit (title, due date, assignee and the rest), complete, cancel, reopen and delete. It locks the organization lead, rechecks scope, validates a new assignee against the Sales assignees, keeps the follow-up in step with its contact task through LeadFollowUpTaskService (closing the follow-up’s own task closes the follow-up and logs it; reopening or moving it sets it), and increments the lead version in the same transaction. The sheet sends its expectedVersion; Tasks writes against the current version. A lead task created from Tasks is still the lead’s contact task by default, merging into the open one.
The surfaces link both ways. A lead task’s linked-customer cell in Tasks names the lead, marked with a lead badge, and the phone column shows the lead’s phone: each Tasks list row of a lead task carries linkedLead (name, phone). The name opens the Sales sheet at /dashboard/leads?lead=<id>. The sheet links each task to /dashboard/tasks?leadId=<id>&task=<taskId>, which opens it among the lead’s tasks, and its whole list to ?leadId=<id> behind a removable filter chip. A write on either side invalidates the other’s queries. The task summary carries no Sales count (the Sales nav badge is the queue’s Needs action count, below).
On the work queue the lead sheet’s “Log result”, and its call and WhatsApp buttons after opening their link, close the sheet and open that lead’s inline result editor in the queue, carrying the channel tapped. The sheet’s history names each entry’s actor, channel and note (GET …/sales-workspace/leads/:leadId returns them per activity in place of the raw payload), and its header stage pill uses the queue’s category colours (the status’s category when the lead has no stage).
Overview, urgent prompt and newsletter
The overview reports the Sales queue rather than the legacy attention ranking. sales-workspace/sales-queue-query.ts holds the queue’s SQL (scope, CTE, view predicates and order), which SalesWorkspaceService and every report on the queue share. For a caller with Sales access, the Action Center replaces leads.attention_needed with leads.needs_action: the Needs action count, its first three leads in queue order and a drilldown to /dashboard/leads?view=needs_action&scope=<scope>. The scope is the one the queue opens on: own for an own grant or the default assignee, otherwise team. Sales managers also get leads.unassigned (the team Unassigned view). Each sample links to its sheet with &lead=<id>. Staff without Sales access get no lead row. operations.tasks_overdue applies the Tasks lead-task scope. The overview’s leads tile lists the leads.needs_action samples, the urgent prompt counts Needs action in the same scope, and staff newsletter lead links (including lead message notifications) open /dashboard/leads?lead=<id>.
The sidebar’s Sales entry, and the home Thrive card’s, are badged with that same leads.needs_action count, read from the Action Center’s own /insights query (useLeadsAttentionCount), so the badge always equals the Needs action view the entry opens on. Staff without Sales access get no row and so no badge. The feed is cached for five minutes, so the queue’s refresh(), which runs after every Sales write (result, trial outcome, follow-up, stage move, assignment, bulk assignment, conversion), also invalidates /insights, and the badge and the Action Center catch up at once.
Trials, conversion and automation
A past appointment becomes work to record an outcome; elapsed time does not imply a no-show. Staff explicitly record attended, no-show or cancelled. Rescheduling supersedes the old occurrence. A confirmed, versioned no-show emits a post-commit event; the existing bounded sweep recovers missed processing without reviving an obsolete occurrence.
A rep schedules a trial either into a class session, picked from GET …/sales-workspace/trial-sessions (published future sessions with spots left), or at a free time for a non-class intro. A session trial starts when the class does, follows it when the class moves, and holds one of its seats while pending or attended (like a confirmed or attended booking): the claim runs in the result’s serializable transaction (409 trial_session_full, 400 trial_session_unavailable). A coach checking the trialist in from the class roster is evidence, not an outcome: the lead sheet shows “Checked in HH:MM by converted (“Converted — booked as member” on the lead sheet) and the member gets a confirmed booking in that class, with no capacity check or waitlist run, still shown to the coach as a trial from Sales. Rescheduling, Not relevant, archiving, and a conversion whose class already started end the pending trial: one the coach checked in ends attended and keeps its seat (it happened), any other ends rescheduled/cancelled, is superseded, and its seat is offered to the class waitlist after commit. Cancelling or deleting the class ends its trials by the same rule, writes a sales_trial activity (session_cancelled) and cancels the rep’s trial task for the cancelled ones, leaving the lead without a next action to rebook. Nothing marks a trial no-show automatically. See ADR-0025 and Trials.
Staff notes on a lead (…/sales-workspace/leads/:leadId/notes, editable by their author or a Sales manager) stay with the person after conversion; important ones reach the coach on the class roster. The roster also shows coaches a trialist’s intake note (leads.note) and lead source, deliberately and without Sales access; nothing else of the Sales record leaves Sales scope.
Conversion and explicit member linking run transactionally, enforce membership uniqueness, close acquisition work and retain history. Finding an existing member produces a preview before linking. A member relationship, trial attendance, free course entitlement and a settled paid win are different milestones.
The existing automation editor, enrollment/step runner and scheduler remain. Sales changes make targeting consistent across event, manual and sweep paths; eligible assignments respect scoped grants; owner changes synchronize the contact task; pauses and explicit callbacks constrain follow-up behavior. Execution status is available in the lead record. Every automation send carries conflict safety: a per-recipient daily budget, a provider idempotency key per enrollment step, a lifecycle re-check immediately before the provider call, lead journey exits on status changes (trial booking exits new/cold nurture, reopening exits trial journeys, frozen exits nurture), and the 6–7 day membership-expiry window reserved for Taikan’s system reminder. The global automation kill switch and unrelated flags remain in force. See the automation audit.
Inbox and external side effects
Unread and needs-reply are independent. Reading a conversation does not resolve work. A human email accepted by the transport or an explicit acknowledgment resolves only the captured inbound watermark. An inbound message arriving while a response is being sent stays actionable. Queued/failed sends, bulk email and automated outreach do not satisfy the obligation.
Messages use a stable clientOperationId and provider idempotency key. A send claim prevents concurrent submission; recovery is bounded to less than the provider’s deduplication window. Unknown older sends require provider-history reconciliation rather than a blind new send. Consent and lead scope are checked server-side.
POST /webhooks/email/delivery verifies Svix headers against the original raw body using EMAIL_DELIVERY_WEBHOOK_SECRET. The internal message ID is attached to provider tags so a delivery event arriving before the send response can still be associated. Verified delivery/failure facts are not overwritten by a later transport timeout or acceptance response. A failure reopens the reply obligation only if it has not been superseded.
Delivery callbacks for Sales messages are factual updates. Existing member email behavior is preserved; lead threads use the Sales contract, and the legacy conversations API neither lists, starts nor replies to them. WhatsApp remains an external handoff, with no inferred send/delivery fact.
Reporting definitions
| Report | Time basis | Attribution |
|---|---|---|
| Daily work | Activity/commitment period plus current backlog | Actual actor for effort; current owner for backlog |
| First response | Lead intake period, measured in working time; plus the live waiting queue | Current owner; also by source |
| Acquisition cohort | Lead intake period | Source/campaign and explicit attempt, contact, trial and conversion milestones |
| Sales/cash | Canonical settlement/refund period | Historical sales attribution where provable; explicit unknown buckets otherwise |
Date windows use organization timezone and are bounded to 366 days. Manager-only reports share filters with CSV export and paginated drilldown. CSV neutralizes formula-leading cells. First attempt and first successful contact are separate facts. Free, failed and pending transactions and recurring renewals do not become new paid wins; linked refunds reduce cash in the refund period. Missing dates, ambiguous identity or absent historical credit are not silently inferred from today’s owner.
First response
First response measures intake (organization_leads.created_at) to first attempt (organization_leads.first_attempt_at, the cohort’s own facts) in working minutes: only the time inside the org’s Sales hours counts. SalesWorkingTime (sales-working-time.ts) walks the local dates between the two instants and clips each working day’s window, from its local opening to its local closing time resolved through the org timezone, so a DST change moves the window rather than the count. A lead arriving at 23:00 and called at 08:30 the next working day (opening 08:00) took 30 working minutes; one called before opening on its arrival day took none. loadSalesHours supplies the hours with the same defaults the follow-up presets use. Setting a follow-up without a result writes no attempt, so it does not end the wait.
The target is SALES_FIRST_RESPONSE_TARGET_MINUTES, 120 working minutes. Sales settings are typed columns, so a configurable target needs a migration and is not offered yet. Each lead that came in during the period falls into exactly one cell: within the target, within one working day (one window’s length), later, or not attempted yet. The report gives those counts per current owner (the rows), per source (firstResponse.bySource) and in total, and the share within the target always shows its count. Classification happens in the service over the period’s intake rows; the drilldown classifies the same way, so each cell lists exactly the leads it counts.
Waiting now ignores the period: it counts the leads the queue lists with ?view=all&reason=untouched_new in the caller’s scope and the report’s owner, source and campaign filters, how many have waited longer than the target in working time, and the longest wait. The report returns that queue filter (firstResponse.waitingNow.queue), and the web links to it, so the count and the queue agree. The CSV export adds the by-source rows and the waiting figures after the owner rows.
Frontend and code map
The leads route renders SalesRoute; the legacy Leads views are not reachable: legacyLeadsRedirect (apps/web/src/lib/sales-workspace-routes.ts) sends /dashboard/leads/list to the Work queue, /inbox to ?view=needs_reply (lead threads are answered in the lead sheet; the API already leaves them out of the conversations inbox), and /pipeline[/<id>], a bare pipeline id or any other word to the Board (board/<id> for a UUID). A ?leadId= or ?lead= on any of them becomes ?lead=, so the lead’s sheet opens. SalesRoute replaces the URL client-side behind its loading skeleton, so no legacy UI flashes. Sales keeps the legacy capabilities Kinetics uses by reusing their dialogs: CSV import (owner only, the import API’s rule) next to Add lead; Tag and Delete (archive, or erase for owner/admin) in the bulk bar for leads:manage; and Edit details (name, email, phone, note through PATCH …/leads/:leadId, AddLeadDialog in edit mode) in the lead sheet for Sales managers with leads:manage. Not carried over, so unavailable: consent switches, bulk stage move, add-to-pipeline and multi-pipeline membership, select-all-matching, restore from archive, the status select and legacy log-touch. The workspace extends Taikan’s existing design system with queue, inline result editor, lead sheet, inbox, reports and management surfaces. English/Hebrew/Russian dictionary keys are shared; Hebrew uses RTL. See the Impeccable surface record and visual evidence limitations.
| Area | Entry point |
|---|---|
| Access | apps/api/src/auth/sales-workspace-access.* |
| Queue, Board and mutations | apps/api/src/sales-workspace/sales-workspace.{controller,service}.ts; phase moves and attempt facts in sales-placement.ts, shared with the legacy touch and task paths |
| Attempt backfill | scripts/backfill-sales-attempts.ts (see the runbook) |
| Lead tasks (sheet and Tasks) | apps/api/src/sales-workspace/sales-lead-tasks.{module,service}.ts, apps/api/src/tasks/sales-lead-task-scope.ts; web components/overview/tasks/, dashboard/tasks/page.tsx |
| Reports/export | apps/api/src/sales-workspace/sales-reports.{controller,service}.ts; Sales hours and working-time arithmetic in sales-working-time.ts |
| Inbox/delivery | apps/api/src/conversations/sales-inbox.*, sales-email-delivery.controller.ts, existing channel adapters |
| Shared contracts | libs/shared/src/lib/schemas/sales-workspace.schema.ts, sales-reports.schema.ts |
| Database | libs/db/src/lib/schema/sales-workspace*.ts, leads.ts, conversations.ts |
| Existing seams | organization-leads/lead-follow-up-task.service.ts, organization-leads.service.ts, tasks/tasks.service.ts, automations/ |
| UI | apps/web/src/app/[lang]/(protected)/dashboard/leads/sales-*.tsx (sales-board.tsx is the Board); sidebar Sales entry for granted coaches, badged with the queue’s Needs action count (leads.needs_action) |
Validation and operational limits
The implementation status records test counts and controlled scale measurements. The load fixture uses 65,000 leads, 500,000 activity events, 3,250 reply conversations and 20 simultaneous callers. It tests the service/database boundary, not production HTTP latency or browser performance. Correct SQL grouping and same-connection transactional activity logging removed measured bottlenecks without a cache or new service.
Remaining acceptance work includes a complete visual pass, authenticated staging journeys, representative multi-organization/automation load, signed provider setup and client sample-month reconciliation. The review guide lists adversarial cases.