diff --git a/.cursor/plans/in-app_email_feasibility_8886f2b9.plan.md b/.cursor/plans/in-app_email_feasibility_8886f2b9.plan.md new file mode 100644 index 000000000..ab364752f --- /dev/null +++ b/.cursor/plans/in-app_email_feasibility_8886f2b9.plan.md @@ -0,0 +1,102 @@ +--- +name: In-app email feasibility +overview: In-app Outlook compose/send is moderate effort (roughly 1–3 days for a solid MVP), not a greenfield project. Send, tokens, and timeline logging already exist for sequences; the missing piece is a compose UI plus a user-facing send mutation. +todos: [] +isProject: false +--- + +# In-app email draft/send — feasibility + +**Short answer:** Yes, it is possible and worth pursuing for a focused MVP. You already send real Outlook mail from the CRM (sequences + daily task push). Replacing `mailto:` with an in-app compose sheet that calls the same Graph path is mostly UI + one mutation, not a new integration. + +## What you have today + +| Piece | Status | +| --- | --- | +| Graph `POST /me/sendMail` | Done — [`outlook-send.client.ts`](apps/api/src/microsoft/outlook-send.client.ts) | +| Per-rep delegated token + `Mail.Send` check | Done — [`microsoft-token.service.ts`](apps/api/src/microsoft/microsoft-token.service.ts) `accessTokenForSend` | +| OAuth scope `Mail.Send` | Done — [`packages/auth/src/scopes.ts`](packages/auth/src/scopes.ts); grant-access asks for it | +| Timeline `ActivityType.EMAIL` on send | Done for sequences — [`sequence-tick.service.ts`](apps/api/src/sequences/sequence-tick.service.ts) ~367–385 | +| Outlook → `EmailThread` / `EmailMessage` sync | Done — Sent Items eventually land on the contact/company timeline | +| Click email → compose | **Not done** — [`contact-sheet.tsx`](apps/app/components/crm/record-sheet/contact-sheet.tsx) uses `mailto:` | +| Timeline “EMAIL” type | Manual log only (“What was said?”) — does **not** send | + +So today: automated sequence mail goes through Outlook; clicking a contact email still opens the OS mail client. + +```mermaid +flowchart LR + click[Click contact email] + today[mailto opens OS mail] + mvp[CRM compose sheet] + graph[Graph me/sendMail] + outlook[Outlook Sent Items] + sync[Mail sync] + act[Activity on timeline] + + click --> today + click -.-> mvp + mvp --> graph + graph --> outlook + outlook --> sync + sync --> act + mvp --> act +``` + +## Two different products (difficulty differs) + +### A. Compose + Send inside the CRM (what you described) + +User clicks email → sheet with To (prefilled) / Subject / Body → Send → mail goes from their Outlook mailbox → activity on the timeline. + +**Difficulty: medium-easy.** Backend path is already proven. Main work is UI and wiring. + +Rough MVP scope: + +1. **UI** — Dialog/sheet from contact (and company) email clicks; guard when `Mail.Send` missing (reuse the sequences “reconnect Microsoft” pattern). +2. **API** — tRPC mutation e.g. `microsoft.sendMail` / `emails.send` calling existing `OutlookSendClient` + `accessTokenForSend`. +3. **Activity** — On success, create `ActivityType.EMAIL` (same pattern as sequences). Sync will also import the Sent Item later; either stamp immediately (best UX) or accept sync lag. +4. **Replace** `mailto:` in contact sheet (only call sites found under `apps/app`). + +Optional later (each adds real work): CC/BCC, attachments, rich HTML editor, reply-to-thread (`inReplyTo` already exists on the send client), templates/merge fields from sequences. + +**Ops caveat (same as sequences):** Entra must have delegated `Mail.Send` + admin consent; each rep must have reconnected. If that is not live in prod yet, in-app send and sequences share that blocker. + +### B. True Outlook draft (sits in Drafts, edit in Outlook) + +Create a Graph draft (`POST /me/messages` with `isDraft`), optionally open Outlook’s deep link, user finishes/sends in Outlook. + +**Difficulty: harder.** Needs `Mail.ReadWrite` (new scope → Entra change → every user reconnects). Draft ↔ CRM sync and “did they send?” tracking are messier. Usually not worth it if the goal is “send from here and log it.” + +Recommendation: pursue **A**, not B. + +## Effort ballpark + +| Scope | Effort | Notes | +| --- | --- | --- | +| MVP: To + Subject + plain/HTML body + Send + Activity | ~1–2 days | Reuses send client + canSend pattern | +| Polished: CC, reply-in-thread, better editor, company mailto too | ~3–5 days | Still no new Graph product | +| Outlook Drafts folder + deep link | 1+ week + Entra | Extra scope and UX edge cases | + +## Worth pursuing? + +**Yes**, if reps already connect Microsoft and you want outreach to stay in the CRM without bouncing to Outlook/Apple Mail. + +Reasons it is a good bet: + +- Send plumbing is battle-tested for sequences. +- Activity + mail sync models already exist. +- Click surface is small (`mailto` only in the contact sheet today). +- Aligns with “CRM as the work surface over Sage/Outlook.” + +Reasons to wait: + +- If Entra `Mail.Send` is still not consented, fix that first (unlocks sequences too). +- If the real need is “open Outlook with a draft,” a deep link / `mailto` may be enough and cheaper. + +## Suggested MVP (if you greenlight later) + +1. `emails.send` (or `microsoft.sendMail`) mutation: to, subject, htmlBody, contactId/companyId → `OutlookSendClient` → stamp `Activity`. +2. Compose sheet opened from contact email click when connected; fallback to `mailto` or a reconnect banner when not. +3. Keep timeline composer EMAIL as **manual log** (past emails); do not conflate with Send. + +No schema change required for MVP. Attachments and draft-folder sync can stay out of v1. diff --git a/.cursor/plans/microsoft_365_expansion_2913c2cf.plan.md b/.cursor/plans/microsoft_365_expansion_2913c2cf.plan.md new file mode 100644 index 000000000..bc48633f3 --- /dev/null +++ b/.cursor/plans/microsoft_365_expansion_2913c2cf.plan.md @@ -0,0 +1,85 @@ +--- +name: Microsoft 365 Expansion +overview: A phased plan to replace Google with Microsoft 365 (Entra SSO), add a full-body Outlook mail/calendar sync whose visibility is gated to people already in the CRM (with targeted backfill when a contact is added), a Screening Room for approving unknown contacts, and a per-rep Follow-ups / Sales Cockpit panel where the agent turns recent email into actionable, deal-aware tasks. +todos: + - id: phase0 + content: "Phase 0: Entra app registration (manual) + additive schema prep (outlookMessageId / outlookEventId)" + status: pending + - id: phase1 + content: "Phase 1: Microsoft SSO via Better Auth (replace Google), scopes, env vars, sign-in UI" + status: pending + - id: phase2 + content: "Phase 2: Microsoft mail + calendar full-body sync, matched-only (mirror apps/api/src/google/)" + status: pending + - id: phase3 + content: "Phase 3: EmailBackfill model + targeted Graph backfill when a contact is added" + status: pending + - id: phase4 + content: "Phase 4: Screening Room - PendingContact harvest in sync, screening router, approve/reject UI" + status: pending + - id: phase5 + content: "Phase 5: Follow-ups panel - FollowUpSuggestion model, propose_followups agent tool, followups router, per-rep UI" + status: pending + - id: phase6 + content: "Phase 6 (optional): Outlook contacts import, meeting-prep trigger, Teams digest" + status: pending + - id: phase7 + content: "Phase 7 (separate track): Sage CRM bidirectional connector / interface layer" + status: pending +isProject: false +--- + +# Microsoft 365 Expansion Plan + +**The canonical, execution-ready copy of this plan lives IN THE REPO at +[docs/plans/m365-expansion.md](docs/plans/m365-expansion.md).** It was written +for lower-skill executing agents: every phase has exact file paths, concrete +steps, a Definition of Done checklist, and verification commands. Edit that +file, not this one. + +## Companion process (also set up in the repo) + +- `HANDOFF.md` at the repo root is the running handoff log: what was + completed, how and why, deviations from this plan, and what is next. + `AGENTS.md` instructs every agent to read it before starting and update it + before stopping. + +## Decisions locked with the user + +- Microsoft 365 replaces Google (email/password stays as fallback). +- Full-body mail sync, matched-only: bodies stored only for threads that + resolve to a known CRM Contact/Company; unmatched mail is never stored. +- Targeted Graph backfill when a contact is added (recent history appears + retroactively, only for that address). +- Agent-detected follow-ups are suggestions; accepting creates a real + `Activity` TASK linked to the contact/deal. +- The Follow-ups / Sales Cockpit panel is per-rep. + +## Phase summary + +- Phase 0 — Entra app registration (manual portal steps documented) + + additive-only schema prep (`outlookMessageId`, `outlookEventId`; no risky + renames). +- Phase 1 — Microsoft SSO via Better Auth (`socialProviders.microsoft`), + Graph scopes in `packages/auth/src/scopes.ts`, `MICROSOFT_*` env vars, + sign-in + grant-access UI. +- Phase 2 — `apps/api/src/microsoft/` module mirroring `apps/api/src/google/` + file-for-file: Graph delta sync for mail + calendar, forward-only baseline, + matched-only storage, timeline wiring, settings card, cron route. +- Phase 3 — `EmailBackfill` model; enqueue on contact create/email change; + worker runs inside the sync tick via Graph `$search="participants:"`, + idempotent via unique `rfcMessageId`. +- Phase 4 — Screening Room: `PendingContact` harvested from unmatched + participants during sync (metadata only), `screening` tRPC router, + approve (create contact + backfill + agent identify) / reject (suppress). +- Phase 5 — Follow-ups panel: `FollowUpSuggestion` model, `propose_followups` + agent tool on a daily per-rep task, `followups` router, three-lane per-rep + UI (suggestions / my open tasks / active deals). +- Phase 6 — optional: Outlook contacts import, meeting-prep trigger, Teams. +- Phase 7 — separate track: Sage CRM connector. + +## Security posture + +Single-tenant Entra app; delegated read-only scopes; bodies at rest but scoped +to CRM-matched threads; Screening Room and follow-up quotes are metadata / +capped excerpts; agent sandbox keeps deny-all egress and no `DATABASE_URL`. diff --git a/.cursor/plans/sage_crm_soap_sync_66743908.plan.md b/.cursor/plans/sage_crm_soap_sync_66743908.plan.md new file mode 100644 index 000000000..cd0287354 --- /dev/null +++ b/.cursor/plans/sage_crm_soap_sync_66743908.plan.md @@ -0,0 +1,120 @@ +--- +name: Sage CRM SOAP sync +overview: "Plan (not build) a bidirectional Sage CRM SOAP integration: a nightly authoritative pull of Companies/People/Opportunities into a local replica, plus an outbound push so records created in this app are written back to Sage. Mirrors the existing Google/M365 sync module shape and hooks into the existing create services." +todos: + - id: write-plan-doc + content: Write canonical plan at docs/plans/sage-crm-sync.md and update the Phase 7 pointer in docs/plans/m365-expansion.md + status: pending + - id: schema + content: Add sage*Id fields, RecordSource.SAGE, SageSyncState, SageRecordSnapshot, SageOutbox to schema.prisma and migrate (add_sage_sync) + status: pending + - id: config + content: Add SAGE_SOAP_* env vars to .env.example and env.validation.ts with all-three-or-none capability helper + status: pending + - id: soap-client + content: Build sage-soap.client.ts (logon/session + query/create/update per entity) against the live Sage WSDL + status: pending + - id: pull + content: "Build sage-pull.service.ts: cursor-based query, upsert core tables by sageId, store raw snapshots, map owners by email" + status: pending + - id: push + content: Build sage-push.service.ts + SageOutbox drain, and enqueue hooks in Companies/Contacts/Deals create services + status: pending + - id: wiring + content: Add /internal/sync/sage route + cron entry, sage.router.ts (status/syncNow), sage.module.ts, register in app.module.ts, regenerate server.ts + status: pending + - id: verify + content: Run check-types/lint/test, smoke test a create->Sage push and a nightly pull, update HANDOFF.md + status: pending +isProject: false +--- + +# Sage CRM SOAP Sync — Plan + +This is a planning document. No code is written yet. It fills in the "Phase 7 — Sage CRM interface layer" placeholder already reserved in [docs/plans/m365-expansion.md](docs/plans/m365-expansion.md). + +## Decisions locked in + +- **Sage is the source of truth.** Nightly pull overwrites the local copy; local edits are pushed to Sage and Sage's version wins on the next pull. +- **Full local replica.** The three core entities map onto existing tables (`Company`, `Contact`, `Deal`), and every synced Sage record also keeps a raw JSON snapshot so no field or relationship is lost even before it is mapped. +- **Scope:** Sage Companies, People (contacts), Opportunities (deals). Owners mapped by matching the Sage user's email to this app's `User.email`; no match leaves the record unassigned. +- **Direction:** inbound pull (authoritative) + outbound push of creates. Both are mechanical, so they live in the API (`apps/api`), consistent with the "intelligence never lives in the API" rule and the Google precedent. +- **Hosting** (Vercel cron vs a long-running Railway worker hitting the same route) is a deploy detail decided at build time; SOAP calls can be slow, so a Railway worker is the safer default. + +## How it fits the codebase + +The write path for every entity is already: thin tRPC router -> service -> Prisma write, and the service is exactly where the existing agent-queue hook lives. Sage push hooks go in the same place. + +- Companies: `create()` in [apps/api/src/companies/companies.service.ts](apps/api/src/companies/companies.service.ts) (already calls `this.agent.companyCreated(...)`) +- Contacts: `create()` in [apps/api/src/contacts/contacts.service.ts](apps/api/src/contacts/contacts.service.ts) (already calls `this.agent.contactCreated(...)`; can auto-create a company) +- Deals: `create()` in [apps/api/src/deals/deals.service.ts](apps/api/src/deals/deals.service.ts) (the only one with no queue hook today) + +The inbound pull copies the Google module shape: a provider module, a cron route guarded by `CRON_SECRET`, cursor state in Postgres, mechanical upserts. See [apps/api/src/google/](apps/api/src/google/) and its cron entry in [apps/api/scripts/build-func.mjs](apps/api/scripts/build-func.mjs). + +```mermaid +flowchart LR + rep[Rep browser] -->|create deal/company/contact| api[NestJS API] + api -->|Prisma write| db[(Postgres)] + api -->|enqueue SageOutbox row| db + cron[Cron or Railway worker] -->|/internal/sync/sage| api + api -->|SOAP logon + query| sage[Sage CRM SOAP] + sage -->|changed records| api + api -->|upsert + raw snapshot| db + api -->|drain SageOutbox: create/update| sage + sage -->|returned Sage id| api + api -->|stamp sageId on local record| db +``` + +## Schema changes (packages/db) + +In [packages/db/prisma/schema.prisma](packages/db/prisma/schema.prisma): + +- `RecordSource` enum: add `SAGE`. +- `Company`: add `sageCompanyId String? @unique` (follows the existing `{provider}{Entity}Id` convention). +- `Contact`: add `sageContactId String? @unique` (Sage "Person"). +- `Deal`: add `sageOpportunityId String? @unique` (Sage "Opportunity"). +- New `SageSyncState` (mirrors `MailboxSync`): one row per entity type (`company | person | opportunity`), holding `status`, `cursor` (last-updated marker), `lastSyncedAt`, `retryAfter`. +- New `SageRecordSnapshot`: `entity`, `sageId`, `payload Json`, `updatedAt`, `@@unique([entity, sageId])` — the raw replica that guarantees no field is lost. +- New `SageOutbox` (durable outbound queue): `entity`, `localId`, `operation` (CREATE/UPDATE), `status` (PENDING/RUNNING/DONE/FAILED), `attempts`, `error`, `createdAt`, `finishedAt`. + +Then `bun run db:migrate` naming the migration `add_sage_sync`. + +## New module: apps/api/src/sage/ + +Mirror the Google module file-for-file: + +- `sage.constants.ts` — entity names, source values, field mappings. +- `sage-soap.client.ts` — wraps a Node SOAP client (`soap` npm package) against the Sage WSDL; handles logon/session, and `query`/`create`/`update` per entity. Reads config via a capability helper so a missing config disables the feature instead of throwing (pattern: `googleCredentials()` in [packages/auth/src/env.ts](packages/auth/src/env.ts)). +- `sage-pull.service.ts` — per entity, query records changed since the cursor; upsert into `Company`/`Contact`/`Deal` by `sage*Id`; write the raw `SageRecordSnapshot`; set `source = SAGE`. Writes Prisma directly (not through `*.service.create`) so the pull never re-triggers an outbound push (loop prevention). +- `sage-push.service.ts` — drain `SageOutbox`: build the SOAP request from the local record, create/update in Sage, store the returned Sage id on the local row, mark DONE/FAILED. +- `sage-match.service.ts` — Sage id <-> local id mapping, owner email -> `User`, and linking People/Opportunities to their Company. +- `sage-sync.service.ts` — orchestrator `runDue()` (pull then drain outbox within a time budget), mirroring `GoogleSyncService.runDue()`. +- Add `GET|POST /internal/sync/sage` to [apps/api/src/google/sync.controller.ts](apps/api/src/google/sync.controller.ts) (same `CRON_SECRET` bearer guard). +- `sage.router.ts` — tRPC `status` / `syncNow`; then `bun run --filter=api trpc:generate` and commit `src/generated/server.ts`. +- `sage.module.ts` — wire and register in `app.module.ts`. +- Cron: add a `/internal/sync/sage` entry in [apps/api/scripts/build-func.mjs](apps/api/scripts/build-func.mjs) (or run it from a Railway worker calling the route). + +## Outbound hooks (where creates push to Sage) + +After each successful create, enqueue a `SageOutbox` CREATE row (mechanical, guarded by the Sage capability): + +- `CompaniesService.create` in [apps/api/src/companies/companies.service.ts](apps/api/src/companies/companies.service.ts) +- `ContactsService.create` in [apps/api/src/contacts/contacts.service.ts](apps/api/src/contacts/contacts.service.ts) (also enqueue the company when it auto-creates one via `CompanyDirectoryService.companyForEmail`) +- `DealsService.create` in [apps/api/src/deals/deals.service.ts](apps/api/src/deals/deals.service.ts) + +Later: the matching `update()` methods enqueue UPDATE rows. Not in the first cut. + +## Env / config + +One `.env` at the root. Add `SAGE_SOAP_URL`, `SAGE_SOAP_USER`, `SAGE_SOAP_PASSWORD` (all-three-or-none capability). Document them in `.env.example` and declare them in [apps/api/src/config/env.validation.ts](apps/api/src/config/env.validation.ts) as optional strings. + +## Open items to resolve at build time + +- Exact Sage WSDL entity/field names and the "changed since" query supported by your Sage version (2022+). Confirm against the live WSDL before mapping. +- Conflict window: a local edit pushed to Sage vs the next authoritative pull — the pull should skip clobbering rows with a still-pending `SageOutbox` entry. +- Whether newly pulled companies should also enqueue an agent enrichment task (`AgentTriggerService.companyCreated`) or stay quiet. +- Final hosting choice (Vercel cron vs Railway worker). + +## Verification (per the repo rules) + +`bun run check-types`, `bun run lint`, `bun run test`, and a manual smoke test. Regenerate and commit `apps/api/src/generated/server.ts` after any router change. Update `HANDOFF.md` when work starts/stops. \ No newline at end of file diff --git a/.cursor/plans/sage_extra_modules_plan_7eefa258.plan.md b/.cursor/plans/sage_extra_modules_plan_7eefa258.plan.md new file mode 100644 index 000000000..07b673c5b --- /dev/null +++ b/.cursor/plans/sage_extra_modules_plan_7eefa258.plan.md @@ -0,0 +1,92 @@ +--- +name: Sage Extra Modules Plan +overview: Write a living plan doc at `docs/plans/sage-extra-modules.md` (pipeline-pulse / sage-crm-sync style) that covers pull of communications + notes into Activity, Sage leads into Contact with a create-as-lead toggle, plus a backlog for opaque modules — then wire it into HANDOFF and the parent Sage plan. +todos: + - id: write-plan-doc + content: Author docs/plans/sage-extra-modules.md with Status, Locked decisions, phases 0–5, backlog, key files, smoke + status: pending + - id: cross-link + content: Point sage-crm-sync.md §3c/§4.5 and HANDOFF Current state + work log at the new plan + status: pending +isProject: false +--- + +# Sage extra-modules plan doc + +## Deliverable + +Create a living plan at [`docs/plans/sage-extra-modules.md`](docs/plans/sage-extra-modules.md), same evolution pattern as [`docs/plans/pipeline-pulse.md`](docs/plans/pipeline-pulse.md) / [`docs/plans/sage-crm-sync.md`](docs/plans/sage-crm-sync.md): Status table, Locked decisions, phased build, backlog, key files, smoke. Cross-link from [`HANDOFF.md`](HANDOFF.md) Current state and from [`docs/plans/sage-crm-sync.md`](docs/plans/sage-crm-sync.md) §3c / §4 item 5. + +This step writes the **plan doc only** (no schema/API/UI code yet). + +## Locked product decisions (bake into the doc) + +1. **Scope**: communications + notes + leads for build; quotes / orders / Mas* / cases / documents / relationships / consent as **backlog only**. +2. **Guiding principle** (same as parent Sage plan): fit Sage into existing CRM models; fewest new columns; raw fidelity in `SageRecordSnapshot`; intelligence stays out of Nest. +3. **Communications → `Activity`**: map Sage `communication` into the existing timeline. Add `Activity.sageCrmCommunicationId String? @unique` (+ `sageUpdatedAt` / echo-guard fields mirroring Company if push comes later). Map Sage `type`/`action` → `ActivityType` (`EMAIL` / `CALL` / `TASK` / `MEETING` / fallback `NOTE`). Resolve `companyId` / `contactId` via Sage company/person FKs on the communication (exact column names confirmed in phase 0 probe). Skip rows that cannot resolve to a known local company (or park under company when only company id exists). +4. **Notes → `Activity` `NOTE`**: entity name is `notes`. Add `Activity.sageCrmNoteId String? @unique`. Use `foreignid` / `foreigntableid` / `personid` to attach to company or contact. No separate Note table. +5. **Leads → `Contact` (no Lead model)**: Sage `lead` upserts into `Contact`. Add `Contact.sageCrmLeadId String? @unique` and `Contact.sageIsLead Boolean @default(false)` (true while the Sage-side record is still a lead). Prefer match order: `sageCrmLeadId` → existing `sageCrmContactId` if lead already converted → email+company soft link → create. When `primarycompanyid` exists, link; else create/find `Company` from denormalized lead company fields (name/city) without inventing domains. +6. **Create-as-lead toggle (push)**: on human “New contact”, a boolean (default off) **Create as Sage lead**. That flag sets `sageIsLead=true` and drives `SageOutbox` to SOAP `add` on entity `lead` instead of `person`. Updates stay on the same Sage entity the row is linked to (`sageCrmLeadId` vs `sageCrmContactId`). Screening Room approve stays “create person/contact” (no lead) unless we later add the same toggle there. +7. **Pull only first** for communications/notes; lead create-as-lead is the first push extension beyond the triad. Bidirectional edit of Sage notes/comms is out of scope. +8. **UI**: Sage-sourced timeline rows show like email/calendar sync (`meta.source: "sage"`); contacts with `sageIsLead` get a small Lead badge on list/sheet (no new Leads page). +9. **Dedup with Outlook/Gmail**: do not merge Sage communications into `emailThreadId` projections in v1 — separate Activity rows; later dedup is backlog. + +## Plan doc structure (what we will write) + +```markdown +# Sage extra modules — communications, notes, leads + +## Status (all TODO initially) +## Locked decisions (2026-08-03) # items 1–9 above +## Probe evidence # link scripts + recency facts +## Architecture +## Phase 0 — field catalog (SOAP getmetadata / sample queries) +## Phase 1 — schema +## Phase 2 — communications pull +## Phase 3 — notes pull +## Phase 4 — leads → Contact pull +## Phase 5 — create-as-lead UI + push +## Backlog (opaque / empty modules) +## Key files +## Local smoke +## Out of scope +## HANDOFF linkage +``` + +### Architecture (to document with a small diagram) + +```mermaid +flowchart LR + SageComm[Sage communication] --> Snap[SageRecordSnapshot] + SageNotes[Sage notes] --> Snap + SageLead[Sage lead] --> Snap + Snap --> Act[Activity] + Snap --> Contact[Contact sageIsLead] + Contact -->|createAsSageLead| Outbox[SageOutbox] + Outbox --> SageLead + Outbox --> SagePerson[Sage person] +``` + +### Phase details the doc will spell out + +| Phase | Work | +| --- | --- | +| **0** | Re-use / extend [`apps/api/scripts/sage-probe-entities.ts`](apps/api/scripts/sage-probe-entities.ts): dump full field lists for `communication`, `notes`, `lead`; confirm company/person FK column names; sample 5 recent rows each. Record mapping catalog in the plan (like sage-crm-sync §3). | +| **1** | Prisma: Activity sage ids; Contact `sageCrmLeadId` + `sageIsLead`; extend `SageSyncState` / snapshot entity strings to `communication` \| `notes` \| `lead`. Migration. | +| **2** | `SagePullService` incremental+backfill for `communication` → Activity; wire into nightly `/internal/sync/sage` after triad. | +| **3** | Same for `notes` → Activity NOTE. | +| **4** | Lead pull → Contact/Company upsert; list/sheet Lead badge. | +| **5** | Contact create sheet toggle; push path in [`sage-push.service.ts`](apps/api/src/sage/sage-push.service.ts) branches lead vs person. | +| **Backlog** | quotes/orders/Mas* (query fails), cases (empty), library/documents (not enabled), relationships/consent/selfservice (no entity). | + +### Cross-links to update when writing the doc + +- [`HANDOFF.md`](HANDOFF.md): Current state bullet pointing at the new plan; work-log entry “plan authored”. +- [`docs/plans/sage-crm-sync.md`](docs/plans/sage-crm-sync.md) §3c / §4 item 5: replace soft “confirm with team” with pointer to `sage-extra-modules.md` and the locked lead→Contact decision. + +## Defaults already chosen (no further product forks) + +- One plan file for all three modules + backlog (not three docs). +- No new `Lead` table. +- No standalone sync of address/phone/email (already nested under company). +- Communications/notes are pull-first; only lead create gets new push behavior in phase 5. diff --git a/.cursor/plans/sage_full_sync_ca35f635.plan.md b/.cursor/plans/sage_full_sync_ca35f635.plan.md new file mode 100644 index 000000000..3945048a2 --- /dev/null +++ b/.cursor/plans/sage_full_sync_ca35f635.plan.md @@ -0,0 +1,106 @@ +--- +name: Sage full sync +overview: Build the safe, resumable full Sage pull (backfill + nightly incremental), pre-create the 11 Sage users so imported deals map to real owners, and design (not yet build) the write-back/ongoing-sync loop. Backfill runs locally off-peak as a one-shot; push is design-only. +todos: + - id: users + content: Add SAGE_USERS (names+ids) and ensureSageUsers() idempotent upsert; one-shot script + status: pending + - id: schema + content: "Additive migration: SageSyncState phase/backfillId/highWater; sageDeactivatedAt; sagePushedAt/sageUpdatedAt" + status: pending + - id: safety + content: withSageSession() Postgres advisory lock; throttle + off-peak config; dry-run mode + status: pending + - id: snapshot-first + content: Refactor pull to snapshot-first, then map from snapshots + status: pending + - id: backfill + content: runBackfill() with id-paged resume + checkpoint; local sage-backfill.ts script; canary then full + status: pending + - id: incremental + content: Nightly incremental pull on /internal/sync/sage once phase=incremental; ~1h overlap + status: pending + - id: reconcile + content: "Monthly reconcile: verify + soft-deactivate unseen Sage ids, never hard-delete" + status: pending + - id: push-design + content: Document SageOutbox + push worker + last-writer-wins echo guard (design only) + status: pending + - id: verify + content: Tests + live canary/full; validation report; update HANDOFF + status: pending +isProject: false +--- + +# Sage full sync — safe backfill, user prep, and ongoing-sync design + +Canonical references: [docs/plans/sage-crm-sync.md](docs/plans/sage-crm-sync.md) §6, and the existing Sage module in [apps/api/src/sage/](apps/api/src/sage/). This plan resolves the eight open questions in [HANDOFF.md](HANDOFF.md) with the two human decisions made (local one-shot backfill; push design-only) plus recommended defaults for the rest. + +## Guardrails (do not break) +- One Sage Web Services session globally. A second `logon` kicks the first. Serialize everything. +- Never retry-spam `logon` (can lock the service account). The client already returns `auth-failed` without retry — keep it. +- Fit Sage into existing models; snapshot is the lossless backstop; keep the `DealStage` enum. +- Intelligence never in the API. This is mechanical: SOAP -> map -> Prisma. + +## Architecture + +```mermaid +flowchart TD + lock["Postgres advisory lock (one Sage session)"] + script["Local one-shot backfill script"] --> lock + cron["Nightly cron GET /internal/sync/sage"] --> lock + push["Push worker (LATER, design only)"] --> lock + lock --> soap["SageSoapClient logon/query/next/logoff"] + soap --> snap["SageRecordSnapshot (raw, lossless)"] + snap --> mapstep["map -> Company / Contact / Deal (idempotent upsert by sageCrm*Id)"] +``` + +## Part A — Pre-create the 11 Sage users (do first) +- Extend [apps/api/src/sage/sage.mappings.ts](apps/api/src/sage/sage.mappings.ts): add a `SAGE_USERS` list of `{ sageId, firstName, lastName, email }` from the supplied CSV; derive `SAGE_USER_EMAILS` / `emailForSageUser` from it (no behaviour change). +- Add `ensureSageUsers(db)` (in a small `sage-users.ts` or on the pull service) that upserts each by `email`, `emailVerified: true`, stable `id: "sage-user-"`, mirroring `seedOwners()` in [packages/db/prisma/seed.ts](packages/db/prisma/seed.ts). Idempotent. +- Expose a one-shot: `bun run scripts/ensure-sage-users.ts` (template: [apps/api/scripts/backfill-icon-tone.ts](apps/api/scripts/backfill-icon-tone.ts)). The backfill also calls it before importing deals. +- Note: `sales@antenna.com` (id 0) is not a `mobilemark.com` address, so it cannot sign in, but can still own deals. Fine. + +## Part B — Schema (additive migration) +Extend [packages/db/prisma/schema.prisma](packages/db/prisma/schema.prisma): +- `SageSyncState`: add `phase String @default("backfill")`, `backfillId String?` (last companyid checkpoint), `highWaterUpdatedAt DateTime?` (incremental cursor; keep `cursor` for back-compat), optional `processed Int?` and `backfillDoneAt DateTime?`. +- Soft-deactivate (§6.7): add `sageDeactivatedAt DateTime?` to `Company` and `Contact` (Deal optional). Never hard-delete Sage rows. +- Push design markers (columns now, used later): `sagePushedAt DateTime?` and `sageUpdatedAt DateTime?` on `Company`/`Contact`/`Deal` — the echo guard for ongoing sync. + +## Part C — Safety layer (shared by all Sage jobs) +- **Cross-process lock**: `withSageSession()` helper wrapping `pg_advisory_lock` (try/finally `logoff` + unlock). Replaces the in-process `private running` flag in [apps/api/src/sage/sage-pull.service.ts](apps/api/src/sage/sage-pull.service.ts) as the real guard. +- **Snapshot-first**: page -> write all `SageRecordSnapshot` rows -> map from snapshots. Decouples network from mapping. +- **Resume by id-paging**: `comp_companyid > :backfillId AND comp_deleted IS NULL`, take max id per page as next `backfillId`, persist after each page. Crash resumes cheaply. (Quick probe first: confirm `query` returns companies ordered by `companyid`; if not, sort client-side and page by the max seen.) +- **Throttle**: `SAGE_PAGE_DELAY_MS` + optional off-peak window as config constants. +- **Dry-run mode**: fetch + snapshot + map + report counts without core-table writes. +- **Idempotency**: unchanged — every upsert keys off unique `sageCrm*Id`. + +## Part D — Local one-shot backfill +- New `apps/api/scripts/sage-backfill.ts` calling a `SagePullService.runBackfill({ dryRun, maxCompanies })`. +- Order (§6.4): `ensureSageUsers` -> companies (nested people) to completion -> opportunities walk (`oppo_deleted IS NULL`). +- Reuse existing `queryCompanies`/`nextCompanies` parser and mappers; add id-paged loop + checkpointing. +- Run recipe: (1) `--dry-run --max=200` canary, inspect; (2) `--max=200` real, spot-check in UI; (3) full run off-peak. Watchable/abortable from this machine. +- After: validation report — counts, unmatched owners, children with null company, domain collisions. + +## Part E — Nightly incremental cron +- Change the entrypoint behaviour in [apps/api/src/sage/sage-sync.controller.ts](apps/api/src/sage/sage-sync.controller.ts): once `phase = incremental`, `GET /internal/sync/sage` runs the incremental pull; keep the test-slice under a separate flag/path until backfill is proven. +- Incremental: `comp_updateddate > (highWater − ~1h) AND comp_deleted IS NULL`, same for opportunities. Sage is source of truth; overlap re-writes harmlessly. +- Mirror the tick/state pattern of [apps/api/src/microsoft/microsoft-sync.service.ts](apps/api/src/microsoft/microsoft-sync.service.ts). + +## Part F — Reconcile (soft-deactivate, monthly not nightly) +- Ids present locally but absent from a full run get a single-company verify query, then `sageDeactivatedAt = now()`. Never hard-delete. + +## Part G — Ongoing sync + push (DESIGN ONLY now) +- **Nightly pull** as above = source of truth for new/changed records since last run. +- **Push (later build)**: a `SageOutbox` table; local edits to mapped fields enqueue a row; a push worker drains it via SOAP `update`, behind the same advisory lock, setting `sagePushedAt`. +- **Conflict rule**: last-writer-wins by timestamp with an echo guard — on nightly pull, if Sage `updateddate <= sagePushedAt` for that record, skip (it is our own push coming back); if Sage changed later, Sage wins. This delivers "push right away, nightly Sage is truth" without ping-pong. + +## Verification +- `check-types` (api) green; Sage unit tests green; add tests for id-paging checkpoint, snapshot-first mapping, and `ensureSageUsers` idempotency. +- Live: canary import, then full; confirm forecast UI still works with a larger open pipeline; confirm the 11 users own the right deals. + +## Timing +~1–2 hours for the full backfill (~143 company pages at 10–20s each; people nested; opportunities a separate short walk). Run once, off-peak. + +## HANDOFF +Record the eight open-question answers and a new dated work-log entry per the handoff protocol before stopping. \ No newline at end of file diff --git a/.cursor/plans/sales_superpowers_roadmap_8934ca8b.plan.md b/.cursor/plans/sales_superpowers_roadmap_8934ca8b.plan.md new file mode 100644 index 000000000..1a5a238f3 --- /dev/null +++ b/.cursor/plans/sales_superpowers_roadmap_8934ca8b.plan.md @@ -0,0 +1,150 @@ +--- +name: Sales Superpowers Roadmap +overview: "A prioritized product roadmap for Mobile Mark CRM based on your answers: grow new pipe and kill quiet deals for ~12–15 reps, keep managers strong on weekly/monthly forecast, and ship habit-forming in-app surfaces before digests." +todos: + - id: lock-quiet-window + content: "Confirm with managers: quiet/stuck window (14d vs 7d) and next-step warn-only vs hard gate" + status: pending + - id: write-roadmap-doc + content: Author docs/plans/sales-superpowers.md from this roadmap; link HANDOFF + status: pending + - id: slice-action-queue + content: "When ready to build: implement plan for Me Action Queue + quiet deals + deal next-step" + status: pending + - id: sage-extra-modules-doc + content: Author docs/plans/sage-extra-modules.md (comms/notes/leads) per existing cursor plan + status: pending + - id: new-pipe-glue + content: "After Action Queue: sequence stall on queue + Sage lead pull + Screening ritual" + status: pending + - id: forecast-risk-pack + content: Manager in-app forecast risk pack for weekly/monthly exec meetings + status: pending +isProject: false +--- + +# Sales superpowers roadmap (from your answers) + +## What you told us (locked context) + +| Signal | Implication | +| --- | --- | +| Pain = **new pipe** + **quiet deals** | Optimize for top-of-funnel intake and silence/next-step hygiene — not more KPIs | +| Reps first; managers for performance + agent reports | Rep home = action queue; managers keep pulse/agent (already strong) | +| Day lives in **Outlook** (+ notes / sometimes Sage) | Superpowers must feel like “open CRM → do these 5 things,” not a second Sage | +| Sage logging is **inconsistent** | Mail + change log are more trustworthy than Sage activity alone; still sync Sage for what *is* logged | +| **In-app until habits stick** | No Slack/email digests in phase 1 | +| Both farming + outbound | Sequences + screening + Sage leads all matter | +| Weekly/monthly **exec forecast** | Protect trusted forecast (certainty × amount × close month); surface risk exceptions for the meeting | +| 12–15 reps, 2 managers; pushing better opp logging | Soft enforcement (required next step, visible “no action”) beats hard gates at first | + +## Already built (do not rebuild) + +- Pipeline pulse + `read_pipeline_report` (manager / exec questions) +- Sequences, Screening (per-rep), Follow-ups from mail, Microsoft sync +- Sage company/person/opportunity + map +- Draft path for Sage extras: [`.cursor/plans/sage_extra_modules_plan_7eefa258.plan.md`](.cursor/plans/sage_extra_modules_plan_7eefa258.plan.md) → `docs/plans/sage-extra-modules.md` (not authored yet) + +## Recommended order (why this order) + +```mermaid +flowchart TD + P0[P0 Habit foundation] + P1[P1 Quiet-deal kill] + P2[P2 New-pipe engine] + P3[P3 Timeline completeness] + P4[P4 Manager forecast ritual] + P5[P5 Later push] + P0 --> P1 + P1 --> P2 + P2 --> P3 + P1 --> P4 + P2 --> P4 + P3 --> P5 + P4 --> P5 +``` + +### P0 — Habit foundation (1–2 weeks of product focus) + +**Goal:** Make “open this app” equal “here is my work today.” + +- **Rep Action Queue** on overview (Me): one list combining + - stuck deals (existing pulse rule, 14d+) + - open deals with **no dated next task** + - accepted follow-ups due / overdue + - sequence enrollments awaiting reply / stalled steps +- Keep Me / Everyone; Me is the rep home. Managers stay on Everyone + agent. +- Soft copy only (“Add next step”) — no stage-lock yet. + +**Why first:** Outlook is home; without a single in-app worklist, every later feature stays optional. + +### P1 — Quiet-deal kill (core “superpower”) + +**Goal:** Silence becomes visible and actionable before the weekly forecast. + +- **Quiet deal definition (mechanical):** open deal AND (no tracked `DealFieldChange` in N days OR no inbound/outbound `EmailMessage` / `Activity` tied to company/contact in N days). Start N = 14 to match stuck; tune with managers. +- Surface on Action Queue + optional column on deals list (“Last touch”). +- Deal sheet: **Next step** block (date + note → `Activity` TASK). Stage move **warns** if missing next step (enforce later if culture holds). +- Agent tool (later thin): `list_quiet_deals` or extend pulse — still observed data only. + +**Why this beats Sage notes sync for quiet deals:** humans skip logging; mailbox sync already runs. Silence in Outlook is the ground truth. + +### P2 — New-pipe engine + +**Goal:** More qualified opportunities enter the system without more admin. + +1. **Finish Screening habits** — apply pending per-rep migration if not done; make Screening a weekly rep ritual (“clear unmatched”). +2. **Sequences → pipeline glue** (from sequences backlog): + - Deal-stage or “no open deal” enroll triggers + - Reply auto-stop is done; add “stalled enrollment” on Action Queue + - Optional: agent-assisted step copy from last real thread (draft only) +3. **Sage leads → Contact** (product decision already in extra-modules plan: `sageIsLead`, no Lead table) — top-of-funnel that already exists in Sage becomes visible here; create-as-lead toggle when reps start new pipe in-app. +4. **Company map + Screening** — farm/account hunting for unlinked or cold regions (lighter; after 1–3). + +**Why:** Your #1 pain is not enough new pipe; leads + sequences + screening are the three intake valves you already partly own. + +### P3 — Timeline completeness (Sage communications + notes) + +**Goal:** When someone *did* log in Sage, it shows here; reduce “check Sage” for history. + +- Execute [sage extra modules](.cursor/plans/sage_extra_modules_plan_7eefa258.plan.md): `communication` + `notes` → `Activity` (pull-first). +- Do **not** treat this as the quiet-deal oracle (logging is spotty). Use it for prep and coaching context. +- Dedup with Outlook email activities stays backlog (v1 = separate rows). + +### P4 — Manager / exec forecast ritual + +**Goal:** Weekly/monthly meetings run on exceptions, not slide archaeology. + +- Everyone pulse already covers movers / stuck / certainty. +- Add **Forecast risk pack** (in-app, pre-meeting): quiet deals in this month’s close bucket, certainty drops in range, close-date slips (from change log), deals with no next step closing this month. +- Pipeline agent prompts/shortcuts: “What’s at risk this month?” wired to report + quiet rules (no invented risk scores). +- Per-rep coaching strip for 2 managers (activity density optional later; start with owned quiet + stuck + won/lost in range). + +### P5 — Later (after habits stick) + +- Email/Teams digests (you deferred) +- Hard “cannot move stage without next step” +- Win/loss reason tags → learning loop +- Meeting-prep agent brief on calendar open +- Bounce/NDR sequence stops, send caps +- Bidirectional Sage notes/comms edit + +## What not to prioritize soon + +- Second forecasting model that fights Sage certainty% +- Generic AI without tools +- Heavy quotes/orders until SOAP works +- Push notifications before Action Queue adoption + +## Suggested first build slice (when you say go) + +1. Author `docs/plans/sales-superpowers.md` (this roadmap as living plan) **or** jump straight to an implementation plan for **Action Queue + quiet deals + next-step on deal**. +2. In parallel track: finish writing `docs/plans/sage-extra-modules.md` (leads = new pipe; comms = timeline) so Sage work has a home. +3. Confirm with managers: quiet window (14d vs 7d) and whether “no next step” is warning-only for 30 days. + +## Success metrics (simple) + +- % open deals with a dated next step (target climb over 4–6 weeks) +- Count of quiet deals week-over-week (should fall) +- Screening clears + sequence enrollments per rep (new pipe volume) +- Forecast meeting: fewer “I don’t know” on at-risk names (qualitative with managers) diff --git a/.cursor/plans/website_visitor_screening_e42571d1.plan.md b/.cursor/plans/website_visitor_screening_e42571d1.plan.md new file mode 100644 index 000000000..45822f55c --- /dev/null +++ b/.cursor/plans/website_visitor_screening_e42571d1.plan.md @@ -0,0 +1,166 @@ +--- +name: Website visitor screening +overview: Company-first visitor ID (Dealfront), person+ICP (Leadpipe), or Apollo (if already in that stack) — filtered by page/ICP intent, routed by territory into Screening. +todos: + - id: vendor-trial + content: "Trial shortlist: Dealfront (company), Leadpipe (person+ICP), and/or Apollo (company free / contact via Inbound) — pixel only, intent+ICP filters, 2–4 weeks, no CRM sync yet" + status: pending + - id: territory-module + content: Commit sales-territory.json + assignRep cascade (exception → geo → unmatched) mapped to User emails + status: pending + - id: pending-visitor-model + content: Add PendingVisitor (or generalized screening intake) separate from email PendingContact + status: pending + - id: webhook-ingest + content: Signed webhook + dedupe + intent re-check + territory assign into Screening + status: pending + - id: screening-ui-web + content: "Screening Web tab: company visits, claim shared pool, approve via companies.similar" + status: pending + - id: gated-content-phase-b + content: "Phase B: gate datasheets/RFQ for self-ID; use visitor accounts as Sales Nav target list" + status: pending +isProject: false +--- + +# Website visitors → Screening (company + intent) + +## Verdict on the Reddit feedback + +That take is right for **technical B2B**, and it applies to Mobile Mark antennas as much as FinOps: + +- **RB2B / person-IP as primary is a bad fit.** It floods Screening with lunch-break browsers and random employees. Match rates are low; noise is high. +- **Useful signal = company + behavior.** “Caterpillar’s office hit the product/datasheet pages three times this week” is actionable. “Someone named Pat visited the homepage” is not. +- **6sense / Demandbase** are real account-intent platforms, but enterprise-priced and ABM-heavy. Defer until company-visitor intake proves value. +- **Gated technical content** (datasheets, RFQ, configurator) remains the highest-quality self-ID path. Treat visitor ID as a **complement**, not a replacement. + +**Default decisions (from this feedback + MM context):** + +1. **Identity level:** company-first. No person-level vendor as the Screening feed. +2. **Routing:** territory when resolvable; shared claim pool only for unmatched. +3. **Vendor shortlist to trial:** Dealfront (company); Leadpipe (person+ICP, US); **Apollo** if the team already wants Apollo for outbound/enrichment (company free, contact needs Inbound add-on). Snitcher as cheap company backup. Skip RB2B, Clearbit/HubSpot-only, and ZoomInfo/6sense for v1. + +## What Screening is today (constraint) + +Screening is **per-rep Outlook harvest**, not a shared claim pool: + +- Model: [`PendingContact`](packages/db/prisma/schema.prisma) — requires `email` + `userId` (mailbox owner); unique `(userId, email)`. +- Entry: Outlook mail sync only ([`screening-harvest.service.ts`](apps/api/src/screening/screening-harvest.service.ts)). +- Approve → Contact via company match ([`createFromScreening`](apps/api/src/contacts/contacts.service.ts)). + +Visitor companies **do not fit `PendingContact` cleanly** (no email; company-centric; may be shared). Do **not** overload that model. + +## Recommended product shape + +```mermaid +flowchart TD + pixel[Site tracking pixel] + vendor[Dealfront Leadpipe Apollo or Snitcher] + filter[Vendor feed: intent pages + ICP filters] + hook[CRM webhook POST] + ingest[API ingest + dedupe] + match{Match local Company?} + except{Exception name?} + geo{State or country?} + repQ[Rep Screening queue] + shared[Shared claim pool] + approve[Approve: attach or create Company or Contact] + + pixel --> vendor --> filter --> hook --> ingest --> match + match -->|use company state or HQ| except + match -->|use vendor geo| except + except -->|distributor gateway key acct| repQ + except -->|no| geo + geo -->|mapped| repQ + geo -->|unknown| shared + shared -->|claim on approve| approve + repQ --> approve +``` + +### Vendor trial criteria (before any CRM code) + +Run a **2–4 week pixel trial** on mobilemark.com (and any product microsites) with **no CRM sync yet**. Score: + +- % of sessions resolved to a named company +- % that match existing Sage companies (domain / name) +- Noise: ISPs, competitors, job seekers, student traffic +- Whether **page filters** (product, datasheet, RFQ, configurator — not homepage alone) cut noise enough for reps + +**Dealfront (Leadfeeder):** company-level. Custom feeds filter `page_url`, visits, region; webhooks / workflows POST company JSON. GDPR-clean company-only posture. Best default if Screening should stay “account visited product pages.” + +**Leadpipe:** person-level on **US** traffic (name, work email, LinkedIn, title, phone + pages); **EU/UK auto-geofenced to company-only**. Claims ICP filtering/scoring, real-time webhooks (First Match / Every Update), state/country in payload (useful for territory), free trial (~500 IDs). Closer to RB2B’s category but stronger CRM/webhook/ICP story — still subject to the Reddit noise risk unless title + page filters are strict before Screening ingest. Good A/B trial against Dealfront if reps want named contacts when available. + +**[Apollo Website Visitor Identification](https://www.apollo.io/product/website-visitor-identification):** company + optional contact. Company ID is on the **free plan** (firmographics + pages); **contact-level needs the Inbound add-on** and is **US-only**. Strong ICP filters (title, size, tech stack, geography) and intent/page scoring — good answer to the Reddit “lunch break noise” problem. Built-in Slack alerts, inbound router, and sequences keep action inside Apollo. For MM-CRM, expect Zapier/workflow → custom webhook or API polling rather than a visitor-native push like Leadpipe; emails/phones burn Apollo credits. Best pick **if** sales already wants Apollo for outbound/enrichment; weaker as a visitor-ID-only buy into a custom Nest CRM. + +**Snitcher:** cheaper company IP ID; evaluate if Dealfront is overkill after the trial. + +**Explicit non-goals for v1:** RB2B as Screening feed; 6sense/Demandbase; HubSpot Breeze. + +#### Quick compare + +- **Dealfront** — company only; intent feeds; mid-market; clean compliance; custom webhook-friendly +- **Leadpipe** — person (US) + company (EU); ICP filters; first-class webhooks; trial easy +- **Apollo** — company free / contact via Inbound; deep ICP + intent; sequences built-in; best if Apollo is already the GTM stack; custom CRM pipe is secondary +- **Snitcher** — company; cheaper; thinner enrichment +- **RB2B** — person US; noisy for technical B2B; weak CRM story → skip +- **6sense / Demandbase** — account intent across the web; enterprise → later + +### Intent filters (the Reddit lesson) + +Only enqueue companies that meet **at least one** high-intent rule, configured in the vendor feed (and optionally re-checked on ingest): + +- Visited product / antenna / datasheet / application / RFQ / contact pages (exact path list from the live site) +- Multiple pageviews or return visits within N days +- Exclude known junk: consumer ISPs if tagged, careers pages only, blocked competitor domains + +Homepage-only visits stay in the vendor UI for marketing, **not** in Screening. + +### CRM data model (after trial) + +New intake, sibling to mail Screening — same UI surface, different source: + +- New model e.g. `PendingVisitor` (or generalize Screening to a polymorphic intake). Fields: vendor id, company name, domain, country/region/state, pages[], visitCount, first/lastSeen, optional person fields if vendor supplies them (email, name, LinkedIn, title), `assignedUserId` nullable (null = shared pool), `claimedById`, status, matched `companyId` if any, source `WEBSITE`. +- Territory config checked into repo: [`data/sales-territory.json`](../../data/sales-territory.json) + `assignRep` / `inferGeoFromForm` in [`packages/db/src/sales-territory.ts`](../../packages/db/src/sales-territory.ts) (shared with webform lead Screening — see `docs/plans/webform-lead-screening.md`). Cascade: **exception → geo → unmatched**. +- Map `rep_code` / email → local `User` (same emails already in Sage user list). Resolve Ken / Demo Sales Sage-27 collision before using Sage IDs for routing. + +### Routing rules + +| Case | Queue | +|------|--------| +| Exception (gateway MFR, distributor, IL key account) | Fixed rep(s); MCA stays shared or manual until policy | +| Matched company with `stateCode` / country | Territory geo | +| Unmatched company with vendor state/region | Territory geo | +| No usable geo | Shared pool (`assignedUserId` null) | +| Approve / claim | Set `claimedById`; hide from other reps’ shared view; create/attach Company (and optional Contact if vendor later supplies one) | + +Do **not** fan out the same visitor to every rep’s queue when geo is known — that creates duplicate work. Shared pool is only for unmatched. + +### Screening UI + +Extend [`/screening`](apps/app/app/(app)/screening/) (tabs or source column): + +- **Mail** — existing `PendingContact` +- **Web** — visitor companies: name, domain, pages, visits, suggested rep / “Unassigned” + +Approve reuses [`companies.similar`](apps/api/src/companies/company-similar.ts) + company create path; owner = claiming / territory rep (not stale Sage acctmgr for this intake — ops rule prefers territory). + +### Complementary track (no vendor required) + +Document as Phase B, not blocking: + +- Gate high-value assets (datasheets, RFQ) behind work email → harvest into Screening / Contacts with proven intent +- Use LinkedIn Sales Navigator for named outreach on accounts that already showed **web intent** (visitor feed as account list, not person ID) + +## Phased delivery + +1. **Decide + trial** — pick Dealfront or Snitcher; install pixel; define intent page list; measure 2–4 weeks; no CRM writes yet. +2. **Territory module** — commit territory JSON; `assignRep` + User email map; unit tests for cascade/exceptions. +3. **Ingest API** — signed webhook; dedupe by vendor company id / domain; apply filters + territory; write `PendingVisitor`. +4. **Screening UI** — Web tab + claim/approve; badge counts include web PENDING for assigned + claimed-by-me shared items. +5. **Ops** — suppress existing customers optionally; Slack/rail badge; refine page filters from real noise. + +## Risks / honesty + +- Reverse-IP often misses remote/mobile; office OEM traffic will ID better than home Wi‑Fi. +- Visitor “state” is not BILL-TO; it is a proxy until the company is matched to a Sage record with address. +- Person-level enrichment (LinkedIn of a buyer) stays a **human** step after company approve, or a later add-on — not the ingest pipe. diff --git a/.cursor/rules/local-dev.mdc b/.cursor/rules/local-dev.mdc new file mode 100644 index 000000000..72d194878 --- /dev/null +++ b/.cursor/rules/local-dev.mdc @@ -0,0 +1,66 @@ +--- +description: How to install, run and work with the CRM locally +alwaysApply: false +--- + +# Local development + +This repo uses **Bun** as its package manager/runtime and **Docker** for Postgres. + +## Prerequisites + +- Bun `1.3.x` (installed to `~/.bun`; ensure `~/.bun/bin` is on `PATH`) +- Docker Desktop running (Postgres container `crm-postgres` on `:5432`) +- Node `>=22` + +## First-time setup + +```bash +bun install # root .env must exist first (db postinstall reads DATABASE_URL) +docker compose up -d # Postgres on :5432 +cp .env.example .env # fill in the required values (see below) +bun run db:deploy # apply migrations +bun run db:seed # optional demo pipeline (idempotent) +bun run dev # app :3000, api :3001 +``` + +## Required `.env` values + +- `BETTER_AUTH_SECRET` — `openssl rand -base64 32` +- `ALLOWED_SIGN_IN` — email domain(s)/address(es) allowed to register or sign in + (empty = nobody). The whole authorisation model. + +`DATABASE_URL` already matches the docker-compose Postgres. Everything else in +`.env.example` is optional; a missing key removes a capability, never throws. + +## Auth (this install: email + password) + +Sign-in runs on **email + password**; Google OAuth is optional. + +- Register/sign in at `/sign-in` with an address on `ALLOWED_SIGN_IN`. +- Google sign-in is hidden unless `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` + are set (redirect URI `http://localhost:3001/api/auth/callback/google`). +- Differs from upstream — see `docs/local-setup.md` › "Auth changes from + upstream" for the exact files. + +## Common tasks + +| Command | What it does | +| ------------------------------------ | ------------------------------------- | +| `bun run dev` | Everything, watch mode | +| `bun run build` | Build all apps and packages | +| `bun run test` | Test suite | +| `bun run check-types` | `tsc --noEmit` everywhere | +| `bun run lint` / `bun run format` | Biome | +| `bun run db:migrate` | Create + apply a migration | +| `bun run db:studio` | Prisma Studio | +| `bun run --filter=api dev:session` | Print a session cookie for a local user | + +Scope any task with a Turborepo filter, e.g. `bun run dev --filter=api`. + +## Notes + +- There is **one `.env`** at the repo root, read by all three processes. + Never add a per-package `.env`. +- `apps/api/src/generated/server.ts` is committed; `build` must never + regenerate it. Regenerate locally with `bun run --filter=api trpc:generate`. diff --git a/.cursor/rules/project-overview.mdc b/.cursor/rules/project-overview.mdc new file mode 100644 index 000000000..89443ae16 --- /dev/null +++ b/.cursor/rules/project-overview.mdc @@ -0,0 +1,147 @@ +--- +description: High-level architecture and non-negotiable rules for the CRM monorepo +alwaysApply: true +--- + +# CRM — project overview + +An open-source, agentic-first CRM. The durable research agent is the product; +the database is where it writes things down. Single-tenant and internal by +design (Google sign-in + one allow-list). + +## Monorepo layout (Turborepo on Bun) + +| Path | What it is | +| --------------- | ------------------------------------------------------ | +| `apps/agent` | The research agent — tools, skills, schedules, sandbox | +| `apps/app` | Next.js front end · http://localhost:3000 | +| `apps/api` | NestJS API — HTTP, auth, tRPC, Google sync · :3001 | +| `packages/db` | Prisma schema, migrations, shared Postgres client | +| `packages/auth` | Better Auth config and the sign-in allow-list | +| `packages/ui` | shadcn/ui components, the Tailwind theme | +| `packages/env` | Finds and loads the root `.env` | + +## Three rules the codebase holds to + +- **Intelligence never lives in the API** (`docs/api.md`). Nest reports that + something happened by writing a queue row; the agent decides what it means. + A Nest service that calls an enrichment API is treated as a bug. +- **`packages/ui` is the only source of UI** (`docs/design.md`). No overriding + styles at the call site. +- **There are no organizations.** Single tenant, deliberately. Do not add an + `organizationId`. + +## The agent's own rule + +Nothing about a person is guessed. No tool accepts a confidence score. Tools +report what they observed; a ledger prices the evidence. Strong evidence writes +to the record, weak evidence becomes a suggestion a human settles. + +## Active track: Sage CRM interface layer (Phase 7) + +This fork is growing into an improvement layer OVER Sage CRM (on-prem, reached +via the `eware.dll` SOAP web service). The team runs sales in Sage — companies, +people, opportunities, and **forecasting** — and this app aims to be a nicer +surface on top, syncing bidirectionally (pull first, push later). + +- Read `docs/plans/sage-crm-sync.md` before any Sage work. SOAP access + field + mappings are confirmed against production; scope/decisions live there. +- Sage sync is MECHANICAL, so it lives in `apps/api/src/sage/` (mirrors the + Google/Microsoft modules) — the "intelligence never in the API" rule still + holds. +- **Forecasting is opportunity-driven in Sage** (deal value + certainty% + target + close), not a separate record. The team enters the deal value in Sage + `forecast` (their `total` is unused/empty), so local `Deal.amount` ← `forecast` + and `weightedAmount` = `amount` × `certainty` (corrected 2026-08-02 — the + original total/forecast assumption was backwards). `Deal` also carries + `probability` / `dealType` / `sageStage` / `sageStatus`; the overview shows a + forecast by close month (weighted vs unweighted). +- **7.4b full pull is DONE** (2026-08-02): ~14.2k companies / ~24.8k contacts / + 525 deals imported by the local one-shot `apps/api/scripts/sage-backfill.ts`; + `phase = incremental`, nightly cron wired. Company/contact owners come from + Sage `acctmgr` (name match). **Push write-back is BUILT** (outbox + echo-guard); + remaining Sage work (reconcile soft-deactivate, nested field push) is + DESIGN-ONLY / deferred. See `docs/plans/sage-crm-sync.md` and + `HANDOFF-SAGE-SYNC.md`. +- **Screening company match + Sage push (DONE 2026-08-03)**: Approve runs + `companies.similar` (shared `company-similar.ts`) with a **Suggested** pick + (Sage 100 > matching-domain contacts > volume). `companyForEmail` soft- + attaches on strong unique / suggested+signals. Approve enqueues Sage person + create when parent has `sageCrmCompanyId` and no same-name twin; failures + never fail local create. Plan: `docs/plans/sage-crm-sync.md` §4 items 6b/6c. +- **Pipeline pulse (DONE 2026-08-03)**: overview change feed + stuck deals + + pipeline AI chat (`read_pipeline_pulse` + `read_pipeline_report` for month / + stage / closing / closed). Sage pull (non-echo) appends `DealFieldChange` + rows with `source: sage`. Plans: `docs/plans/pipeline-pulse.md`, + `docs/plans/pipeline-agent-reports.md`. +- **Companies map (DONE 2026-08-03)**: `/map` — filterable list + Leaflet pins + (city-level geocode via Nominatim). Prod + local geocoded (~12.1k pins). + List follows map viewport / cluster; select flies to pin; **Open company** + opens `CompanySheet` on `/map`. Plan: `docs/plans/companies-map.md`. + Re-run `apps/api/scripts/geocode-companies.ts` when cities change; optional + local copy via `packages/db/scripts/pull-geocode-from-prod.ts`. +- **Trip Planner (DONE 2026-08-04)**: `/trip-planner` — hub city/days, optional + must-visits, ACTIVE/SALVAGE deal windows, agent itinerary + client PDF. + Plan: `docs/plans/trip-planner.md`. +- **Research agent on Railway (DONE 2026-08-03)**: always-on `agent` + service; `Dockerfile.agent` + Node 24 start script; app + `AGENT_URL` / `AGENT_BRIDGE_SECRET`. Ops notes: + `docs/plans/agent-railway.md`. +- **Website form leads → Screening (DONE 2026-08-05)**: Customer Question + emails from `info@` → `PendingWebLead`, territory-routed into the same + Screening list as mail (Web/Mail badge + Claim). Territory map: + `data/sales-territory.json` + `@crm/db` `assignRep`. Prod: Entra app + `Mail.Read` (application) + Exchange access policy limited to + `info@` via group `CRM-Webform-Mailbox-Access`; api + `WEBFORM_MAILBOX=info@mobilemark.com`; Railway `cron-webform` every + 5 min → `/internal/sync/webform`. Plan: + `docs/plans/webform-lead-screening.md`. +- **TEMPORARY agent model (2026-08-04)**: `apps/agent/agent/agent.ts` is on + `deepseek/deepseek-v4-pro` (cheaper while testing / burning tokens). **Plan + to change back to `anthropic/claude-sonnet-5`** when testing is done. Luna + was considered but skipped — economy tier, not a Sonnet peer. DeepSeek V4 + Pro is agentic-capable and far cheaper (~$0.44/$0.87 vs Sonnet ~$2/$10). + +## Prod Postgres from a laptop (temporary TCP proxy) + +Railway `DATABASE_URL` points at the **private** host +(`*.railway.internal`). That is correct for `api` / `agent` / `app` on the +Railway network, but a laptop cannot reach it — `railway run` alone still +fails with "Can't reach database server". + +For one-shot prod data work (repairs, invites, restores), open a **temporary +TCP proxy**, rewrite the URL host/port locally, run the script, then **delete +the proxy**. Do not leave a public Postgres proxy up. + +```bash +# 1. Open (only while you need it) +railway tcp-proxy create --port 5432 --service Postgres --json +# → domain + proxyPort (e.g. tokaido.proxy.rlwy.net:15025) + +# 2. Run (from apps/api) — injects secrets via railway run, swaps host/port +MM_PROXY_HOST=… MM_PROXY_PORT=… railway run -s api -- \ + bun run scripts/run-via-tcp-proxy.ts ./fix-sage-website-notes.ts --dry-run +# then without --dry-run; or ./ensure-user.ts --email … --name "…" + +# 3. Close +railway tcp-proxy delete --service Postgres --yes +railway tcp-proxy list --service Postgres --json # expect [] +``` + +Helper: `apps/api/scripts/run-via-tcp-proxy.ts`. Used for Sage website repair +and user invites when signup is disabled. Prefer concurrent/batched script +writes — interactive Prisma transactions of 100 rows time out over the proxy. + +## Where to read more + +- `AGENTS.md` — the authoritative task-entry rules (read it first) +- `docs/agent.md`, `docs/api.md`, `docs/design.md`, `docs/environment.md` +- `docs/local-setup.md` — how this machine was set up and what's left to do +- `docs/plans/sage-crm-sync.md` — Phase 7 Sage sync (confirmed SOAP + mappings) +- `HANDOFF-SAGE-SYNC.md` — Sage push write-back + Screening→Sage enqueue rules +- `docs/plans/pipeline-pulse.md` — overview pulse + pipeline agent +- `docs/plans/pipeline-agent-reports.md` — month / stage / closing reports +- `docs/plans/companies-map.md` — companies map + geocode script +- `docs/plans/trip-planner.md` — sales trip planner + agent itinerary +- `docs/plans/agent-railway.md` — eve agent on Railway (ops runbook) +- `HANDOFF.md` — running agent handoff (current state + work log) diff --git a/.cursor/rules/skills-and-docs.mdc b/.cursor/rules/skills-and-docs.mdc new file mode 100644 index 000000000..05de6950a --- /dev/null +++ b/.cursor/rules/skills-and-docs.mdc @@ -0,0 +1,27 @@ +--- +description: Where the domain skills and docs live — review the relevant one before coding +alwaysApply: true +--- + +# Skills & docs — read before you code + +Before starting a task, check whether a bundled skill covers the area and read +it. Cursor auto-discovers these under `.cursor/skills/`. There is a single +source of truth — `.agents/skills/` — and each harness just symlinks into it: +`.cursor/skills/` (Cursor), `.claude/skills/` (Claude Code). Edit skills in +`.agents/skills/`, never in the symlink dirs. Available skills: + +- `better-auth-best-practices` — auth work in `packages/auth` / `apps/api` +- `prisma-database-setup` — schema and migrations in `packages/db` +- `nestjs-best-practices`, `nestjs-trpc` — the API in `apps/api` +- `eve` — the research agent in `apps/agent` +- `shadcn`, `nuqs`, `no-use-effect`, `vercel-react-best-practices`, + `web-design-guidelines` — the front end in `apps/app` / `packages/ui` +- `turborepo`, `typescript-advanced-types` — monorepo + typing + +The full `eve` documentation also ships in +`apps/agent/node_modules/eve/docs` and matches the installed version — prefer it +over memory when writing eve code. + +Deeper design docs: `docs/agent.md`, `docs/api.md`, `docs/design.md`, +`docs/environment.md`. Architecture decisions: `adrs/`. diff --git a/.cursor/skills/ai-elements b/.cursor/skills/ai-elements new file mode 120000 index 000000000..ccc122f64 --- /dev/null +++ b/.cursor/skills/ai-elements @@ -0,0 +1 @@ +../../.agents/skills/ai-elements \ No newline at end of file diff --git a/.cursor/skills/better-auth-best-practices b/.cursor/skills/better-auth-best-practices new file mode 120000 index 000000000..d28d6bd7d --- /dev/null +++ b/.cursor/skills/better-auth-best-practices @@ -0,0 +1 @@ +../../.agents/skills/better-auth-best-practices \ No newline at end of file diff --git a/.cursor/skills/eve b/.cursor/skills/eve new file mode 120000 index 000000000..c1d546847 --- /dev/null +++ b/.cursor/skills/eve @@ -0,0 +1 @@ +../../.agents/skills/eve \ No newline at end of file diff --git a/.cursor/skills/nestjs-best-practices b/.cursor/skills/nestjs-best-practices new file mode 120000 index 000000000..ed0955adb --- /dev/null +++ b/.cursor/skills/nestjs-best-practices @@ -0,0 +1 @@ +../../.agents/skills/nestjs-best-practices \ No newline at end of file diff --git a/.cursor/skills/nestjs-trpc b/.cursor/skills/nestjs-trpc new file mode 120000 index 000000000..4b4e2d7b1 --- /dev/null +++ b/.cursor/skills/nestjs-trpc @@ -0,0 +1 @@ +../../.agents/skills/nestjs-trpc \ No newline at end of file diff --git a/.cursor/skills/no-use-effect b/.cursor/skills/no-use-effect new file mode 120000 index 000000000..7e259d037 --- /dev/null +++ b/.cursor/skills/no-use-effect @@ -0,0 +1 @@ +../../.agents/skills/no-use-effect \ No newline at end of file diff --git a/.cursor/skills/nuqs b/.cursor/skills/nuqs new file mode 120000 index 000000000..2e031211d --- /dev/null +++ b/.cursor/skills/nuqs @@ -0,0 +1 @@ +../../.agents/skills/nuqs \ No newline at end of file diff --git a/.cursor/skills/prisma-database-setup b/.cursor/skills/prisma-database-setup new file mode 120000 index 000000000..f188a7900 --- /dev/null +++ b/.cursor/skills/prisma-database-setup @@ -0,0 +1 @@ +../../.agents/skills/prisma-database-setup \ No newline at end of file diff --git a/.cursor/skills/shadcn b/.cursor/skills/shadcn new file mode 120000 index 000000000..8d5af6f6e --- /dev/null +++ b/.cursor/skills/shadcn @@ -0,0 +1 @@ +../../.agents/skills/shadcn \ No newline at end of file diff --git a/.cursor/skills/skill-creator b/.cursor/skills/skill-creator new file mode 120000 index 000000000..b87455490 --- /dev/null +++ b/.cursor/skills/skill-creator @@ -0,0 +1 @@ +../../.agents/skills/skill-creator \ No newline at end of file diff --git a/.cursor/skills/turborepo b/.cursor/skills/turborepo new file mode 120000 index 000000000..b69a7d9eb --- /dev/null +++ b/.cursor/skills/turborepo @@ -0,0 +1 @@ +../../.agents/skills/turborepo \ No newline at end of file diff --git a/.cursor/skills/typescript-advanced-types b/.cursor/skills/typescript-advanced-types new file mode 120000 index 000000000..4d2e52306 --- /dev/null +++ b/.cursor/skills/typescript-advanced-types @@ -0,0 +1 @@ +../../.agents/skills/typescript-advanced-types \ No newline at end of file diff --git a/.cursor/skills/vercel-composition-patterns b/.cursor/skills/vercel-composition-patterns new file mode 120000 index 000000000..55a19e8f4 --- /dev/null +++ b/.cursor/skills/vercel-composition-patterns @@ -0,0 +1 @@ +../../.agents/skills/vercel-composition-patterns \ No newline at end of file diff --git a/.cursor/skills/vercel-react-best-practices b/.cursor/skills/vercel-react-best-practices new file mode 120000 index 000000000..e567923b3 --- /dev/null +++ b/.cursor/skills/vercel-react-best-practices @@ -0,0 +1 @@ +../../.agents/skills/vercel-react-best-practices \ No newline at end of file diff --git a/.cursor/skills/web-design-guidelines b/.cursor/skills/web-design-guidelines new file mode 120000 index 000000000..886b26ded --- /dev/null +++ b/.cursor/skills/web-design-guidelines @@ -0,0 +1 @@ +../../.agents/skills/web-design-guidelines \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..345680d9b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +node_modules +**/node_modules +.git +.env +.env.* +!.env.example +.next +**/.next +.turbo +**/.turbo +dist +**/dist +*.md +!README.md +.cursor +.agents +.claude +agent-transcripts +**/*.dump +crm-local.dump diff --git a/.env.example b/.env.example index 0fa8c1773..c8310416e 100644 --- a/.env.example +++ b/.env.example @@ -15,9 +15,39 @@ BETTER_AUTH_SECRET="" # Subdomains count, so "acme.com" also admits "you@mail.acme.com". ALLOWED_SIGN_IN="" +# Optional CRM admins — comma-separated email addresses only (not domains). +# Admins may edit any deal and reassign owners. Everyone else may edit only +# deals they own. Leave empty for owner-only with no overrides. +# CRM_ADMIN_EMAILS="jjohnson@mobilemark.com" +CRM_ADMIN_EMAILS="" + GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_SECRET="" +# Microsoft Entra (single-tenant). Set all three to enable Microsoft sign-in. +# In Entra → App registration → Authentication, add the Web redirect URI: +# http://localhost:3001/api/auth/callback/microsoft +# (production: `{API_URL}/api/auth/callback/microsoft`) +# Delegated Graph permissions (admin consent): User.Read, Mail.Read, +# Calendars.Read, Mail.Send, offline_access, openid, profile, email. +# See docs/plans/sequences.md for the Mail.Send step. +MICROSOFT_CLIENT_ID="" +MICROSOFT_CLIENT_SECRET="" +MICROSOFT_TENANT_ID="" + +# Shared mailbox for website "Customer Question" form leads (Screening). Optional — +# unset disables ingest. Needs application Mail.Read + an Exchange application +# access policy limited to this mailbox. See docs/plans/webform-lead-screening.md. +# WEBFORM_MAILBOX="info@mobilemark.com" + +# Sage CRM SOAP (eware.dll web service). Optional — set all three to enable the +# Sage sync (pull companies/contacts/opportunities). Leave empty and the +# capability is simply off. URL is the SOAP endpoint, e.g. +# https://your-crm/crm/eware.dll/WebServices/SOAP. See docs/plans/sage-crm-sync.md. +SAGE_SOAP_URL="" +SAGE_SOAP_USER="" +SAGE_SOAP_PASSWORD="" + # ── Where things are ───────────────────────────────────────────────────────── # Only needed when you deploy. The defaults below are the localhost ones. @@ -25,10 +55,20 @@ GOOGLE_CLIENT_SECRET="" # The API. Also the origin that mints session cookies and serves /api/auth/*. # API_URL="http://localhost:3001" +# Server-only origin the Next.js app uses to reach Nest (proxy + RSC). Defaults +# to API_URL. On Railway behind Cloudflare, set the private hostname so the app +# does not hairpin through the public CDN (HTML error → "Unexpected token '<'"): +# INTERNAL_API_URL="http://api.railway.internal:3001" + # The web app. Comma-separate if it is served from more than one origin — # the first is canonical, and the rest are simply allowed to call the API. # APP_URL="http://localhost:3000" +# Absolute origin used in sequence tracking / unsubscribe links inside outbound +# email. Defaults to API_URL (then APP_URL) when unset. Set when the API is not +# the URL you want recipients to hit. +# APP_PUBLIC_URL="http://localhost:3001" + # Only when the API and the app are on different subdomains of one parent, # e.g. api.example.com and app.example.com — then set ".example.com" so one # session cookie covers both. @@ -48,6 +88,11 @@ GOOGLE_CLIENT_SECRET="" # else changes: the agent still runs on its own schedule. # AGENT_BRIDGE_SECRET="" +# Auto research from mail/calendar/cron. Default off — only Re-enrich / +# Research on a company sheet queues work. Set true on both `api` and `agent` +# to restore sync-driven identify / company-profile / meeting-prep / followups. +# AGENT_AUTO_ENRICH="false" + # PORT="3001" @@ -90,11 +135,18 @@ GOOGLE_CLIENT_SECRET="" # REDIS_URL="redis://localhost:6379" # CACHE_TTL_MS="60000" -# Bearer token guarding POST /internal/sync/google, the Gmail/Calendar cron -# route. The route refuses to run without it. At least 16 characters. +# Bearer token guarding POST /internal/sync/google, /internal/sync/microsoft, +# /internal/agent/followups, and /internal/sequences/tick. +# The routes refuse to run without it. At least 16 characters. # CRON_SECRET="" # Log every SQL statement Prisma runs, at debug level, without bound # parameters. Off by default: it buries every other line under a wall of # SELECTs. # PRISMA_LOG_QUERIES="true" + +# X-API-Key guard on GET /company/:masCustomerNo/order-defaults (external, +# read-only — the sister PO-processing app's lookup). Unset refuses the route +# rather than running unguarded. At least 16 characters. +# openssl rand -hex 32 +# CRM_API_KEY="" diff --git a/AGENTS.md b/AGENTS.md index 5b87af21f..3dc62f08e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,23 @@ You should always check and see if there are any relevant skill files you should review before starting a task e.g. if you're working on better-auth, always review the better auth best practice skill - if you're working on prisma, review your prisma-database-setup skill. +## Handoff protocol — HANDOFF.md (read first, update last) + +`HANDOFF.md` at the repo root is the running handoff log between agents. + +- **Before starting any work**: read `HANDOFF.md`. Its "Current state" section + and newest work-log entry tell you exactly where the last agent stopped, + what deviated from the plan, and what to do next. +- **Before stopping** (task done, phase done, or you're interrupted): update + `HANDOFF.md`. Append a new dated entry at the top of the work log answering: + what was completed (with file paths), how and why, deviations from the plan + or upstream ("None" if none), and the exact next step for the next agent. + Also refresh the "Current state" section. Never rewrite or delete old + entries. +- If you are executing a plan (e.g. `docs/plans/m365-expansion.md`), name the + plan and phase in your entry so the next agent can find its place without + guessing. + Please check below, if you're working on anything related review the rules and let the user know you've read them: ## Design @@ -39,3 +56,27 @@ no real customer names, addresses or company data in fixtures, tests, screenshots or docs — the seed in `packages/db/prisma/seed.ts` is the source of demo data. See @CONTRIBUTING.md and @SECURITY.md. +## Writing Style: ASD-STE100 Simplified Technical English + +When responding, follow ASD-STE100 Simplified Technical English (STE) — a controlled +writing standard developed by aerospace and defense organizations to produce clear, +unambiguous technical text. + +### Key Rules + +- **Use approved words only.** Stick to a defined word list. Each word should have + exactly one meaning in this context — avoid synonyms or alternate senses. +- **Use one word for one idea.** Don't use two different words to describe the same thing. +- **Write short sentences.** Aim for 20 words or fewer per instruction/sentence. +- **Use active voice.** Write "Turn the switch," not "The switch must be turned." +- **Write short paragraphs.** Keep one topic per paragraph. + +### Goal + +The goal is easy reading, especially for readers who may not be native English +speakers. Clear, simple text helps them understand and act correctly and safely. + +### Application + +Apply these rules to all responses in this agent's scope unless the user explicitly +requests a different style. \ No newline at end of file diff --git a/Dockerfile.agent b/Dockerfile.agent new file mode 100644 index 000000000..51c1f2833 --- /dev/null +++ b/Dockerfile.agent @@ -0,0 +1,36 @@ +# Research agent (eve) — Bun for install/build tooling; Node 24+ for eve. +# +# Railway has no Docker/KVM sandbox, so eve falls back to just-bash. That +# package patches Node's Module API and fails under Bun +# (`DefenseInDepthBox: Module._resolveFilename`). eve itself also requires +# Node.js >= 24. Do NOT use `npx` here — npm refuses this repo's +# `devEngines.packageManager = bun`. Invoke eve with `node` directly. +# See docs/plans/agent-railway.md. +FROM oven/bun:1.3 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl gnupg \ + && curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && node -v \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package.json bun.lock turbo.json ./ +COPY apps ./apps +COPY packages ./packages +COPY scripts/railway-agent-start.sh /app/scripts/railway-agent-start.sh + +# prisma generate (postinstall) reads DATABASE_URL from prisma.config.ts — +# only needed at build time for client generation, not a real connection. +ENV DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/crm?schema=public" + +RUN bun install --frozen-lockfile \ + && bunx turbo run build --filter=agent \ + && chmod +x /app/scripts/railway-agent-start.sh + +ENV NODE_ENV=production +EXPOSE 2000 + +CMD ["/app/scripts/railway-agent-start.sh"] diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 000000000..fbf6f6162 --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,24 @@ +# Nest API — Bun runtime (Railpack does not auto-detect Bun). +FROM oven/bun:1.3 + +WORKDIR /app + +COPY package.json bun.lock turbo.json ./ +COPY apps ./apps +COPY packages ./packages +COPY data ./data + +# prisma generate (postinstall) reads DATABASE_URL from prisma.config.ts — +# only needed at build time for client generation, not a real connection. +ENV DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/crm?schema=public" + +RUN bun install --frozen-lockfile \ + && bunx turbo run build --filter=api + +COPY scripts/railway-api-start.sh /app/scripts/railway-api-start.sh +RUN chmod +x /app/scripts/railway-api-start.sh + +ENV NODE_ENV=production +EXPOSE 3001 + +CMD ["/app/scripts/railway-api-start.sh"] diff --git a/Dockerfile.app b/Dockerfile.app new file mode 100644 index 000000000..0bfc8c004 --- /dev/null +++ b/Dockerfile.app @@ -0,0 +1,31 @@ +# Next.js app — Bun runtime (Railpack does not auto-detect Bun). +# API_URL / APP_URL must be set as Railway variables. Dockerfile builds only +# receive them when declared as ARG (then ENV) — otherwise Next inlines the +# localhost fallback into the browser bundle and Microsoft sign-in CORS-fails. +FROM oven/bun:1.3 + +WORKDIR /app + +COPY package.json bun.lock turbo.json ./ +COPY apps ./apps +COPY packages ./packages + +# prisma generate (postinstall) reads DATABASE_URL from prisma.config.ts — +# only needed at build time for client generation, not a real connection. +ENV DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/crm?schema=public" + +# Railway passes matching service variables as --build-arg. +ARG API_URL +ARG APP_URL +ARG NEXT_PUBLIC_API_URL +ENV API_URL=$API_URL +ENV APP_URL=$APP_URL +ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-$API_URL} + +RUN bun install --frozen-lockfile \ + && bunx turbo run build --filter=app + +ENV NODE_ENV=production +EXPOSE 3000 + +CMD ["bun", "run", "--cwd", "apps/app", "start"] diff --git a/HANDOFF-SAGE-SYNC.md b/HANDOFF-SAGE-SYNC.md new file mode 100644 index 000000000..2e86289f6 --- /dev/null +++ b/HANDOFF-SAGE-SYNC.md @@ -0,0 +1,261 @@ +# HANDOFF-SAGE-SYNC.md — Sage push write-back + +Dedicated handoff for the **local → Sage CRM push** work (plan: +`.cursor/plans/sage_push_write-back_7ae8cdad.plan.md`, canonical design: +`docs/plans/sage-crm-sync.md`). Read this **before** continuing Sage push work. +Keep the main `HANDOFF.md` for other tracks; update **both** when you stop. + +## How to update this file + +- **Append, never rewrite history.** New dated entry at the top of "Work log". +- Each entry: what completed (with paths), how/why, deviations, what's next. +- Keep **Current state** edited in place. + +--- + +## Current state (2026-08-05) + +### Verdict vs plan + +| Plan phase | Status | Notes | +| --- | --- | --- | +| 0 Verify SOAP `add`/`update` | **DONE** (live) | Variant A envelope confirmed. Opp 557 update restored. Add created **opp `805`** on company 24 (`forecast=1`) — **delete in Sage**. | +| 1 `SageOutbox` schema | **DONE** | Migration `20260803120000_add_sage_outbox` (already applied locally). | +| 2 Reverse mappings | **DONE** | `toSage*` in `sage.mappings.ts` + unit tests. **+ company `acctmgr` from Owner** (2026-08-05). | +| 3 `SagePushService.flush()` | **DONE** | Outbox drain under `withSageSession`, 3 retries, parent-before-child. | +| 4 Human UI enqueue hooks | **DONE** | Companies / contacts / deals create+update (+ deal `setStage`). **+ Screening Approve** when parent has `sageCrmCompanyId` and no same-name Sage twin. **+ contact fact accept** for `title` / `name` (`decideFact`). **+ company `ownerId` change** (2026-08-05). | +| 5 Immediate + periodic flush | **DONE** | `enqueueAndKick` + cron drains after pull on `/internal/sync/sage`. | +| 6 Tests + smoke | **PARTIAL** | Unit tests green. Screening→Sage + UI→outbox smoke still owed in browser. Owner→`acctmgr` smoke owed. | +| Docs | **DONE** | This file + `sage-crm-sync.md` §4 6c / §5 item 7 updated. | + +### Related: pipeline change log (not push, but touches pull) + +Pull diffs on deals (when **not** a push echo) now append `DealFieldChange` +rows (`source: sage`) via `DealChangeRecorder` in `SagePullService.upsertDeal`. +That feeds the overview pulse / pipeline agent (`docs/plans/pipeline-pulse.md`). +Echo-guard still skips mapped-field overwrite **and** skips change-log rows on +echo, so a local push does not double-count. + +### Locked decisions (do not relitigate) + +- **Scope**: UPDATE + CREATE for company / contact / deal. +- **Create trigger**: human UI create/update, **plus Screening Approve** when + the parent company already has `sageCrmCompanyId` and no same-name + Sage-linked twin sits on that company. Still not agent / Google / + Microsoft auto-create / Sage pull. Screening enqueue failures never fail + the local create (outbox is best-effort). +- **Timing**: durable `SageOutbox`; best-effort immediate flush; max **3** attempts then `failed`. +- **Conflict**: **local wins** on mapped fields. Pull echo-guard skips Sage rows whose `updateddate` ≤ local `sagePushedAt`. + +### Confirmed SOAP write shape (Phase 0) + +``` + + opportunity|company|person + + value + … + + +``` + +- Namespaces: request `http://tempuri.org/`, types `http://tempuri.org/type`. +- Field names are **short** (same as query response), not `oppo_`/`comp_`/`pers_` predicates. +- **Update** response: `1true`. +- **Add** response: `805` (not ``). Client: `parseAddId`. +- Probe: `apps/api/scripts/sage-push-probe.ts` + - `--logon-only` / `--update` / `--add` + - Test targets: opp **557**, company **24**. + +### Cleanup owed in Sage + +- Opportunity **`805`** on company 24 — throwaway add probe (`PUSH PROBE ADD A …`, forecast `$1`). Delete manually in Sage CRM. + +### Key files + +| Path | Role | +| --- | --- | +| `apps/api/src/sage/sage-soap.client.ts` | `add` / `update` + `writeBody` | +| `apps/api/src/sage/sage-xml.ts` | `parseAddId`, `parseUpdateResult` | +| `apps/api/src/sage/sage.mappings.ts` | `toSage*Fields`, `sageStageForPush`, `isPushEcho`, `sageUserIdForEmail` | +| `apps/api/src/sage/sage-push.service.ts` | `enqueue`, `enqueueAndKick`, `flush` | +| `apps/api/src/sage/sage-pull.service.ts` | echo-guard on company/contact/deal upserts; stamps `sageUpdatedAt` | +| `apps/api/src/sage/sage-sync.controller.ts` | returns `{ pull, push }` after scheduled sync | +| `apps/api/src/sage/sage.module.ts` | exports `SagePushService` | +| `apps/api/src/{companies,contacts,deals}/*` | import `SageModule`; UI create/update kick push | +| `packages/db/prisma/schema.prisma` | `SageOutbox` model (`@@map("sageOutbox")`) | +| `packages/db/prisma/migrations/20260803120000_add_sage_outbox/` | migration | +| `apps/api/scripts/sage-push-probe.ts` | live write-shape probe | +| `apps/api/test/sage-mappings.spec.ts` | reverse mapping + echo tests | +| `apps/api/test/sage-xml.spec.ts` | add/update parse tests | + +### Flow + +``` +Human tRPC mutation + → Companies/Contacts/DealsService (actor from @Ctx) + → DB write + → SagePushService.enqueueAndKick(entity, localId, actorId) + → SageOutbox pending row (dedupe per entity+localId) + → void flush() // best-effort; never blocks UI + +Nightly /internal/sync/sage (CRON_SECRET) + → pull.runScheduled() + → push.flush() // same advisory lock rule; sequential, not concurrent +``` + +### Known gaps / next agent work + +1. **Human smoke (highest priority)** + - Restart API (`bun run src/main.ts` in `apps/api` — no hot reload). + - Edit deal **557** locally (name / certainty / amount) → confirm Sage opp updates. + - Create a small local deal under company 24 → confirm Sage create + `sageCrmOpportunityId` written back. + - Confirm next incremental pull does **not** overwrite the local edit (echo-guard). + - Delete Sage opp **805**. + +2. **Nested company fields not pushed** + Company phone/email/address and contact email/phone are **not** in `toSageCompanyFields` / `toSagePersonFields` (flat write cut). Catalog §3 maps them on pull; push of nested children is a later phase. + +3. **Failed outbox UI** + Rows park as `status=failed` after 3 attempts; no admin UI / retry button yet. Query: + `SELECT * FROM "sageOutbox" WHERE status IN ('pending','failed') ORDER BY "createdAt";` + +4. **Reconcile soft-deactivate** (`sageDeactivatedAt`) still DESIGN-ONLY (`sage-crm-sync.md` §6.7). + +5. **`sage.router.ts` status/syncNow** still not built (cron uses CRON_SECRET HTTP route). + +6. **Lease-row lock upgrade** still deferred (`withSageSession` advisory-lock caveat with pooling — documented in `sage-session.ts`). + +7. **Cron response shape changed** — `/internal/sync/sage` now returns `{ pull, push }` instead of the bare pull summary. Any external monitor that expected the old shape needs updating. + +### Verification already run + +- Live: `sage-push-probe.ts --logon-only` OK; `--update` on 557 OK + restored; `--add` created **805**. +- `bun test test/sage-mappings.spec.ts test/sage-xml.spec.ts` — 44 pass. +- `bunx tsc --noEmit` in `apps/api` — green. +- `prisma migrate deploy` — outbox migration already applied. + +--- + +## Work log (newest first) + +### 2026-08-05 — Company Owner → Sage `acctmgr` push + +**What was completed** +- `toSageCompanyFields` writes `acctmgr` from mapped owner email + (`acctMgrNameForEmail`). +- `pushCompany` loads `owner.email`; company update enqueues on `ownerId`. +- Local coverage check: 5,985 owned Sage companies; 0 matchable-but-null + (Wallgren/Sertich/Moore unmatched by design). +- Files: `sage.mappings.ts`, `sage-push.service.ts`, `companies.service.ts`, + `sage-mappings.spec.ts`, `docs/plans/sage-crm-sync.md` §3.1. + +**How and why** +- Pull already mapped `acctmgr` → Owner; push only wrote company `name`, + and Owner edits did not enqueue. Local Owner changes now round-trip. + +**Deviations** +- Null/unmapped owners omit `acctmgr` (do not clear Sage), same as deal + `assigneduserid`. + +**What's next** +- Soft smoke: change a company Owner in UI → outbox → Sage Account Manager. +- Nested phone/email/address push still deferred. + +### 2026-08-04 — Fact accept (title/name) enqueues Sage push + +**What was completed** +- `contacts.decideFact` accepts for `title` or `name` now call + `sagePush.enqueueAndKick`, same as Details field saves. +- File: `apps/api/src/contacts/contacts.service.ts`. + +**How and why** +- Reps often accept a signature title and leave; without this, Sage + never gets the update unless they edit another pushable field. + +**Deviations** +- None. Profile-URL facts still local-only (not in person push map). + +**What's next** +- Soft smoke accept → outbox → Sage title. Lindsay's prior accept needs + a one-time re-save after deploy. + +### 2026-08-03 — Screening Approve enqueues Sage person create + +**What was completed** +- Screening Approve best-effort pushes when parent has `sageCrmCompanyId` + and no same-name Sage-linked twin at that company. +- Local create never fails on enqueue/flush errors. +- Decide returns `sagePushQueued`; toast says "queued for Sage" when true. +- Locked create-trigger decision + `sage-crm-sync.md` §4/§5 updated. +- Files: `contacts.service.ts`, `screening.service.ts`, + `screening-table.tsx`, this handoff, `sage-crm-sync.md`. + +**How and why** +- Approve is a human choice onto a known Sage company; without push the + contact stays CRM-only and pull cannot invent a Sage person. + +**Deviations** +- Nested person email/phone still not in the push mapping (pre-existing). + +**What's next** +- Soft check Approve → outbox → `sageCrmContactId` stamped. +- Optional later: nested email on person create so Sage shows the address. + +### 2026-08-03 — Note: DealFieldChange on Sage pull (pipeline pulse) + +**What was completed (adjacent track)** +- `SagePullService.upsertDeal` records field diffs to `DealFieldChange` when + the update is **not** a push echo (`source: sage`). Same echo-guard as + mapped-field overwrite. See `docs/plans/pipeline-pulse.md` and main + `HANDOFF.md`. + +**How and why** +- Overview pulse needs forward-only change history; pull is a primary source + of opportunity moves. Logging on echo would double-count local UI edits that + were already written with `source: app`. + +**Deviations** +- None for push itself — push phases unchanged; E2E smoke still owed. + +**What's next** +- Unchanged: delete Sage opp **805**; restart API; smoke UI edit of deal 557 + + create under company 24; confirm echo-guard on next pull. + +### 2026-08-03 — Sage push write-back wired end-to-end (agent: Cursor) + +**Plan**: `sage_push_write-back_7ae8cdad.plan.md` Phases 0–6. + +**What was completed** + +- **Phase 0 live verify**: SOAP write shape confirmed (variant A). Update on opp 557 worked and was restored. Add under company 24 created opp **805** (`crmid`). Probe logging fixed to read ``. +- Scaffolding already present from prior WIP was completed/wired: + - SOAP `add`/`update` in `sage-soap.client.ts` + - `SageOutbox` + `SagePushService` + - Reverse mappings `toSage*` +- **Enqueue hooks**: actor threaded into companies/contacts routers; `enqueueAndKick` from human create/update (+ deal `setStage`). Screening/`EMAIL` creates do **not** enqueue. +- **Cron flush**: `SageSyncController` runs pull then `push.flush()`. +- **Echo-guard**: pull upserts skip mapped-field overwrite when `isPushEcho(sageUpdatedAt, sagePushedAt)`; still stamps `sageUpdatedAt`. +- **Tests**: reverse mappings + parseAddId/parseUpdateResult + isPushEcho. +- **Docs**: `docs/plans/sage-crm-sync.md` §5.7 → BUILT; this handoff file created. + +**How and why** + +- Durable outbox + kick matches single-session / slow SOAP constraints without blocking UI saves. +- Local-wins + echo-guard prevents the nightly pull from undoing a rep's edit that we just pushed. +- Human-only enqueue keeps Google/Microsoft/agent/screening from flooding Sage with creates. + +**Deviations** + +- Cron response is now `{ pull, push }` (plan said "extend the route"; shape change is the concrete form). +- Company/contact nested phone/email/address still not pushed (same as the existing reverse-mapping cut — documented, not expanded). +- E2E UI smoke left for the human/next agent (API must be restarted first). +- Probe initially logged `new id=null` because it looked for `opportunityid` instead of `crmid` — fixed; real id is **805**. + +**What's next** + +1. Delete Sage opportunity **805**. +2. Restart API; smoke edit deal 557 + create a tiny deal under company 24. +3. Optionally add failed-outbox visibility in settings. +4. Do **not** start reconcile soft-deactivate unless asked (`§6.7`). +) diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 000000000..220ddffbd --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,4940 @@ +# HANDOFF.md — agent handoff log + +This file is the single source of truth for "where is this project and what +happens next". Every agent MUST read it before starting work and MUST update +it before stopping. The rules for maintaining it live in `AGENTS.md` +("Handoff protocol"). + +## How to update this file + +- **Append, never rewrite history.** Add a new dated entry at the top of the + "Work log" section. Keep old entries untouched. +- Each entry must answer four questions: + 1. **What was completed** — concrete outcomes, with file paths. + 2. **How and why** — the approach taken and the reason for it. + 3. **Deviations** — anything that differs from the plan or from upstream, + and why. If nothing deviated, say "None". + 4. **What's next** — the exact next step a new agent should take, with a + pointer to the relevant plan section. +- Also keep the "Current state" section below up to date (edit in place — + this section IS a rewrite-in-place summary, unlike the log). + +--- + +## Current state (keep this section up to date) + +- **Sage cursor time zone fix (DONE prod `5b37dce` + `dff0909`, + 2026-10-01)**: Sage datetimes are `America/Chicago` wall time. + The nightly cursor was written in UTC, so every Sage save + between the 1:00 AM CDT run and ~5:00 AM was skipped forever. + Now `toSageClock` / `fromSageClock` (`sage-clock.ts`) format + the cursor and parse `updateddate` in Central. Catch-up ran in + prod: all 271 Sage opportunities saved since 2 Aug match the + CRM (0 stale). Only opp **807** is absent (no company in Sage). + Re-pull tool: `scripts/sage-backfill.ts --opportunities-since=`. + Companies in that window were already current (11 rows). +- **2:35am CDT cron crashes (checked prod 2026-09-30)**: + `cron-sequences`, `cron-microsoft`, and `cron-webform` + showed CRASHED between 07:35 and 07:54 UTC. The web + app, api, and agent processes stayed up. Cause: those + crons curl the public API and exit on any non-200. + At 07:35 UTC Cloudflare returned 404 and Railway never + saw the requests. At 07:43 UTC the API returned 500 + because `prisma.account.findMany()` failed with + `getaddrinfo ETIMEOUT`. Postgres was checkpointing + normally. At 07:54 UTC `/internal/sequences/tick` hung + until Cloudflare returned 524 and the client closed + (edge 499). By 07:55 UTC the same routes were 200 + again. Separate ongoing issue: webform ingest still + logs `Access is denied` for `info@` and returns 200. +- **Sage cron health (re-checked prod 2026-09-15)**: Nightly + pull is healthy. Sep 15 06:02 UTC finished **clean on + the first try** (no 503): 226 companies / 585 contacts / + **0 deals** / 811 snapshots. That is yesterday’s Sage + company edits landing as expected. Deals stay at 0 + because Sage still is not changing opportunities (last + opp **848** on Sep 9). Sep 10/11/14 still had a 503 + + curl retry; Sep 12/13/15 were first-try OK. Outbox + failures (opp **821**, one contact create) are push-only. +- **Won vs new-pipeline chart colors (DONE local 2026-09-11)**: + Closed won stays green (`--success`). New pipeline is + blue (`--chart-2`). `--chart-1` is also green, so both + series used to look the same. File: `sales-dashboard.tsx`. + Shipping with this push. +- **Everyone recent activity is CRM-only (DONE local 2026-09-11)**: + Overview Everyone no longer shows mailbox rows: email + threads, calendar events, or “added from your inbox” + company/contact creates. Team notes, calls, tasks, + stage changes, and other enrichment stay. Me still + shows the signed-in user's mail. Files: + `dashboard.service.ts` `recentActivityWhere`, + `dashboard-summary.tsx`, `dashboard-range.spec.ts`. + Shipping with this push. +- **Stage changes are owner-only (DONE local 2026-09-11)**: + Only the assigned deal owner can move stage. Admins + (`CRM_ADMIN_EMAILS`, e.g. jjohnson) can still edit + fields and reassign owner, but not Harry Kim's stage. + API `deals.setStage` returns 403 for anyone else. + UI locks the sheet rail, header menu, and table cell. + Files: `packages/auth/src/admins.ts` `canChangeDealStage`, + `deals.service.ts`, `use-deal-edit-access.ts`, + `deal-sheet.tsx`, `owned-deal-stage-menu.tsx`. + **Needs api + app deploy.** +- **Overview Everyone hides overdue tasks (DONE prod `d896ec5`)**: + Everyone tab no longer shows the Overdue tasks card. + Deals in progress is full width. Me still shows both + cards side by side. File: `dashboard-summary.tsx`. +- **DealQuote `isPrimary` as disambiguator (DONE prod `d896ec5`)**: + Backfill had marked every clone primary. Prod repaired + via TCP proxy + `repair-deal-quote-primaries.ts`: **11** + colliding numbers now have **exactly one** primary + (`Q260413-003` 8→1). Unique numbers stay false. + Attached **Q260622-002** to `#Q260622-001 & 002: …` + (sage opp **679**). 202 rows. Do not parse + `opportunity_name` in Analytics. Do not store + `SavedQuote.id`. Orphans not in `fct_quotes` left + alone: `Q260116-003`, `Q260716-006`, `Q260724-006`. + Nightly warehouse extract `0 9 * * *` UTC re-pulls + `dealQuote`; no new extract needed. Script: + `apps/api/scripts/repair-deal-quote-primaries.ts`. +- **Overview overdue KPI + locked/live board (DONE local 2026-09-11)**: + Lost tile removed (win rate still names wins and losses). + New **Overdue** tile: open deals whose `expectedCloseDate` is + before now (same rule as Deals → Closing → Overdue). Not the + pulse **Stuck** list (14d no stage move). Locked cluster + (inverted, ignores date range): Due this month, Open pipeline, + Overdue, Stuck. Live cluster (follows range): Closed won, Win + rate, Won, Deal Maturity moves. Files: `stat-card.tsx` + `tone="locked"`, `dashboard.tsx` `StatBoard` + `StatGroup` + `columns="pair"`, `dashboard.service.ts` `overdueOpenWhere`, + `sales-dashboard.tsx`. **Needs api + app deploy.** +- **Overview recent-feed filters (DONE local 2026-09-11)**: Recent + deal moves on `/` filter by rep (`pulseRep`) and change reason + (`pulseChange`). Filters live in the URL. Unfiltered rows still + come from `dashboard.summary.pulse`. A filter on queries + `dashboard.pulseRecent` so a year-long range can still fill 24 + matching rows. Rep select is Everyone only. Files: + `packages/db/src/pipeline-pulse.ts`, `dashboard.contracts.ts`, + `dashboard.service.ts`, `dashboard.router.ts`, + `overview-search-params.ts`, `pipeline-pulse.tsx`, + `apps/app/app/(app)/page.tsx`. Plan: + `docs/plans/pipeline-pulse.md`. **Needs api + app deploy.** +- **Deal quote numbers (DONE prod 2026-09-11 / deploy `bd6e7b5`)**: + `DealQuote` is live. Canonical `QYYMMDD-###` only; Sage + shorthand expands on save; never stores quote-tool `quote_id`. + Prod: **202** quotes (was 201; +`Q260622-002`). Human add / + remove / set-primary on the deal sheet. Analytics extract + is live (`raw.mm_crm_deal_quotes`). Exact-id tiers: + unique → `deal_quote_exact`; one primary → + `deal_quote_primary`; else `deal_quote_ambiguous`. + Join `upper(trim(quoteNumber))` = `fct_quotes.quote_number`. + `GRANT SELECT ON "dealQuote"` already applied. +- **closedAt forward fix (DONE prod `bd6e7b5`)**: Pull no longer + copies `targetclose` / `opened` into `closedAt`. Rule: + Sage `closed` if Chicago date ≤ today; else freeze an existing + past `closedAt`; else stamp now once (first observe). Future + dates rejected even when Sage filled `closed`. Historical past + fallback dates stay frozen (173 empty-`closed` Won not rewritten). + Next nightly Sage pull will fix query-C futures (TALLEY 837 + $256k → sync day; 525/589 future Sage `closed` → now). +- **Sage incremental pagination restart (DONE prod `bd6e7b5`)**: + Nightly `cron-sage` was 503 on the first curl then 200 on `--retry` + because Sage SOAP `next` returned `List index out of bounds (75)` + (Sep 10) / `Query failed to run successfully.` (Sep 11). In-process + restart only covered session-lost + transport; those two faults now + restart the changed-set walk (`isSagePaginationFault`, same cap + `SAGE_SESSION_RESTART_LIMIT`). Files: `sage.constants.ts`, + `sage-pull.service.ts` comments, `test/sage-session-lost.spec.ts`, + `docs/plans/sage-crm-sync.md` §6.8. +- **Sage Deal.currency id leak (DONE prod 2026-09-09)**: Mapper writes + Sage lookup id `1` → `"USD"`. Incremental sync rematerializes leaked + ids. **api** `b2761aa` is live. Manual Sage sync ran (SSH into `api`, + same `/internal/sync/sage` route as `cron-sage`; a from-source + redeploy of `cron-sage` finished without hitting the API). Confirm: + `SELECT currency, count(*) FROM deal GROUP BY 1` → **USD 565**, no + `'1'`. MM-Analytics unchanged. +- **Overview Everyone activity hides others' mail (SUPERSEDED 2026-09-11)**: + The 21 Aug rule still showed the signed-in user's inbox on + Everyone. Replaced by **Everyone recent activity is CRM-only**. +- **Git**: `origin` = `jj-mobilemark/crm` (fork); `upstream` = `trycompai/crm` + (read-only). Work on `main`. Last upstream sync is `288d41a` (2 Aug). + **Passed on trycompai/crm v1.14.0** (18 Aug) — 150 upstream commits / + 66 fork commits / 124 overlapping files; org model + colliding Prisma + timestamps. Revisit later via cherry-pick (Context.dev people + + enrichment queue), not a full merge onto `main`. +- **Agent auto-enrich OFF by default (DONE local 2026-08-07)**: Sync / + calendar / daily followups no longer enqueue `AgentTask` unless + `AGENT_AUTO_ENRICH=true` on **both** `api` and `agent`. Company sheet + Re-enrich / Research (`companyRequested`, priority ≥ 100) still + queues. Agent dispatcher retires auto backlog when the flag is off. + Files: `agent-trigger.service.ts`, `tasks.ts`, `dispatch.ts`, + `.env.example`, `env.validation.ts`, `docs/plans/agent-railway.md`. + **Needs deploy of `api` + `agent` to stop prod spend** (unset flag = + off; no Railway var required). +- **External company APIs for PO intake (DONE 2026-08-07)**: Sister + PO app. Auth `X-API-Key` → `CRM_API_KEY`. + - `GET /company/:mas/order-defaults` (+ `?name=&zip=` exact; soft + `?name=` / `?email_domain=` / `?phone=` → candidates) + - **`POST /company/resolve`** — ranked resolve from letterhead/buyer/ + email/MAS hint; ship-to never auto-accepted by default; vendor + names rejected; auto-accept ≥0.85 + MAS id. Files: + `company-resolve.service.ts`, `order-defaults.controller.ts`, + `crm-api-key.ts`, tests in `test/company-resolve.spec.ts`. + - order-defaults fields: attention/phone/email/terms_code/rep_*/ + is_distributor. Still no tracking/ship_via/freight. Plan §7. +- **Overview Closed won through month-end + opp 799 (DONE 2026-08-18)**: + "This month" now counts the full calendar month, not month-to-date + (`resolveRange` in `dashboard.service.ts`). Echo-guard TTL 2h — + Sage often does not bump `updateddate` after our push, which froze + rows. Prod repair from snapshots: **71 deals** remapped (incl. + opp 799 Proposal → Closed won). Open pipeline was inflated by + Sage Won/Lost rows left on open stages after the Aug 4 sageStage + migration. Needs **api** deploy for the month-end card. TCP proxy + used then deleted. +- **Sage SOAP walk retries (DONE prod via `b2761aa` 2026-09-09)**: + Incremental pull restarts on session-lost **or** transport blips + (timeout / unable to connect). SOAP deadline 90s. `logon` retries + 3× with backoff on transport failure only — never on `auth-failed`. + Pagination faults (`List index out of bounds` / company-opportunity + `Query failed…`) are the 2026-09-11 local follow-up above. +- **Sage cron health (2026-09-03 check; superseded 2026-09-14)**: + See the 2026-09-14 Sage cron health bullet at the top of + this section. +- **Webform lead Screening (DONE prod wiring 2026-08-05)**: Customer + Question emails from shared mailbox → `PendingWebLead`, + territory-routed into the same Screening list as mail (Web/Mail badge + + Claim for unassigned). Territory: `data/sales-territory.json` + + `@crm/db` `assignRep`. **Prod ready:** Entra application `Mail.Read` + + Exchange policy group `CRM-Webform-Mailbox-Access` → `info@` only; + api `WEBFORM_MAILBOX=info@mobilemark.com`; Railway `cron-webform` + (`*/5 * * * *`) → `GET /internal/sync/webform`. Policy may take up to + ~1h to fully grant; smoke with a manual curl after + `Test-ApplicationAccessPolicy` shows Granted. Plan: + `docs/plans/webform-lead-screening.md`. Migration + `20260805140000_add_pending_web_lead` applied on api deploy. +- **Daily Task Push (DONE prod cron fix 2026-08-06)**: Settings switch on + the Microsoft card; opt-in emails open tasks at 9:00 America/Chicago + via Graph `Mail.Send`. Route + `GET/POST /internal/notifications/daily-tasks` (`?force=1` smoke OK). + Railway `cron-daily-tasks` `0 14,15 * * *` UTC — start command fixed + (literal API host; was `URL rejected: No host part`). Plan: + `docs/plans/daily-task-push.md`. +- **Owner ↔ Sage acctmgr push + Trip owner-aware (DONE local 2026-08-05)**: + Company Owner edits enqueue Sage push and write `acctmgr` (mapped reps + only). Local owner coverage verified: 5,985 owned / 0 matchable gaps + (former-rep blanks by design). Trip Planner ranks mine → unassigned → + other; agent asks before scheduling other-owned. Files: + `sage.mappings.ts`, `sage-push.service.ts`, `companies.service.ts`, + `trip-plan.ts`, `search_trip_candidates.ts`, `preamble.ts`, + `instructions.md`. Docs: `sage-crm-sync.md` §3.1, `trip-planner.md`. +- **Map re-geocode after state/country (DONE local + prod 2026-08-04)**: + Stale city-only place keys (`englewood||`) pinned wrong cities + (Englewood CO→NJ, etc.). Cleared with `--refresh-stale`; re-ran with + **Open-Meteo** (`concurrency=4`, `--no-fallback`). Local ~5.5 min + (4888 places → 7375 companies); prod ~20 min (5436 places → 10931 + companies). Spot-check: Englewood/Aurora CO in Denver bbox. ~1k + unique places still uncached-as-fail (junk/odd city strings). Code + not fully committed: `open-meteo.geocoder.ts` + geocode script edits. + Docs: `docs/plans/companies-map.md`. TCP proxy deleted after. +- **Trip Planner must-visit chip labels (DONE local 2026-08-04)**: + Reloading a trip no longer shows truncated company ids on must-visit + chips — `companies.byIds` resolves names. Files: + `company-multi-picker.tsx`, `companies.service.ts` / router / + contracts, generated `server.ts`. +- **Trip Planner open-deal priority (DONE local 2026-08-04)**: Candidate + ranking keeps must-visits first, then **open-deal accounts by deal + size**; ACTIVE mode also includes still-open deals outside the + look-back window. Agent preamble/tools tell the model to fill leftover + day slots that way. Files: `packages/db/src/trip-plan.ts`, + `search_trip_candidates.ts`, `preamble.ts`, `instructions.md`, + `docs/plans/trip-planner.md`. +- **Trip Planner UI polish (DONE local 2026-08-04)**: `/trip-planner` + rebuilt to match CRM density — Empty state, trip list with + StatusIndicator, focused create form (FieldSet / ToggleGroup), + edit workspace with brief + itinerary + agent. Files: + `trip-planner-client.tsx`, `trip-planner/page.tsx`. Plan: + `docs/plans/trip-planner.md`. +- **Trip Planner (DONE local 2026-08-04; owner-aware 2026-08-05)**: + `/trip-planner` nav (Carbon Plane). Persisted `TripPlan` + agent kind + `trip` (`x-crm-trip` → `tripPlanId`). Deal-only ACTIVE/SALVAGE ranking + via shared `@crm/db` helpers; candidates prefer the planner's accounts + (mine → unassigned → other; agent asks before other-owned). Agent tools + `read_trip_plan` / `search_trip_candidates` / `write_trip_itinerary`. + Client PDF via `jspdf`. Plan: `docs/plans/trip-planner.md`. + Migration `20260804150000_add_trip_plan` applied locally. +- **TEMPORARY agent model (2026-08-04)**: research agent on + `deepseek/deepseek-v4-pro` for cheaper testing; **revert to + `anthropic/claude-sonnet-5` when done**. Files: + `apps/agent/agent/agent.ts`, `.cursor/rules/project-overview.mdc`. +- **Fact accept → Sage push (DONE local 2026-08-04)**: Accepting a + contact fact for `title` or `name` enqueues `SageOutbox` the same way + as editing those fields in Details (`contacts.decideFact` → + `sagePush.enqueueAndKick`). LinkedIn/etc. facts still local-only. + File: `apps/api/src/contacts/contacts.service.ts`. +- **Deals Owner default + Stage multiselect + contact company + picker (DONE local 2026-08-04)**: `/deals` Owner facet defaults to + `"me"` (signed-in user) so the list opens on your pipeline. Stage + facet is multiselect (comma-joined URL); all stage labels always + shown (Leads → Unqualified). Contact sheet Company uses searchable + `CompanyPicker` (Sage 100 # + contact count). Files: + `deals-search-params.ts`, `deals-table.tsx`, `deals/page.tsx`, + `deals.service.ts`, `data-table.tsx`, `inline-field.tsx`, + `contact-sheet.tsx`, `company-picker.tsx`. +- **Certainty by rep + stage-locked certainty (DONE local 2026-08-04)**: + Everyone overview **Deal Maturity by rep** grid below forecast — + reps × stage bands with historical close windows (This month / + Last month / This quarter / Last quarter / YTD / Custom). API + `dashboard.certaintyByRep`. Certainty locked to stage; UI label is + "Deal Maturity". Migration `20260804140000` applied locally. Docs: + sage-crm-sync §3.3. +- **Recent deal moves Amount column (DONE local 2026-08-04)**: Pulse + feed includes current deal `amountCents`; Overview Recent deal moves + shows an Amount column after Deal. Shared loader: + `packages/db/src/pipeline-pulse.ts`; UI: `pipeline-pulse.tsx`. +- **Company picker disambiguation (DONE local 2026-08-04)**: + `CompanyPicker` and screening/create-company match dialogs show + Sage 100 customer # + contact count on the secondary line (no domain + in the picker; no "Sage 100" label — just `{id} · N contacts`). + `companies.options` returns those fields. Helper: + `apps/app/components/crm/company-disambiguation.ts`. +- **Company state/country + junk email repair (DONE local + prod + 2026-08-04)**: Full pull never wrote `stateCode`/`country` (only + `city`); `/map` started persisting them later. Snapshot backfill + fills street/postal/state/country — **13,670** local / **13,484** + prod. Sage `emailaddress` notes rejected by `normaliseEmail`; cleared + **428** junk company emails local + prod + (`fix-sage-company-email-notes.ts`). TCP proxy deleted after. Docs: + `docs/plans/sage-crm-sync.md` §3.1. +- **Company street address (DONE local + prod 2026-08-04)**: `Company.streetAddress` + + `postalCode`; Sage pull maps nested `address1` / `postcode` (zip + aliases). City-level geocode unchanged. Migration + `20260804130000_company_street_address` applied locally + on Railway + api deploy (`f794eae`). Snapshot backfill filled **12,191** local and + **12,192** prod companies + (`backfill-company-street-from-snapshots.ts`; TCP proxy deleted after). + Sheet + map selection show full address. Docs: + `docs/plans/sage-crm-sync.md` §3.1, `docs/plans/companies-map.md`. +- **Nav count bubbles (DONE local 2026-08-04)**: Screening + Follow-ups + rail icons show a primary `CountBadge` when the signed-in user has + uncleared items. APIs: `screening.count` (PENDING), `followups.count` + (PROPOSED + due SNOOZED). Rail polls every 60s; decide/accept paths + invalidate via `cache.screening` / `cache.followup`. UI: + `packages/ui/.../count-badge.tsx`, `app-icon-rail.tsx`. +- **Map deal-years filter (DONE local 2026-08-04)**: `/map` dropdown + `dealYears` 0 (= any time) or 1–10. Keeps companies with a deal + `createdAt` or `closedAt` within that window. URL + + `companies.mapList`. Docs: `docs/plans/companies-map.md`. +- **Forecast by close month window (DONE local 2026-08-04)**: Overview + month table keeps last month + this month + next 12 months (and + "No date"); older overdue and far-future months drop out. Totals / + Forecast by rep still use all open deals. `buildForecast` in + `dashboard.service.ts`; copy in `sales-dashboard.tsx`. +- **Sales-rep sheet + deal stages (DONE local 2026-08-04)**: Five open + stages — Leads / Investigation / Quote / Negotiation / In Purchasing + (`IN_PURCHASING` enum) with certainty defaults 10/25/50/75/90. Sage map + splits Proposal vs Purchasing; blank → Leads. Migrations + `20260804120000` + `20260804120100` applied locally + on Railway with + `f794eae` api deploy. Overview Everyone: clickable `OwnerCell` + + Forecast by rep opens `SalesRepSheet` (`?record=user:`); + `dashboard.repSummary` KPIs + certainty×month grid. Plan: + `.cursor/plans/sales_rep_sheet_b7f28c67.plan.md`. Docs: + `docs/plans/sage-crm-sync.md` §3.3. +- **Sage website notes (DONE local + prod 2026-08-03)**: Sage `website` + is often a credit/account note, not a URL. Pull keeps only URL-shaped + values; push never writes `website`. Prod repair ran via temporary + TCP proxy + `fix-sage-website-notes.ts` (cleared notes, backfilled + from contact emails; Hitachi → `website=https://cleverdevices.com`). + Research works without a domain (Perplexity); Re-enrich accepts a URL + website. Plan: `docs/plans/sage-crm-sync.md` §3.1. +- **Screening company match (DONE 2026-08-03)**: Approve calls + `companies.similar` (domain→name guess + related-host scoring) and offers + Use this / Create from domain (`preferDomainCompany` skips soft-attach). + Matches mark a **Suggested** pick (Sage 100 > contacts on the typed + domain > contact volume). `companyForEmail` soft-attaches on a strong + unique match or a suggested pick with Sage 100 / matching-domain + contacts. **Screening → Sage**: Approve enqueues person create when + parent has `sageCrmCompanyId` and no same-name Sage-linked twin; + failures never fail local create. Shared ranking: `company-similar.ts`. + Hitachi Teresa cleanup on **prod** done; Sage probe clean. Script: + `apps/api/scripts/fix-hitachi-screening-dup.ts`. Plan note: + `docs/plans/sage-crm-sync.md` §4 item 6c. +- **Companies Hide empty filter (DONE 2026-08-03)**: companies list + checkbox (on by default) keeps rows with ≥1 contact **and** a Sage 100 + customer #. URL `hideEmpty=yes|all`. Files: + `companies.contracts.ts`, `companies.service.ts`, + `companies-search-params.ts`, `companies-table.tsx`. +- **Overview 8-KPI strip (DONE 2026-08-03)**: one `StatGroup` with sales + + pulse cells (Closed won / Due / Open / Win rate / Won / Lost / + Certainty / Stuck). **Won/Lost** = deal `closedAt` counts in the selected + range (same as Closed won / win rate). **Certainty** (+ movers/feed) + follow the range via `loadPipelinePulse({ since, until })` change log. + Stuck stays 14d+. Static KPIs use `tone="static"`; range-bound values + animate. Files: `sales-dashboard.tsx`, `pipeline-pulse.tsx`, + `stat-card.tsx`, `pipeline-pulse.ts`, `dashboard.service.ts`. +- **Sage extra-module probe (DONE 2026-08-03)**: live SOAP check of UI tabs + beyond company/person/opportunity. **Actively used + not synced:** + `communication` (tasks/calls/email, 2026), `notes` (2026), `lead` + (2026, same-day updates). Already covered via company nest: `address` / + `phone` / `email`. `users` (~27) works as entity name `users`. Cases + empty; documents (`library`) / forecast / campaign not WS-enabled; + relationships / consent / selfservice have no SOAP entity. Quotes / + orders / Mas* still "Query failed" even with `1=1`. Scripts: + `apps/api/scripts/sage-probe-entities.ts`, `sage-probe-recency.ts`. + Plan table updated: `docs/plans/sage-crm-sync.md` §1 Entity availability. +- **Company create duplicate guard (DONE 2026-08-03)**: before + `companies.create`, UI calls `companies.similar` (local name/domain + soft-match). Domain hit → must use existing; name hit → confirm Use this + or Create anyway. No live Sage search. See + `docs/plans/sage-crm-sync.md` §4 item 6b. +- **Companies/contacts list UX (DONE 2026-08-03)**: companies table has a + **Primary contact** column (designated primary, else most recently + created); name + email with copy. Contacts table email column has the + same copy control (`EmailValue`). No migration. +- **Screening per-rep (CODE DONE 2026-08-03, migrate pending)**: + `PendingContact.userId` + `@@unique([userId, email])`; harvest stamps + mailbox owner; `screening.list` / `decide` scoped to session user. + Migration `20260803200000_pending_contact_per_user` (clears old shared + rows). **Not applied yet** — Docker was down locally; apply with + `bun run db:deploy` locally + on Railway `api` before relying on + Screening in prod. Follow-ups were already per-rep; `proposeFollowUp` + now also requires cited messages `syncedByUserId = userId`. +- **Prod crons (DONE 2026-08-03)**: Railway curl services hit + `api` with `CRON_SECRET` (UTC schedules). No Google cron. + - `cron-microsoft` `*/5 * * * *` → `/internal/sync/microsoft` + (smoke OK: synced=4) + - `cron-sequences` `*/5 * * * *` → `/internal/sequences/tick` + (Railway min interval is 5m; was 2m on Vercel) + - `cron-followups` `0 13 * * *` → `/internal/agent/followups` + (8:00 AM CDT). Agent claims these rows (see prod agent below). + - `cron-sage` `0 6 * * *` → `/internal/sync/sage` (1:00 AM CDT) + - `cron-daily-tasks` `0 14,15 * * *` → `/internal/notifications/daily-tasks` + (Chicago 9:00 gate; needs api deploy with the route) + Image `curlimages/curl:8.12.1`; start via `sh -c 'curl …'`; + vars `API_PUBLIC_URL` + `CRON_SECRET` on each cron service. +- **Prod agent (DONE 2026-08-03)**: Railway service `agent` always-on; + `Dockerfile.agent` + `scripts/railway-agent-start.sh` (Node 24 hosts + eve / just-bash; Bun for install). App has + `AGENT_URL=http://agent.railway.internal:2000` + matching + `AGENT_BRIDGE_SECRET`. Optional: `RAPIDAPI_KEY` (LinkedIn) on + **agent** only. Plan: `docs/plans/agent-railway.md`. +- **Auth registration (OPEN for @mobilemark.com via Microsoft, + 2026-08-04)**: email/password removed. Microsoft first sign-in creates + the user when `ALLOWED_SIGN_IN` matches (domain allow-list hook). + Google still has `disableImplicitSignUp: true` if configured. +- **Prod tRPC proxy (FIXED 2026-08-03)**: Settings "Check now" was failing + with `Unexpected token '<', "`; + imports `CrmModule` (script could not start on `main` — + `DealChangeRecorder` missing). +- Tests: `test/sage-clock.spec.ts` (11), backfill spec updated. + Sage specs 59/59. Full api suite: same 6 pre-existing fails + as `main` (Auth e2e, CompanyResolveService). +- Deployed api `5b37dce`, then `dff0909`. Commits rebuilt with + `git commit-tree` to drop the Cursor co-author trailer. +- Prod catch-up inside the api container: + `bun run scripts/sage-backfill.ts --opportunities-since=2026-08-02T00:00:00` + → 271 fetched, 270 upserted, 1 skipped (807, no company). +- Real changes recorded (source `sage`): 12 close dates (361, + 467, 502, 509, 584, 587, 605, 663, 694, 703, 713, 788); 502 + Closed Won $56,466 (closed 7 Sep); 206 Won; 568, 643 Lost; + 314 Sage stage Lost. New deals 803 (Lost) and 849. +- Deleted 35 `dealFieldChange` rows from that run that moved + `expectedCloseDate` back exactly one day. Cause: the 3 Aug + backfill ran on a Mac (Central) and parsed `targetclose` as + Central; the api container parses it as UTC. Same Sage + value; Central display unchanged. +- Verify: live SOAP vs prod snapshot for all 271 opportunities + saved since 2 Aug → 0 stale. + +**How and why** +- User asked that Sage changes never be skipped again. Proof of + zone: the misses were exactly 01:00–05:00 Sage time, and + `targetclose` shows 22:00 in summer, 21:00 in winter (DST). + +**Deviations** +- `targetclose` parse left as host-local (UTC in prod). Changing + it would shift every stored close date and log a change row + per deal. `sageDateForPush` also left as is. +- `railway ssh … psql` hangs when stdin is open on long output; + append `< /dev/null`. + +**What's next** +- Watch the 2 Oct 06:00 UTC `cron-sage`. The cursor is + `2026-10-01 14:52 UTC`; the predicate should read `08:52` + Central. Opp 807 needs a company set in Sage. +- Optional: a one-time normalise of Aug-backfill + `expectedCloseDate` values to the UTC parse, so the next + Sage edit on those deals does not log a one-day move. + +### 2026-10-01 — Sage 1am–5am cursor gap count + +**What was completed** +- Read-only compare of live Sage opportunities with + `updateddate > 2026-08-04` (254 rows, 3 SOAP pages) + against prod `sageRecordSnapshot` (579 rows). +- 52 opportunities are still older in the CRM than in + Sage. Every one of those Sage save times is between + 01:00 and 05:00. Zero stale rows sit outside that + window. Nights: 31 Aug (7), 1 Sep (1), 28 Sep (44). +- 9 of the 52 have a different `targetclose`. The other + 43 have the same Close By. +- Never imported: 803 (28 Sep 01:47), 849 (15 Sep 05:01, + edge of the same window), 807 (5 Aug 09:02, outside + this window). +- Cause: `sageDate()` in `sage-backfill.util.ts` formats + the cursor in UTC. The API container is UTC. Sage + `updateddate` is Central wall time. The 1:00 AM CDT + pull then asks next time for saves after ~5:00 AM. + +**How and why** +- User asked how often the close-date miss can happen + and how many rows are still wrong. + +**Deviations** +- Temporary `apps/api/scripts/sage-stale-audit.ts` was + used for the SOAP read and then deleted. + +**What's next** +- Format `sageDate()` in `America/Chicago`. +- One opportunity catch-up for + `oppo_updateddate > '2026-08-31T00:00:00'` so the 52 + (and 803 / 849) land. Do not rely on tonight's cron; + the cursor is already past those saves. + +### 2026-10-01 — Manual Sage sync for missed close dates + +**What was completed** +- Rewound prod `sageSyncState.highWaterUpdatedAt` for + company and opportunity to `2026-09-30 00:00:00+00`, + then ran `GET /internal/sync/sage` on the api container. +- Pull outcome ok in ~70s: 217 companies, 619 contacts, + 11 deals. Push processed 0 (no pending outbox). +- Close dates now match live Sage `targetclose`: + opp **664** and **832** are `2026-11-29 21:00:00`. + Also updated **642**, **787**, **813** (same 1:00 AM + CDT window on 30 Sep). Cursor is `2026-10-01 14:52 UTC`. + +**How and why** +- A plain incremental run would skip these. Their Sage + `updateddate` values sit after the 1:00 AM CDT query + and before the next cursor (`updateddate` string + compared to a UTC `sageDate()`). User asked to sync + production now so the deals table gets the dates. + +**Deviations** +- Cursor was moved back on purpose so this run could + see the missed rows. It is advanced again to the + run start. + +**What's next** +- The 1:00–5:00 AM CDT gap can miss saves every night + because `sageDate()` formats the cursor in UTC and + Sage `updateddate` is Central wall time. Fix + `sageDate()` / the incremental predicate so the next + nightly pull does not skip that window. + +### 2026-10-01 — Opp 664 Close By is November in Sage + +**What was completed** +- Deal `cmscp7f151p14ob8ogiaujn33` / Sage opportunity **664** + / quote `Q260608-001` (Harry Kim, Westermo). +- CRM `expectedCloseDate` is `2026-07-31 03:00:00`. Snapshot + from `2026-08-03` has `targetclose` `2026-07-30T22:00:00` + and `updateddate` `2026-06-30`. +- Live SOAP read: `targetclose` `2026-11-29T21:00:00`, + `updateddate` `2026-10-01T03:49:10`. Same field we map. + Amount `32385` is unchanged. +- Last `cron-sage` (1:07 AM CDT) did not include 664. + +**How and why** +- Ken sees Close By in November. The CRM shows 31 Jul. + Close By is Sage `targetclose`. The CRM copy is from the + 3 Aug import. Sage saved the row at 03:49, after the + 1:07 AM CDT pull. API container timezone is UTC. + +**Deviations** +- None. + +**What's next** +- Wait for the 2 Oct 06:00 UTC pull, then confirm deal + 664 `expectedCloseDate` is 29 Nov 2026. If it is still + 31 Jul, the incremental `oppo_updateddate` predicate + missed this row and needs a one-id re-pull. + +### 2026-10-01 — Harry Kim opportunities, last 7 days + +**What was completed** +- Read-only prod count for owner `sage-user-1` (Harry Kim, + `hkim@mobilemark.com`). +- 5 of 72 Sage-linked deals have `sageUpdatedAt` in the + last 7 days: 685, 860, 641, 854, 704. +- Opportunity 832 is his. Its `sageUpdatedAt` is + `2026-08-27`, outside the window. + +**How and why** +- Follow-up to the opp 832 close-date check. + +**Deviations** +- None. + +**What's next** +- Same as the opp 832 entry: wait for the 2 Oct pull, or + check live Sage `updateddate` on opportunity 832. + +### 2026-10-01 — Sage opp 832 close date + +**What was completed** +- Read-only prod query via `railway ssh -s Postgres`. +- Deal `cmtcjy84707pp01rrmw6r180i` (Sage opportunity 832, + quote `Q260827-002`): `expectedCloseDate` is + `2026-09-29 22:00:00`. Snapshot `targetclose` matches. + Snapshot `updateddate` is `2026-08-27T19:54:21`. + Snapshot row `updatedAt` is `2026-08-28 06:10 UTC`. + No `dealFieldChange` rows on this deal. +- Last `cron-sage` at `2026-10-01T06:07:41Z` was ok after + one 503 retry. It upserted 5 deals: Sage ids 685, 857, + 858, 859, 860. Opportunity 832 was not in that set. + +**How and why** +- A user changed the Sage close date to about 30 Nov 2026 + and still sees 29 Sep 2026 in the CRM. The stored Sage + copy has not changed since 28 Aug. + +**Deviations** +- None. + +**What's next** +- If the Sage edit was after 06:07 UTC on 1 Oct, wait for + the 06:00 UTC pull on 2 Oct. If the edit was earlier, + check live Sage `oppo_updateddate` on opportunity 832. + The incremental pull only fetches rows newer than the + high-water mark. + +### 2026-09-30 — 2am Railway crash check + +**What was completed** +- Read-only Railway check of production MM-CRM + (`406256f8-aff6-422c-a980-9607e66a870b`). +- Crash events, cron logs, api logs, Postgres logs, + and HTTP edge logs for 07:25–08:05 UTC. + +**How and why** +- User asked why the app crashed many times around 2am. + The CRASHED events are the five-minute curl crons, + not the app, api, or agent containers. They failed + because the public API returned 404, then 500 + (`getaddrinfo ETIMEOUT` on `account.findMany`), then + a 524/499 hang on the sequence tick. Postgres stayed up. + +**Deviations** +- None. + +**What's next** +- No deploy. If this repeats, check API DNS to Postgres + and the Cloudflare path in front of + `api.mobilemarksalestool.com`. Webform mailbox + `Access is denied` is a separate Graph permission issue. + +### 2026-09-15 — Sage cron follow-up (clean night) + +**What was completed** +- Read-only `cron-sage` + api logs for last night. +- Sep 15 06:02 UTC: first-try HTTP 200, no walk restart. + Pull 226 companies / 585 contacts / 0 deals / 811 + snapshots. Push idle (`processed=0`). + +**How and why** +- User asked if we were all good after the Sep 14 check. + Last night was the first run after that review and it + picked up the extra company/person volume from Sep 14 + daytime Sage edits. + +**Deviations** +- None. + +**What's next** +- No pull action. Quiet deals remain a Sage-side fact + until opportunities change again. Failed outbox 821 + is still a separate push item. + +### 2026-09-14 — Sage prod sync health check + +**What was completed** +- Read-only check: Railway `cron-sage` + `api` logs, + prod `SageSyncState` / snapshots / outbox (temporary + TCP proxy, deleted after), and a live Sage SOAP count + of recent opportunities. +- Last five nights (Sep 10–14) all finished + `pull.outcome=ok`. 503 on first curl Sep 10, 11, 14; + retry succeeded. This morning: 188 companies, 264 + contacts, 0 deals, 452 snapshots. +- Cursor is current: both entities `IDLE` / + `incremental`, `highWaterUpdatedAt` 2026-09-14T06:04:53Z, + `lastError=null`. Last 7 days: 1364 company / 2612 + person snapshots, **5** opportunity snapshots (opps + 845–848, 665). Last Sage deal field change 2026-09-09. +- Live SOAP: 0 opportunities with `oppo_updateddate` + after 2026-09-10. Sage itself has not changed deals + since opp 848 on Sep 9. Company updates from today + afternoon wait for tonight's cron (last pull 1:05 AM + CDT). +- `SageOutbox`: 2 failed (opp 821 UnexpectedEvent; + contact create, parent not in Sage). One-shot scripts + deleted; proxy list empty. + +**How and why** +- User was not seeing many new/updated records and + asked whether Railway Sage sync was broken. Cron JSON + + DB + SOAP agree: company/person incremental is + moving; opportunity volume is genuinely ~0 since + Sep 9. + +**Deviations** +- None. Investigation only; no code or schema change. + +**What's next** +- No pull fix required. Optional: tonight's 06:00 UTC + cron will pick up today's Sage company edits. Failed + outbox opp 821 is a push issue, not pull. Quiet + pipeline pulse is expected until Sage opportunities + change again. + +### 2026-09-11 — Separate won vs new-pipeline chart colors + +**What was completed** +- New pipeline on the overview area chart uses + `--chart-2` (blue). Closed won stays `--success` + (green). File: `sales-dashboard.tsx`. + +**How and why** +- Both series used green (`--success` and `--chart-1`). + The legend and plot were hard to tell apart. + +**Deviations** +- None. Used existing categorical tokens; no new hex. + +**What's next** +- Hard-refresh Everyone after this push. + +### 2026-09-11 — Everyone recent activity is CRM-only + +**What was completed** +- Everyone overview no longer includes mailbox rows: + `EMAIL` activities, email threads, calendar events, or + “Company/Contact added from your inbox”. +- Me still shows the signed-in user's mail. +- Copy: “Notes, tasks and stage changes across the team. + No mail.” +- Files: `dashboard.service.ts` `recentActivityWhere`, + `dashboard-summary.tsx`, `dashboard-range.spec.ts`. + +**How and why** +- The prior Everyone filter kept the viewer's own inbox + (`createdById = me`). That leaked team-adjacent mail + subjects and inbox creates onto a shared analytics tab. + +**Deviations** +- None vs the ask (CRM / opportunity only; no email). + +**What's next** +- Hard-refresh Everyone after this push. + +### 2026-09-11 — Owner-only deal stage changes + +**What was completed** +- Stage moves are owner-only. An admin may still edit + other deal fields and reassign the owner. +- Helper `canChangeDealStage` in + `packages/auth/src/admins.ts`. Tests in + `packages/auth/test/admins.spec.ts`. +- API: `DealsService.setStage` uses + `assertCanChangeStage` (403: + "Only the deal's owner can change its stage."). +- UI: `canChangeStage` from `useDealEditAccess`. + Sheet header menu, stage rail, deals table, company + and contact deal lists all lock for non-owners. + Tooltip: "Only the deal owner can change the stage". +- Verified locally as jjohnson on Harry Kim's Bosch + India deal (`#Q260622-003…`): rail and header + disabled; `setStage` 403. Jordan's own test deal + still has an enabled stage control. + +**How and why** +- Jordan is in `CRM_ADMIN_EMAILS`, so `canEdit` was + true on every deal. Stage used that flag. Forecast + follows stage, so only the assigned rep should move + it. + +**Deviations** +- None. Other field edits stay admin-or-owner. + +**What's next** +- Deploy **api** + **app**. Confirm Harry Kim's Bosch + India sheet shows a locked Investigation control + when signed in as jjohnson. + +### 2026-09-11 — Hide overdue tasks on Everyone overview + +**What was completed** +- Everyone (`scope=everyone`) no longer renders the + Overdue tasks card. Deals in progress uses the full + row. Me still shows the two-column pair. +- File: `apps/app/app/(app)/dashboard-summary.tsx`. + +**How and why** +- Team overdue tasks are not this page. The empty + “your tasks” column on Everyone wasted half the + deals table. + +**Deviations** +- None. API still returns `overdueTasks` on Everyone; + the card is hidden in the UI only. + +**What's next** +- Hard-refresh Everyone on prod. Deals in progress + should be full width. + +### 2026-09-11 — DealQuote `isPrimary` is a disambiguator + +**What was completed** +- Attach no longer marks the first quote on a deal as + primary. New rows start false. After insert, + `reconcileQuotePrimaries` sets exactly one true row + per colliding `quoteNumber`. Unique numbers stay false. +- Tie-break (mechanical): full id in the deal name, then + fewest quotes on that deal, then source rank + (HUMAN → SAGE_NOTE → PO_TOOL → SAGE_DESCRIPTION), then + earliest `createdAt`. +- `setPrimaryQuote` now clears that number on every deal, + then sets this row. `removeQuote` deletes and + re-reconciles the number. +- Deal sheet titles: “Primary deal for this quote number” + / “Mark this deal as the primary match”. +- Files: `quote-number.ts`, `deals.service.ts`, + `backfill-deal-quotes.ts`, + `repair-deal-quote-primaries.ts`, `deal-sheet.tsx`, + `schema.prisma` comment, `sage-crm-sync.md` §3.3, + `test/quote-number.spec.ts` (18 pass). +- Prod data (TCP proxy, then deleted): 11 collisions + repaired (`Q260413-003` 8→1). Attached `Q260622-002` + to `#Q260622-001 & 002: …` (sage 679). 202 rows. + Unique still-primary: none. + +**How and why** +- Analytics exact-id is live. All-primary clones left + `matched_opportunity_id` null (`deal_quote_ambiguous`). + `deal_quote_primary` stays unused until exactly one + primary per colliding number. Do not parse + `opportunity_name`. Do not store `SavedQuote.id`. + +**Deviations** +- Optional 002 attach is a one-shot observed title + (`Q260622-001` + `& 002`). Parser still ignores + `#Q260703-001 & 002`. Orphans not in `fct_quotes` + left alone. + +**What's next** +- Nightly warehouse cron `0 9 * * *` UTC re-pulls. + `deal_quote_primary` should start filling. + +### 2026-09-11 — Overview Overdue KPI + locked/live board + +**What was completed** +- Dropped the Lost tile. Win rate still shows won · lost. +- Added **Overdue**: open, `expectedCloseDate` before now. + Same rule as Deals → Closing → Overdue. Not Stuck + (14d no stage move). API: `overdueOpenWhere` + + `overdueOpenTotal` on `dashboard.summary`. +- KPI board is two 2×2 clusters. Left locked (inverted, + ignores the date range): Due this month, Open pipeline, + Overdue, Stuck. Right live (follows the range): Closed + won, Win rate, Won, Deal Maturity moves. +- UI primitives: `StatBoard`, `StatGroup` `columns="pair"` + / `tone="locked"`, `StatCard` `tone="locked"`. + +**How and why** +- User asked for a locked tile for open deals past + scheduled close, and to group four locked vs four live. + Stuck was already the inactivity name, so the new tile + is Overdue. + +**Deviations** +- None vs the ask. Locked fill uses `foreground` / + `background` tokens (design invert), not a new grey. + +**What's next** +- Sign in locally and flip the date range: locked four + stay put; live four move. Deploy api + app. + + +### 2026-09-11 — Filter recent deal moves by rep and change + +**What was completed** +- Recent deal moves on the overview now filter by rep and by + change reason (Won, Lost, Stage, Deal Maturity, Amount, Close + date, Owner, Priority, Sage stage). +- URL keys: `pulseRep`, `pulseChange`. Shared parsers in + `apps/app/app/(app)/overview-search-params.ts` (`pulseFeedParsers`) + so the rest of the dashboard does not re-render on filter change. +- New mechanical query `dashboard.pulseRecent` → + `loadPipelinePulseRecent` in `packages/db/src/pipeline-pulse.ts`. + Same date range as the pulse strip. Used only when a filter is + on, so the unfiltered 24-row slice stays on `summary.pulse`. +- UI: two `Select`s in the card header (`pipeline-pulse.tsx`). + Rep filter hides on Me. Empty copy changes when filters hide + every row. +- Tests: `packages/db/test/pipeline-pulse.spec.ts` for + `pulseChangeWhere` / `pulseOwnerWhere`. +- tRPC types regenerated (`apps/api/src/generated/server.ts`). + +**How and why** +- Client-only filter of the latest 24 rows would hide most Amount / + Owner / Priority moves on a year-long range. The extra query + fills the table with matching rows from the same window. +- Me already scopes to the signed-in owner; a leftover `pulseRep` + is ignored (`pulseOwnerWhere`). + +**Deviations** +- None vs the ask (filter by rep + change reason). Sort was not + added — the feed stays newest-first. + +**What's next** +- Sign in locally and click the two selects on Everyone. Local + `dealFieldChange` is empty, so the table stays empty until a + Sage pull or a deal edit writes rows. Query filters were + checked with a rolled-back insert (amount / won / rep). + Deploy api + app when ready. + +### 2026-09-11 — Ship DealQuote `bd6e7b5` + warehouse CRM pull + +**What was completed** +- Commit `bd6e7b5` on `main` (`origin/jj-mobilemark/crm`). + api / app / agent deployed SUCCESS. +- Prod: migrate `dealQuote`, `GRANT SELECT` to + `mm_analytics_reader`, `backfill-deal-quotes.ts` via + `railway ssh -s api` — **201** quotes on **191 / 566** deals. +- Warehouse CRM pull (cron redeploy only ran migrations; + cron SSH is idle). Ran extract + dbt on + `mm-analytics-ingest` with cron `EXTRACT_MODE=mm_crm`: + deals **566**, companies **14,335**, users **17**, field + changes **353**, activities **963**, deal contacts **542**. + dbt snapshot PASS=3, freshness 11/11, build + **PASS=174 WARN=0 ERROR=0**. Marts: + `fct_sales_pipeline` 566, `dim_company` 14,335, + `fct_quotes` 751. + +**How and why** +- User asked to commit, push, wait, then pull CRM with the + Railway CLI. Same-project ingest can reach private CRM + + warehouse DNS. Did not run open-orders or Blue Folder + (cron has no `BLUE_FOLDER_MODE`; fixture would wipe live + raw). Live Analytics image is still `a14b906` — no + `extract.mm_crm_deal_quotes` yet (local Analytics work is + uncommitted). + +**Deviations** +- Cron redeploy `69b5a1f5` did not run `run_pipeline.sh` + (known: pre-deploy migrations only). Pull was SSH ingest, + not a cron tick. + +**What's next** +- Commit and deploy MM-Analytics DealQuote extract + (`raw.mm_crm_deal_quotes` + exact-id bridge), then pull + again. Spot-check deal sheet quotes (Sage opp **663**). + Optional: uncomment GRANT in Analytics + `docs/mm_crm_reader_role.sql` once that extract ships. + +### 2026-09-11 — DealQuote junction + Sage parse + deal UI + +**What was completed** +- Prisma `DealQuote` + enum `DealQuoteSource` + (`packages/db/prisma/schema.prisma`, + `migrations/20260912040000_add_deal_quote`). +- Parse/expand in `apps/api/src/deals/quote-number.ts`; 13 tests + in `test/quote-number.spec.ts`. +- Sage pull attaches quotes after every opportunity upsert + (`sage-pull.service.ts`). Additive; no Sage push on quote edits. +- tRPC `deals.addQuotes` / `removeQuote` / `setPrimaryQuote`. +- Deal sheet Quotes section + deals-table Quotes column. +- Agent preamble + deal search include quote numbers. +- One-shot `apps/api/scripts/backfill-deal-quotes.ts`. Ran on + local: 166 rows / 158 deals. Plan §3.3 updated. + +**How and why** +- Junction (not `text[]`) so `quoteNumber` is indexed and + `isPrimary` is a real column. Expand `#Q260622-003,4,5,6,7` + before insert so Analytics can exact-join. Note-like Sage + fields win over `description`; quoting-tool stamp in the name + counts as `SAGE_NOTE`. + +**Deviations** +- First attached row becomes primary when none exists (note + order first). On opp 663 that made `Q260526-001` primary, not + `Q260622-003`. Human can change it. Extra quoting-tool ids on + that deal were kept (observed, not guessed). + +**What's next** +- Prod: migrate `dealQuote`, run + `bun run scripts/backfill-deal-quotes.ts` (TCP proxy if needed), + deploy api + app. Tell Analytics the extract table is + `dealQuote`. They add the exact-id tier. Do not parse names in + the warehouse. + +### 2026-09-11 — Deal quote-number contract locked with Analytics + +**What was completed** +- Locked CRM shape from the 2026-09-11 warehouse answers + (746 quotes, 566 deals). Not built. No schema yet. + +**How and why** +- Fuzzy quote↔opp match is 120 high / 598 none. Bosch 663 is + unmatched because $50–72M quotes ≠ $2.99M deal. Typed + `quote_number` list is the hard link. Scalar column would drop + siblings. `text[]` is weaker than a junction for primary + + reverse lookup. Do not store `SavedQuote.id`. + +**Deviations** +- None vs Analytics ask-back: list + optional primary. Chose + junction over `text[]` so `quoteNumber` can be indexed and + `isPrimary` is a real column. + +**What's next** +- When asked: Prisma `DealQuote` + Sage parse/expand + deal UI + + extract column for Analytics. Until then do not add a + name-parse tier in either repo. + +### 2026-09-11 — closedAt forward path (no forecast-as-actual) + +**What was completed** +- New `resolvedClosedAt` in `apps/api/src/sage/sage-closed-at.ts`. + Pull (`sage-pull.service.ts`) and echo-repair script use it. + Tests: `apps/api/test/sage-closed-at.spec.ts` (9). Plan §3.3 + mapping row updated. `sage-backfill-deal-dates.ts` marked + RETIRED (old `closed` → `targetclose` → `opened` rule). +- Rules: Sage `closed` when Chicago date is today or earlier; + else keep a usable existing `closedAt`; else stamp `now` once. + Open deals → null. Never copy `targetclose` / `opened`. + Future `closed` rejected. Later sync is idempotent unless Sage + later sends a real `closed`. + +**How and why** +- Forecast was landing in MM-Analytics `closed_date` / Power BI. + First-observed `now` is only for new closes and future-dated + rows (query C). Past historical fallback dates stay put so + nightly sync does not rewrite $7.35M of empty-`closed` wins. + +**Deviations** +- Did not backfill history. Did not add a raw-Sage-`closed` + column (snapshot already has it). + +**What's next** +- Deploy **api**. Next Sage pull fixes future `closedAt` (837 + TALLEY, 525, 589). Confirm open deals still have `closedAt` + null. Leftover talk: historical empty-`closed` label, + `DealWon` stage name, Investigation+Won hygiene. + +### 2026-09-11 — closedAt dry-run (local snapshots + live Sage SOAP) + +**What was completed** +- Read-only script `apps/api/scripts/sage-closedat-dry-run.ts` + (`--soap` for live Won/Lost + `getmetadata`). Nothing wrote. +- Local Postgres is still the Aug 2026 525-deal pull. Prod/Sage is + current (292 Won / 118 Lost). `dealFieldChange` is empty locally. +- Live Sage has **no extra close date**. Metadata date-ish fields: + `closed`, `createddate`, `decisiontimeframe` (0 rows), `notifytime` + (11), `opened`, `timestamp` (= `updateddate` on 410/410), + `updateddate`. `targetclose` is filled on every Won/Lost row. +- Empty Sage `closed`: 173 Won / $7.35M (Chris Talbert $5.35M of + that). B (`closedAt` == `targetclose`) is the historical norm, not + a small bug. +- 2026 YTD won $ by rule: current fallback **$3.11M** (matches the + Sep 11 $3.1M card); Sage `closed` only **$1.34M**; + `closed`??`updateddate` **$3.42M**. `updateddate` dumps **$3.0M** + into Apr 2025 — batch edits, not closes. +- Fixtures (live): **837 TALLEY INC** $256k empty `closed` target + 30 Sep / updated 2 Sep; **665** 200-LTM502 closed=target=9 Sep; + **795** B2543WN closed 30 Jul target 29 Sep. Sage also stores + future `closed` on 525 (14 Oct) and 589 (23 Sep). +- Four Sage rows use stage `DealWon` (status Won — already maps). + +**How and why** +- MM-Analytics copies `deal.closedAt` into `closed_date` for Power + BI. Need the blast radius before any transform change. SOAP was + required because local is stale and the named fixtures are not in + the August pull as closed. + +**Deviations** +- Did not mutate `resolvedClosedAt` or backfill. Did not open a + prod Postgres proxy. + +**What's next** +- Phase 1 only: stop falling back to `targetclose` / `opened`; + freeze `closedAt` after first close; Sage `closed` still wins; + reject future dates (including Sage `closed` in the future). +- Do not rewrite the 173 historical empty-`closed` Won rows to + `stageChangedAt` or `updateddate`. +- Then talk through leftover items (Analytics month restatement, + DealWon label, Investigation+Won hygiene). + +### 2026-09-11 — Restart Sage incremental walk on pagination `next` faults + +**What was completed** +- Nightly `cron-sage` first attempt was HTTP 503 two nights in a row + (Sep 10 `List index out of bounds (75)`, Sep 11 `Query failed to + run successfully.`), then curl `--retry-delay 90` succeeded on a + fresh `query`. Those SOAP `next` faults were not in + `isSageWalkRestartable`, so the API failed the request instead of + restarting the walk. +- `isSagePaginationFault` now matches those two strings. + `isSageWalkRestartable` includes it (still only used by the + company/opportunity incremental walk). Cap unchanged: + `SAGE_SESSION_RESTART_LIMIT = 2`. +- Files: `apps/api/src/sage/sage.constants.ts`, comments in + `sage-pull.service.ts`, `apps/api/test/sage-session-lost.spec.ts`, + `docs/plans/sage-crm-sync.md` §6.8. + +**How and why** +- Sage `query`/`next` is session-stateful; a wedged `next` cannot + resume. Upserts are idempotent and high-water only advances after + a full walk, so an in-process restart is the same recovery curl + already did — without a 90s wait or a red 503 in cron logs. +- "Query failed to run successfully" stays a non-transport, non- + session-lost fault (`isSageTransientFailure` / `isSageSessionLost` + unchanged). It is restartable only via the pagination helper, and + only on this walk — it is still a sticky error on `case` / + `quotes` / `orders` probes. + +**Deviations** +- Earlier tests treated "Query failed to run successfully" as + *not* walk-restartable, to keep it distinct from session loss. + Prod evidence (Sep 10–11) showed it is a transient `next` flake + on company/opportunity, so the walk-restart test now expects true. + +**What's next** +- Deploy **api**. Watch tonight's `cron-sage` (06:00 UTC): first + attempt should log a walk-restart warning and return HTTP 200, + not 503 + curl retry. `curl --retry 3` stays as the outer net. + +### 2026-09-09 — Sage Deal.currency: map lookup id 1 → USD + +**What was completed** +- Sage opportunity pull no longer writes the literal currency id + `"1"` onto `Deal.currency`. `mapSageCurrency` in + `apps/api/src/sage/sage.mappings.ts` maps confirmed Sage lookup + ids (`1` → `USD`), keeps a 3-letter ISO code, and defaults blank / + unknown non-ISO values to `USD`. `mapOpportunity` uses it. +- Incremental Sage sync rematerializes existing leaked ids at the + start of `runIncremental` (`sage-pull.service.ts`) via the same + mapper, so the next cron corrects the ~78 bad prod rows without + a standalone SQL UPDATE. +- Tests in `apps/api/test/sage-mappings.spec.ts`. Plan note in + `docs/plans/sage-crm-sync.md` §3.3. MM-Analytics untouched. + +**How and why** +- Production 2026-09-09: ~78 of 564 deals had `currency = '1'` + (Sage currency id leaking through); the rest were `USD`. Power BI + therefore showed a slicer of `"1"` and `"USD"`. Incremental pull + only rewrites recently changed opportunities, so a mapper-only + fix would leave stale rows until Sage touched them. Rematerialize + on the next sync is the repair path the request asked for. +- No FX: amounts stay as Sage sent them. Only the code is + normalised. The only confirmed Sage currency id in this tenant + is `1` = USD. + +**Deviations** +- None from the request. Unknown numeric ids (none seen in prod) + also become `USD` so a Sage id cannot land on the column again. + +**What's next** +- Done on prod. Warehouse / MM-Analytics needs no change — it copies + `deal.currency` as-is and only warn-tests USD. Nightly `cron-sage` + will keep using the mapper. + +### 2026-09-03 — Sage cron health re-check (Railway logs + prod DB) + +**What was completed** +- Read-only check on project `MM-CRM` / `production`, `cron-sage` + service, plus a direct (read-only) query against prod Postgres. +- `cron-sage` deploy logs for the last 8 days (Aug 28 – Sep 3): six + clean nights (no `curl` error, meaning HTTP 2xx) and **one hard + failure on 2026-08-30 06:02 UTC** — `curl: (22) The requested URL + returned error: 503`, after retries. Checked `api` deploy logs for + the same window: **no `SageSyncController` log lines at all** (no + "Sage sync finished with a hard failure", no `/internal/sync/sage` + HTTP line) — the request never reached the Nest app, so this reads + as a private-network blip between `cron-sage` and `api`, not a Sage + SOAP/session failure. No `api` restart in that window either. +- Confirmed via `SageSyncState` (temporary TCP proxy, deleted after): + `company` and `opportunity` both `status=IDLE`, `phase=incremental`, + `lastError=null`, `highWaterUpdatedAt` = **2026-09-03T06:02:04Z** + (this morning's run). `company.processed` = 14,254. + `SageRecordSnapshot`: 1,192 rows touched in the last 24h, 4,650 in + the last 7 days, most recent at 06:03:49 UTC today. `SageOutbox`: + 5 `done`, 1 `pending`, **0 `failed`**. 233 companies / 985 contacts / + 8 deals updated in the last 24h (any source, not Sage-only). +- One-off diagnostic script (`apps/api/scripts/sage-status-check.ts`) + was written, run via `run-via-tcp-proxy.ts`, then **deleted** — not + meant to be a permanent script. TCP proxy created then removed + (`list_tcp_proxies` confirmed empty after). + +**How and why** +- User asked to check Railway for whether the Sage sync is running + successfully, pointing at `docs/plans/sage-crm-sync.md`. Log-only + evidence (Aug 30 gap) wasn't conclusive on its own, so cross-checked + against `SageSyncState` / `SageRecordSnapshot` / `SageOutbox` directly + — the authoritative signal — via the standard temporary-TCP-proxy + pattern in `AGENTS.md`. +- The idempotent design (§6.8 of the plan) means the Aug 30 gap is not + data loss: the incremental cursor didn't advance that night, so the + ~1h overlap plus every following night's `comp_updateddate > cursor` + query re-covers anything missed. + +**Deviations** +- None. Investigation only; no code or schema changes shipped. + +**What's next** +- Sage sync is healthy as of 2026-09-03. No action required. If the + Aug 30-style 503 (private-network, not app-level) recurs often, + worth a Railway support / metrics look at `api`'s private-network + reachability during that nightly window — not a Sage code issue. + +### 2026-08-24 — Sage cron health (Railway logs) + +**What was completed** +- Read-only Railway check on project `MM-CRM` / `production`. +- `cron-sage` last run 2026-08-24 06:04 UTC (1:04 AM CDT): SUCCESS. + API `GET /internal/sync/sage` 200 in 47.2s. +- Pull JSON: 183 companies / 258 contacts / 0 deals / 441 snapshots / + 3 skipped. Push: 0 processed (empty outbox). +- Last 7 nights (Aug 18–24) all `pull.outcome=ok` / `push.outcome=ok`. + Aug 21 spike: 1339 companies / 2177 contacts / 4 deals. + +**How and why** +- User asked how to verify Sage sync beyond the GUI SUCCESS badge. + Used `railway logs --service cron-sage --since 8d` (curl prints the + JSON body) and `api` logs for `Sage incremental finished`. No TCP + proxy; Postgres SSH key not set up. + +**Deviations** +- None. Investigation only. + +**What's next** +- No Railway action required. Optional: query `sageSyncState` / + `sageRecordSnapshot.updatedAt` via a temporary TCP proxy if you need + which rows actually changed vs re-upserted. + +### 2026-08-21 — Hide other people's mail on Everyone overview + +**What was completed** +- Overview Recent activity on `scope=everyone` no longer lists other + reps' synced email subjects or calendar titles. Filter: + `recentActivityWhere` in `apps/api/src/dashboard/dashboard.service.ts`. + Copy in `apps/app/app/(app)/dashboard-summary.tsx`. Tests in + `apps/api/test/dashboard-range.spec.ts`. Dropped `body` from this + payload so snippets never ride the overview. + +**How and why** +- Mail sync projects one `Activity` per thread (`emailThreadId`) and + per calendar event (`calendarEventId`). Everyone was `where: {}`, so + the latest 12 rows were almost all other people's inbox. Everyone + means team pipeline, not shared mailboxes. Overdue tasks already + stayed on the acting user. + +**Deviations** +- None. Company / contact timelines still show matched mail for that + record (account history, not a workspace dump). + +**What's next** +- Deploy **api** + **app**. Confirm + `/?scope=everyone&range=this_month` Recent activity shows team CRM + rows plus your own mail only. + +### 2026-08-18 — Closed won full month + repair Sage stage drift + +**What was completed** +- Overview "This month" now spans the calendar month (1st → next + 1st), so Won deals dated later this month count. File: + `apps/api/src/dashboard/dashboard.service.ts`. Test: + `apps/api/test/dashboard-range.spec.ts`. +- Echo-guard TTL 2 hours (`SAGE_ECHO_TTL_MS`). Sage often leaves + `updateddate` unchanged after our push; a permanent skip froze + local rows. Files: `sage.constants.ts`, `sage.mappings.ts`, + `test/sage-mappings.spec.ts`. +- Prod repair from snapshots: **71 deals** remapped to match Sage + status (incl. opp **799** Proposal → Closed won). Script: + `apps/api/scripts/sage-repair-stale-echo.ts`. Second dry-run: + drifted=0. TCP proxy created then deleted. + +**How and why** +- User asked to count the rest of the month and to fix 799. + 799: Sarah marked Closed won in the app (3 Aug 20:16 UTC), + push succeeded, Sage `updateddate` stayed 15:16, nightly pull + echo-skipped, Aug 4 sageStage migration rewrote stage to Quote. + Same class: Sage status Won/Lost still sitting on open local + stages. + +**Deviations** +- Repaired all 71 drifted rows, not only 799 — they were the + same mapping error. + +**What's next** +- Deploy **api** so "This month" Closed won uses the month-end + window (~$65.5K for August, plus 799). Data repair is already + on prod Postgres. + +### 2026-08-18 — Verify overview Closed won vs Sage + +**What was completed** +- Live Sage SOAP: 269 Won opportunities. Dashboard-rule August + (Sage `closed` → `targetclose` → `opened`): **11 deals / $65,482**. + Sage `closed` in August: **1 deal / $5,207**. 156/265 Won deals + have no Sage `closed` date ($6.7M). July dashboard-rule: + **11 deals / $524,328** (matches the overview prior-period + $524.3K). +- Prod replica (temporary TCP proxy, then deleted): **10 Closed + won** with August `closedAt` totaling **$65,468**. Sync high + water 2026-08-18 06:04 UTC. Opp 799 is Won in Sage, still + Proposal locally ($14). +- Root cause of the $11.9K card: overview `this_month` is + `[month start, now)` in `dashboard.service.ts` `resolveRange`. + Four deals with `closedAt` already past (710, 770, 808, 771) + sum to $11.9K. Six more Won deals have `closedAt` = Sage + target close later this month (691 $23.6K, 789 $8.1K, 793 + $7.1K, 779 $6.2K, 708 $4.9K, 773 $3.7K). + +**How and why** +- User saw $11.9K Closed won (Everyone / This month) and asked + to check Sage. Queried Sage SOAP (Won + `oppo_closed` + + August `updateddate`) and prod `Deal` / `SageSyncState`. + +**Deviations** +- None. Investigation only; no code change. + +**What's next** +- If the card should show the full August Won total (~$65K), + either count calendar-month `closedAt` through month-end, or + stop using `targetclose` as `closedAt` for already-Won Sage + deals (use Sage `closed`, else `updateddate`). Do not ship + that without a product call — July already matches Sage. + +### 2026-08-18 — Skip upstream v1.14.0; commit Sage walk retries + +**What was completed** +- Reviewed trycompai/crm [v1.14.0](https://github.com/trycompai/crm/releases/tag/v1.14.0). + Last common commit is still `288d41a`. Full merge would mix 150 + upstream commits into 66 fork commits (Sage, Trip, Screening, + Microsoft, map). Human chose to pass. +- Sage incremental pull: restart the walk on timeouts / connect + failures as well as "You are not logged on." SOAP timeout 90s. + Logon retries transport failures 3×; never retries `auth-failed`. + Files: `sage.constants.ts`, `sage-soap.client.ts`, + `sage-pull.service.ts`, `test/sage-session-lost.spec.ts`. + +**How and why** +- v1.14.0 itself is small (Context.dev people, enrichment queue), + but taking the tag also pulls organizations, their Microsoft + module, and 3 Prisma timestamps that collide with this fork. + Cherry-pick later if those two features are wanted. +- Nightly Sage already recovered from session loss (`5bb4ce0`); + remaining overnight fails were connect/timeout, so the same + restart path now covers those. + +**Deviations** +- None from the human call to skip the upgrade. + +**What's next** +- Deploy **api** so prod Sage cron gets the 90s timeout + logon + retries. Optional later: cherry-pick upstream #158 / #159 / #160 + onto a branch; do not merge `v1.14.0` onto `main`. + +### 2026-08-17 — Railway cron health check (Sage + others) + +**What was completed** +- Read-only Railway CLI check on project `MM-CRM` / `production`. +- Confirmed all six cron services exist and are scheduled: + `cron-sage`, `cron-microsoft`, `cron-sequences`, `cron-webform`, + `cron-followups`, `cron-daily-tasks`. +- Sage nightly pull/push `ok` for Aug 11–17 inclusive (today: + 183 companies, 258 contacts, 0 deals). No Sage `failed` logs in + the retained window. +- Other crons hitting 200 in the last day; api 4xx in 24h are + scanner 404s only (`/`, `/robots.txt`, `/.git/HEAD`). + +**How and why** +- User asked to verify Sage sync and Railway cron jobs. Used + `railway service list`, `deployment list`, and bounded `railway logs` + (deploy + HTTP). Sage uses the private API host so edge HTTP logs + do not show it; cron-sage deploy logs carry the JSON body. + +**Deviations** +- None. `cron-sage` start command is the internal URL (not the + public host noted in the 2026-08-06 handoff). That is working. + +**What's next** +- No Railway action required. Optional: confirm whether anyone has + Daily Task Push enabled (`cron-daily-tasks` `considered:0` / + `sent:0` at 9 AM Chicago). + +### 2026-08-07 — Agent auto-enrich gated (manual only by default) + +**What was completed** +- `AGENT_AUTO_ENRICH` (default off): `companyCreated` / `contactCreated` / + `meetingSoon` / `followupsDue` no-op unless explicitly `true`. +- Manual company Re-enrich / Research (`companyRequested`) always queues + (priority ≥ 100). +- Agent dispatcher retires auto backlog and only claims manual + company-profile when the flag is off. +- Docs: `.env.example`, `env.validation.ts`, `docs/plans/agent-railway.md`. +- Tests: `apps/agent/test/tasks.integration.spec.ts`. + +**How and why** +- Mail sync was queuing research for every new domain/nameless contact + and burning the Vercel AI Gateway budget with nobody using the Agent + tab. Code paths stay; flip `AGENT_AUTO_ENRICH=true` on api+agent to + restore. + +**Deviations** +- Daily followups cron is gated by the same flag (also auto AI spend), + not only identify/company-profile. + +**What's next** +- Deploy `api` + `agent` to Railway so prod stops auto-research. No + env var needed (unset = off). Optionally commit when asked. + +### 2026-08-07 — POST /company/resolve for incomplete PO extracts + +**What was completed** +- New `POST /company/resolve` for the sister PO intake app when Bill-To + is missing: weighted signals (MAS hint → email → buyer/letterhead → + bill-to → phone → ship-to-as-candidate-only), vendor-name reject, + auto-accept only at confidence ≥ 0.85 with a MAS id and a clear top. +- Extended `GET /company/order-defaults` soft lookups: `?name=` alone, + `?email_domain=`, `?phone=` return ranked candidates (no longer 400 + when ZIP is absent). Exact `?name=&zip=` unchanged. +- Shared `assertCrmApiKey` helper. Tests against local MCL ENTERPRISES + (`0007242`) pass. Docs: `docs/plans/sage-crm-sync.md` §7. + +**How and why** +- PO pages often only have letterhead + ship-to + email. Resolve must + understand signal roles and prefer Sage-backed MAS accounts for CUST #; + intake then calls existing order-defaults for stamp defaults. + +**Deviations** +- Response `company_id` / `sage_company_id` are strings (cuid / Sage + string ids), not integers as in the intake brief — matches this + schema. `terms_description` only for known codes in a small local map. + +**What's next** +- Deploy + smoke from the sister app with the MCL / Urban Transportation + PO case. Still deferred: tracking / ship_via / freight. + +### 2026-08-06 — Order-defaults: add `terms_code` from Sage snapshot + +**What was completed** +- `GET /company/.../order-defaults` now returns `terms_code` (Sage 100 + AR code string, e.g. `"03"`) read from + `SageRecordSnapshot.payload.company.mas_termscode` via + `Company.sageCrmCompanyId`. No new Prisma column — the nightly pull + already stores it (~4.8k of ~14k company snapshots filled locally). +- Files: `apps/api/src/sage/order-defaults.service.ts`; plan §7 and + Current state updated (`docs/plans/sage-crm-sync.md`, `HANDOFF.md`). + +**How and why** +- Sister app asked for terms on the stamp; Sage CRM SOAP already carries + `mas_termscode` on company records. Snapshot join is mechanical and + avoids a schema migration until the CRM UI needs to show/sort terms. + +**Deviations** +- None vs the snapshot-first approach called out in §7.2. Not promoted + to a `Company` column yet. + +**What's next** +- Deploy + smoke a known MAS customer number that has a terms code + (e.g. local sample CoWAVE / `0006002` → `03`). Still deferred: + `tracking` / `ship_via` / freight. + +### 2026-08-06 — Order-defaults endpoint for the PO-processing sister app + +**What was completed** +- New external, read-only lookup for the doc-scanner / PO-processing sister + app, agreed after a feasibility discussion (see §7 added to + `docs/plans/sage-crm-sync.md`): + - `GET /company/:masCustomerNo/order-defaults` — primary lookup by + `Company.sage100CustomerNo`. + - `GET /company/order-defaults?name=&zip=` — fallback lookup by name + + postal code when the caller has no MAS customer number. + - Both guarded by `X-API-Key` against `CRM_API_KEY` (new optional env + var, `env.validation.ts` + `.env.example`; constant-time compare, + 503 if unset, 403 on mismatch — same shape as the existing + `CRON_SECRET` guards but a separate secret/scope). + - Response fields: `mas_customer_no`, `mas_ar_division_no`, `attention` + / `phone` / `email` (company's primary contact, falling back to + most-recently-created contact — the same rule `CompaniesService` + already uses), `rep_owner` (Company owner ← Sage `acctmgr`) and + `rep_territory` (from `assignRep()` / `data/sales-territory.json`) + returned **separately, not reconciled** so the caller can cross-check, + `is_distributor` (new `isDistributor()` helper — same + `exceptions.distributors` name list `assignRep` already uses, but + independent of rep resolution so an ambiguous-rep distributor still + reports `true`), and `fields_returned` (which of the above came back + non-null). `tracking`, `ship_via`, `freight_acct`, `terms_code` + deliberately omitted — no source for them yet. + - New files: `apps/api/src/sage/order-defaults.service.ts`, + `order-defaults.controller.ts`; registered in `sage.module.ts`. + - `packages/db/src/sales-territory.ts`: added `isDistributor()` + (exported from `index.ts`), plus tests in `sales-territory.test.ts`. + - `packages/db/prisma/schema.prisma`: `@@index([sage100CustomerNo])` on + `Company` (was unindexed) — migration + `20260806190000_add_sage100_customer_no_index`. + +**How and why** +- Kept this mechanical (returns stored facts, never infers) so it doesn't + cross the "intelligence never in the API" line — lives in + `apps/api/src/sage/` alongside the rest of the Sage-derived data, even + though it never calls SOAP itself; it only reads what the nightly pull + already wrote. +- `rep_owner` and `rep_territory` are intentionally two separate fields + rather than one merged "best guess" — the sister app asked to see both + so it can reconcile them itself. +- Response is snake_case to match the sister app's existing + `docs/ENRICHMENT.md`-style contracts (its own repo, not this one). + +**Deviations** +- The migration SQL was **hand-written**, not generated by + `prisma migrate dev` — the sandbox this was built in had no Docker/DB + access (`permission denied` on the Docker socket). The SQL is a single + `CREATE INDEX`, matches the format of every other migration in the + repo, and was verified by `tsc --noEmit` + `bun test`, but it has never + actually run against a live database. **Run `db:migrate` (or + `db:deploy` in prod) and confirm it applies cleanly before depending on + it.** +- No route/API-key guard test was added (the existing `/internal/sync/*` + controllers don't have unit tests for their guards either — the closest + precedent, `sage-session-lost.spec.ts`, tests a pure function, not the + controller). Worth adding an e2e/integration test once there's a DB to + test against. + +**What's next** +- Run the new migration against a real Postgres and generate the Prisma + client (`db:generate`) — confirm `sage100CustomerNo` index applies and + nothing else drifted. +- Commit and deploy; set `CRM_API_KEY` in Railway (generate with + `openssl rand -hex 32`) and hand it to the sister app alongside the + route docs already given to that team in chat. +- Smoke-test both routes against a couple of known Mobile Mark + `sage100CustomerNo` values once deployed. +- Longer term (design-only, §7 of the plan): mapping `tracking`/AP email/ + `ship_via`/freight from Sage snapshots or Sage 100 ODBC, if that access + ever lands. + +### 2026-08-06 — Sage cron false-green + daily-tasks empty URL + +**What was completed** +- Diagnosed Railway `cron-sage` logs: Aug 5 06:00 pull + `failed` / `You are not logged on.` while Railway showed success + (HTTP 200). Aug 6 06:04 + manual smoke `ok` (184 companies; 0 deals + in window — Overview pulse can look stale when only companies move). +- Code (prod-deployed via Railway upload; **git commit still needed**): + - `isSageSessionLost` + incremental restart on session drop + (`sage.constants.ts`, `sage-pull.service.ts`, `sage-soap.client.ts`) + - `/internal/sync/sage` → **503** on hard failure + (`sage-sync.controller.ts`) so `curl -f` fails the cron + - `test/sage-session-lost.spec.ts` +- Railway cron start commands: `cron-sage` + `cron-daily-tasks` now + call `https://api.mobilemarksalestool.com/...` literally. + `cron-daily-tasks` had been failing with curl error 3 (empty host) + despite `API_PUBLIC_URL` showing in variables; `?force=1` smoke OK. + +**How and why** +- Session-stateful Sage `next` cannot resume after kick/timeout — + restart the changed-set walk (idempotent upserts). Make cron health + match pull outcome. Unblock daily-tasks by not relying on empty + runtime expansion of `$API_PUBLIC_URL`. + +**Deviations** +- Prod api deployed from local tree without a git commit/push — commit + the Sage files so GitHub and Railway stay aligned. + +**What's next** +- Commit + push the Sage sync resilience changes. +- Watch tonight’s `cron-sage` (06:00 UTC): expect green only when + `outcome:ok`; red + 503 if session drops after restart budget. +- Confirm `cron-daily-tasks` at next 14:00/15:00 UTC run. + +### 2026-08-05 — Webform Screening prod wiring (Entra + cron) + +**What was completed** +- Entra/Exchange (ops): security group `CRM-Webform-Mailbox-Access` + (`info@mobilemark.com`) + Application Access Policy RestrictAccess for + the CRM Entra app Client ID; application `Mail.Read` assumed granted. +- Railway api already had `WEBFORM_MAILBOX=info@mobilemark.com`. +- Created Railway `cron-webform` (curl image, `*/5 * * * *`) calling + `GET $API_PUBLIC_URL/internal/sync/webform` with `CRON_SECRET`. +- Documented in `.cursor/rules/project-overview.mdc`, this HANDOFF, + `docs/plans/webform-lead-screening.md`, `docs/plans/agent-railway.md`. + +**How and why** +- Finish the ops path so Customer Question mail can land in Screening + without manual forwards. + +**Deviations** +- None. Exchange policy replication can lag up to ~1 hour. + +**What's next** +- After `Test-ApplicationAccessPolicy` → Granted for `info@`, smoke + `/internal/sync/webform` and confirm a Web row on `/screening`. +- Optional: website form webhook later (bypass email). + +### 2026-08-05 — Website form leads → Screening + +**What was completed** +- Territory: `data/sales-territory.json` + `packages/db/src/sales-territory.ts` + (`assignRep`, `inferGeoFromForm`) with unit tests. +- Prisma: `PendingWebLead` + `WebformMailboxSync`; migration + `20260805140000_add_pending_web_lead` applied locally. +- Screening list/count merge mail + web; `claim` + `decide` with + `source: mail|web`; phone on `createFromScreening`. +- Ingest: Customer Question parsers, app-only Graph token, + `WebformIngestService`, cron `/internal/sync/webform`; env + `WEBFORM_MAILBOX` (optional). `Dockerfile.api` copies `data/`. +- UI: one Screening table with Mail/Web badge + Claim for unassigned. +- Docs: `docs/plans/webform-lead-screening.md`; visitor plan points at + shared territory JSON. + +**How and why** +- Replace manual forward from info@ to reps. Territory auto-assign when + clear; shared claim pool otherwise. Same Screening approve path for + company match + contact + optional Sage push. + +**Deviations** +- None material. App-only Graph (not delegated shared-mailbox) as planned; + ingest stays off until IT grants application Mail.Read + mailbox policy. + +**What's next** +- IT: app `Mail.Read` + access policy for `info@`; set `WEBFORM_MAILBOX` + on Railway api; add cron for `/internal/sync/webform`; deploy api + (migrate). Smoke with a real Customer Question message. +- Optional later: website form webhook (bypass email). + +### 2026-08-05 — Daily Task Push (Settings + cron) + +**What was completed** +- Schema: `User.dailyTaskPush` + `dailyTaskPushLastSentOn`; migration + `20260805120000_user_daily_task_push` applied locally. +- Microsoft settings switch + `microsoft.setDailyTaskPush` / + status `dailyTaskPush` + `canSendMail`. +- `DailyTaskPushService` buckets open tasks (Overdue / Due today / + Due this week / Other), HTML summary, Graph send to self; route + `/internal/notifications/daily-tasks` (`?force=1` for smoke). +- Railway `cron-daily-tasks` (`curlimages/curl`, `0 14,15 * * *` UTC). +- Docs: `docs/plans/daily-task-push.md`, `docs/plans/agent-railway.md`. + +**How and why** +- Reps want a morning task list in Outlook without leaving the CRM + mailbox path already used by sequences. + +**Deviations** +- None vs plan. Cron is live but the api route ships only after the + next api deploy + migrate. + +**What's next** +- Deploy api (migrate + route). Local smoke with `?force=1`. Flip the + Settings switch with Mail.Send granted. + +### 2026-08-05 — Owner ↔ Sage acctmgr push + Trip Planner ownership + +**What was completed** +- Company Owner → Sage `acctmgr` push (`acctMgrNameForEmail`, + `toSageCompanyFields`, `pushCompany`, enqueue on `ownerId` change). +- Local coverage: 5,985 owned / 0 matchable gaps (former reps unmatched + by design) — no backfill re-run needed. +- Trip Planner owner-aware candidates: `plannerUserId`, + `ownership` mine/unassigned/other, rank mine first; agent asks before + scheduling other-owned. Files: `trip-plan.ts`, + `search_trip_candidates.ts`, `preamble.ts`, `instructions.md`, + `trip-plans.service.ts`, `packages/db/test/trip-plan.spec.ts`. +- Docs: `sage-crm-sync.md` §3.1, `trip-planner.md`, + `HANDOFF-SAGE-SYNC.md`. + +**How and why** +- Sarah saw another rep's client in Trip Planner because candidates + ignored ownership. Pull already mapped `acctmgr` → Owner; push and + trip ranking did not use it. + +**Deviations** +- Other-owned accounts stay on the shortlist (ask, do not hard-hide). +- Unmapped/null owners omit `acctmgr` on push (do not wipe Sage). + +**What's next** +- Soft smoke Owner edit → Sage Account Manager; Sarah re-test Trip + Planner in her region. Commit/push when ready. + +### 2026-08-04 — Trip Planner: fix must-visit chip labels on reload + +**What was completed** +- Added `companies.byIds` (picker-shaped rows for known ids). +- `CompanyMultiPicker` looks up selected ids on load so chips show + company names after save/revisit, not truncated ids. +- Regenerated nestjs-trpc `server.ts`. + +**How and why** +- Labels were only cached in component state after a pick in-session; + reload had empty cache and no open search, so the UI fell back to + `id.slice(0, 8)`. + +**Deviations** +- None. + +**What's next** +- Commit/push when ready; redeploy api + app. + +### 2026-08-04 — Trip Planner: prioritize nearby open deals by size + +**What was completed** +- `searchTripCandidates`: ACTIVE includes still-open deals even outside + the look-back window; sort is must-visit → open deals by + `openPipelineAmount` → other activity → distance; expose + `openDealCount`. +- Agent copy: preamble, tool note, and `instructions.md` tell the model + to fill leftover day slots with nearby open-deal accounts (largest + first). +- Docs: `docs/plans/trip-planner.md` ranking section. + +**How and why** +- Reps want trip fill-ins to be other accounts in the area with open + pipeline, ordered by deal size — not only recent create/close activity. + +**Deviations** +- None vs locked decisions (still mechanical ranking in `@crm/db`). + +**What's next** +- Redeploy agent + api so prod picks up the ranking/prompt change + (no new migration). + +### 2026-08-04 — Map re-geocode (Open-Meteo, faster) + +**What was completed** +- Stale geocodes (place key without state, e.g. `englewood||`) cleared + via `--refresh-stale` on local + prod. +- Default geocoder switched to Open-Meteo with concurrency 4; Photon + and Nominatim kept as `--provider=` options. Retryable errors (429) + are not permanently cached. +- Local: 4888 places in ~330s → 7375 companies updated (3870 ok / + 1008 fail). Prod: 5436 places in ~1218s → 10931 companies updated + (4367 ok / 1068 fail). TCP proxy deleted after. +- Files: `apps/api/scripts/geocode-companies.ts`, + `apps/api/src/geocode/open-meteo.geocoder.ts` (new), + `photon.geocoder.ts`, `docs/plans/companies-map.md`. + +**How and why** +- Nominatim at 1 req/s would take ~90 min for ~5k unique places. + Open-Meteo allows parallel lookups and finished local in ~5.5 min. +- State/country snapshot backfill left old lat/lng; map showed + Englewood CO near NYC until keys were refreshed. + +**Deviations** +- Photon hit 429 / was unreachable from this IP; Open-Meteo became + the default instead. Ran prod with `--no-fallback` to avoid + Nominatim rate limits during the bulk pass. + +**What's next** +- Commit/push Open-Meteo + script changes if desired. +- Optional: clear failed `GeocodeCache` rows and retry with Nominatim + fallback for the ~1k miss places. +- Reload `/map` and confirm CO / ambiguous cities look right. + +### 2026-08-04 — Trip Planner UI polish + +**What was completed** +- Rebuilt `/trip-planner` client UX to match CRM patterns + (`Empty`, `StatusIndicator`, `FieldSet` / `FieldGroup`, + `ToggleGroup` for Active/Salvage, numbered itinerary stops). +- Flow: list → focused create → edit workspace (brief | itinerary + + agent) with back navigation, PDF/Delete in the trip header. +- Files: `apps/app/app/(app)/trip-planner/trip-planner-client.tsx`, + `page.tsx`; status note in `docs/plans/trip-planner.md`. + +**How and why** +- First-pass form was functional but sparse; polish uses the same + composition as Sequences / Follow-ups so the page reads as part + of the product, not a wireframe. + +**Deviations** +- None vs locked trip-planner decisions (still CRM density, `@crm/ui` + only — no marketing layout). + +**What's next** +- Smoke-test the polished flows locally (empty → create → agent → + PDF). +- Apply `TripPlan` migration on Railway when deploying API. +- Optional later: map overlay of itinerary stops. + +### 2026-08-04 — Trip Planner (v1) + +**What was completed** +- Schema: `TripPlan` + `TripActivityMode` / `TripPlanStatus` + + `AgentConversation.tripPlanId` (migration `20260804150000`). +- Shared loaders: `packages/db/src/trip-plan.ts` (haversine, + ACTIVE/SALVAGE deals-only, must-visits, itinerary write). +- Nest: `apps/api/src/trip-plans/*` CRUD + hub geocode; + `companies.nearHub` for the must-visit picker; Sage 100 # in + company search filter. +- App: `/trip-planner` page, nav Plane icon, multi company picker, + `TripAgentPanel`, client PDF (`jspdf`). +- Agent: kind `trip` end-to-end (record / bridge / proxy / + conversations / preamble / tools / transcript VERBS / + instructions.md). +- Docs: `docs/plans/trip-planner.md`. + +**How and why** +- Mirrors pipeline agent specialization: persist brief, pass + `tripPlanId` in JWT, mechanical candidate query in DB, agent + sequences days and writes itinerary JSON for PDF. + +**Deviations** +- Nav icon is Carbon `Plane` (no `Airplane` in the icon set). +- PDF via `jspdf` rather than `@react-pdf/renderer`. + +**What's next** +- Smoke-test locally: create trip → agent plan → PDF download. +- Apply migration on Railway when deploying API. +- Optional later: map overlay of itinerary stops. + +### 2026-08-04 — Temporary cheaper agent model (DeepSeek V4 Pro) + +**What was completed** +- Switched research agent model from `anthropic/claude-sonnet-5` to + `deepseek/deepseek-v4-pro` for cheaper testing / token burn. +- Clear TEMPORARY + revert comments in + `apps/agent/agent/agent.ts` and + `.cursor/rules/project-overview.mdc`. + +**How and why** +- Need a cheaper model while iterating; still want agentic tool use. +- Skipped `openai/gpt-5.6-luna` (economy tier, not a Sonnet peer). +- Skipped Kimi K3 (often pricier than Sonnet on Gateway). +- DeepSeek V4 Pro is tagged for agentic tool orchestration and is far + cheaper (~$0.44/$0.87 vs Sonnet ~$2/$10). Safer peer if quality + drops: `openai/gpt-5.6-terra`. + +**Deviations** +- Temporary only — plan is to revert to `anthropic/claude-sonnet-5`. + +**What's next** +- Redeploy / restart the Railway `agent` service so the new model + loads. Watch identity-matching quality; revert when testing ends. + +### 2026-08-04 — Accepting title/name facts enqueues Sage push + +**What was completed** +- `contacts.decideFact`: on accept of `title` or `name`, call + `sagePush.enqueueAndKick("contact", …)` after the local write. +- Matches the human Details save path so reps do not need a second + field edit to write back to Sage. +- Files: `apps/api/src/contacts/contacts.service.ts`, + `HANDOFF-SAGE-SYNC.md`. + +**How and why** +- Accepting a signature suggestion is a deliberate human decision onto + Sage-mapped columns (`title` / `firstname`+`lastname`). Without + enqueue, the local record updates and Sage stays stale until someone + remembers to re-save another field. + +**Deviations** +- None. LinkedIn/twitter/github accepts still do not push (not in + `toSagePersonFields`). + +**What's next** +- Deploy API; for Lindsay Luba (already accepted before this change), + re-save Title once or nudge first name to enqueue the push. +- Soft smoke: accept a title suggestion on a Sage-linked contact → + outbox row → Sage `title` updates. + +### 2026-08-04 — Deal Maturity labels + historical grid windows + +**What was completed** +- Deal Maturity × rep presets are historical: This month / Last month / + This quarter / Last quarter / YTD / Custom (dropped Next 30/3m/6m). +- Column headers: stage label on top (larger), % underneath. +- UI copy "Certainty" → "Deal Maturity" on overview, pulse, deals + table, deal sheet, sales-rep sheet. + +**How and why** +- Grid is a lookback, not a forecast. "Certainty" implied win odds; + maturity = progress through the sales process. + +**Deviations** +- None. + +**What's next** +- Smoke Everyone grid windows; commit/push when ready. + +### 2026-08-04 — Deals Owner default, Stage multiselect, contact company picker + +**Completed** +- `/deals` Owner facet defaults to `"me"` (signed-in user) via + `facetDefaults` in `deals-search-params.ts`; resolved to the real user + id in `deals/page.tsx` (SSR) and `deals-table.tsx` (client) before + `deals.list`. +- Stage facet is multiselect: `DataTableFacet.multiple` in + `packages/ui/.../data-table.tsx` (checkbox menu, comma-joined URL); + API `parseStageFacet` in `deals.service.ts` accepts one or many + stages. All `DEAL_STAGE_OPTIONS` always listed (Leads → Unqualified) + so zero-count stages stay visible with Mobile Mark labels. +- Contact sheet Company field uses searchable `CompanyPicker` + (`InlineCompanyField` in `inline-field.tsx`; `variant="inline"` on + `company-picker.tsx`) with Sage 100 # + contact count. + +**How / why** +Reps open Deals to work their own pipeline first. Multi-stage filter +matches how they slice open work. Contact reassignment needed the same +disambiguated company search as create/screening (14k+ companies). + +**Deviations** +None. Stage labels were already correct in `deal-stage.tsx`; the +dropdown had been hiding Leads/Unqualified when facet count was 0. + +**What's next** +Smoke `/deals` (Owner = you; Stage checkboxes; clear Owner → all). +Reassign a contact’s company in the sheet and confirm Sage 100 line. +Optional: same `InlineCompanyField` on the deal sheet Company row. + + +### 2026-08-04 — Microsoft auto-provision + drop email/password + +**Completed** +- Microsoft OAuth may create users on first sign-in + (`packages/auth/src/auth.ts` — removed `disableImplicitSignUp` for + Microsoft). `databaseHooks.user.create` still enforces + `ALLOWED_SIGN_IN` (e.g. `mobilemark.com`). +- Email/password auth disabled (`emailAndPassword.enabled: false`). +- Sign-in UI is Microsoft-only: removed credentials form + “or” + separator (`apps/app/app/(auth)/sign-in/page.tsx`; deleted + `credentials-form.tsx`). Copy explains first visit creates the account. +- Docs: `docs/local-setup.md`, `packages/auth/README.md`; env comment in + `apps/api/src/config/env.validation.ts`. + +**How / why** +Colleagues at mobilemark.com should get in with Continue with Microsoft +without an admin pre-creating rows. Entra is already single-tenant; the +allow-list is the second gate for domain emails. + +**Deviations** +None. Google button still appears if `GOOGLE_*` is set (hidden here). + +**What's next** +Restart the API so auth config reloads. Smoke: a new @mobilemark.com +Microsoft user lands in the app with a new `user` row; a non-domain +account still gets 403. Deploy API + app when ready for prod. + + +### 2026-08-04 — Certainty by rep grid + lock certainty to stage + +**What was completed** +- `dashboard.certaintyByRep` — open deals by `expectedCloseDate`, Closed + won/lost by `closedAt`; counts by owner × stage + (`dashboard.contracts.ts` / `.service.ts` / `.router.ts`). +- Everyone UI: `CertaintyByRepGrid` + window control (This month / + Next 30 / 3m / 6m / Custom) via `certWindow` URL params; placed under + forecast cards (`certainty-by-rep-grid.tsx`, `sales-dashboard.tsx`). +- Certainty stage-locked: removed deal-sheet edit; dropped + `update.probability`; Sage `mapOpportunity` uses `certaintyForStage`; + migration `20260804140000_lock_deal_certainty_to_stage` applied + locally. Docs: sage-crm-sync §3.3. + +**How and why** +- Managers need a printed-forecast-style certainty matrix on Everyone. +- Independent certainty drifted from stage and confused weighted totals. + +**Deviations** +- None vs agreed plan (Unqualified omitted from grid columns). + +**What's next** +- Smoke Everyone overview: grid + window filter; deal sheet Certainty + read-only. +- Apply `20260804140000` on Railway `api` when deploying. + +### 2026-08-04 — Recent deal moves: Amount column + +**What was completed** +- `loadPipelinePulse` now selects deal `amount` and serializes + `deal.amountCents` on movers/recent rows + (`packages/db/src/pipeline-pulse.ts`). +- Overview Recent deal moves table adds an Amount column (current + unweighted total) between Deal and Change + (`apps/app/app/(app)/pipeline-pulse.tsx`). + +**How and why** +- Managers scanning Everyone moves need deal size at a glance; amount + only appeared when the change itself was an amount edit. + +**Deviations** +- None. Biggest movers / stuck unchanged (stuck already shows amount + under the company line). + +**What's next** +- Smoke Everyone overview: Amount column populated for deals with + totals; empty cell when null/zero. + +### 2026-08-04 — Forecast month window: last / this / next 12 + +**What was completed** +- Tightened `buildForecast` to last month + this month + next 12 + months (`FORECAST_MONTHS_LOOKBACK=1`, `LOOKAHEAD=12`). Far-future + buckets (e.g. Nov 2027 from Aug 2026) drop out; "No date" kept. +- Card copy: "Last month, this month, and the next 12". + +**How and why** +- Prior "last 12 months + all upcoming" still left a long overdue + tail and sparse far-future rows. This matches how reps scan the + live book. + +**Deviations** +- None. + +**What's next** +- Smoke overview: expect ~Jul 2026 … Aug 2027 (+ No date) in Aug 2026. +- Still pending: apply stage migrations on Railway `api`. + +### 2026-08-04 — Drop "Sage 100" label from picker meta + +**What was completed** +- `formatCompanyDisambiguation` now shows `{customerNo} · N contacts` + (no "Sage 100" prefix). Same helper feeds picker + match dialogs. + +**How and why** +- The number alone is enough; the label cluttered the secondary line. + +**Deviations** +- None. + +**What's next** +- None for this tweak. + +### 2026-08-04 — Company picker Sage 100 + contact count + +**What was completed** +- `companies.options` returns `sage100CustomerNo` + `contactCount`. +- `CompanyPicker` secondary line: `Sage 100 {id} · N contacts` (no domain). +- Screening + create-company match dialogs: same Sage 100 + always-on + contact count; dropped Sage CRM id from the subtitle. +- Shared helper: `apps/app/components/crm/company-disambiguation.ts`. + Files: `companies.service.ts`, `company-picker.tsx`, + `screening-table.tsx`, `create-company-sheet.tsx`. + +**How and why** +- Reps often face duplicate/dirty Sage company names when attaching + contacts; the customer # and contact volume disambiguate faster than + domain alone. + +**Deviations** +- None. + +**What's next** +- Smoke locally: open contact create / contacts filter / screening + approve match dialog and confirm secondary lines. + +### 2026-08-04 — Fill state/country from snapshots; reject junk Sage emails + +**What was completed** +- `normaliseEmail` requires `local@domain.tld`; note-shaped values → null + (company + contact pull). Exported for repair scripts. Tests added. +- Extended `backfill-company-street-from-snapshots.ts` to also fill + `stateCode` / `country` / `countryCode` via `mapCompanyTree` from + snapshots. Local: **13,670** companies updated. +- New `fix-sage-company-email-notes.ts`: cleared **428** junk company + emails locally. Docs: `docs/plans/sage-crm-sync.md` §3.1. + +**How and why** +- Full pull only wrote `city`; state/country persistence landed with + `/map` (Aug 3), so almost all rows stayed null while snapshots had + nested address.street. Sage `emailaddress` is often a billing note + (same class as website notes) — VIBRA-TECH was the exemplar. + +**Deviations** +- Did not rewrite `city` when company scalar differs from nested address + (e.g. Snellville vs Louisville) — only fill null state/country. +- Contact junk emails not bulk-cleared (unique constraint; company-only + repair). Pull will null them on next touch. + +**What's next** +- Smoke VIBRA-TECH (and a few others) on prod sheet / map — state, + country, empty email. + +### 2026-08-04 — Prod state/country backfill + junk email clear + +**What was completed** +- Pushed `28b6ba9` (email shape guard + extended snapshot backfill). +- Prod address backfill: **13,484** companies updated (state/country). +- Prod email clear: **428** junk company emails nulled. +- TCP proxy `36687ffa-…` (maglev.proxy.rlwy.net:14694) deleted; list empty. + +**How and why** +- Same snapshot repair as local; scripts run via `run-via-tcp-proxy.ts`. + +**Deviations** +- None. + +**What's next** +- Smoke VIBRA-TECH on prod. + +### 2026-08-04 — Prod street backfill via TCP proxy + +**What was completed** +- Pushed `f794eae` (all pending work). Api deploy SUCCESS; migrations + applied on start (`db:deploy`), including street address + deal stages. +- Prod snapshot backfill: **12,192** companies updated + (`backfill-company-street-from-snapshots.ts` via + `run-via-tcp-proxy.ts`). Sample street=29/50, postal=31/50. +- Temporary Postgres TCP proxy deleted after; list empty. + +**How and why** +- Same as local snapshot repair — no Sage SOAP. Proxy only for laptop + reachability to private Railway Postgres. + +**Deviations** +- None. + +**What's next** +- Smoke street display on prod company sheet / map selection. + +### 2026-08-04 — Map filters: compact 2×2 dropdowns + +**What was completed** +- `/map` sidebar: replaced three ToggleGroup rows + stacked selects with a + 2×2 `Select` grid (owner / Sage / location / deal years). Sort + dir sit + on the results count row. `map-panel.tsx`. + +**How and why** +- Toggle rows ate too much vertical space next to the company list. + +**Deviations** +- None. + +**What's next** +- Same as prior map entry (optional default `dealYears=6`). + +### 2026-08-04 — Company street address (display + sync) + +**What was completed** +- Schema: `Company.streetAddress` + `postalCode`; migration + `20260804130000_company_street_address` (local deploy done). +- Sage: `mapCompanyTree` / `mapCompany` copy `address1` + `postcode` + (also `zip` / `zipcode`); `sage-pull` upsert writes them; street/postal + alone do not clear city geocode. Docs: `docs/plans/sage-crm-sync.md` + §3.1, `docs/plans/companies-map.md`. +- Backfill: `apps/api/scripts/backfill-company-street-from-snapshots.ts` + — sample 42/50 street, 38/50 postal; filled **12,191** local companies + from `SageRecordSnapshot` (no SOAP). Quoting-tool dump not needed. +- API/UI: update contracts + `byId` / `mapList`; company sheet Street / + State / Postal fields + header; map selected preview via + `formatCompanyLocation` (`apps/app/components/crm/company-location.ts`). +- Tests: `sage-mappings.spec.ts`, `sage-xml.spec.ts` (address1/postcode). + +**How and why** +- Street was always in Sage nested address + snapshots; we only mapped + city/state/country for firmographics / map pins. Display needs the + full line without changing Nominatim city-level geocode. + +**Deviations** +- None vs plan. Snapshot coverage was strong — no quoting-tool path. + +**What's next** +- Prod deploy + backfill (see entry above) — done. + +### 2026-08-04 — Nav count bubbles + map deal-years filter + +**What was completed** +- Nav: `CountBadge` in `packages/ui/src/components/count-badge.tsx`; + Screening + Follow-ups icons in `apps/app/components/app-icon-rail.tsx` + show uncleared counts (desktop overlay + mobile inline). +- API: `screening.count` / `followups.count`; cache helpers + `cache.screening` + `followups.count` invalidation on decide. +- Map: `dealYears` (0–10) on `companies.mapList` + URL parsers; + companies with a deal opened (`createdAt`) or closed (`closedAt`) in + the last N years. UI select on `/map`. Docs: + `docs/plans/companies-map.md`. + +**How and why** +- Reps need a glance signal for Screening / Follow-ups without opening + those pages. Lightweight count queries avoid loading full queues on + every route. +- Map of ~12k companies is noisy; a years-back deal filter surfaces + recent customers without replacing the full map (default = any time). + +**Deviations** +- Follow-ups badge counts PROPOSED + due SNOOZED before prefs filters + (list may be slightly smaller). Prefer a cheap count over re-running + prefs in the rail. +- Default deal-years is "Any deal time" (0), not 6 — user picks 1–10. + +**What's next** +- Apply deal-stage migrations on Railway `api` if not done. +- Optional: default map `dealYears=6` if the team wants that as the + usual view. + +### 2026-08-04 — Forecast by close month: last 12 months + +**What was completed** +- `buildForecast` in `apps/api/src/dashboard/dashboard.service.ts` + drops month buckets older than 11 months before the current month + (last 12 months + any upcoming; "No date" kept). Totals and + `byOwner` still sum every open deal. +- Overview card copy in `apps/app/app/(app)/sales-dashboard.tsx` + notes "Last 12 months and upcoming". + +**How and why** +- Everyone (and Me) showed open deals with close dates back to 2021, + all Overdue. That list was long and not useful for the live forecast. + +**Deviations** +- None. Agent `read_pipeline_report` forecast mode is unchanged + (optional month filter still available there). + +**What's next** +- Smoke on overview Me/Everyone: month table should start ~Sep 2025 + (or current−11) and still show future months. +- Still pending: apply stage migrations on Railway `api`. + +### 2026-08-04 — Sales-rep sheet + five-stage deal alignment + +**What was completed** +- Added `DealStage.IN_PURCHASING`; migrations + `packages/db/prisma/migrations/20260804120000_deal_stage_in_purchasing` + + `20260804120100_remap_deal_stages_by_sage` (local deploy OK). +- Remapped Sage pull/push in `apps/api/src/sage/sage.mappings.ts`; + `STAGE_CERTAINTY` + open-stage lists in API, pulse, report, seed. +- UI labels in `apps/app/components/crm/deal-stage.tsx` (Leads → + In Purchasing); stage stepper picks up five open steps. +- `dashboard.repSummary` + `SalesRepSheet` (`record=user:`); clickable + `OwnerCell`; Forecast-by-rep entry. Docs: sage-crm-sync §3.3. + +**How and why** +- Managers need a per-rep panel on Everyone (certainty × close month + like the printed forecast). Proposal and Purchasing must not share + one enum slot; Certainty defaults match Mobile Mark bands. + +**Deviations** +- None vs plan (Base Business / Your Estimate still skipped). + +**What's next** +- Apply the two stage migrations on Railway prod (`db:deploy` on `api`). +- Smoke: Everyone → click a rep → certainty grid + nested deal sheet. +- Optional: sales-superpowers Action Queue (separate roadmap). + +### 2026-08-04 — Prod user: Robert Johnson + +**What was completed** +- Created prod `User` for Robert Johnson + (`rjohnson@mobilemark.com`, `emailVerified: true`, id + `invite-8a6b8239aca7a3374ba17508`) via temporary Railway TCP proxy. +- Added reusable `apps/api/scripts/ensure-user.ts` (idempotent by email). + +**How and why** +- Public signup is disabled (`disableSignUp` / + `disableImplicitSignUp`). Microsoft SSO needs an existing user row; + account linking attaches the Entra account on first sign-in. No + separate manager role — same shape as other teammates. + +**Deviations** +- None. + +**What's next** +- Robert signs in at prod with Microsoft as `rjohnson@mobilemark.com`. + If `ALLOWED_SIGN_IN` is not the whole `mobilemark.com` domain, add his + address there on Railway. + +### 2026-08-03 — Deploy + prod Sage website repair + +**What was completed** +- Pushed `bee13d4` (mapping/UI/Research) and `35e060f` (faster repair + script + TCP-proxy runner) to `origin/main`. +- Prod data repair via temporary Railway TCP proxy: + cleared remaining junk websites/domains and backfilled from contact + emails (2,166 updates in the final pass; earlier partial pass had + already cleared most notes). Proxy deleted after. +- Verified prod Hitachi Rail CD US LTD: + `website=https://cleverdevices.com`, `domain=null` (domain still owned + by Clever Devices LTD). Dry-run now reports `toUpdate: 0`. + +**How and why** +- Private Railway `DATABASE_URL` is not reachable from a laptop; same + temporary TCP-proxy pattern as the earlier prod restore. + +**Deviations** +- None. + +**What's next** +- Wait for Railway deploy of `api`/`app`/`agent` from `main`. Confirm + Research/Re-enrich on Hitachi in prod UI; ensure agent has + `PERPLEXITY_API_KEY` / optional `CONTEXT_DEV_API_KEY`. + +### 2026-08-03 — Research/Re-enrich without Sage website notes + +**What was completed** +- **Why buttons were disabled:** both gated on `Company.domain`. Hitachi had + none after clearing the Sage note; `cleverdevices.com` is already owned by + another company row, so domain could not be claimed. +- **Research:** always enabled. API no longer requires domain. + `research_company` falls back to Perplexity by company name when there is + no URL (`PERPLEXITY_API_KEY` on the agent). +- **Re-enrich:** enabled when domain **or** URL-shaped website exists. + `enrich_company` looks up via `domain ?? host(website)`. Soft-claims a + free domain / fills website from contact emails on click. +- **Prod data repair (not a migration):** + `apps/api/scripts/fix-sage-website-notes.ts` — clears junk websites/ + domains, then backfills from contact work emails. Local already run + (Hitachi → `website=https://cleverdevices.com`, domain null). + Prod: `DATABASE_URL=… bun run scripts/fix-sage-website-notes.ts` + from `apps/api` (dry-run first). +- LinkedIn RapidAPI is for **people**, not these company buttons. + Company brand Re-enrich still uses `CONTEXT_DEV_API_KEY`. + +**How and why** +- Domain uniqueness blocked the naive "set domain from emails" fix for + renamed accounts. Website is not unique, so Research/Re-enrich can use + it; Perplexity covers the no-URL case. + +**Deviations** +- None. + +**What's next** +- Run the repair script on **prod**. Deploy API/app/agent so Research + works without domain. Confirm `PERPLEXITY_API_KEY` (+ optional + `CONTEXT_DEV_API_KEY`) on Railway `agent`. + +### 2026-08-03 — Sage `website` was credit notes, not URLs + +**What was completed** +- Root cause: Sage `comp_website` in this tenant holds free-text notes + ("FORMERLY CLEVER DEVICES 7/1/26", "NET 30 …", "DO NOT SELL …"). Pull + mapped them as-is into `Company.website`; the sheet subtitle used that + string as a link. Weak `domainFrom` also produced junk domains from + notes that contained a `.`. +- **Mapping**: `mapCompany` keeps `website` only when `normalizeDomain` + accepts it; domain falls back to email. Push no longer writes + `website` (would overwrite Sage notes). +- **UI**: `DomainLink` only uses URL-shaped website values as href. +- **Cleanup (local)**: `apps/api/scripts/fix-sage-website-notes.ts` + cleared 1,272 websites + 108 domains. Hitachi Rail CD US LTD now has + `website=null`, `domain=null`. +- Docs: `docs/plans/sage-crm-sync.md` §3.1. Tests in + `sage-mappings.spec.ts`. + +**How and why** +- Full Sage resync not needed: local columns were wrong, Sage data is + fine as notes. One SQL-style pass + stricter pull is enough. Nightly + incremental will keep rejecting notes. + +**Deviations** +- None vs intent. Did not migrate Sage notes into a local notes column + (quick display fix only). + +**What's next** +- Run `bun run scripts/fix-sage-website-notes.ts` against **prod** + (`DATABASE_URL` → Railway). Deploy mapping/push/UI changes so prod + pull does not re-pollute. Optional later: derive domains from contact + emails where `domain` is null. + +### 2026-08-03 — Screening company match + Hitachi cleanup + +**What was completed** +- **Prod cleanup**: Teresa Whitacre + (`twhitacre@hitachirail-cd.com`) moved to Sage-backed **Hitachi Rail**; + orphan company `hitachirail-cd.com` deleted. Local DB had no orphan / + no Teresa row. +- **Sage probe**: no Whitacre person, no junk company for that domain — + Screening create was local-only; no Sage reparent needed. +- **Product**: shared `findSimilarCompanies` / `rankSimilar` / + `pickStrongUniqueMatch` in `company-similar.ts`; domain→name guess + + related-host scoring; **Suggested** pick (Sage 100 > matching-domain + contacts > contact volume); `companyForEmail` soft-attaches on strong + unique or suggested+signals; Screening Approve dialog (Use this / + Create from domain with `preferDomainCompany`); same Suggested cue on + New company; docs §4 item 6c. +- **Screening → Sage push**: Approve enqueues person create when parent + has `sageCrmCompanyId` and no same-name Sage-linked twin at that + company; enqueue failures never fail local create. Toast: + "queued for Sage" when enqueued. Locked decision updated in + `HANDOFF-SAGE-SYNC.md`. +- Script: `apps/api/scripts/fix-hitachi-screening-dup.ts`. +- Tests: `apps/api/test/company-similar.spec.ts`. + +**How and why** +- Exact domain-only matching invented domain-named companies when Sage + already had the org under another domain/null. Soft-match + UI pick + stops that; account signals break ties among several Hitachi-style hits. +- Screening was excluded from Sage push to avoid flood; Approve is a + human decision onto a known Sage company, so push (best-effort) keeps + CRM and Sage aligned and stamps `sageCrmContactId` from the add. + +**Deviations** +- Cleanup target stayed **Hitachi Rail** per original plan. Live + suggestion for `hitachirail-cd.com` prefers **HITACHI RAIL CD US LTD** + (Sage 100 + 64 contacts) over Hitachi Rail (5) — better for future + Approves. +- Person push still does not send nested email/phone (existing push cut). + +**What's next** +- Soft check: Approve onto a Sage-backed Suggested company → toast + "queued for Sage"; contact gains `sageCrmContactId` after flush. +- Apply pending screening per-rep migration if not yet + (`20260803200000_pending_contact_per_user`) locally + Railway `api`. +- Optional later: nested email on person create so Sage shows the address. + +### 2026-08-03 — Companies list Hide empty filter + +**What was completed** +- Companies table: **Hide empty** checkbox (on by default) in the filter + row. When on, list keeps companies with ≥1 contact **and** a non-empty + Sage 100 customer #. +- API: `companyListInput.hideEmpty` (`yes` | `all`, default `yes`); + `buildWhere` adds `contacts.some` + `sage100CustomerNo` guards. +- URL: `hideEmpty` via `companiesSearchParams` facet + + `facetDefaults: { hideEmpty: "yes" }`. Checkbox in `leadingActions` + (not a dropdown). +- Files: `apps/api/src/companies/companies.contracts.ts`, + `companies.service.ts`, + `apps/app/app/(app)/companies/companies-search-params.ts`, + `companies-table.tsx`. + +**How and why** +- Reps want the list to show real accounts (people + Sage 100 link), not + empty shells from sync. Uncheck to audit the rest. Same Prisma pattern + as the map's Sage 100 linked filter. + +**Deviations** +- None. (Earlier note about a Sage filter referred to `/map`, not this + table.) + +**What's next** +- Soft check in the browser: default list should drop zero-contact / + no-Sage-100 rows; uncheck restores them. +- Optional: apply pending screening migration. + +### 2026-08-03 — Overview Won/Lost KPIs use deal closedAt + +**What was completed** +- Won / Lost in the eight-KPI strip now use `wonThisMonth.count` and + `performance.losses` (deal outcomes in the selected range), not + forward-only `DealFieldChange` stage→won/lost counts. +- Docs: `docs/plans/pipeline-pulse.md` decision 5/8; Current state in + `HANDOFF.md`. + +**How and why** +- With YTD selected, Closed won showed 88 deals while Won stayed at 1 — + the change log only started when pulse shipped. Deal-based counts match + Closed won / win rate and react correctly to the date range. + +**Deviations** +- Certainty moves / movers / feed still use the change log (forward-only). + Agent `read_pipeline_pulse` won/lost counts remain change-log based. + +**What's next** +- Soft check in the browser: YTD Won should match Closed won deal count. + Optional: apply pending screening migration. + +### 2026-08-03 — Overview: 8-KPI grid + range-aware pulse counts + +**What was completed** +- Merged pulse KPIs (Won / Lost / Certainty / Stuck) into the sales + `StatGroup` as a second row — one 8-cell strip above the charts. +- `loadPipelinePulse` accepts optional `since` / `until`; overview passes + the selected dashboard range. Stuck stays fixed at 14d+. Exact counts + via `groupBy` (not capped by the feed scan). +- `StatCard` gains `tone="static"` (muted cell + title hint) and + `animate` (`StatCount` CSS `@property` integer counter; + `StatTick` enter motion for money/percent). `StatGroup` borders fixed + for wrapped rows. +- Pulse tables stay below charts; copy uses `windowDays` from the payload. +- Files: `packages/db/src/pipeline-pulse.ts`, + `apps/api/src/dashboard/dashboard.service.ts`, + `apps/app/app/(app)/sales-dashboard.tsx`, + `apps/app/app/(app)/pipeline-pulse.tsx`, + `packages/ui/src/components/stat-card.tsx`, + `packages/ui/src/components/dashboard.tsx`, + `packages/ui/src/styles/globals.css`, + `apps/agent/agent/tools/read_pipeline_pulse.ts` (description only). + +**How and why** +- Reps wanted the newer pulse KPIs next to the sales KPIs, and for + change counts to track the date control. Static metrics (due this + month, open pipeline, stuck) stay visually quieter so range flips + read clearly; animated values make the reactive cells obvious. + +**Deviations** +- Agent `read_pipeline_pulse` still defaults to 7 days (unchanged call + shape). Overview is the surface that passes the selected range. + +**What's next** +- Soft visual pass in the browser (range flip + static mute). Optional: + apply pending screening migration `20260803200000` locally + Railway. + Sage module follow-ups from the SOAP probe stay design-only until + scoped (`docs/plans/sage-crm-sync.md`). + +### 2026-08-03 — Sage SOAP probe: other modules beyond the triad + +**What was completed** +- Live read-only SOAP probe of record-tab / plan §3c entities against + production (`crm.mobilemark.com`). +- Scripts: `apps/api/scripts/sage-probe-entities.ts` (availability + + sample fields) and `apps/api/scripts/sage-probe-recency.ts` (2024–2026 + date filters). Uses `node:https` + public DNS (local DNS cannot resolve + the host). +- Updated entity table in `docs/plans/sage-crm-sync.md` §1. + +**How and why** +- Screenshot tabs (Notes, Communications, Cases, Addresses, Phone/email, + Documents, Relationships, Consent, Self Service) + plan extras (lead, + quotes, orders, users, forecast, campaign, Mas*) were queried with + common predicates, then re-checked for recent `updateddate` / + `comm_datetime`. +- Goal: confirm we did not miss an actively used module after syncing + only company / person / opportunity. + +**Deviations** +- None from scope. Local default DNS cannot resolve + `crm.mobilemark.com`; probes force `8.8.8.8` / `1.1.1.1`. + +**What's next** +- Decide with the team whether to pull **communications** (→ local + `Activity`) and/or **notes** / **leads** next (`docs/plans/sage-crm-sync.md` + §3c / §4 item 5). Do not sync address/phone/email standalone — already + nested under company. Quotes/orders/Mas* need admin/WS investigation + if order history matters. + +### 2026-08-03 — Company create: confirm possible matches + +**What was completed** +- Soft-match before creating a company so two reps do not fork the same + Sage org by typing a similar name: + - `normalizeCompanyName` — strips legal suffixes / punctuation + (`apps/api/src/companies/company-name.ts` + unit test). + - `companies.similar` query — local CRM only (domain exact + name soft + rank); returns up to 8 matches with `reason` / `blocksCreate` + (`companies.contracts.ts`, `companies.service.ts`, + `companies.router.ts`; tRPC regenerated). + - Create sheet: check similar on submit → Dialog "Use this" / + "Create new anyway" (domain hits hide Create anyway) + (`apps/app/app/(app)/companies/create-company-sheet.tsx`). + - Docs: `docs/plans/sage-crm-sync.md` §4 item 6b; this HANDOFF. + +**How and why** +- Domain uniqueness already hard-blocks same website. Name is not unique, + so "Acme" vs "Acme Inc" could create a second local + Sage company. + Confirm against the local mirror (post Sage pull) — no live SOAP search. + +**Deviations** +- None vs the approach agreed in chat (local-first, confirm, no auto-merge). + +**What's next** +1. Still apply Screening migration locally + Railway when Docker is up + (`20260803200000_pending_contact_per_user`). +2. Optional later: same similar-confirm on contact create (email already + unique); live Sage search only if local miss becomes a real pain. + +### 2026-08-03 — Primary contact column + email copy + +**What was completed** +- `companies.list` returns `primaryContact` (designated primary, else + most recently created contact): + `apps/api/src/companies/companies.service.ts`. +- Companies table: **Primary contact** column (name + email + copy): + `apps/app/app/(app)/companies/companies-table.tsx`. +- Contacts table: email column uses the same copy control: + `apps/app/app/(app)/contacts/contacts-table.tsx`. +- Shared `EmailValue` (mirrors Sage ID copy pattern): + `apps/app/components/crm/email-value.tsx`. + +**How and why** +- Reps need a quick way to see and copy the main contact from the + company list, and to copy emails from the contacts list, without + opening the sheet. Same stop-propagation + toast pattern as Sage IDs. + +**Deviations** +- None. + +**What's next** +- Apply screening migration if not done (`bun run db:deploy` local + + Railway `api`). Otherwise continue from Current state priorities. + +### 2026-08-03 — Screening scoped to logged-in mailbox + +**What was completed** +- Screening was a shared tenant queue (`pendingContact.email` unique only), + so `/screening` showed unmatched mail from every connected Outlook + mailbox. Fixed to per-rep (same model as Follow-ups): + - Schema: `PendingContact.userId` + `@@unique([userId, email])` + + index `(userId, status, lastSeenAt)`; User relation + `pendingContacts` (`packages/db/prisma/schema.prisma`). + - Migration: `packages/db/prisma/migrations/20260803200000_pending_contact_per_user/` + — deletes existing shared PENDING rows, then adds `userId`. + - Harvest: `ScreeningHarvestService.harvest({ userId, … })`; Outlook + sync passes `row.userId` + (`apps/api/src/screening/screening-harvest.service.ts`, + `apps/api/src/microsoft/outlook-mail-sync.service.ts`). + - API: `screening.list(userId)` / `decide` rejects other users' rows + (`apps/api/src/screening/screening.service.ts`, + `screening.router.ts`). tRPC regenerated. + - UI copy: "People from your synced mailbox…" + (`apps/app/app/(app)/screening/page.tsx`). + - Follow-ups already filtered by `userId`; hardened + `proposeFollowUp` so cited messages must have + `syncedByUserId = input.userId` + (`apps/agent/agent/lib/followups.ts`). + - Plan note: `docs/plans/m365-expansion.md` Phase 4 model updated. + +**How and why** +- Copy said "People you email" but the table was team-wide. Cesar was + Jordan's; Lindsay / Raymond came from other reps' sync. Domain + suppress stays tenant-wide (noise filter), candidates are per mailbox. + +**Deviations** +- Original Phase 4 design was a shared Screening Room; product now wants + per-rep like Follow-ups. Cleared existing PENDING rows rather than + guessing owners (no `userId` existed to backfill). +- Local `db:deploy` not run — Docker daemon was not available. Code + + migration file are ready. + +**What's next** +1. Start Docker Desktop, then `cd packages/db && bun run db:deploy` + locally. +2. Deploy / migrate on Railway `api` (same migration) before shipping + the Screening fix to prod — otherwise the new column will break + harvest. +3. Optional: after migrate, reject/suppress any leftover junk on your + own queue once Outlook sync re-harvests your mailbox only. + +--- + +### 2026-08-03 — Pipeline agent advanced reports + +**What was completed** +- Shared loader `packages/db/src/pipeline-report.ts` (+ export from + `packages/db/src/index.ts`): modes `open_by_stage`, + `forecast_by_close_month`, `closing_in_month`, `closed_in_month`; Me/Everyone + scope; local calendar month bounds; deal list capped at 40 with full + aggregates. +- Agent tool `apps/agent/agent/tools/read_pipeline_report.ts`. +- Wiring: `pipelinePreamble` + `instructions.md` (pulse vs report); transcript + label; overview chip “What's closing this month?”; `docs/agent.md`. +- Unit tests: `packages/db/test/pipeline-report.spec.ts` (month parse/bounds + + scope filter). App transcript/session tests still green (new tool covered). + +**How and why** +- Pulse only covers the rolling 7-day change log; managers need forward-looking + and period questions from the same CRM deals. Same pattern as + `loadPipelinePulse` — agent reads `@crm/db` directly, no Nest hop. + +**Deviations** +- None vs plan. Local DB smoke skipped (Postgres not running in this session). + +**What's next** +- Smoke on overview agent (local or after agent redeploy): “What can you tell me + about my pipeline for August 2026?” — expect `read_pipeline_report` with + `month=2026-08` and real deal rows. +- Redeploy Railway `agent` so prod picks up the new tool. +- Optional later (out of plan scope): Nest dashboard reuse of the shared + loader; closed-in-month chip; alert digests. + +Plan doc: `docs/plans/pipeline-agent-reports.md`. + +### 2026-08-03 — Clean agent Railway docs (remove stale checklist) + +**What was completed** +- Rewrote `docs/plans/agent-railway.md` as an ops runbook: prod Online, + deployed checklist, Node 24 / just-bash / start-script notes, env matrix, + smoke + failure table. Removed “still have to do” / next-agent order that + contradicted the DONE status line. +- Project-overview link text → “ops runbook”. + +**How and why** +- Status said DONE while the body still called the agent Missing and asked + for a Dockerfile that already ships on `main`. + +**Deviations** +- None. + +**What's next** +- Optional: Perplexity / Context.dev keys on **agent**; Entra Mail.Send for + sequences; UI smoke of Agent tab if not already confirmed. + +### 2026-08-03 — Railway agent online (Dockerfile + service + bridge) + +**What was completed** +- Added `Dockerfile.agent` and `scripts/railway-agent-start.sh` (Node 24 + runs `eve start`; Bun installs/builds; `just-bash` runtime dep). +- Created Railway `agent` service (repo `jj-mobilemark/crm`, Dockerfile + builder, `PORT=2000`, `DATABASE_URL`, `AI_GATEWAY_API_KEY`, + `AGENT_BRIDGE_SECRET`). +- Set on `app`: `AGENT_URL=http://agent.railway.internal:2000` + same + bridge secret; redeployed. +- Smoke: follow-ups enqueue `{"enqueued":2}`; agent listens on :2000; + boot shows LinkedIn capability when `RAPIDAPI_KEY` is set. +- Docs: `docs/plans/agent-railway.md` status DONE; Current state updated. + +**How and why** +- Follow-ups cron only writes `AgentTask` rows; eve must be always-on to + claim them and serve the Agent tab via the app bridge. + +**Deviations** +- `npx eve start` crashes (npm vs `packageManager: bun`) — start script + invokes `node …/eve/bin/eve.js` instead. +- GitHub-sourced builds need `Dockerfile.agent` on `main` (this commit). + +**What's next** +- Optional: set `PERPLEXITY_API_KEY` / `CONTEXT_DEV_API_KEY` on **agent**. +- Confirm Entra **Mail.Send** for sequences. +- UI smoke: `/follow-ups` + contact Agent tab in prod. + +### 2026-08-03 — Document agent Railway go-live checklist + +**What was completed** +- Added `docs/plans/agent-railway.md`: why follow-ups enqueue-only today, + what’s already on Railway, and the exact next steps (Dockerfile.agent, + `agent` service, env matrix, `AGENT_URL` / `AGENT_BRIDGE_SECRET` on + app, smoke tests). +- Pointed Current state + project-overview at that plan. + +**How and why** +- Cron workers are live but the eve process is not; operators needed one + place that says how to hook app ↔ agent ↔ follow-ups cron. + +**Deviations** +- Docs only — did not add `Dockerfile.agent` or create the Railway + service in this pass. + +**What's next** +- Execute `docs/plans/agent-railway.md` (start with Dockerfile + service). + +### 2026-08-03 — Railway cron workers (Microsoft / sequences / followups / Sage) + +**What was completed** +- Created four Railway cron services in project MM-CRM / production: + `cron-microsoft`, `cron-sequences`, `cron-followups`, `cron-sage` + (`curlimages/curl:8.12.1`). +- Schedules (UTC): `*/5` microsoft + sequences; `0 13 * * *` + followups (8am CDT); `0 6 * * *` sage (1am CDT). +- Start commands curl the matching `/internal/...` routes with + `Authorization: Bearer $CRON_SECRET`. +- Vars: `API_PUBLIC_URL=https://api.mobilemarksalestool.com`, + `CRON_SECRET` (same as api). No Google cron. +- Smoke: `deploymentRestart` on `cron-microsoft` → API reported + `attempted=4 synced=4`. + +**How and why** +- Upstream crons lived in Vercel Build Output (`build-func.mjs`). + Prod is Railway; Vercel Cron does not run. Railway cron services + call the existing Nest routes. + +**Deviations** +- Sequences every **5** minutes (Railway minimum), not every 2. +- Follow-ups enqueue `AgentTask` rows only; eve agent is still not + deployed on Railway, so the daily sweep will queue without a + dispatcher until agent is hosted. +- `railway environment edit` did not apply start/cron; used + GraphQL `serviceInstanceUpdate` instead. Curl image needs + `sh -c '…'` so ENTRYPOINT does not swallow the command. + +**What's next** +- Execute `docs/plans/agent-railway.md` (Dockerfile.agent → Railway + `agent` service → wire `AGENT_URL` / `AGENT_BRIDGE_SECRET`). +- Confirm Entra **Mail.Send** before relying on sequences tick. +- Optional: watch first nightly `cron-sage` run in Railway logs. + +### 2026-08-03 — Disable public account registration + +**What was completed** +- Removed “Create account” / mode toggle from + `apps/app/app/(auth)/sign-in/credentials-form.tsx` (sign-in only). +- Server: `emailAndPassword.disableSignUp: true`; Microsoft + Google + `disableImplicitSignUp: true` in `packages/auth/src/auth.ts`. +- Sign-in page copy notes existing accounts only. + +**How and why** +- Protect prod CRM data: no new users from the welcome form or a raw + sign-up API call. Admins seed users out-of-band. + +**Deviations** +- None. + +**What's next** +- Deploy; smoke that Create account is gone and `/sign-up/email` + rejects. When adding a teammate, create the user in DB (or temporarily + re-enable signup). + +### 2026-08-03 — Map Sage filter uses Sage 100, not CRM + +**What was completed** +- Map `sage=linked|unlinked` now filters on `Company.sage100CustomerNo` + (ERP customer #), not `sageCrmCompanyId`. +- Pins / list dots / legend / selected footer / page blurb say + Sage 100. `CompanyMapRow` returns `sage100CustomerNo`. +- Files: `companies.service.ts`, `companies.contracts.ts`, + `map-panel.tsx`, `companies-map-canvas.tsx`, `map/page.tsx`, + `docs/plans/companies-map.md`. + +**How and why** +- Every company has a Sage CRM id after the full pull, so “Has Sage + ID” was a no-op. Reps care about Sage 100 (~4.8k with / ~9.5k + without). + +**Deviations** +- URL param stays `sage=linked|unlinked` (same values, new meaning). + +**What's next** +- Restart local Nest; smoke `/map` → Has Sage 100 (~4.5k pins) vs + No Sage 100 (~7.6k pins). + +### 2026-08-03 — Map filter “Has Sage ID” empty results + +**What was completed** +- Fixed `/map` showing `0 in view · 0 on map` after toggling Sage + (and other) filters even when the API returned thousands of rows. +- `apps/app/app/(app)/map/map-panel.tsx`: `placeholderData` so the map + stays mounted during refetch; clear stale `mapBounds` on filter/ + search change; `isUsableBounds` + antimeridian-aware `inBounds`; + show load errors; clearer empty copy; “Updating…” while fetching. +- `apps/api/src/companies/companies.service.ts` `buildMapWhere`: linked + = non-null and not `""`; unlinked = null or `""` (nested under AND + so search `OR` is not overwritten). + +**How and why** +- Local DB has 14 252 companies all with Sage ids and 12 147 with + pins — the Sage SQL was not the empty cause. Filter change cleared + React Query data → full-screen loading → map remount + bad/stale + Leaflet bounds filtered the list to zero. Keeping prior data and + resetting bounds fixes the flash/empty UI. + +**Deviations** +- None. Empty-string Sage handling is defensive; this dataset has no + empty ids. + +**What's next** +- Smoke `/map` → Has Sage ID (expect ~12k pins). Restart local Nest + if the API process predates the service change. + +### 2026-08-03 — Fix Settings "Check now" Cloudflare hairpin + +**What was completed** +- Diagnosed prod toast `Unexpected token '<', "`, keyed by email, `emailVerified`. + Ken (27) is Jordan's stand-in + the fallback owner. +- **Part B — schema**: additive migration + `packages/db/prisma/migrations/20260802220000_add_sage_backfill_state/` + (applied). `SageSyncState` +`phase`/`backfillId`/`highWaterUpdatedAt`/ + `processed`/`backfillDoneAt` (kept `cursor`). `Company`/`Contact`/`Deal` + +`sageDeactivatedAt` (soft-deactivate, §6.7) +`sagePushedAt`/`sageUpdatedAt` + (push echo-guard markers, design-only). +- **Part C — safety**: `withSageSession(db, soap, work)` in + `apps/api/src/sage/sage-session.ts` — Postgres advisory lock + (`pg_try_advisory_lock` key `742000777`), always `logoff` + unlock in finally. + Replaces the in-process `running` flag (test slice uses it too). Throttle + `SAGE_PAGE_DELAY_MS`, page ceiling `SAGE_MAX_BACKFILL_PAGES`, dry-run mode. +- **Part D — backfill**: `SagePullService.runBackfill({ dryRun, maxCompanies })` + + `apps/api/scripts/sage-backfill.ts` one-shot (boots a slim Nest context). + Company walk uses Sage `query`→`next` (walks the COMPLETE set — confirmed + needed: page 1 already returned companyid 13821, so results are NOT id-ordered). + Then a full opportunity walk (`oppo_deleted IS NULL`). Owner + company-id + + taken-domain lookups preloaded once (not per row). Only a COMPLETE walk flips + `phase` to incremental — a `--max`/ceiling run stays `backfill`. +- **Part E — route**: `GET /internal/sync/sage` now calls `runScheduled()` — + test slice while `phase = backfill`, nightly `runIncremental()` once the + backfill flips it. Backfill never runs via the web route (script only). No + router change, so `server.ts` is untouched. +- **Tests**: `apps/api/test/sage-backfill.spec.ts` (pure helpers extracted to + `sage-backfill.util.ts`). All Sage tests + whole-monorepo `check-types` green. +- **Canary** (`--dry-run --max=200`): ok in 44s (~22s/page). 200 companies → + 293 contacts; **546 opportunities in Sage**, 525 map, 21 skipped (legacy rows + with no id/description/company — never fail the import). +- **First full run STARTED** (background, off-peak ~10:10pm): 11 users created, + writing companies/contacts/deals to the LOCAL DB. Expect ~50–55 min. Every row + is `source = SAGE` (reversible in bulk). + +**How and why** + +- Followed the plan's two human decisions + recommended defaults. Chose + `query`/`next` over id-paging because Sage row order is not guaranteed (proven + at runtime); idempotent upserts make a fresh re-run the safe crash recovery. +- Names came only from the human's CSV; `SAGE_USER_EMAILS` derives from the list. + +**Deviations** + +- **id-paged resume (plan Part C / §6.3) deferred**: use `query`/`next` full + walk instead (correctness — Sage is not id-ordered). `backfillId` is a progress + marker only; a crash recovers by re-running (idempotent). +- **Advisory-lock caveat**: session-level lock is bound to the Postgres + connection; with the API's pool the explicit unlock may no-op and release only + on connection close. Fine for the standalone script (process exit frees it). + Documented in `sage-session.ts`; upgrade to a lease row when the long-lived API + host runs Sage jobs alongside the cron/push. +- Snapshot-first is at RECORD granularity (existing `upsertCompanyTree` writes + each snapshot before its upsert), not a separate page pass. + +**What's next** + +- Confirm the full run finished ok (`SageSyncState.phase = incremental`, + `backfillDoneAt` set; `/tmp/sage-backfill.log`). Spot-check a non–Mobile Mark + company + its contacts + deals in the UI; confirm the forecast overview still + works with the larger open pipeline. +- Nightly incremental is already wired (cron → `/internal/sync/sage`). + Reconcile/soft-deactivate (§6.7) and push (`SageOutbox`, Part G) remain + DESIGN-ONLY — do not build unless asked. +- Forecast UI + this 7.4b work are uncommitted; commit when the human is ready + (this agent committed nothing). + +### 2026-08-02 — Purged local seed CRM data (kept Mobile Mark Sage slice) + +**What was completed** — Deleted 18 non–Mobile Mark companies (seed + a few +MANUAL/CALENDAR rows) and related contacts/deals/activities from local +Postgres. Left the 8 Sage `Mobile Mark%` companies + 120 contacts + 4 deals. +Did **not** change `packages/db/prisma/seed.ts`. + +**How and why** — One-shot Prisma transaction: clear `primaryContactId`, +delete deals/contacts/activities for non-keep company ids, unlink +email/calendar, then delete companies. Keep rule: name/domain matches Mobile +Mark **or** `sageCrmCompanyId` set. Prep for a real full Sage sync without +demo Stripe/Linear/etc. noise. + +**Deviations** — None; operational cleanup only. + +**What's next** — Still gated on human Q&A before 7.4b full pull +(`docs/plans/sage-crm-sync.md` §6 / §6.0). Do not re-run `bun run db:seed`. + +### 2026-08-02 — Docs gate for 7.4b full pull (agent: Grok via Cursor) + +**Plan / phase**: docs only — prep human Q&A before 7.4b. + +**What was completed** + +- Refreshed HANDOFF Current state: forecast UI done (uncommitted), accurate + deal stages from DB, reuse inventory for scale, **gate** so next agent does + not start 7.4b until open questions are answered. +- Listed eight open questions (worker vs chunked, soft-deactivate, lock, + route shape, throttle, commit-first, etc.). +- Updated `docs/plans/sage-crm-sync.md` §5/§6 gate + open-questions block; + `m365-expansion.md` Phase 7 stub; `.cursor/rules/project-overview.mdc`. + +**How and why** + +- Human asked to update docs for full-pull readiness and to leave room for + questions to the next agent before implementing. + +**Deviations** + +- None (docs only). + +**What's next** + +- Human ↔ next agent: answer Open questions. Then commit forecast UI (if not + already), then implement 7.4b per recipe above. + + +### 2026-08-02 — Forecast view UI (agent: Grok via Cursor) + +**Plan / phase**: `docs/plans/sage-crm-sync.md` §3b — forecast surface. + +**What was completed** + +- `deals.list` / `deals.byId` now return `probability`, `weightedAmountCents`, + `dealType`, `sageStage`, `sageStatus`, `sageCrmOpportunityId`; list also + returns `openWeightedCents`. +- `dashboard.summary` adds `forecast` (totals, months by expected close, + byOwner) and `closingThisMonthTotal.weightedCents`. +- Overview (`sales-dashboard.tsx`): Weighted forecast KPI; Forecast by close + month table; Forecast by rep when scope is everyone. +- Deals table: Weighted, Certainty columns; Type + Sage CRM ID (hidden by + default). Footer shows weighted total. +- Deal sheet: Amount / Weighted / Certainty / Close stats; type + Sage stage; + Sage CRM ID copy section. +- Plan stubs (`sage-crm-sync.md` §3b/§4/§5, `m365-expansion.md` Phase 7) + updated — next is 7.4b. + +**How and why** + +- Forecasting in Sage is opportunity-driven; the UI reconstructs it from Deal + columns already imported. Folded aggregates into `dashboard.summary` (same + scope me/everyone) rather than a parallel route or nav item. + +**Deviations** + +- Replaced the "Average deal" KPI with "Weighted forecast" (four-card strip). + Average deal remains available via the existing performance block if needed + later. +- Deal sheet header stats swapped "In stage" / Owner for Weighted / Certainty + (owner still editable in Details). + +**What's next** + +- 7.4b full pull (Current state recipe). + +### 2026-08-02 — Commit Deal import + handoff for forecast UI (agent: Grok via Cursor) + +**Plan / phase**: docs + commit — prep next agent for forecast view (§3b). + +**What was completed** + +- Committed Deal schema + opportunity test-slice import (migration, + `mapOpportunity`, pull, tests, HANDOFF). +- Refreshed Current state with a single forecast-UI recipe (API fields → + dashboard/table → optional deal Sage-ID → smoke). +- Updated `docs/plans/sage-crm-sync.md` §4/§5 status, Phase 7 stub in + `docs/plans/m365-expansion.md`, and `.cursor/rules/project-overview.mdc` + (Deal columns landed; gap is the forecast view). + +**How and why** + +- Human asked to commit then prep docs so the next agent can continue without + re-discovering status. + +**Deviations** + +- None. + +**What's next** + +- Unchanged recipe: Forecast view UI (Current state). + +### 2026-08-02 — Deal schema + opportunity test-slice import (agent: Grok via Cursor) + +**Plan / phase**: `docs/plans/sage-crm-sync.md` §§3.3, 3b — Deal fields + +opportunity pull. + +**What was completed** + +- Committed prior 7.4a/7.4c work as `9a0d06c`. +- Additive Prisma migration `20260802200000_add_deal_sage_fields`: + `sageCrmOpportunityId`, `probability`, `weightedAmount`, `dealType`, + `sageStage`, `sageStatus` on `Deal`. +- `mapOpportunity` + `mapSageDealStage` in `sage.mappings.ts`; unit tests + (29 Sage tests green). +- `queryAllRecords` on SOAP client; `importTestSlice` pulls opportunities for + slice company ids (`oppo_deleted IS NULL`), upserts Deal + snapshot, links + `DealContact` when `primarypersonid` resolves; owner fallback Ken then + earliest User. +- Live smoke: `dealsUpserted: 4`, `dealContactsLinked: 4` on company 24. + +**How and why** + +- Included `sageStage`/`sageStatus` now (cheap with the migration; needed for + push/board without snapshot reads). Named the weighted column + `weightedAmount`. Linked primary person via `DealContact` when present. + +**Deviations** + +- Opp `383` has blank Sage `total` → local `amount` null while + `weightedAmount` is set. Snapshot retains the raw row. + +**What's next** + +- Forecast view UI (plan §3b). + +### 2026-08-02 — Handoff tightened for next agent (agent: Grok via Cursor) + +**Plan / phase**: docs only — `HANDOFF.md` Current state + plan open items. + +**What was completed** + +- Replaced ambiguous "deals OR 7.4b" next step with a single 5-step Deal + + opportunity-import recipe (schema fields, mapOpportunity, pull filter, + fallback owner `ken@mobilemark.com`, smoke). +- Noted **uncommitted** 7.4a/7.4c file set; listed key Sage files; recorded that + `Deal` still lacks forecasting columns. +- Cleared stale "BLOCKING: supply owner map" in plan §4 (map is in + `sage.mappings.ts`); set fallback owner rule. +- Updated `m365-expansion.md` Phase 7 stub status. + +**How and why** + +- Human asked whether the next agent could continue with no questions. Gaps + were: forked next step, missing default-owner decision, stale blocking note, + no mention that work is uncommitted / Deal schema untouched. + +**Deviations** + +- None. + +**What's next** + +- Unchanged: Deal schema + opportunity import (Current state recipe). + +### 2026-08-02 — Phase 7.4c Sage-ID UI + person-email parse fix (agent: Grok via Cursor) + +**Plan / phase**: `docs/plans/sage-crm-sync.md` — 7.4c (+ eyeball of 7.4a data). + +**What was completed** + +- Eyeballed Mobile Mark import: 8 companies, Sage 100 on 24 (`00-0000777`) and + 4139 (`00-MME`); dirty first names (`*`); **0/120 emails** exposed a parser + bug (person nested email/phone were dropped / stolen by company walk). +- Fixed hierarchical parse: direct-child collection only + `enrichPerson` for + nested email/phone (`sage-xml.ts`). Re-import → **94 emails / 26 blank**. +- `CopyButton` in `packages/ui/src/components/copy-button.tsx` (Carbon Copy + + sonner toast, stops row-click propagation). +- API: Sage id fields on `companies.list` / `companies.byId` and + `contacts.list` / `contacts.byId` (+ nested company Sage 100 on contacts) + (`companies.service.ts`, `contacts.service.ts`). +- UI: default-hidden Sage CRM ID + Sage 100 ID columns on + `companies-table.tsx` / `contacts-table.tsx`; "Sage" section on + `company-sheet.tsx` / `contact-sheet.tsx` (read-only + copy). Helpers: + `sage-id.ts`, `sage-id-value.tsx`. + +**How and why** + +- Handoff next was 7.4c once real ids existed. Eyeball first caught the email + gap; fixed before shipping UI so sheets show useful contact data too. + +**Deviations** + +- None vs plan section 3d. Opportunities still deferred with Deal fields. + +**What's next** + +- **ONE path**: Deal schema + opportunity import (company 24 has 4 opps). + Exact 5-step recipe in Current state above and plan §§3.3 / 3b. Do not start + 7.4b until deals land. Work is still uncommitted — commit first if asked. + +### 2026-08-02 — Phase 7.4a Mobile Mark test-slice import (agent: Grok via Cursor) + +**Plan / phase**: `docs/plans/sage-crm-sync.md` — 7.4a. + +**What was completed** + +- Hierarchical company parse + `` flag in `apps/api/src/sage/sage-xml.ts` + (`parseCompanyTrees`, `parseCompanyPage`, `parseMore`). +- SOAP client: `queryCompanies` / `nextCompanies` / `queryAllCompanies` + `next` + op in `apps/api/src/sage/sage-soap.client.ts`. +- Mappings: `mapCompanyTree`, `companyname` fallback, `<>` email sanitize, + nested parent company id for people (`sage.mappings.ts`). +- `SagePullService.importTestSlice` — upsert Company/Contact by `sageCrm*Id` + (else domain/email link), write `SageRecordSnapshot`, set primary contact, + in-process session lock + always `logoff` + (`apps/api/src/sage/sage-pull.service.ts`). +- `POST/GET /internal/sync/sage` (`CRON_SECRET`) — + `apps/api/src/sage/sage-sync.controller.ts`; wired in `sage.module.ts`. +- Unit tests: 24 pass (`test/sage-xml.spec.ts`, `test/sage-mappings.spec.ts`). +- Live smoke against prod Sage: 8 companies, 120 contacts, 128 snapshots, 16 + skipped (people missing firstname), ~7s. + +**How and why** + +- Handoff next step was 7.4a. Sibling knowledge: pull `company` hierarchically, + paginate with `next`, never hold two sessions. Kept opportunities out — Deal + forecasting columns are still deferred. Domain uniqueness: near-duplicate + Mobile Mark rows get `domain: null` when the web domain is already taken. + +**Deviations** + +- Skipped `getmetadata` (plan mentioned it; not required for the upsert path — + optional follow-up). +- Skipped opportunity import (depends on deferred Deal fields), as handoff + already noted. + +**What's next** + +- **7.4c Sage-ID UI** (plan section 3d): `CopyButton` in `packages/ui`; expose + ids on company/contact list + byId; default-hidden table columns; "Sage" + section on company + contact sheets. Real Mobile Mark ids are in the DB now. +- Then Deal forecasting columns + opportunity import; then 7.4b full pull. + +### 2026-08-02 — Phase 7 principle: fit Sage into the CRM, don't reshape it (agent: Opus via Cursor) + +**Plan / phase**: `docs/plans/sage-crm-sync.md` — new "Guiding principle" + +section 3.3 (no code). + +**What was completed** + +- Added a guiding principle to the plan: Sage bends to the CRM's out-of-the-box + shape; add the fewest columns (only for UI need or 1:1 push); the raw snapshot + is the lossless backstop; the mapping catalog is the 1:1 push contract. +- REVERSED the earlier "adopt Sage's stages / replace `DealStage` enum + re-key + the board" decision. Now: keep the CRM enum, map Sage->local for display, + store the raw Sage stage for exact push (optional `Deal.sageStage` only if the + UI needs it). Updated section 3.3, the 7.1 build bullet, and open item #3. +- Reaffirmed forecasting's 3 `Deal` columns as the one deliberate exception (a + capability the CRM lacks), not a reshape. + +**How and why** + +- The human clarified they don't want a big new contacts/companies shape — fit + Sage into what the CRM gives us, keep only the fields that push back 1:1. The + external-id + Sage 100 columns already added are references, not a reshape, so + they stand; the enum swap was the one over-customization to drop. + +**Deviations** + +- Reverses the 2026-08-02 stage decision (documented above). No other change. + +**What's next** + +- Unchanged: 7.4a test-slice import (nested `company` pull + `getmetadata`), + then 7.4b per section 6. + +### 2026-08-02 — Phase 7 plan corrected from sibling-project knowledge (agent: Opus via Cursor) + +**Plan / phase**: `docs/plans/sage-crm-sync.md` — sections 1c + 6 (no code). + +**What was completed** + +- Folded a knowledge dump from the team's other Sage-sync app (proven in prod at + ~14k companies) into the plan. Corrections/confirmations: + - Volumes INVERTED: ~14k companies / ~26k contacts (not 27k/14k); ~4.75k + companies have a MAS customer number. + - Pagination is `query` -> `next` while `true` (~100/page), not a + `maxrecords` cap. Session-stateful, so it can't resume in a later process. + - ONE Web Services session at a time (2nd logon kicks 1st) -> global lock + mandatory. Bad password can LOCK the service account -> back off, no + retry-spam. API ~10-20s/page, ~1h full sync. + - Backfill = query `company`, take people from NESTED children (do NOT query + `person` separately) — resolved the flat-vs-hierarchical fork to + hierarchical. Filter `comp_deleted IS NULL` (status filters miss rows). + `comp_deleted`/`oppo_deleted` exist; reconcile = soft-deactivate, never + hard-delete. `getmetadata` for field discovery. + - Opportunity create (push, later): unprefixed field names on `add`, + `description` max 50 chars. +- New runtime shape in section 6.3: initial backfill as a Railway worker holding + one session (~1h, off-peak); incremental as nightly cron. + +**How and why** + +- The sibling app already solved this against the same server; adopting its + hard-won constraints avoids us re-learning the session/pagination/lockout + traps the expensive way. + +**Deviations / impact on the built foundation** + +- The client built earlier now has KNOWN GAPS for the full pull: no `next` + operation, `parseRecords` drops nested children (need a hierarchical parser), + and no `<>` email sanitize. All fine for the small test slice; must be added + for 7.4b. Documented in the plan and current-state. + +**What's next** + +- 7.4a test-slice import using the nested `company` pull + `getmetadata`; then + 7.4b per the revised section 6 (worker backfill + nightly incremental). + +### 2026-08-02 — Phase 7 foundation built: schema + SOAP client + mappings (agent: Opus via Cursor) + +**Plan / phase**: `docs/plans/sage-crm-sync.md` — 7.1 (schema), 7.2 (config + +client), 7.3 (mappings). Deal work + import + UI deferred. + +**What was completed** (all green: check-types 10/10, lint 7/7, test 127 pass) + +- Schema (additive) + migration `packages/db/prisma/migrations/20260802190000_add_sage_sync`: + `RecordSource.SAGE`; `Company.sageCrmCompanyId @unique` + + `sage100CustomerNo` + `sage100ArDivisionNo`; `Contact.sageCrmContactId @unique`; + models `SageSyncState` (per-entity cursor) + `SageRecordSnapshot` (raw replica). + Prisma client regenerated. +- Config: `SAGE_SOAP_URL/USER/PASSWORD` documented in `.env.example` and declared + `@IsOptional() @IsString()` in `apps/api/src/config/env.validation.ts`. + `apps/api/src/sage/sage.config.ts` — `sageCredentials()` all-three-or-none + capability helper (mirrors `googleCredentials()`). +- `apps/api/src/sage/` module: + - `sage-soap.client.ts` — `SageSoapClient`: logon (session cache + one + re-logon on stale-session fault), `query(entity, predicate)`, `logoff`; raw + `fetch` + hand-built envelopes; `SageResult` union (never throws). + - `sage-xml.ts` — pure parsing via `fast-xml-parser` (added dep): + `parseSessionId`, `parseFault`, `parseRecords` (returns a record's OWN + scalar fields; drops nested child collections so a `company` response's + nested people don't leak). + - `sage.mappings.ts` — `mapCompany`/`mapContact` (+ `sage100Display`), and the + static Sage-user->email owner map from the team with `emailForSageUser` + (unknown ids -> null, i.e. former employees -> fallback owner). + - `sage.constants.ts`, `sage.module.ts`; `SageModule` registered in + `app.module.ts`. +- Tests: `apps/api/test/sage-xml.spec.ts` + `test/sage-mappings.spec.ts` (16). + +**How and why** + +- Picked the durable, non-breaking foundation for one session: schema + client + + mappings, fully typed/linted/tested. Migration authored by hand + `migrate + deploy` because `prisma migrate dev` needs a TTY (non-interactive here). + Used `fast-xml-parser` rather than regex because the `company` response nests + child records — regex can't reliably scope a record's own fields. + +**Deviations** + +- Deferred ALL Deal changes (the `DealStage` enum swap re-keys the deal board UI + and existing deals — too big to bundle safely) and the import route/UI. The + SOAP client therefore has no runtime caller yet; it is exercised only by tests. +- Owner map: Jordan Johnson intentionally imitates Ken (id 27); unknown/former + users resolve to null for a fallback owner (per the human's instruction). + +**What's next** + +- 7.4a: the Mobile Mark test-slice import (companies + people) behind a + `CRON_SECRET` internal route, then 7.4c (Sage-ID UI), then a dedicated deals + session (enum swap + forecasting + opportunity import). Full pull (7.4b) must + paginate — Sage caps queries at ~100 rows. + +### 2026-08-02 — Phase 7 feature-gap pass: Sage modules + forecasting (agent: Opus via Cursor) + +**Plan / phase**: `docs/plans/sage-crm-sync.md` — feature-completeness review. + +**What was completed** + +- Probed Sage module availability over SOAP. Enabled: `company`, `person`, + `opportunity`, `communication` (activities), `lead`. NOT enabled: `forecast`, + `campaign`. Ambiguous ("Query failed", likely enabled but wrong id column): + `case`, `quotes`, `orders`, `user`. Two distinct error strings distinguish + "not Web Service enabled" from "query error" — documented in the plan. +- Enumerated opportunity vocabulary (100-row sample): stages + Investigation/Prospecting, Proposal, Negotiation, Purchasing, Closed Won, + Lost; statuses In Progress/Won/Lost/Closed; types Key Opportunity/New + Business/Baseline Business; certainty 0-100%. +- Answered the forecasting question in the plan (section 3b): Sage forecasting + is OPPORTUNITY-driven (`forecast`/`total`/`certainty`/`targetclose`/`type`), + not a separate syncable record. Local `Deal` is missing + probability/weighted-amount/type + any forecast view — the headline gap for a + Sage interface layer. Added a feature-completeness table (section 3c) and new + open items (stage model A/B, forecasting scope, extra modules). +- Updated `HANDOFF.md` and the `project-overview` rule pointer to Phase 7. + +**How and why** + +- The human flagged that the team forecasts in Sage and worried about missing + crucial functionality. Probed live to ground the answer instead of guessing. + Forecasting turns out to be the real risk: without it the app is not yet a + replacement surface for the team. + +**Deviations** + +- None. New constraint: `forecast` entity is not Web-Service enabled, so formal + Sage forecast submissions cannot be synced (reconstruct from opportunities). + +**What's next** + +- Get decisions in plan section 4 (owner fallback, amount source, stage model, + forecasting-in-first-cut, extra modules), then start Phase 7.1 schema. + +### 2026-08-02 — Phase 7 scoping: Sage CRM SOAP confirmed + mappings (agent: Opus via Cursor) + +**Plan / phase**: `docs/plans/sage-crm-sync.md` — Phase 7.0 (de-risk / scope). + +**What was completed** + +- Probed the live Sage CRM SOAP endpoint (`.../eware.dll/WebServices/SOAP`) + with the service account in root `.env`. Confirmed logon -> `sessionid` + (SOAP header `sessionheader`), `query { queryString, Entity }`, and `logoff`. + Namespaces: request `http://tempuri.org/`, response `http://tempuri.org/type`. +- Confirmed the triad is queryable: `company`, `person`, `opportunity` all + return records. `user` entity is NOT exposed (query fails) — blocks direct + owner mapping. GET/WSDL return empty; only SOAP POST works. +- Captured full field lists for company/person/opportunity (+ nested + email/phone/address) and wrote the field-mapping catalog into + `docs/plans/sage-crm-sync.md` (section 3). Predicates use DB names + (`comp_`/`pers_`/`oppo_`); responses use short names; cursor = `updateddate`. +- Test slice sized: `comp_name like 'Mobile Mark%'` = 8 companies (<=10 cap). + Company `24` (MOBILE MARK INC) has 4 opportunities incl. real data + ("249 PR-LTMWG944-SP716" Closed Won, "Jordan Test Push From Sales Tool"). +- Company carries `mas_ardivisionno` + `mas_customerno` — the Sage 100 key that + joins to `MasHeader`/`MasOrderDetailHistory` for a future order-history phase. +- Repointed the Phase 7 stub in `docs/plans/m365-expansion.md` at the new doc. + +**How and why** + +- Scope-before-build per the user's request. Probing live proved the triad is + reachable (only `Mas*` was proven before) and gave real field names so the + mapping catalog is grounded, not guessed. Pull each entity flat (person via + `pers_companyid`, opportunity via `oppo_primarycompanyid`) instead of walking + the deep nested `company` doc — simpler parser, smaller payloads. + +**Deviations** + +- None from the plan. New constraint discovered: no `user` entity over SOAP, so + Deal owner (required) needs a fallback owner / static map — logged as open + item #1 in the Sage plan. + +**What's next** + +- Phase 7.1: schema migration `add_sage_sync` (sage*Id columns, + `RecordSource.SAGE`, `SageSyncState`, `SageRecordSnapshot`). Then 7.2 client, + 7.3 mappings, 7.4a Mobile Mark test-slice import. Decide open items (owner + fallback, amount = forecast vs total) before/at build. + +### 2026-08-02 — Phase 5 human smoke complete; Phase 6 deferred (agent: Grok via Cursor) + +**Plan / phase**: `docs/plans/m365-expansion.md` — Phase 5 DoD close-out; +Phase 6 decision. + +**What was completed** + +- Human smoke on `/follow-ups`: saw the suggested follow-up, accepted it, + TASK was created, then cleared the task (happy path). Phase 5 marked + DONE in Current state + plan DoD. +- Phase 6 reviewed with human and deferred: + 1. Outlook contacts import — skip (Screening-from-mail covers most + cases; needs `Contacts.Read`). + 2. Meeting prep — already present in + `apps/api/src/microsoft/outlook-calendar-sync.service.ts` + (`prepareForMeeting` → `AgentTriggerService.meetingSoon`); plan + bullet was stale. + 3. Teams digest — skip for now. + +**How and why** + +- Close Phase 5 on observed accept→task→clear rather than leaving open + smoke. Record Phase 6 skip so the next agent does not re-propose those + extras. + +**Deviations** + +- Phase 6 meeting-prep item was already implemented in Phase 2 MS calendar + sync; plan text left as optional extras with a note in Current state. + +**What's next** + +1. Optional: Microsoft settings panel + sign-in visual pass vs upstream + design refresh. +2. Phase 7 (Sage) only with a dedicated plan — do not start without one. +3. No Phase 6 work unless the human re-asks. + +### 2026-08-02 — Phase 5 agent smoke (suggestions written) (agent: Grok via Cursor) + +**Plan / phase**: `docs/plans/m365-expansion.md` — Phase 5 smoke +(curl/dispatch side). + +**What was completed** + +- Confirmed `AI_GATEWAY_API_KEY` present in root `.env`; restarted + `apps/agent` (`eve dev` on `:2000`). +- Enqueue: `POST /internal/agent/followups` → `{"enqueued":1}`. +- Dispatch: `POST /eve/v1/dev/schedules/dispatch` → + `{"scheduleId":"dispatch","sessionIds":["wrun_01KZ217NWH2NB5WF9VW3T3ZK01"]}` + (HTTP 200). Turn completed with model tokens/cost; tools included + `read_rep_followup_context` + `propose_followups`. +- DB: 1 `FollowUpSuggestion` PROPOSED (`reply-owed`, summary about Ten18 + antenna order). Evidence `messageId=cmsbxjv9j000cf48o6ippaxmi` exists in + `emailMessage` (subject "Coming back at you"). `mailCount=8`. +- Left accept / snooze / dismiss + Priority prefs reshape to the human on + `/follow-ups` (per request). + +**How and why** + +- Finished the blocked agent path without touching UI decisions the human + owns. Gateway key was the missing piece from the prior handoff. + +**Deviations** + +- Only one suggestion this run (enough for evidence spot-check). Root + session may still show `running` briefly under eve while the turn is + already `completed` — suggestions were committed either way. + +**What's next** + +1. Human: on `/follow-ups`, accept one suggestion (confirm TASK on timeline + + My open tasks), try snooze/dismiss, change Priority selects. +2. Check remaining Phase 5 DoD boxes in `docs/plans/m365-expansion.md`. +3. Phase 6 (asked): choose first extra — Outlook contacts → Screening, + meeting prep `meetingSoon()`, or Teams digest webhook. + +### 2026-08-02 — Phase 5 prefs smoke notes + Priority 3-col UI (agent: Grok via Cursor) + +**Plan / phase**: `docs/plans/m365-expansion.md` — Phase 5 priority prefs +(follow-up polish + smoke). + +**What was completed** + +- Human smoke started: cron enqueue returns `{"enqueued":1}` once + `CRON_SECRET` is read from root `.env` (bare `$CRON_SECRET` in the shell is + empty → 403). +- Restarted API so `followups.prefs` / `pipeline` / `updatePrefs` exist + (long-lived `bun run src/main.ts` does not pick up new routers). Fixed + Priority card so a failed prefs query shows defaults instead of an endless + spinner (`priority-prefs.tsx`). +- `FieldGroup layout="columns"` in `packages/ui` — Priority selects render as + a 3-column row on `md+` (`md:grid-cols-3`). +- Documented: `eve dev` does **not** fire schedules on cron; trigger with + `POST http://127.0.0.1:2000/eve/v1/dev/schedules/dispatch`. A triggered + followups session failed: AI Gateway had no credentials + (`AI_GATEWAY_API_KEY` / `eve link` OIDC). Synced mail exists (~8 messages); + suggestions table still empty until the model can run. +- Plan already has Priority prefs + future on-page agent ask; no Phase 6 work. + +**How and why** + +- Separated UI/API wiring bugs from agent model auth so the next agent does + not re-debug an empty Follow-ups page as a product bug. + +**Deviations** + +- None from the prefs design. Smoke DoD still incomplete without gateway key. + +**What's next** + +1. Add `AI_GATEWAY_API_KEY` to root `.env` (see `.env.example`), restart + `apps/agent`, then: + - optional re-enqueue: + `curl -X POST -H "Authorization: Bearer $(grep '^CRON_SECRET=' .env | cut -d= -f2- | tr -d \"\\'\")" http://localhost:3001/internal/agent/followups` + - `curl -X POST http://127.0.0.1:2000/eve/v1/dev/schedules/dispatch` +2. On `/follow-ups`: confirm suggestions with cited evidence; change Priority + selects; accept / snooze / dismiss. +3. Check Phase 5 DoD boxes in `docs/plans/m365-expansion.md` when green. +4. Phase 6 only if asked. + +### 2026-08-02 — Phase 5 priority prefs (filters + sweep bias) (agent: Grok via Cursor) + +**Plan / phase**: `docs/plans/m365-expansion.md` — Phase 5 add-on +("Priority prefs"). + +**What was completed** + +- Plan: added "Priority prefs (Phase 5 add-on)" — three selects, filter-first + v1, daily sweep reads the same prefs, future on-page agent ask noted as not + built. Phase 6 left alone. +- Schema: `FollowUpPreference` (`floatFirst` / `lookback` / `scope`) + + migration + `packages/db/prisma/migrations/20260802180000_add_followup_preference/`. + Shared constants/helpers in `packages/db/src/followup-prefs.ts`. +- API: `followups.prefs`, `followups.updatePrefs`, `followups.pipeline`; + `followups.list` filters/reorders by prefs. Regenerated + `apps/api/src/generated/server.ts` (12 routers / 54 procedures). +- Agent: `loadFollowupPrefs` + lookback/scope in `repFollowupContext` / + `proposeFollowUp`; `followupsPreamble` states the three biases. +- UI: `/follow-ups` Priority card (`priority-prefs.tsx`); deals lane uses + `followups.pipeline`. Cache invalidation covers prefs + pipeline. +- `check-types` / `lint` / `test` pass. Migration applied locally. + +**How and why** + +- Prefs are mechanical filters (and light sweep prompt text), not intelligence + in the API — matches `docs/api.md`. One DB row per rep so list and sweep + stay aligned without a free-form chat UI yet. + +**Deviations** + +- `shared` means deals the rep owns **or** has logged activity on (no + deal-membership table). +- On-page agent ask deferred (documented in plan only). + +**What's next** + +1. **Human smoke (Phase 5 DoD + prefs)**: + - Connected Outlook mailbox with synced mail. + - `curl -X POST -H "Authorization: Bearer $CRON_SECRET" http://localhost:3001/internal/agent/followups` + - Open `/follow-ups`: change Float first / Look back / Whose work; confirm + list and deals lane reshape; accept / snooze / dismiss still work. +2. Phase 6 only if asked. Phase 7 needs its own plan. + +### 2026-08-02 — Forked to jj-mobilemark/crm; main is now the real branch (agent: Sonnet via Cursor) + +**Plan / phase**: Not a phase — repo ownership change requested by the human. + +**What was completed** + +- `origin` no longer points at `trycompai/crm` (it never had write access + there — confirmed `viewerPermission: "READ"` before touching anything). + Renamed that remote to `upstream`; created a fork at + `github.com/jj-mobilemark/crm` and added it as the new `origin`. +- Pushed `m365-expansion` (commit `8d025d2`, all of Phases 0-5) to the fork. +- Fast-forwarded local `main` to `8d025d2` (a clean fast-forward — `main` was + already an ancestor of `m365-expansion`, so no merge commit was needed) and + pushed it to `origin/main`. The fork's default branch is `main`. +- Updated the "Current state" git-layout note above for the new remotes and + the simpler go-forward workflow (work on `main` directly; pull upstream + with `git fetch upstream && git merge upstream/main`). + +**How and why** + +- Human asked to commit and push; `git push` to the old `origin` would have + failed outright (read-only), and even with access, pushing personal/company + work-in-progress to someone else's public OSS repo without being asked + would have been wrong. A fork is the correct destination, and the human + confirmed: fork under their own account, merge `m365-expansion` into `main` + so `main` is the full working CRM, and keep `upstream` around for pulling + future `trycompai/crm` releases. + +**Deviations** + +- None from what was asked. `m365-expansion` branch was left in place on the + fork (not deleted) in case anything ever needs to be diffed against it — + it points at the same commit as `main` right now, so it costs nothing to + keep. + +**What's next** + +1. Continue work directly on `main` from here — no more dual-branch + maintenance for local-vs-upstream. +2. Everything under "Next step" below still applies (Phase 5 human smoke, + then Phase 6 only if asked). + +### 2026-08-02 — Phase 5 Follow-ups / Sales Cockpit (agent: Sonnet via Cursor) + +**Plan / phase**: `docs/plans/m365-expansion.md` — Phase 5 implemented. + +**What was completed** + +- Schema: `FollowUpSuggestion` model (userId/contactId/companyId/dealId, + kind, summary, capped `quote`, `dueHint`, `evidence` Json, status, + `activityId`) + migration + `packages/db/prisma/migrations/20260802155904_add_followup_suggestion/`. + Additive `AgentTask.userId String?` + index in the same migration, for a + per-rep task alongside the existing per-record ones. +- Agent (`apps/agent`): `agent/lib/followups.ts` (`repFollowupContext`, + `proposeFollowUp` — verifies every cited message id exists before writing); + tools `read_rep_followup_context.ts` (free read) and + `propose_followups.ts` (one suggestion per call); `agent/lib/preamble.ts` + gained `followupsPreamble` + a `userId` branch in `sessionPreamble`; + `agent/instructions/task.ts` passes `attributes.userId` through; + `agent/schedules/dispatch.ts` passes `userId` in session attributes and + added the `"followups"` case to `work()`; `agent/lib/tasks.ts` carries + `userId` through `claimDue`/`retireExhausted`/`completeTask`/`taskSubject`. +- API: `AgentTriggerService.followupsDue(userId, reason)` + (`apps/api/src/agent/agent-trigger.service.ts`); new + `apps/api/src/followups/` module — `followups.service.ts` (`list`, + `decide`, `enqueueDue`), `followups.router.ts` (alias `followups`), + `followups.controller.ts` (`GET|POST /internal/agent/followups`, + `CRON_SECRET`-guarded like the sync routes), `followups.module.ts`; + registered in `app.module.ts`; cron entry added to + `apps/api/scripts/build-func.mjs` (`0 13 * * *`, once daily). Regenerated + `apps/api/src/generated/server.ts` (12 routers / 51 procedures). +- UI: `apps/app/app/(app)/follow-ups/` — `page.tsx`, `suggestions-panel.tsx` + (accept / snooze with quick presets / dismiss), `my-work-panels.tsx` (my + open tasks + my active deals, reusing `activities.myTasks` and + `deals.list`). Added to `app-icon-rail.tsx`. `useCrmCache` gained a + `followup()` invalidation entry. +- Fixed a dropped `###` section header in this file's own work log (the + Phase 3 entry had lost its heading during a previous edit) while I was in + here. +- `check-types` (10/10), `lint` (7/7), `test` (7/7 task groups, incl. a new + `TOOL_VERBS` entry each for the two new tools in + `apps/app/lib/agent-transcript.ts`) all pass. + +**How and why** + +- See the "Done, with these deviations" note under Phase 5 in the plan doc + for the four deviations from the original design (userId on `AgentTask` + instead of a new table; API cron instead of an agent-side schedule; two + tools instead of one; `CardPanel`/`SimpleTable` instead of `DataTable`) — + each one is mechanical, not a scope change. +- Evidence verification lives in `proposeFollowUp`, not just the tool + description: the project's own rule is that nothing is guessed, and a tool + that only *asks* the model to cite something real is not the same as a tool + that checks. + +**Deviations** + +- See above (plan doc has the full reasoning). Nothing outside Phase 5's + intended scope. +- Also fixed, unrelated to Phase 5: local dev servers were down when this + session started (killed during my own earlier smoke-testing on this + machine). Restarted `apps/api` and `apps/app` directly rather than through + root `bun run dev` — see "Current state" above. + +**What's next** + +1. **Human smoke (Phase 5 DoD)**: + - Ensure at least one rep has a connected Outlook mailbox with real synced + mail (Phase 2/3 state). + - Trigger the sweep by hand: + `curl -X POST -H "Authorization: Bearer $CRON_SECRET" http://localhost:3001/internal/agent/followups` + — expect `{"enqueued": }`. This queues one `AgentTask` (`kind: + "followups"`) per mailbox-connected rep; the agent's `dispatch.ts` + schedule (`* * * * *` in dev) picks it up within a minute. + - Open `/follow-ups`: suggestions should appear with cited evidence once + the agent session finishes. Spot-check that a suggestion's `evidence` + message ids are real (they are — `propose_followups` verifies this at + write time — but confirm the UI shows something sensible). + - Accept one: confirm a TASK activity appears on the named + contact/company/deal's timeline AND in "My open tasks" here. Try snooze + and dismiss too. + - Confirm a second sweep does not duplicate an outstanding suggestion. +2. Root-cause the turbo/`bun --watch` dev-stack crash noted in "Current + state" if it recurs — capture the exact log leading up to the silent + exit next time (there was none to capture this session; the process was + simply gone). +3. Once Phase 5 DoD is fully green: Phase 6 is optional-only work (only + start if asked). Phase 7 (Sage CRM) is a separate track needing its own + plan doc — do not start it from this plan. + +### 2026-08-02 — Phase 4 Screening Room smoke confirmed (agent: Sonnet via Cursor) + +**Plan / phase**: Phase 4 Definition of Done — human smoke test. + +**What was completed** + +- Human confirmed the three open Phase 4 DoD items: stranger mail produces a + metadata-only `/screening` row, Approve creates a `Contact` (source `EMAIL`) + and runs backfill, Reject-with-suppress holds the domain out via + `SuppressedDomain`. +- Re-verified `check-types` (10/10), `lint` (7/7), `test` (api + agent + app, + all green) on the current tree before moving on. +- Checked the remaining Phase 4 boxes in `docs/plans/m365-expansion.md`. +- Fixed a dropped section header in this file's work log (the Phase 3 entry + had lost its `###` heading during a previous edit). + +**Deviations** + +- None. + +**What's next** + +1. Start **Phase 5 — Follow-ups / Sales Cockpit** + (`docs/plans/m365-expansion.md` Phase 5): `FollowUpSuggestion` model + + migration, `apps/agent` `propose_followups` tool on a daily per-rep task, + `followups.router.ts`, `/follow-ups` UI (three lanes: suggestions / my + open tasks / my active deals). Read the `eve` skill before touching + `apps/agent`. + +### 2026-08-02 — Phase 4 Screening Room (agent: Grok via Cursor) + +**Plan / phase**: Phase 2 calendar DoD confirmed by human; Phase 4 implemented. + +**What was completed** + +- Human confirmed Phase 2 calendar: Outlook meeting with Jordan as attendee + appeared as a MEETING activity on his timeline. +- Schema: `PendingContact` model + migration + `packages/db/prisma/migrations/20260802170000_add_pending_contact/`. +- Harvest inside unmatched Outlook drop: + `screening-harvest.service.ts` + hook in + `outlook-mail-sync.service.ts` (metadata only — no bodies). +- API: `apps/api/src/screening/` — `list` + `decide` (`screening` tRPC alias); + approve → `ContactsService.createFromScreening` (source `EMAIL`, backfill, + `contactCreated` reason `"approved in screening room"`); reject + optional + `suppressDomain` → `SuppressedDomain`. +- UI: `/screening` page + table with Approve / Reject + suppress checkbox; + rail item in `app-icon-rail.tsx` (UserFollow icon). +- Regenerated `apps/api/src/generated/server.ts` (11 routers / 49 procedures). +- `check-types` / `lint` / `test` pass. + +**Deviations** + +- Screening list uses `Table` + `Empty` rather than `DataTable`, because + `DataTable` requires URL query/pagination state that a short review queue + does not need. + +**What's next** + +1. Human smoke: mail from a stranger (work domain, not in CRM) → sync → row + on `/screening` (no thread/body stored). Approve → contact + backfill; + Reject with suppress → domain stays out. +2. Start **Phase 5 — Follow-ups / Sales Cockpit** in + `docs/plans/m365-expansion.md`. + +### 2026-08-02 — Phase 3 EmailBackfill (agent: Grok via Cursor) + +**Plan / phase**: Phase 2 DoD (mail) confirmed by human; Phase 3 implemented. + +**What was completed** + +- Human confirmed Phase 2 mail path: email from Ten18 Tech → Mobile Mark + mailbox synced onto Jordan Johnson's timeline after sync. +- Phase 3 schema: `EmailBackfill` model + migration + `packages/db/prisma/migrations/20260802160000_add_email_backfill/`. +- Enqueue on contact create / email change: + `apps/api/src/contacts/contacts.service.ts` (`enqueueEmailBackfill`). +- Graph search + worker: + - `outlook-mail.client.ts` `searchByParticipant` (`$search` + + `ConsistencyLevel: eventual`) + - `outlook-mail-backfill.service.ts` (≤3 addresses/tick, 5×50 pages, + 180-day cutoff, all connected Outlook mailboxes) + - `OutlookMailSyncService.ingestMessage` reuses the Phase 2 store path + - Wired at end of `MicrosoftSyncService.runDue` / `runForUser` +- Smoke: enqueued `jordan@ten18.tech` → sync wrote **7** messages (incl. + June history before delta baseline); re-run wrote **0** (idempotent). +- `check-types` / `lint` / `test` pass. + +**Deviations** + +- Jordan was created before Phase 3 shipped, so the first smoke used a + manual `EmailBackfill` upsert rather than contact-create enqueue. Create / + email-change paths are wired for future contacts. + +**What's next** + +1. Human: open Jordan Johnson Activity — should show older Ten18 threads + (not only today's sync). +2. Optional: calendar event with Jordan as attendee (Phase 2 leftover DoD). +3. Start **Phase 4 — Screening Room** in `docs/plans/m365-expansion.md`. + +### 2026-08-02 — API dying / infinite contact tab spinners (agent: Grok via Cursor) + +**Plan / phase**: Phase 2 unblock (local ops). + +**What was completed** + +- Contact sheet infinite spinners (Overview/Deals/Email filter/Agent) were not + a contact-data bug: Nest on `:3001` was exiting, so React Query refetches + never resolved. Activity "All" could still look fine briefly from cache. +- Cause: `apps/api` `dev` used `concurrently` with `bun --watch src/main.ts` + **and** `nestjs-trpc watch` rewriting `src/generated/server.ts`. That rewrite + SIGTERM-restarts Nest; `enableShutdownHooks()` exits cleanly, so bun --watch + often does not bring the API back — leaving orphaned `nestjs-trpc` (PPID 1) + and nothing on 3001. Mid-session `pkill` debugging made the flap worse. +- Fix in `apps/api/package.json`: `dev` is now only + `bun --watch --no-clear-screen src/main.ts`. Added optional `dev:trpc` for + intentional codegen watch. Router type regen stays + `bun run --filter=api trpc:generate` (AGENTS.md). +- Hard-restarted `bun run dev` in its own session; API pid held `:3001` through + a 45s health poll with no `nestjs-trpc watch` process. + +**Deviations** + +- Local `api:dev` no longer auto-watches routers for tRPC codegen (upstream + did). Manual generate after router edits — matches AGENTS.md. + +**What's next** + +1. Human: hard-refresh, open Jordan Johnson, flip tabs — should not spin. +2. Finish Phase 2 smoke (mail to/from `jordan@ten18.tech` + sync). +3. Optional visual pass on MS settings vs new comp design system (prior log). +4. Then Phase 3. + +### 2026-08-02 — Sync to upstream comp design system (288d41a) (agent: Opus via Cursor) + +**Plan / phase**: Not a phase — a safe upstream update requested by the human +after finishing local Phase 2 work. + +**What was completed** + +- Committed ALL local work (74 files, Phases 0-2 + auth fallback + + CompanyPicker + docs + `.cursor/`) as a safety snapshot on a new branch + `m365-expansion` (commit `421e556`). `.env` stayed ignored; no secret was + staged. +- Fast-forwarded `main` from `856ae2f` to upstream `288d41a` (15 commits: the + "comp design system" UI overhaul, favicon resolver, row-hover prefetch, + list-search-in-header, and small fixes). +- Merged `main` into `m365-expansion`. Result: **zero conflicts**. Only three + files were touched on both sides — `AGENTS.md` and the two create sheets — + and the edits sat in different line regions, so the 3-way merge combined + them automatically (verified: CompanyPicker + upstream ` + + + + ); +} diff --git a/apps/app/app/(app)/companies/companies-search-params.ts b/apps/app/app/(app)/companies/companies-search-params.ts index b65f3143a..46613cd63 100644 --- a/apps/app/app/(app)/companies/companies-search-params.ts +++ b/apps/app/app/(app)/companies/companies-search-params.ts @@ -9,5 +9,8 @@ export const companiesSearchParams = createListSearchParams({ // something up alphabetically — that is what ⌘K is for. defaultSort: "createdAt", defaultDir: "desc", - facetIds: ["owner", "industry", "enrichment"] as const, + facetIds: ["owner", "industry", "enrichment", "hideEmpty"] as const, + // On by default: the list is for real accounts (contacts + Sage 100), not + // empty shells from sync. Uncheck to audit the rest. + facetDefaults: { hideEmpty: "yes" }, }); diff --git a/apps/app/app/(app)/companies/companies-table.tsx b/apps/app/app/(app)/companies/companies-table.tsx index dd9e8ae17..e2a3c088d 100644 --- a/apps/app/app/(app)/companies/companies-table.tsx +++ b/apps/app/app/(app)/companies/companies-table.tsx @@ -5,11 +5,13 @@ import { type DataTableColumn, type DataTableFacet, } from "@crm/ui/components/data-table"; +import { Checkbox } from "@crm/ui/components/checkbox"; import { EmptyCellValue } from "@crm/ui/components/empty-cell"; import { EntityLogo, type EntityLogoTone, } from "@crm/ui/components/entity-logo"; +import { Label } from "@crm/ui/components/label"; import { relativeTimeFromIso } from "@crm/ui/lib/format"; import { useQuery } from "@tanstack/react-query"; import { @@ -18,7 +20,11 @@ import { EnrichmentIndicator, isEnriching, } from "@/components/crm/enrichment-status"; +import { contactName } from "@/components/crm/contact-name"; +import { EmailValue } from "@/components/crm/email-value"; import { OwnerCell } from "@/components/crm/owner-cell"; +import { formatSage100Id } from "@/components/crm/sage-id"; +import { SageIdValue } from "@/components/crm/sage-id-value"; import { usePrefetchRecord } from "@/components/crm/record-sheet/record-prefetch"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; import { useTableQuery } from "@/components/data-table/use-table-query"; @@ -26,6 +32,8 @@ import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@/lib/trpc/types"; import { companiesSearchParams } from "./companies-search-params"; +const HIDE_EMPTY_ID = "companies-hide-empty"; + type CompanyRow = RouterOutputs["companies"]["list"]["rows"][number]; const COLUMNS: DataTableColumn[] = [ @@ -54,6 +62,7 @@ const COLUMNS: DataTableColumn[] = [ sortable: true, width: "w-[16%]", hideBelow: "md", + defaultHidden: true, cell: (row) => row.domain ? ( {row.domain} @@ -80,8 +89,25 @@ const COLUMNS: DataTableColumn[] = [ sortable: true, width: "w-[16%]", hideBelow: "md", + defaultHidden: true, cell: (row) => , }, + { + id: "primaryContact", + header: "Primary contact", + width: "w-[18%]", + hideBelow: "md", + cell: (row) => { + const contact = row.primaryContact; + if (!contact) return ; + return ( + + {contactName(contact)} + {contact.email ? : null} + + ); + }, + }, { id: "contacts", header: "Contacts", @@ -142,6 +168,29 @@ const COLUMNS: DataTableColumn[] = [ ), }, + { + id: "sageCrmId", + header: "Sage CRM ID", + defaultHidden: true, + width: "w-[10%]", + cell: (row) => ( + + ), + }, + { + id: "sage100Id", + header: "Sage 100 ID", + width: "w-[12%]", + cell: (row) => ( + + ), + }, ]; export function CompaniesTable() { @@ -202,6 +251,8 @@ export function CompaniesTable() { }, ]; + const hideEmptyOn = (query.filters.hideEmpty ?? "yes") === "yes"; + return ( + + query.setFilter("hideEmpty", checked === true ? "yes" : "all") + } + /> + + + } getRowId={(row) => row.id} loading={companies.isFetching} onRowHover={(row) => prefetchRecord({ kind: "company", id: row.id })} diff --git a/apps/app/app/(app)/companies/create-company-sheet.tsx b/apps/app/app/(app)/companies/create-company-sheet.tsx index 600c4a309..92b62c139 100644 --- a/apps/app/app/(app)/companies/create-company-sheet.tsx +++ b/apps/app/app/(app)/companies/create-company-sheet.tsx @@ -2,6 +2,14 @@ import Add from "@carbon/icons-react/es/Add"; import { Button } from "@crm/ui/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@crm/ui/components/dialog"; import { Field, FieldDescription, @@ -28,27 +36,37 @@ import { SheetTrigger, } from "@crm/ui/components/sheet"; import { Spinner } from "@crm/ui/components/spinner"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { parseAsBoolean, useQueryState } from "nuqs"; import { useId, useState } from "react"; import { toast } from "sonner"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; +import { formatCompanyDisambiguation } from "@/components/crm/company-disambiguation"; import { useCrmCache } from "@/lib/trpc/cache"; import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@/lib/trpc/types"; const UNASSIGNED = "unassigned"; +type SimilarMatch = + RouterOutputs["companies"]["similar"]["matches"][number]; + /** * Name, domain, owner — nothing else. * * Everything a form could ask for next (industry, address, logo, socials) is * something the agent can find from the domain, and a form that asks a rep to * type it is a form they will skip. + * + * Before create, we soft-match local companies (name / domain). Domain hits + * block create; name hits ask the rep to use the existing row or confirm a new + * one — so two reps do not fork the same Sage company by accident. */ export function CreateCompanySheet() { const openRecord = useOpenRecord(); const trpc = useTRPC(); const cache = useCrmCache(); + const queryClient = useQueryClient(); // Open state lives in the URL, like every other view state here: "add a // company" is then a link you can send someone, and Back closes the sheet @@ -60,6 +78,8 @@ export function CreateCompanySheet() { const [name, setName] = useState(""); const [domain, setDomain] = useState(""); const [ownerId, setOwnerId] = useState(UNASSIGNED); + const [checking, setChecking] = useState(false); + const [matches, setMatches] = useState(null); const nameId = useId(); const domainId = useId(); @@ -73,106 +93,244 @@ export function CreateCompanySheet() { toast.success(`${company.name} added.`); // `null` rather than `false` so the closed state leaves a clean URL. await setOpen(null); - setName(""); - setDomain(""); - setOwnerId(UNASSIGNED); + resetForm(); openRecord({ kind: "company", id: company.id }); }, onError: (error) => toast.error(error.message), }), ); + function resetForm() { + setName(""); + setDomain(""); + setOwnerId(UNASSIGNED); + setMatches(null); + } + + function createPayload() { + return { + name, + domain: domain || undefined, + ownerId: ownerId === UNASSIGNED ? null : ownerId, + }; + } + + async function checkThenCreate() { + setChecking(true); + try { + const result = await queryClient.fetchQuery( + trpc.companies.similar.queryOptions({ + name, + domain: domain || undefined, + }), + ); + if (result.matches.length > 0) { + setMatches([...result.matches]); + return; + } + create.mutate(createPayload()); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Could not check for matches.", + ); + } finally { + setChecking(false); + } + } + + function useExisting(companyId: string) { + setMatches(null); + void setOpen(null); + resetForm(); + openRecord({ kind: "company", id: companyId }); + } + + function createAnyway() { + setMatches(null); + create.mutate(createPayload()); + } + + const busy = create.isPending || checking; + const domainBlocks = Boolean(matches?.some((row) => row.blocksCreate)); + return ( - setOpen(next || null)}> - - - - - - New company - - Give it a name and a domain. The agent fills in the logo, - description, industry, address and socials. - - - -
{ - event.preventDefault(); - create.mutate({ - name, - domain: domain || undefined, - ownerId: ownerId === UNASSIGNED ? null : ownerId, - }); - }} - > - - - Name - setName(event.target.value)} - placeholder="Stripe" - autoComplete="off" - required - /> - - - - Domain - setDomain(event.target.value)} - placeholder="stripe.com" - autoComplete="off" - inputMode="url" - /> - - A full URL is fine — it is reduced to the bare host, which has - to be unique. - - - - - Owner - - - -
- - - - - - - -
-
+ + + + New company + + Give it a name and a domain. The agent fills in the logo, + description, industry, address and socials. + + + +
{ + event.preventDefault(); + void checkThenCreate(); + }} + > + + + Name + setName(event.target.value)} + placeholder="Stripe" + autoComplete="off" + required + /> + + + + Domain + setDomain(event.target.value)} + placeholder="stripe.com" + autoComplete="off" + inputMode="url" + /> + + A full URL is fine — it is reduced to the bare host, which has + to be unique. + + + + + Owner + + + +
+ + + + + + + +
+ + + { + if (!next) setMatches(null); + }} + > + + + + {domainBlocks + ? "This company already exists" + : "Possible matches"} + + + {domainBlocks + ? "That domain is already on a company. Open the existing record instead of creating a duplicate." + : "A similar company is already in the CRM (including Sage imports). Use an existing one, or create a new company if these are different."} + + + +
    + {(matches ?? []).map((match) => ( +
  • + + + {match.name} + {match.suggested ? ( + + Suggested + + ) : null} + + + {[ + match.domain, + [match.city, match.stateCode].filter(Boolean).join(", "), + formatCompanyDisambiguation({ + sage100CustomerNo: match.sage100CustomerNo, + contactCount: match.contactCount, + }), + match.reason === "domain" + ? "Same domain" + : "Similar name", + ] + .filter(Boolean) + .join(" · ")} + + + +
  • + ))} +
+ + + {domainBlocks ? null : ( + + )} + + +
+
+ ); } diff --git a/apps/app/app/(app)/contacts/contacts-table.tsx b/apps/app/app/(app)/contacts/contacts-table.tsx index 3ee24496f..70df193bd 100644 --- a/apps/app/app/(app)/contacts/contacts-table.tsx +++ b/apps/app/app/(app)/contacts/contacts-table.tsx @@ -10,8 +10,12 @@ import { EmptyCellValue } from "@crm/ui/components/empty-cell"; import { initialsFromName, relativeTimeFromIso } from "@crm/ui/lib/format"; import { useQuery } from "@tanstack/react-query"; import { CompanyCell } from "@/components/crm/company-cell"; +import { CompanyPicker } from "@/components/crm/company-picker"; import { contactName } from "@/components/crm/contact-name"; +import { EmailValue } from "@/components/crm/email-value"; import { OwnerCell } from "@/components/crm/owner-cell"; +import { formatSage100Id } from "@/components/crm/sage-id"; +import { SageIdValue } from "@/components/crm/sage-id-value"; import { usePrefetchRecord } from "@/components/crm/record-sheet/record-prefetch"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; import { useTableQuery } from "@/components/data-table/use-table-query"; @@ -57,12 +61,7 @@ const COLUMNS: DataTableColumn[] = [ sortable: true, width: "w-[24%]", hideBelow: "md", - cell: (row) => - row.email ? ( - {row.email} - ) : ( - - ), + cell: (row) => , }, { id: "company", @@ -77,6 +76,7 @@ const COLUMNS: DataTableColumn[] = [ sortable: true, width: "w-[16%]", hideBelow: "md", + defaultHidden: true, cell: (row) => , }, { @@ -109,6 +109,30 @@ const COLUMNS: DataTableColumn[] = [ ), }, + { + id: "sageCrmId", + header: "Sage CRM ID", + defaultHidden: true, + width: "w-[10%]", + cell: (row) => ( + + ), + }, + { + id: "sage100Id", + header: "Sage 100 ID", + label: "Sage 100 ID (company)", + width: "w-[12%]", + cell: (row) => ( + + ), + }, ]; export function ContactsTable() { @@ -122,7 +146,6 @@ export function ContactsTable() { placeholderData: (previous) => previous, }); const users = useQuery(trpc.users.list.queryOptions()); - const companies = useQuery(trpc.companies.options.queryOptions({ q: "" })); const facetCounts = contacts.data?.facetCounts; @@ -141,13 +164,19 @@ export function ContactsTable() { { id: "company", label: "Company", - options: [ - { value: "none", label: "No company" }, - ...(companies.data ?? []).map((company) => ({ - value: company.id, - label: company.name, - })), - ].filter((option) => (facetCounts?.company?.[option.value] ?? 0) > 0), + // Options unused — searchable picker queries companies.options itself. + options: [], + render: ({ value, onChange, label }) => ( + + ), }, ]; diff --git a/apps/app/app/(app)/contacts/create-contact-sheet.tsx b/apps/app/app/(app)/contacts/create-contact-sheet.tsx index 62002d9d2..3563f9e38 100644 --- a/apps/app/app/(app)/contacts/create-contact-sheet.tsx +++ b/apps/app/app/(app)/contacts/create-contact-sheet.tsx @@ -27,6 +27,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { parseAsBoolean, useQueryState } from "nuqs"; import { useId, useState } from "react"; import { toast } from "sonner"; +import { CompanyPicker } from "@/components/crm/company-picker"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; import { useCrmCache } from "@/lib/trpc/cache"; import { useTRPC } from "@/lib/trpc/client"; @@ -61,7 +62,6 @@ export function CreateContactSheet({ const titleId = useId(); const users = useQuery(trpc.users.list.queryOptions()); - const companies = useQuery(trpc.companies.options.queryOptions({ q: "" })); const create = useMutation( trpc.contacts.create.mutationOptions({ @@ -159,19 +159,14 @@ export function CreateContactSheet({ Company - + setCompany(id ?? NONE)} + includeNone + noneLabel="No company" + placeholder="No company" + /> diff --git a/apps/app/app/(app)/dashboard-summary.tsx b/apps/app/app/(app)/dashboard-summary.tsx index 24fd28566..e5c128ef1 100644 --- a/apps/app/app/(app)/dashboard-summary.tsx +++ b/apps/app/app/(app)/dashboard-summary.tsx @@ -28,19 +28,22 @@ import { } from "@crm/ui/lib/format"; import { useMutation, useQuery } from "@tanstack/react-query"; import Link from "next/link"; -import { useQueryState } from "nuqs"; +import { useQueryStates } from "nuqs"; import type { CSSProperties } from "react"; import { toast } from "sonner"; import { DealStageIndicator, dealStageColor, } from "@/components/crm/deal-stage"; +import { PipelineAgentPanel } from "@/components/crm/agent-panel"; +import { PriorityBadge } from "@/components/crm/priority"; import { RecordLink } from "@/components/crm/record-sheet/record-link"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; import { activityLabel } from "@/components/crm/timeline/activity-icon"; import { useCrmCache } from "@/lib/trpc/cache"; import { useTRPC } from "@/lib/trpc/client"; import { overviewParsers } from "./overview-search-params"; +import { PipelinePulse } from "./pipeline-pulse"; import { SalesDashboard } from "./sales-dashboard"; /** @@ -66,15 +69,21 @@ export function DashboardSummary() { const cache = useCrmCache(); const openRecord = useOpenRecord(); - // Read, not written, here: the toggle in the page header owns the setter, and - // both sides agree because the value lives in the URL rather than in a - // provider threaded between them. - const [scope] = useQueryState("scope", overviewParsers.scope); + // Read, not written, here: the header controls own the setters, and both + // sides agree because the values live in the URL rather than in a provider. + const [params] = useQueryStates(overviewParsers); + const { scope, range, from, to } = params; + + const summaryInput = { + scope, + range, + ...(range === "custom" && from && to ? { from, to } : {}), + }; const summaryQuery = useQuery({ - ...trpc.dashboard.summary.queryOptions({ scope }), - // Switching scope keeps the previous numbers on screen while the new ones - // load, the same way the tables hold their rows while paging. + ...trpc.dashboard.summary.queryOptions(summaryInput), + // Switching scope/range keeps the previous numbers on screen while the + // new ones load, the same way the tables hold their rows while paging. placeholderData: (previous) => previous, }); @@ -98,7 +107,11 @@ export function DashboardSummary() { const { biggestOpen, overdueTasks, recentActivity } = summary; const mine = scope === "me"; - const largestOpenCents = biggestOpen[0]?.amountCents ?? 0; + const dealDisplayCents = (deal: (typeof biggestOpen)[number]) => + deal.amountCents ?? deal.weightedAmountCents ?? 0; + const largestOpenCents = biggestOpen[0] + ? dealDisplayCents(biggestOpen[0]) + : 0; const openColumns: SimpleTableColumn[] = [ { header: "Deal" }, @@ -114,6 +127,7 @@ export function DashboardSummary() { const taskColumns: SimpleTableColumn[] = [ { srLabel: "Done", width: "w-8" }, { header: "Task" }, + { header: "Priority", width: "w-24", className: "hidden sm:table-cell" }, { header: "Overdue", width: "w-24", align: "right" }, ]; @@ -129,12 +143,32 @@ export function DashboardSummary() {
+ + + + + Ask about the pipeline + + What moved, who is stuck, and where deals were lost — from the + change log, not guesses + + + + + + + {/* - * Two panels of one fixed height rather than four cards that each grew - * to fit their rows: the pair always ends level, and the activity list - * below them stays where a rep last saw it however many deals are open. + * Me: two panels of one height so the pair ends level. Everyone: + * deals only, full width — team overdue tasks are not this page. */} -
+
Deals in progress @@ -176,17 +210,21 @@ export function DashboardSummary() { 0 - ? ((deal.amountCents ?? 0) / largestOpenCents) * 100 + ? (dealDisplayCents(deal) / largestOpenCents) * 100 : 0 } color={dealStageColor(deal.stage)} /> - {deal.amountCents === null ? ( + {deal.amountCents === null && + deal.weightedAmountCents === null ? ( ) : ( - formatMoneyCompact(deal.amountCents, deal.currency) + formatMoneyCompact( + dealDisplayCents(deal), + deal.currency, + ) )} @@ -196,60 +234,77 @@ export function DashboardSummary() { - - - Overdue tasks - - {overdueTasks.length === 0 - ? "Every task you have logged is either done or still to come" - : `${formatCount(overdueTasks.length, "task")} past due`} - - - - {overdueTasks.length === 0 ? ( - Nothing overdue. Good. - ) : ( - - {overdueTasks.map((task) => ( - - - - complete.mutate({ id: task.id, completed: true }) - } - /> - - - - {task.subject} - - {task.deal ? ( - - {task.deal.name} - - ) : task.company ? ( - - {task.company.name} - - ) : null} + {mine ? ( + + + Overdue tasks + + {overdueTasks.length === 0 + ? "Every task you have logged is either done or still to come" + : `${formatCount(overdueTasks.length, "task")} past due`} + + + + {overdueTasks.length === 0 ? ( + Nothing overdue. Good. + ) : ( + + {overdueTasks.map((task) => ( + + + + complete.mutate({ + id: task.id, + completed: true, + }) + } + /> + + + + {task.subject} + + {task.deal ? ( + + {task.deal.name} + + ) : task.company ? ( + + {task.company.name} + + ) : null} + - - - - - - - ))} - - )} - - + + + + + + + ))} + + )} + + + ) : null}
@@ -260,7 +315,7 @@ export function DashboardSummary() { {mine ? "Every note, task and stage change you have logged" - : "Every note, task and stage change across the workspace"} + : "Notes, tasks and stage changes across the team. No mail."} + + + + + + {SNOOZE_OPTIONS.map((option) => ( + + decide.mutate({ + id: row.id, + decision: "snooze", + dueAt: daysFromNow(option.days), + }) + } + > + {option.label} + + ))} + + + + + )} + + ); +} + +function SuggestionSubject({ row }: { row: SuggestionRow }) { + if (row.deal) { + return ( + + {row.deal.name} + + ); + } + if (row.contact) { + return ( + + {row.contact.name} + + ); + } + if (row.company) { + return ( + + {row.company.name} + + ); + } + return null; +} + +export function SuggestionsPanel() { + const trpc = useTRPC(); + const list = useQuery(trpc.followups.list.queryOptions()); + const rows = list.data?.rows ?? []; + + return ( + + + Suggested follow-ups + + Grounded in your own synced mail — every one cites a real message. + + + + {list.isLoading ? ( +
+ +
+ ) : rows.length === 0 ? ( + + + + + + Nothing outstanding + + The daily sweep runs against your synced mail and open deals. + Suggestions will show up here once it finds something worth + raising. + + + + ) : ( +
+ + + + Suggestion + Kind + About + + {" "} + + + Actions + + + + + {rows.map((row) => ( + + + + {row.summary} + {row.quote ? ( + + “{row.quote}” + + ) : null} + + + + + + + + + + {row.status === "SNOOZED" + ? `Snoozed until ${relativeTimeFromIso(row.dueHint)}` + : null} + + + + + + ))} + +
+
+ )} +
+ ); +} + +function daysFromNow(days: number): string { + const date = new Date(); + date.setDate(date.getDate() + days); + return date.toISOString(); +} diff --git a/apps/app/app/(app)/layout.tsx b/apps/app/app/(app)/layout.tsx index 6facff828..e77d5362b 100644 --- a/apps/app/app/(app)/layout.tsx +++ b/apps/app/app/(app)/layout.tsx @@ -3,17 +3,17 @@ import { AppIconRail } from "@/components/app-icon-rail"; import { QuickSwitcher } from "@/components/crm/quick-switcher"; import { RecordSheetHost } from "@/components/crm/record-sheet/record-sheet-host"; import { MobileNavProvider } from "@/components/mobile-nav"; -import { requireGoogleAccess } from "@/lib/session"; +import { requireSession } from "@/lib/session"; export default async function AppLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - // Not just `requireSession`: mailbox access is a condition of using this - // tool, and a granular-consent untick would otherwise leave someone inside - // the app with a CRM that silently syncs nothing. - const { user } = await requireGoogleAccess(); + // A valid session is enough to enter the app. Gmail/Calendar sync is a + // Google-only capability, so gating on granted mailbox scopes + // (`requireGoogleAccess`) would lock out Microsoft SSO users entirely. + const { user } = await requireSession(); return ( diff --git a/apps/app/app/(app)/map/companies-map-canvas.tsx b/apps/app/app/(app)/map/companies-map-canvas.tsx new file mode 100644 index 000000000..e67616af7 --- /dev/null +++ b/apps/app/app/(app)/map/companies-map-canvas.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { + Map, + MapBoundsListener, + MapFlyTo, + MapMarker, + MapMarkerClusterGroup, + MapTileLayer, + MapZoomControl, + type MapLatLngBounds, +} from "@crm/ui/components/map"; + +export type MapCompanyPoint = { + id: string; + name: string; + latitude: number; + longitude: number; + isMine: boolean; + sage100CustomerNo: string | null; +}; + +export type { MapLatLngBounds }; + +const US_CENTER: [number, number] = [39.8283, -98.5795]; +const SELECT_ZOOM = 12; + +type MarkerWithCompany = { + options: { title?: string; crmCompanyId?: string }; + getLatLng?: () => { lat: number; lng: number }; +}; + +function markerTone(point: MapCompanyPoint): string { + if (point.isMine) return "text-primary"; + if (point.sage100CustomerNo) return "text-chart-2"; + return "text-warning"; +} + +function PinIcon({ className }: { className: string }) { + return ( + + + + ); +} + +function SelectedPinIcon({ className }: { className: string }) { + return ( + + + + + + + + ); +} + +function companyIdFromMarker( + marker: MarkerWithCompany, + pointsByCoord: globalThis.Map, +): string[] { + const fromOptions = marker.options.crmCompanyId ?? marker.options.title; + if (fromOptions) return [fromOptions]; + const latLng = marker.getLatLng?.(); + if (!latLng) return []; + return pointsByCoord.get(`${latLng.lat},${latLng.lng}`) ?? []; +} + +export function CompaniesMapCanvas({ + points, + selectedId, + onSelect, + onClusterSelect, + onBoundsChange, +}: { + points: MapCompanyPoint[]; + selectedId: string; + onSelect: (id: string) => void; + onClusterSelect: (ids: string[]) => void; + onBoundsChange: (bounds: MapLatLngBounds) => void; +}) { + const first = points[0]; + const center: [number, number] = + points.length === 1 && first + ? [first.latitude, first.longitude] + : US_CENTER; + + const selectedPoint = + selectedId === "" + ? null + : (points.find((point) => point.id === selectedId) ?? null); + + const pointsByCoord = new globalThis.Map(); + for (const point of points) { + const key = `${point.latitude},${point.longitude}`; + const list = pointsByCoord.get(key); + if (list) list.push(point.id); + else pointsByCoord.set(key, [point.id]); + } + + return ( + + + + + {selectedPoint ? ( + + ) : null} + MarkerWithCompany[] }; + }) => { + const ids = [ + ...new globalThis.Set( + event.layer + .getAllChildMarkers() + .flatMap((marker) => + companyIdFromMarker(marker, pointsByCoord), + ), + ), + ]; + if (ids.length > 0) onClusterSelect(ids); + }, + }} + > + {points.map((point) => { + const isSelected = selectedId === point.id; + return ( + onSelect(point.id), + add: (event: { target: MarkerWithCompany }) => { + event.target.options.crmCompanyId = point.id; + }, + }} + icon={ + + } + iconAnchor={[12, 24]} + /> + ); + })} + + {/* Unclustered highlight so the selection stays visible above clusters. */} + {selectedPoint ? ( + + } + iconAnchor={[20, 28]} + /> + ) : null} + + ); +} diff --git a/apps/app/app/(app)/map/map-panel.tsx b/apps/app/app/(app)/map/map-panel.tsx new file mode 100644 index 000000000..27d07a168 --- /dev/null +++ b/apps/app/app/(app)/map/map-panel.tsx @@ -0,0 +1,436 @@ +"use client"; + +import { Button } from "@crm/ui/components/button"; +import { Input } from "@crm/ui/components/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@crm/ui/components/select"; +import { useSearchInput } from "@crm/ui/hooks/use-search-input"; +import { cn } from "@crm/ui/lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import { useQueryStates } from "nuqs"; +import { useRef, useState } from "react"; +import { formatCompanyLocation } from "@/components/crm/company-location"; +import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; +import { useTRPC } from "@/lib/trpc/client"; +import { + CompaniesMapCanvas, + type MapLatLngBounds, +} from "./companies-map-canvas"; +import { mapParsers, mapQueryInput } from "./map-search-params"; + +function inBounds( + latitude: number, + longitude: number, + bounds: MapLatLngBounds, +): boolean { + if (latitude < bounds.south || latitude > bounds.north) return false; + // Leaflet can report west > east when the view crosses the antimeridian. + if (bounds.west <= bounds.east) { + return longitude >= bounds.west && longitude <= bounds.east; + } + return longitude >= bounds.west || longitude <= bounds.east; +} + +/** Ignore getBounds() from an unsized / not-yet-laid-out map. */ +function isUsableBounds(bounds: MapLatLngBounds): boolean { + const latSpan = bounds.north - bounds.south; + const lngSpan = + bounds.west <= bounds.east + ? bounds.east - bounds.west + : 360 - (bounds.west - bounds.east); + return latSpan > 0.05 && lngSpan > 0.05; +} + +function boundsKey(bounds: MapLatLngBounds): string { + return `${bounds.north}:${bounds.south}:${bounds.east}:${bounds.west}`; +} + +export function MapPanel() { + const trpc = useTRPC(); + const openRecord = useOpenRecord(); + const [params, setParams] = useQueryStates(mapParsers); + const input = mapQueryInput(params); + /** Company ids inside the last-clicked map cluster; null = use viewport. */ + const [clusterIds, setClusterIds] = useState(null); + const [mapBounds, setMapBounds] = useState(null); + const lastBoundsKeyRef = useRef(null); + + const [searchValue, setSearchValue] = useSearchInput(params.q, (next) => { + setClusterIds(null); + setMapBounds(null); + lastBoundsKeyRef.current = null; + void setParams({ q: next, selected: "" }); + }); + + const mapList = useQuery({ + ...trpc.companies.mapList.queryOptions(input), + // Keep pins/list visible while a filter refetch runs — otherwise the map + // unmounts, remounts ~12k markers, and the UI flashes empty. + placeholderData: (previous) => previous, + }); + + const rows = mapList.data?.rows ?? []; + const clusterIdSet = + clusterIds == null ? null : new Set(clusterIds); + const usableBounds = + mapBounds != null && isUsableBounds(mapBounds) ? mapBounds : null; + + const listRows = (() => { + if (clusterIdSet != null) { + return rows.filter((row) => clusterIdSet.has(row.id)); + } + if (usableBounds == null || params.hasLocation === "no") { + return rows; + } + return rows.filter( + (row) => + row.latitude != null && + row.longitude != null && + inBounds(row.latitude, row.longitude, usableBounds), + ); + })(); + + const selected = + listRows.find((row) => row.id === params.selected) ?? + rows.find((row) => row.id === params.selected) ?? + null; + + const points = rows.flatMap((row) => + row.latitude != null && row.longitude != null + ? [ + { + id: row.id, + name: row.name, + latitude: row.latitude, + longitude: row.longitude, + isMine: row.isMine, + sage100CustomerNo: row.sage100CustomerNo, + }, + ] + : [], + ); + + function clearMapFocusAndSet(patch: Parameters[0]) { + setClusterIds(null); + // Drop stale viewport so the list shows every match until the map + // reports bounds for the current view again. + setMapBounds(null); + lastBoundsKeyRef.current = null; + void setParams(patch); + } + + const viewportActive = + clusterIdSet == null && + usableBounds != null && + params.hasLocation !== "no"; + const initialLoading = mapList.isPending && !mapList.data; + const filterError = + mapList.error instanceof Error + ? mapList.error.message + : mapList.error + ? String(mapList.error) + : null; + + return ( +
+ + +
+
+ + + My accounts + + + + Has Sage 100 + + + + No Sage 100 + +
+ +
+ {initialLoading ? ( +
+ Loading map… +
+ ) : ( + void setParams({ selected: id })} + onClusterSelect={(ids) => { + setClusterIds(ids); + void setParams({ selected: "" }); + }} + onBoundsChange={(bounds) => { + if (!isUsableBounds(bounds)) return; + const key = boundsKey(bounds); + if (lastBoundsKeyRef.current !== key) { + lastBoundsKeyRef.current = key; + setClusterIds(null); + } + setMapBounds(bounds); + }} + /> + )} + {mapList.isFetching && !initialLoading ? ( +
+ Updating… +
+ ) : null} +
+ + {selected ? ( +
+
+

{selected.name}

+

+ {formatCompanyLocation(selected) || "No location on file"} + {selected.domain ? ` · ${selected.domain}` : ""} + {selected.owner ? ` · ${selected.owner.name}` : " · Unassigned"} + {selected.sage100CustomerNo + ? ` · Sage 100 ${selected.sage100CustomerNo}` + : " · No Sage 100"} +

+
+ +
+ ) : null} +
+
+ ); +} diff --git a/apps/app/app/(app)/map/map-search-params.ts b/apps/app/app/(app)/map/map-search-params.ts new file mode 100644 index 000000000..aed175ccb --- /dev/null +++ b/apps/app/app/(app)/map/map-search-params.ts @@ -0,0 +1,49 @@ +import { + createLoader, + parseAsInteger, + parseAsString, + parseAsStringLiteral, + type SearchParams, +} from "nuqs/server"; + +const OWNERS = ["all", "me", "unassigned"] as const; +const SAGE = ["all", "linked", "unlinked"] as const; +const LOCATION = ["all", "yes", "no"] as const; +const SORTS = ["name", "city", "owner"] as const; +const DIRS = ["asc", "desc"] as const; + +export const mapParsers = { + q: parseAsString.withDefault(""), + owner: parseAsStringLiteral(OWNERS).withDefault("all"), + sage: parseAsStringLiteral(SAGE).withDefault("all"), + hasLocation: parseAsStringLiteral(LOCATION).withDefault("all"), + /** 0 = any time; 1–10 = deal opened or closed within that many years. */ + dealYears: parseAsInteger.withDefault(0), + sort: parseAsStringLiteral(SORTS).withDefault("name"), + dir: parseAsStringLiteral(DIRS).withDefault("asc"), + selected: parseAsString.withDefault(""), +}; + +export const loadMapSearchParams = createLoader(mapParsers); + +export type MapSearchValues = Awaited>; + +export function mapQueryInput(values: MapSearchValues) { + const dealYears = + values.dealYears >= 1 && values.dealYears <= 10 ? values.dealYears : 0; + return { + q: values.q, + owner: values.owner, + sage: values.sage, + hasLocation: values.hasLocation, + dealYears, + sort: values.sort, + dir: values.dir, + }; +} + +export async function parseMapSearchParams( + searchParams: Promise, +) { + return loadMapSearchParams(searchParams); +} diff --git a/apps/app/app/(app)/map/page.tsx b/apps/app/app/(app)/map/page.tsx new file mode 100644 index 000000000..38135c867 --- /dev/null +++ b/apps/app/app/(app)/map/page.tsx @@ -0,0 +1,54 @@ +import type { Metadata } from "next"; +import type { SearchParams } from "nuqs/server"; +import { + PageShell, + PageShellContent, + PageShellDescription, + PageShellHeader, + PageShellHeading, + PageShellTitle, +} from "@/components/page-shell"; +import { requireSession } from "@/lib/session"; +import { HydrateClient } from "@/lib/trpc/hydrate"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; +import { MapPanel } from "./map-panel"; +import { mapQueryInput, parseMapSearchParams } from "./map-search-params"; + +export const metadata: Metadata = { + title: "Map", +}; + +export default async function MapPage({ + searchParams, +}: { + searchParams: Promise; +}) { + await requireSession(); + + const values = await parseMapSearchParams(searchParams); + const trpc = getServerTrpc(); + const queryClient = getServerQueryClient(); + + await queryClient.prefetchQuery( + trpc.companies.mapList.queryOptions(mapQueryInput(values)), + ); + + return ( + + + + Map + + Companies by city — filter by owner, Sage 100, and recent deals. + + + + + + + + + + + ); +} diff --git a/apps/app/app/(app)/overview-range.tsx b/apps/app/app/(app)/overview-range.tsx new file mode 100644 index 000000000..6ed179dac --- /dev/null +++ b/apps/app/app/(app)/overview-range.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { Button } from "@crm/ui/components/button"; +import { DatePicker } from "@crm/ui/components/date-picker"; +import { + Popover, + PopoverAnchor, + PopoverContent, +} from "@crm/ui/components/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@crm/ui/components/select"; +import { useQueryStates } from "nuqs"; +import { useRef, useState } from "react"; +import { + OVERVIEW_RANGES, + type OverviewRange, + overviewParsers, +} from "./overview-search-params"; + +const LABELS: Record = { + today: "Today", + this_week: "This week", + this_month: "This month", + this_year: "Since the 1st of the year", + past_30: "Past 30 days", + custom: "Custom", +}; + +function isRange(value: string): value is OverviewRange { + return (OVERVIEW_RANGES as readonly string[]).includes(value); +} + +function todayDay(): string { + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + const d = String(now.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +function monthStartDay(): string { + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + return `${y}-${m}-01`; +} + +/** + * Closed-won / win-rate window on the overview. + * + * Lives in the URL next to `scope` so a manager can send "team, past 30 days" + * as a link. Open pipeline and the forecast tables ignore it. + */ +export function OverviewRangeControl() { + const [params, setParams] = useQueryStates(overviewParsers); + const [customOpen, setCustomOpen] = useState(false); + const [draftFrom, setDraftFrom] = useState( + () => params.from ?? monthStartDay(), + ); + const [draftTo, setDraftTo] = useState(() => params.to ?? todayDay()); + // Select dismisses with a pointer-up that lands "outside" the popover we + // just opened — Radix then closes it. Ignore those closes briefly. + const ignoreCloseUntil = useRef(0); + const openTimer = useRef | null>(null); + + const openCustomPanel = (from: string, to: string) => { + setDraftFrom(from); + setDraftTo(to); + if (openTimer.current) clearTimeout(openTimer.current); + ignoreCloseUntil.current = Date.now() + 200; + // After the Select portal finishes tearing down; opening in the same + // tick loses to the dismiss pointer-up. + openTimer.current = setTimeout(() => setCustomOpen(true), 10); + }; + + const applyPreset = (next: OverviewRange) => { + if (next === "custom") { + const from = params.from ?? monthStartDay(); + const to = params.to ?? todayDay(); + openCustomPanel(from, to); + return; + } + if (openTimer.current) clearTimeout(openTimer.current); + setCustomOpen(false); + void setParams({ range: next, from: null, to: null }); + }; + + const applyCustom = () => { + if (!draftFrom || !draftTo || draftFrom > draftTo) return; + void setParams({ range: "custom", from: draftFrom, to: draftTo }); + setCustomOpen(false); + }; + + const triggerLabel = + params.range === "custom" && params.from && params.to + ? `${params.from} → ${params.to}` + : LABELS[params.range]; + + return ( + { + if (!open && Date.now() < ignoreCloseUntil.current) return; + setCustomOpen(open); + }} + > + +
+ +
+
+ { + const target = event.target as HTMLElement | null; + if (target?.closest('[data-slot="popover-content"]')) { + event.preventDefault(); + } + }} + > +
+
+ From + +
+
+ To + +
+ +
+
+
+ ); +} diff --git a/apps/app/app/(app)/overview-search-params.ts b/apps/app/app/(app)/overview-search-params.ts index dc51d69ac..1bf86f83a 100644 --- a/apps/app/app/(app)/overview-search-params.ts +++ b/apps/app/app/(app)/overview-search-params.ts @@ -1,4 +1,4 @@ -import { createLoader, parseAsStringLiteral } from "nuqs/server"; +import { createLoader, parseAsString, parseAsStringLiteral } from "nuqs/server"; /** * The overview URL. Shared by the page's server-side prefetch and the client @@ -14,10 +14,82 @@ export const OVERVIEW_SCOPES = ["me", "everyone"] as const; export type OverviewScope = (typeof OVERVIEW_SCOPES)[number]; +/** + * Kept in step with `DASHBOARD_RANGES` in the API's dashboard contracts. + * + * Closed-won / win-rate only — open pipeline and forecast ignore this. + */ +export const OVERVIEW_RANGES = [ + "today", + "this_week", + "this_month", + "this_year", + "past_30", + "custom", +] as const; + +export type OverviewRange = (typeof OVERVIEW_RANGES)[number]; + +/** + * Close window for the Everyone deal-maturity × rep grid. + * Kept in step with `CERTAINTY_BY_REP_WINDOWS` in the API contracts. + */ +export const CERTAINTY_BY_REP_WINDOWS = [ + "this_month", + "last_month", + "this_quarter", + "last_quarter", + "ytd", + "custom", +] as const; + +export type CertaintyByRepWindow = (typeof CERTAINTY_BY_REP_WINDOWS)[number]; + +/** + * Recent deal-move filters. Kept in step with `PULSE_CHANGE_FILTERS` in + * `@crm/db` / `dashboardPulseRecentInput`. Own parser map so the rest of + * the overview does not re-render when only the feed filters change. + */ +export const PULSE_CHANGE_FILTERS = [ + "all", + "won", + "lost", + "stage", + "probability", + "amount", + "expectedCloseDate", + "ownerId", + "priority", + "sageStage", +] as const; + +export type PulseChangeFilter = (typeof PULSE_CHANGE_FILTERS)[number]; + +export const PULSE_REP_ALL = "all"; + +export const pulseFeedParsers = { + pulseRep: parseAsString.withDefault(PULSE_REP_ALL), + pulseChange: parseAsStringLiteral(PULSE_CHANGE_FILTERS).withDefault("all"), +}; + export const overviewParsers = { // A literal parser, not a plain string: `?scope=nonsense` then falls back to // the default rather than reaching the API as an unhandled value. scope: parseAsStringLiteral(OVERVIEW_SCOPES).withDefault("me"), + range: parseAsStringLiteral(OVERVIEW_RANGES).withDefault("this_year"), + /** `YYYY-MM-DD` — only sent when `range=custom`. */ + from: parseAsString, + /** `YYYY-MM-DD` — only sent when `range=custom`. */ + to: parseAsString, + /** Deal-maturity × rep grid window — Everyone only; independent of `range`. */ + certWindow: parseAsStringLiteral(CERTAINTY_BY_REP_WINDOWS).withDefault( + "this_month", + ), + certFrom: parseAsString, + certTo: parseAsString, }; -export const loadOverviewSearchParams = createLoader(overviewParsers); +export const loadOverviewSearchParams = createLoader({ + ...overviewParsers, + ...pulseFeedParsers, +}); diff --git a/apps/app/app/(app)/page.tsx b/apps/app/app/(app)/page.tsx index 6f4f99006..27f8bc5a1 100644 --- a/apps/app/app/(app)/page.tsx +++ b/apps/app/app/(app)/page.tsx @@ -11,6 +11,7 @@ import { HydrateClient } from "@/lib/trpc/hydrate"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; import { DashboardSummary } from "./dashboard-summary"; import { OverviewGreeting } from "./overview-greeting"; +import { OverviewRangeControl } from "./overview-range"; import { OverviewScopeToggle } from "./overview-scope"; import { loadOverviewSearchParams } from "./overview-search-params"; @@ -21,19 +22,40 @@ export default async function OverviewPage({ }) { await requireSession(); - // Parsed with the same parser the toggle uses, so the first paint is already - // scoped to whoever the URL says rather than to the default. - const { scope } = await loadOverviewSearchParams(searchParams); + // Parsed with the same parsers the header controls use, so the first paint + // already matches the URL rather than the defaults. + const { scope, range, from, to, pulseRep, pulseChange } = + await loadOverviewSearchParams(searchParams); const trpc = getServerTrpc(); const queryClient = getServerQueryClient(); + const summaryInput = { + scope, + range, + ...(range === "custom" && from && to ? { from, to } : {}), + }; + const effectiveRep = scope === "me" ? "all" : pulseRep; + const pulseFiltersOn = effectiveRep !== "all" || pulseChange !== "all"; + // Both awaited: the greeting is one line of text and the dashboard is the // whole page, so a skeleton that flashes for the length of one API call is // worse than rendering a beat later. await Promise.all([ queryClient.prefetchQuery(trpc.users.me.queryOptions()), - queryClient.prefetchQuery(trpc.dashboard.summary.queryOptions({ scope })), + queryClient.prefetchQuery(trpc.users.list.queryOptions()), + queryClient.prefetchQuery( + trpc.dashboard.summary.queryOptions(summaryInput), + ), + pulseFiltersOn + ? queryClient.prefetchQuery( + trpc.dashboard.pulseRecent.queryOptions({ + ...summaryInput, + ...(effectiveRep !== "all" ? { ownerId: effectiveRep } : {}), + change: pulseChange, + }), + ) + : Promise.resolve(), ]); return ( @@ -45,6 +67,7 @@ export default async function OverviewPage({ + diff --git a/apps/app/app/(app)/pipeline-pulse.tsx b/apps/app/app/(app)/pipeline-pulse.tsx new file mode 100644 index 000000000..08089185f --- /dev/null +++ b/apps/app/app/(app)/pipeline-pulse.tsx @@ -0,0 +1,527 @@ +"use client"; + +import type { DealStage } from "@crm/db/enums"; +import { + Card, + CardAction, + CardDescription, + CardHeader, + CardPanel, + CardPanelEmpty, + CardTitle, +} from "@crm/ui/components/card"; +import { CardTableEmpty } from "@crm/ui/components/card-table"; +import { EmptyCellValue } from "@crm/ui/components/empty-cell"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@crm/ui/components/select"; +import { + SimpleTable, + type SimpleTableColumn, + SimpleTableRow, +} from "@crm/ui/components/simple-table"; +import { Spinner } from "@crm/ui/components/spinner"; +import { StatusIndicator } from "@crm/ui/components/status-indicator"; +import { TableCell } from "@crm/ui/components/table"; +import { + formatMoneyCompact, + formatPercent, + relativeTimeFromIso, +} from "@crm/ui/lib/format"; +import { useQuery } from "@tanstack/react-query"; +import { useQueryStates } from "nuqs"; +import { dealStageLabel } from "@/components/crm/deal-stage"; +import { OwnerCell } from "@/components/crm/owner-cell"; +import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@/lib/trpc/types"; +import { + overviewParsers, + PULSE_CHANGE_FILTERS, + PULSE_REP_ALL, + type PulseChangeFilter, + pulseFeedParsers, +} from "./overview-search-params"; + +type Summary = RouterOutputs["dashboard"]["summary"]; +type Pulse = NonNullable; +type PulseChange = Pulse["recent"][number]; + +const CELL = "px-3 py-2.5 align-middle"; + +const EMPTY_PULSE: Pulse = { + windowDays: 7, + stuckDays: 14, + since: new Date(0).toISOString(), + until: new Date(0).toISOString(), + scope: "me", + counts: { + won: 0, + lost: 0, + certainty: 0, + stage: 0, + amount: 0, + expectedClose: 0, + owner: 0, + priority: 0, + sageStage: 0, + total: 0, + }, + movers: [], + recent: [], + stuck: [], +}; + +/** + * Manager pulse tables: biggest movers, stuck deals, and the change feed. + * KPI counts live in the sales dashboard strip (same `pulse` payload). + */ +export function PipelinePulse({ pulse }: { pulse: Pulse | undefined }) { + const data = pulse ?? EMPTY_PULSE; + const openRecord = useOpenRecord(); + const { movers, recent, stuck } = data; + + const moverColumns: SimpleTableColumn[] = [ + { header: "Deal" }, + { header: "Change", width: "w-44" }, + { header: "Source", width: "w-20", className: "hidden sm:table-cell" }, + { header: "When", width: "w-20", align: "right" }, + ]; + + const stuckColumns: SimpleTableColumn[] = [ + { header: "Deal" }, + { header: "Stage", width: "w-36", className: "hidden lg:table-cell" }, + { header: "Rep", width: "w-32", className: "hidden md:table-cell" }, + { header: "Stuck", width: "w-24", align: "right" }, + ]; + + return ( +
+
+ + + Biggest movers + + Largest deal maturity, amount, and stage moves in the last{" "} + {data.windowDays} days + + + + {movers.length === 0 ? ( + + No deal field changes yet in this window. Edits in the app and + Sage pulls will show here. + + ) : ( + + {movers.map((change) => ( + + openRecord({ kind: "deal", id: change.deal.id }) + } + > + + + + + + + + + + {relativeTimeFromIso(change.createdAt)} + + + + ))} + + )} + + + + + + Stuck deals + + Open deals with no stage or deal maturity move in {data.stuckDays} + + days + + + + {stuck.length === 0 ? ( + + Nothing stuck past {data.stuckDays} days. Good. + + ) : ( + + {stuck.map((deal) => ( + openRecord({ kind: "deal", id: deal.id })} + > + + + + + + + + + + ))} + + )} + + +
+ + +
+ ); +} + +const FEED_COLUMNS: SimpleTableColumn[] = [ + { header: "Deal" }, + { header: "Amount", width: "w-24", align: "right" }, + { header: "Change", width: "w-48" }, + { header: "Rep", width: "w-32", className: "hidden md:table-cell" }, + { header: "Source", width: "w-20", className: "hidden sm:table-cell" }, + { header: "When", width: "w-20", align: "right" }, +]; + +/** + * Full-width change feed. Filters live in the URL and hit the change log + * when they are not "all", so a year-long range can still fill the table + * with one rep or one change reason. + */ +function RecentDealMoves({ + windowDays, + unfiltered, +}: { + windowDays: number; + unfiltered: PulseChange[]; +}) { + const openRecord = useOpenRecord(); + const trpc = useTRPC(); + const [overview] = useQueryStates(overviewParsers); + const [filters, setFilters] = useQueryStates(pulseFeedParsers); + const users = useQuery(trpc.users.list.queryOptions()); + + const { scope, range, from, to } = overview; + const effectiveRep = scope === "me" ? PULSE_REP_ALL : filters.pulseRep; + const filtersOn = + effectiveRep !== PULSE_REP_ALL || filters.pulseChange !== "all"; + + const summaryInput = { + scope, + range, + ...(range === "custom" && from && to ? { from, to } : {}), + }; + + const filtered = useQuery({ + ...trpc.dashboard.pulseRecent.queryOptions({ + ...summaryInput, + ...(effectiveRep !== PULSE_REP_ALL ? { ownerId: effectiveRep } : {}), + change: filters.pulseChange, + }), + enabled: filtersOn, + placeholderData: (previous) => previous, + }); + + const recent = filtersOn ? (filtered.data?.recent ?? []) : unfiltered; + const waiting = filtersOn && filtered.isPending && !filtered.data; + const showEveryoneReps = scope === "everyone"; + + return ( + + + Recent deal moves + + Deal maturity, stage, amount, close date, owner, and priority — app + and Sage, last {windowDays} days + + + {showEveryoneReps ? ( + + ) : null} + + + + {waiting ? ( +
+ +
+ ) : recent.length === 0 ? ( + + {filtersOn + ? "No deal moves match these filters in this window." + : "The change log starts when deals are edited here or updated from Sage. Older history is not backfilled."} + + ) : ( + + {recent.map((change) => ( + openRecord({ kind: "deal", id: change.deal.id })} + > + + + + + {change.deal.amountCents === null || + change.deal.amountCents === 0 ? ( + + ) : ( + formatMoneyCompact( + change.deal.amountCents, + change.deal.currency, + ) + )} + + + + + + + + + {relativeTimeFromIso(change.createdAt)} + + + + ))} + + )} +
+ ); +} + +function DealLines({ + name, + company, + meta, +}: { + name: string; + company: string; + meta?: string; +}) { + return ( + + {name} + + {meta ? `${company} · ${meta}` : company} + + + ); +} + +function ChangeLines({ change }: { change: PulseChange }) { + return ( + + + {fieldLabel(change.field, change.toValue)} + + + {formatTransition(change)} + + + ); +} + +function SourceBadge({ source }: { source: "app" | "sage" }) { + return ( + + ); +} + +function isPulseChangeFilter(value: string): value is PulseChangeFilter { + return (PULSE_CHANGE_FILTERS as readonly string[]).includes(value); +} + +function changeFilterLabel(filter: PulseChangeFilter): string { + switch (filter) { + case "all": + return "All changes"; + case "won": + return "Won"; + case "lost": + return "Lost"; + case "stage": + return "Stage"; + case "probability": + return "Deal Maturity"; + case "amount": + return "Amount"; + case "expectedCloseDate": + return "Close date"; + case "ownerId": + return "Owner"; + case "priority": + return "Priority"; + case "sageStage": + return "Sage stage"; + } +} + +function fieldLabel(field: string, toValue: string | null): string { + if (field === "stage") { + if (toValue === "CLOSED_WON") return "Won"; + if (toValue === "CLOSED_LOST") return "Lost"; + return "Stage"; + } + switch (field) { + case "probability": + return "Deal Maturity"; + case "amount": + return "Amount"; + case "expectedCloseDate": + return "Close date"; + case "ownerId": + return "Owner"; + case "priority": + return "Priority"; + case "sageStage": + return "Sage stage"; + default: + return field; + } +} + +function formatTransition(change: PulseChange): string { + const from = formatFieldValue( + change.field, + change.fromValue, + change.deal.currency, + ); + const to = formatFieldValue( + change.field, + change.toValue, + change.deal.currency, + ); + if (change.field === "ownerId") { + return "Owner reassigned"; + } + if (from === "—" && to === "—") return "Changed"; + return `${from} → ${to}`; +} + +function formatFieldValue( + field: string, + value: string | null, + currency: string, +): string { + if (value === null || value === "") return "—"; + switch (field) { + case "stage": + return dealStageLabel(value as DealStage); + case "sageStage": + return value; + case "probability": { + const n = Number(value); + return Number.isFinite(n) ? formatPercent(n / 100) : value; + } + case "amount": { + const dollars = Number(value); + if (!Number.isFinite(dollars)) return value; + return formatMoneyCompact(Math.round(dollars * 100), currency); + } + case "priority": + return value; + default: + return value; + } +} diff --git a/apps/app/app/(app)/sales-dashboard.tsx b/apps/app/app/(app)/sales-dashboard.tsx index 0a7fa5a6c..80b48a996 100644 --- a/apps/app/app/(app)/sales-dashboard.tsx +++ b/apps/app/app/(app)/sales-dashboard.tsx @@ -7,8 +7,19 @@ import { CardTitle, } from "@crm/ui/components/card"; import type { ChartConfig } from "@crm/ui/components/chart"; -import { DashboardRow, StatGroup } from "@crm/ui/components/dashboard"; +import { + DashboardRow, + StatBoard, + StatGroup, +} from "@crm/ui/components/dashboard"; +import { EmptyCellValue } from "@crm/ui/components/empty-cell"; +import { + SimpleTable, + type SimpleTableColumn, + SimpleTableRow, +} from "@crm/ui/components/simple-table"; import { StatCard, type StatDelta } from "@crm/ui/components/stat-card"; +import { TableCell } from "@crm/ui/components/table"; import { formatCount, formatMoney, @@ -18,19 +29,53 @@ import { import Link from "next/link"; import type { ReactNode } from "react"; import { dealStageColor, dealStageLabel } from "@/components/crm/deal-stage"; +import { OwnerCell } from "@/components/crm/owner-cell"; import { AreaTrend, DonutStat } from "@/components/dashboard-charts"; import type { RouterOutputs } from "@/lib/trpc/types"; +import { CertaintyByRepGrid } from "./certainty-by-rep-grid"; type Summary = RouterOutputs["dashboard"]["summary"]; +const EMPTY_FORECAST: Summary["forecast"] = { + totals: { amountCents: 0, weightedCents: 0, dealCount: 0 }, + months: [], + byOwner: [], +}; + +const EMPTY_PULSE: NonNullable = { + windowDays: 7, + stuckDays: 14, + since: new Date(0).toISOString(), + until: new Date(0).toISOString(), + scope: "me", + counts: { + won: 0, + lost: 0, + certainty: 0, + stage: 0, + amount: 0, + expectedClose: 0, + owner: 0, + priority: 0, + sageStage: 0, + total: 0, + }, + movers: [], + recent: [], + stuck: [], +}; + +const CELL = "px-3 py-2.5 align-middle"; + /** * Won is the outcome, created is the input that produces it six weeks later. - * `--success` for the first because a rep already reads green as "closed"; the - * chart ramp for the second because it is a leading indicator, not a verdict. + * Green (`--success`) for closed; blue (`--chart-2`) for new pipeline so + * the two series separate in the legend and on the plot. `--chart-1` is + * also green, so it cannot sit next to `--success`. */ const TREND_CONFIG: ChartConfig = { won: { label: "Closed won", color: "var(--success)" }, - created: { label: "New pipeline", color: "var(--chart-1)" }, + created: { label: "New pipeline", color: "var(--chart-2)" }, }; /** @@ -54,8 +99,12 @@ function changeDelta( } /** - * The KPI strip and the two charts behind it: six months of closed-won against - * new pipeline, and where the open pipeline currently sits. + * KPI board and the charts behind it: closed-won against new pipeline, and + * where the open pipeline currently sits. + * + * Locked cells ignore the date range (due this month, open pipeline, + * overdue, stuck). Live cells follow the range and animate on change. + * Lost is not its own tile — win rate already names wins and losses. */ export function SalesDashboard({ summary }: { summary: Summary }) { const { @@ -66,6 +115,27 @@ export function SalesDashboard({ summary }: { summary: Summary }) { trend, closingThisMonthTotal, } = summary; + // Stale dehydrated/cached summaries (pre-forecast API) omit this field — + // fall back so the overview still paints while the query refetches. + const forecast = summary.forecast ?? EMPTY_FORECAST; + const pulse = summary.pulse ?? EMPTY_PULSE; + const rangeLabel = summary.range?.label ?? "Year to date"; + // Unweighted close-this-month total. Fall back to weighted only when amount + // is empty — same idea as dealMoneyCents, so a Sage deal that only has + // forecast still shows up while certainty is still noisy as a KPI. + const overdueOpenTotal = summary.overdueOpenTotal ?? { + count: 0, + valueCents: 0, + weightedCents: 0, + }; + const dueThisMonthCents = + closingThisMonthTotal.valueCents !== 0 + ? closingThisMonthTotal.valueCents + : (closingThisMonthTotal.weightedCents ?? 0); + const overdueOpenCents = + overdueOpenTotal.valueCents !== 0 + ? overdueOpenTotal.valueCents + : (overdueOpenTotal.weightedCents ?? 0); const hasTrend = trend.some((point) => point.won > 0 || point.created > 0); @@ -79,56 +149,99 @@ export function SalesDashboard({ summary }: { summary: Summary }) { })) .filter((slice) => slice.value > 0); + const showOwnerBreakdown = + summary.scope === "everyone" && forecast.byOwner.length > 1; + + const monthColumns: SimpleTableColumn[] = [ + { header: "Close month" }, + { header: "Deals", width: "w-16", align: "right" }, + { header: "Amount", width: "w-24", align: "right" }, + { header: "Weighted", width: "w-24", align: "right" }, + ]; + + const ownerColumns: SimpleTableColumn[] = [ + { header: "Rep" }, + { header: "Deals", width: "w-16", align: "right" }, + { header: "Amount", width: "w-24", align: "right" }, + { header: "Weighted", width: "w-24", align: "right" }, + ]; + + const { counts, stuck } = pulse; + return (
- - - - - - + + + + + + + + + + + + + + {hasTrend ? (
@@ -201,6 +314,114 @@ export function SalesDashboard({ summary }: { summary: Summary }) { )} + + {/* + * Forecast by close month — the Sage view reps rely on. Weighted is + * certainty × amount (`weightedAmount`); amount is the unweighted total. + */} +
+ + + Forecast by close month + + Last month, this month, and the next 12 — weighted vs + unweighted + + + {forecast.months.length === 0 ? ( + + ) : ( + + {forecast.months.map((month) => ( + + + + + {month.label} + + {month.overdue ? ( + + Overdue + + ) : null} + + + + {month.dealCount} + + + {month.amountCents === 0 ? ( + + ) : ( + formatMoneyCompact(month.amountCents) + )} + + + {month.weightedCents === 0 ? ( + + ) : ( + formatMoneyCompact(month.weightedCents) + )} + + + ))} + + )} + + + {showOwnerBreakdown ? ( + + + Forecast by rep + + Open weighted pipeline — click a rep for their sheet + + + + {forecast.byOwner.map((row) => ( + + + + + + {row.dealCount} + + + {row.amountCents === 0 ? ( + + ) : ( + formatMoneyCompact(row.amountCents) + )} + + + {row.weightedCents === 0 ? ( + + ) : ( + formatMoneyCompact(row.weightedCents) + )} + + + ))} + + + ) : null} +
+ + {summary.scope === "everyone" ? : null}
); } diff --git a/apps/app/app/(app)/screening/page.tsx b/apps/app/app/(app)/screening/page.tsx new file mode 100644 index 000000000..c407a68cb --- /dev/null +++ b/apps/app/app/(app)/screening/page.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from "next"; +import { + PageShell, + PageShellContent, + PageShellDescription, + PageShellHeader, + PageShellHeading, + PageShellTitle, +} from "@/components/page-shell"; +import { requireSession } from "@/lib/session"; +import { HydrateClient } from "@/lib/trpc/hydrate"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; +import { ScreeningTable } from "./screening-table"; + +export const metadata: Metadata = { + title: "Screening", +}; + +export default async function ScreeningPage() { + await requireSession(); + + const trpc = getServerTrpc(); + const queryClient = getServerQueryClient(); + await queryClient.prefetchQuery(trpc.screening.list.queryOptions()); + + return ( + + + + Screening + + People from synced mail and website form leads who are not in the + CRM yet. + + + + + + + + + + + ); +} diff --git a/apps/app/app/(app)/screening/screening-table.tsx b/apps/app/app/(app)/screening/screening-table.tsx new file mode 100644 index 000000000..08155c78a --- /dev/null +++ b/apps/app/app/(app)/screening/screening-table.tsx @@ -0,0 +1,403 @@ +"use client"; + +import Checkmark from "@carbon/icons-react/es/Checkmark"; +import Close from "@carbon/icons-react/es/Close"; +import UserFollow from "@carbon/icons-react/es/UserFollow"; +import { Button } from "@crm/ui/components/button"; +import { Checkbox } from "@crm/ui/components/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@crm/ui/components/dialog"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@crm/ui/components/empty"; +import { EmptyCellValue } from "@crm/ui/components/empty-cell"; +import { Icon } from "@crm/ui/components/icon"; +import { Label } from "@crm/ui/components/label"; +import { Spinner } from "@crm/ui/components/spinner"; +import { StatusIndicator } from "@crm/ui/components/status-indicator"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@crm/ui/components/table"; +import { relativeTimeFromIso } from "@crm/ui/lib/format"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +import { formatCompanyDisambiguation } from "@/components/crm/company-disambiguation"; +import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; +import { companyNameGuessFromDomain } from "@/lib/company-name-guess"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@/lib/trpc/types"; + +type PendingRow = RouterOutputs["screening"]["list"]["rows"][number]; +type SimilarMatch = RouterOutputs["companies"]["similar"]["matches"][number]; + +function PendingActions({ row }: { row: PendingRow }) { + const trpc = useTRPC(); + const cache = useCrmCache(); + const openRecord = useOpenRecord(); + const queryClient = useQueryClient(); + const [suppressDomain, setSuppressDomain] = useState(false); + const [checking, setChecking] = useState(false); + const [matches, setMatches] = useState(null); + const suppressId = useId(); + + const invalidate = async () => { + await cache.screening(); + }; + + const decide = useMutation( + trpc.screening.decide.mutationOptions({ + onSuccess: async (result) => { + setMatches(null); + await invalidate(); + if (result.decision === "approve" && result.contactId) { + await cache.contact(result.contactId); + toast.success( + result.sagePushQueued + ? "Contact created — queued for Sage." + : "Contact created.", + ); + openRecord({ kind: "contact", id: result.contactId }); + return; + } + toast.success( + suppressDomain ? "Rejected — domain suppressed." : "Rejected.", + ); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const claim = useMutation( + trpc.screening.claim.mutationOptions({ + onSuccess: async () => { + await invalidate(); + toast.success("Lead claimed."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const busy = decide.isPending || checking || claim.isPending; + + async function approveWithCompanyCheck() { + setChecking(true); + try { + const name = + (row.source === "web" && row.companyName?.trim()) || + companyNameGuessFromDomain(row.domain); + const result = await queryClient.fetchQuery( + trpc.companies.similar.queryOptions({ + name: name || row.domain, + domain: row.domain, + }), + ); + if (result.matches.length > 0) { + setMatches([...result.matches]); + return; + } + decide.mutate({ + id: row.id, + source: row.source, + decision: "approve", + }); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Could not check for matching companies.", + ); + } finally { + setChecking(false); + } + } + + function selectExistingCompany(companyId: string) { + decide.mutate({ + id: row.id, + source: row.source, + decision: "approve", + createContact: { companyId }, + }); + } + + function createFromDomain() { + decide.mutate({ + id: row.id, + source: row.source, + decision: "approve", + createContact: { preferDomainCompany: true }, + }); + } + + return ( + <> + + + {row.source === "web" && row.claimable ? ( + + ) : null} + + + + + setSuppressDomain(checked === true)} + disabled={busy} + /> + + + + + { + if (!next && !decide.isPending) setMatches(null); + }} + > + + + Matching companies + + {row.source === "web" && row.companyName + ? `${row.companyName} may already be in the CRM.` + : `${row.domain} may already be in the CRM.`}{" "} + The suggested company is the best account match (Sage 100, + contacts on this domain, size). Use it, pick another, or create + one named after the domain. + + + +
    + {(matches ?? []).map((match) => ( +
  • + + + {match.name} + {match.suggested ? ( + + Suggested + + ) : null} + + + {[ + match.domain, + [match.city, match.stateCode].filter(Boolean).join(", "), + formatCompanyDisambiguation({ + sage100CustomerNo: match.sage100CustomerNo, + contactCount: match.contactCount, + }), + match.reason === "domain" + ? "Related domain" + : "Similar name", + ] + .filter(Boolean) + .join(" · ")} + + + +
  • + ))} +
+ + + + + +
+
+ + ); +} + +function detailLine(row: PendingRow): string | null { + if (row.source === "web") { + const parts = [ + row.companyName, + row.connectLocation || + [row.locationText].filter(Boolean)[0]?.split("\n")[0], + row.assignedLabel, + ].filter(Boolean); + return parts.length > 0 ? parts.join(" · ") : null; + } + return row.sampleSubject; +} + +export function ScreeningTable() { + const trpc = useTRPC(); + const list = useQuery(trpc.screening.list.queryOptions()); + const rows = list.data?.rows ?? []; + + if (list.isLoading) { + return ( +
+ +
+ ); + } + + if (rows.length === 0) { + return ( + + + + + + Nothing to review + + Unmatched mailbox people and website form leads show up here. + + + + ); + } + + return ( +
+ + + + Source + Name + Email + Company + + Seen + + Detail + + Actions + + + + + {rows.map((row) => ( + + + + + + {row.displayName ? ( + {row.displayName} + ) : ( + + {row.email} + + )} + + + {row.email} + + + {row.source === "web" && row.companyName ? ( + row.companyName + ) : ( + {row.domain} + )} + + + {relativeTimeFromIso(row.lastSeenAt)} + + + {detailLine(row) ? ( + {detailLine(row)} + ) : ( + + )} + + + + + + ))} + +
+
+ ); +} diff --git a/apps/app/app/(app)/sequences/enroll-contacts.tsx b/apps/app/app/(app)/sequences/enroll-contacts.tsx new file mode 100644 index 000000000..8037e75b5 --- /dev/null +++ b/apps/app/app/(app)/sequences/enroll-contacts.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { Button } from "@crm/ui/components/button"; +import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + ContactMultiPicker, + type PickedContact, +} from "@/components/crm/contact-multi-picker"; +import { useTRPC } from "@/lib/trpc/client"; + +type EnrollContactsProps = { + sequenceId: string; + onEnrolled: () => Promise; +}; + +export function EnrollContacts({ + sequenceId, + onEnrolled, +}: EnrollContactsProps) { + const trpc = useTRPC(); + const [picked, setPicked] = useState([]); + + const enroll = useMutation( + trpc.sequences.enroll.mutationOptions({ + onSuccess: async (result) => { + await onEnrolled(); + setPicked([]); + const skipped = result.skipped.length; + toast.success( + skipped > 0 + ? `Enrolled ${result.enrolled.length}; skipped ${skipped}.` + : `Enrolled ${result.enrolled.length} contact${result.enrolled.length === 1 ? "" : "s"}.`, + ); + if (skipped > 0) { + const reasons = result.skipped + .slice(0, 3) + .map((s) => s.reason) + .join(" "); + toast.message(reasons); + } + }, + onError: (error) => toast.error(error.message), + }), + ); + + return ( +
+ + +
+ ); +} diff --git a/apps/app/app/(app)/sequences/enrollment-table.tsx b/apps/app/app/(app)/sequences/enrollment-table.tsx new file mode 100644 index 000000000..1902c4c05 --- /dev/null +++ b/apps/app/app/(app)/sequences/enrollment-table.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { Button } from "@crm/ui/components/button"; +import { EmptyCellValue } from "@crm/ui/components/empty-cell"; +import { Spinner } from "@crm/ui/components/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@crm/ui/components/table"; +import { relativeTimeFromIso } from "@crm/ui/lib/format"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@/lib/trpc/types"; + +type EnrollmentRow = + RouterOutputs["sequences"]["enrollments"]["rows"][number]; + +type EnrollmentTableProps = { + sequenceId: string; +}; + +export function EnrollmentTable({ sequenceId }: EnrollmentTableProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const openRecord = useOpenRecord(); + const list = useQuery( + trpc.sequences.enrollments.queryOptions({ sequenceId }), + ); + + const invalidate = async () => { + await queryClient.invalidateQueries({ + queryKey: trpc.sequences.enrollments.queryKey({ sequenceId }), + }); + await queryClient.invalidateQueries({ + queryKey: trpc.sequences.byId.queryKey({ id: sequenceId }), + }); + await queryClient.invalidateQueries({ + queryKey: trpc.sequences.list.queryKey(), + }); + }; + + const pause = useMutation( + trpc.sequences.pauseEnrollment.mutationOptions({ + onSuccess: async () => { + await invalidate(); + toast.success("Enrollment paused."); + }, + onError: (error) => toast.error(error.message), + }), + ); + const resume = useMutation( + trpc.sequences.resumeEnrollment.mutationOptions({ + onSuccess: async () => { + await invalidate(); + toast.success("Enrollment resumed."); + }, + onError: (error) => toast.error(error.message), + }), + ); + const stop = useMutation( + trpc.sequences.stopEnrollment.mutationOptions({ + onSuccess: async () => { + await invalidate(); + toast.success("Enrollment stopped."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + if (list.isLoading) { + return ( +
+ +
+ ); + } + + const rows = list.data?.rows ?? []; + if (rows.length === 0) { + return ( +

No enrollments yet.

+ ); + } + + return ( +
+ + + + Contact + Status + Step + Next send + Actions + + + + {rows.map((row) => ( + + openRecord({ kind: "contact", id: row.contact.id }) + } + onPause={() => pause.mutate({ id: row.id })} + onResume={() => resume.mutate({ id: row.id })} + onStop={() => stop.mutate({ id: row.id })} + /> + ))} + +
+
+ ); +} + +function EnrollmentRowView({ + row, + busy, + onOpen, + onPause, + onResume, + onStop, +}: { + row: EnrollmentRow; + busy: boolean; + onOpen: () => void; + onPause: () => void; + onResume: () => void; + onStop: () => void; +}) { + const name = [row.contact.firstName, row.contact.lastName] + .filter(Boolean) + .join(" "); + const lastRun = row.runs[0]; + + return ( + + + +
+ {row.contact.email ? row.contact.email : } + {row.contact.company ? ` · ${row.contact.company.name}` : ""} +
+ {lastRun?.openedAt || lastRun?.clickedAt ? ( +
+ {lastRun.openedAt ? "Opened" : null} + {lastRun.openedAt && lastRun.clickedAt ? " · " : null} + {lastRun.clickedAt ? "Clicked" : null} +
+ ) : null} +
+ + {row.status.toLowerCase().replaceAll("_", " ")} + {row.stoppedReason ? ( +
{row.stoppedReason}
+ ) : null} +
+ + {row.currentStepOrder + 1} + + + {row.status === "ACTIVE" + ? relativeTimeFromIso( + typeof row.nextRunAt === "string" + ? row.nextRunAt + : new Date(row.nextRunAt).toISOString(), + ) + : "—"} + + +
+ {row.status === "ACTIVE" ? ( + + ) : null} + {row.status === "PAUSED" ? ( + + ) : null} + {row.status === "ACTIVE" || row.status === "PAUSED" ? ( + + ) : null} +
+
+
+ ); +} diff --git a/apps/app/app/(app)/sequences/page.tsx b/apps/app/app/(app)/sequences/page.tsx new file mode 100644 index 000000000..4501f8315 --- /dev/null +++ b/apps/app/app/(app)/sequences/page.tsx @@ -0,0 +1,47 @@ +import type { Metadata } from "next"; +import { + PageShell, + PageShellContent, + PageShellDescription, + PageShellHeader, + PageShellHeading, + PageShellTitle, +} from "@/components/page-shell"; +import { requireSession } from "@/lib/session"; +import { HydrateClient } from "@/lib/trpc/hydrate"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; +import { SequencesPanel } from "./sequences-panel"; + +export const metadata: Metadata = { + title: "Sequences", +}; + +export default async function SequencesPage() { + await requireSession(); + + const trpc = getServerTrpc(); + const queryClient = getServerQueryClient(); + await Promise.all([ + queryClient.prefetchQuery(trpc.sequences.list.queryOptions()), + queryClient.prefetchQuery(trpc.sequences.canSend.queryOptions()), + ]); + + return ( + + + + Sequences + + Multi-step email cadences sent from your Outlook mailbox. + + + + + + + + + + + ); +} diff --git a/apps/app/app/(app)/sequences/sequence-builder.tsx b/apps/app/app/(app)/sequences/sequence-builder.tsx new file mode 100644 index 000000000..536698ce5 --- /dev/null +++ b/apps/app/app/(app)/sequences/sequence-builder.tsx @@ -0,0 +1,327 @@ +"use client"; + +import Add from "@carbon/icons-react/es/Add"; +import TrashCan from "@carbon/icons-react/es/TrashCan"; +import { Button } from "@crm/ui/components/button"; +import { Icon } from "@crm/ui/components/icon"; +import { Input } from "@crm/ui/components/input"; +import { Label } from "@crm/ui/components/label"; +import { Switch } from "@crm/ui/components/switch"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import { HtmlEditor } from "@/components/crm/html-editor"; +import { useTRPC } from "@/lib/trpc/client"; + +type StepDraft = { + order: number; + delayMinutes: number; + subject: string; + bodyTemplate: string; +}; + +const DEFAULT_STEP: StepDraft = { + order: 0, + delayMinutes: 0, + subject: "", + bodyTemplate: "

Hi {{firstName}},

\n

", +}; + +const WEEKDAYS = [ + { value: 1, label: "Mon" }, + { value: 2, label: "Tue" }, + { value: 3, label: "Wed" }, + { value: 4, label: "Thu" }, + { value: 5, label: "Fri" }, + { value: 6, label: "Sat" }, + { value: 0, label: "Sun" }, +]; + +type SequenceBuilderProps = { + onDone: (id: string) => void; + onCancel: () => void; +}; + +export function SequenceBuilder({ onDone, onCancel }: SequenceBuilderProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [timezone, setTimezone] = useState( + Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + ); + const [windowStart, setWindowStart] = useState(9); + const [windowEnd, setWindowEnd] = useState(17); + const [sendDays, setSendDays] = useState([1, 2, 3, 4, 5]); + const [stopOnReply, setStopOnReply] = useState(true); + const [trackingEnabled, setTrackingEnabled] = useState(false); + const [steps, setSteps] = useState([{ ...DEFAULT_STEP }]); + + const create = useMutation( + trpc.sequences.create.mutationOptions({ + onSuccess: async (sequence) => { + await queryClient.invalidateQueries({ + queryKey: trpc.sequences.list.queryKey(), + }); + toast.success("Sequence created."); + onDone(sequence.id); + }, + onError: (error) => toast.error(error.message), + }), + ); + + function updateStep(index: number, patch: Partial) { + setSteps((prev) => + prev.map((step, i) => (i === index ? { ...step, ...patch } : step)), + ); + } + + function addStep() { + setSteps((prev) => [ + ...prev, + { + order: prev.length, + delayMinutes: 3 * 24 * 60, + subject: "", + bodyTemplate: "

Hi {{firstName}},

\n

", + }, + ]); + } + + function removeStep(index: number) { + setSteps((prev) => + prev + .filter((_, i) => i !== index) + .map((step, order) => ({ ...step, order })), + ); + } + + function toggleDay(day: number) { + setSendDays((prev) => + prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day].sort(), + ); + } + + function handleSubmit() { + if (!name.trim()) { + toast.error("Give the sequence a name."); + return; + } + if (steps.length === 0) { + toast.error("Add at least one step."); + return; + } + if (steps.some((s) => !s.subject.trim() || !s.bodyTemplate.trim())) { + toast.error("Every step needs a subject and body."); + return; + } + if (sendDays.length === 0) { + toast.error("Pick at least one send day."); + return; + } + + create.mutate({ + name: name.trim(), + description: description.trim() || null, + timezone, + sendWindowStartMinute: windowStart * 60, + sendWindowEndMinute: windowEnd * 60, + sendDays, + stopOnReply, + trackingEnabled, + steps: steps.map((step, order) => ({ + order, + delayMinutes: order === 0 ? 0 : step.delayMinutes, + subject: step.subject, + bodyTemplate: step.bodyTemplate, + })), + }); + } + + return ( +
+
+
+ + setName(e.target.value)} + placeholder="New business intro" + /> +
+
+ + setDescription(e.target.value)} + placeholder="Optional" + /> +
+
+ + setTimezone(e.target.value)} + /> +
+
+ +
+ setWindowStart(Number(e.target.value))} + aria-label="Window start hour" + /> + to + setWindowEnd(Number(e.target.value))} + aria-label="Window end hour" + /> +
+
+
+ +
+ {WEEKDAYS.map((day) => { + const active = sendDays.includes(day.value); + return ( + + ); + })} +
+
+
+
+

Stop on reply

+

+ End the enrollment when the contact replies. +

+
+ +
+
+
+

Open / click tracking

+

+ Off by default — can hurt deliverability for B2B mail. +

+
+ +
+
+ +
+
+

Steps

+

+ Merge fields: {"{{firstName}}"}, {"{{lastName}}"},{" "} + {"{{companyName}}"}, {"{{title}}"}, {"{{senderName}}"} +

+
+ + {steps.map((step, index) => ( +
+
+

Step {index + 1}

+ {steps.length > 1 ? ( + + ) : null} +
+ {index > 0 ? ( +
+ + + updateStep(index, { + delayMinutes: Number(e.target.value), + }) + } + /> +

+ Tip: 4320 = 3 days, 10080 = 7 days. +

+
+ ) : null} +
+ + updateStep(index, { subject: e.target.value })} + placeholder="Quick intro" + /> +
+
+ + + updateStep(index, { bodyTemplate: html }) + } + placeholder="Write the email…" + /> +
+
+ ))} + + +
+ +
+ + +
+
+ ); +} diff --git a/apps/app/app/(app)/sequences/sequence-detail.tsx b/apps/app/app/(app)/sequences/sequence-detail.tsx new file mode 100644 index 000000000..83ce764d2 --- /dev/null +++ b/apps/app/app/(app)/sequences/sequence-detail.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { Button } from "@crm/ui/components/button"; +import { Spinner } from "@crm/ui/components/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@crm/ui/components/table"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useTRPC } from "@/lib/trpc/client"; +import { EnrollContacts } from "./enroll-contacts"; +import { EnrollmentTable } from "./enrollment-table"; + +type SequenceDetailProps = { + sequenceId: string; + canSend: boolean; +}; + +export function SequenceDetail({ sequenceId, canSend }: SequenceDetailProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const detail = useQuery(trpc.sequences.byId.queryOptions({ id: sequenceId })); + + const invalidate = async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: trpc.sequences.byId.queryKey({ id: sequenceId }), + }), + queryClient.invalidateQueries({ + queryKey: trpc.sequences.list.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: trpc.sequences.enrollments.queryKey({ sequenceId }), + }), + ]); + }; + + const update = useMutation( + trpc.sequences.update.mutationOptions({ + onSuccess: async () => { + await invalidate(); + toast.success("Sequence updated."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + if (detail.isLoading || !detail.data) { + return ( +
+ +
+ ); + } + + const sequence = detail.data; + const isActive = sequence.status === "ACTIVE"; + const isArchived = sequence.status === "ARCHIVED"; + + return ( +
+
+
+

{sequence.name}

+ {sequence.description ? ( +

+ {sequence.description} +

+ ) : null} +

+ {sequence.status.toLowerCase()} · {sequence.timezone} · window{" "} + {formatMinute(sequence.sendWindowStartMinute)}– + {formatMinute(sequence.sendWindowEndMinute)} · days{" "} + {sequence.sendDays.join(",")} +

+

+ Stats: {sequence.stats.enrolled} enrolled · {sequence.stats.sent}{" "} + sent · {sequence.stats.opened} opened · {sequence.stats.clicked}{" "} + clicked · {sequence.stats.replied} replied +

+
+
+ {!isActive && !isArchived ? ( + + ) : null} + {isActive ? ( + + ) : null} + {!isArchived ? ( + + ) : null} +
+
+ +
+

Steps

+
+ + + + # + Subject + Delay + + + + {sequence.steps.map((step) => ( + + {step.order + 1} + {step.subject} + + {step.order === 0 + ? "Immediate" + : formatDelay(step.delayMinutes)} + + + ))} + +
+
+
+ +
+

Enroll contacts

+ {!isActive ? ( +

+ Activate the sequence before enrolling contacts. +

+ ) : !canSend ? ( +

+ Grant Outlook Mail.Send to enroll and send. +

+ ) : ( + + )} +
+ +
+

Enrollments

+ +
+
+ ); +} + +function formatMinute(minute: number): string { + const h = Math.floor(minute / 60); + const m = minute % 60; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; +} + +function formatDelay(minutes: number): string { + if (minutes < 60) return `${minutes}m`; + if (minutes < 24 * 60) return `${Math.round(minutes / 60)}h`; + return `${Math.round(minutes / (24 * 60))}d`; +} diff --git a/apps/app/app/(app)/sequences/sequences-panel.tsx b/apps/app/app/(app)/sequences/sequences-panel.tsx new file mode 100644 index 000000000..584b2fcfc --- /dev/null +++ b/apps/app/app/(app)/sequences/sequences-panel.tsx @@ -0,0 +1,186 @@ +"use client"; + +import Add from "@carbon/icons-react/es/Add"; +import ArrowLeft from "@carbon/icons-react/es/ArrowLeft"; +import EmailNew from "@carbon/icons-react/es/EmailNew"; +import { Button } from "@crm/ui/components/button"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@crm/ui/components/empty"; +import { Icon } from "@crm/ui/components/icon"; +import { Spinner } from "@crm/ui/components/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@crm/ui/components/table"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@/lib/trpc/types"; +import { SequenceDetail } from "./sequence-detail"; +import { SequenceBuilder } from "./sequence-builder"; + +type SequenceRow = RouterOutputs["sequences"]["list"]["rows"][number]; + +type View = + | { kind: "list" } + | { kind: "create" } + | { kind: "detail"; id: string }; + +export function SequencesPanel() { + const trpc = useTRPC(); + const list = useQuery(trpc.sequences.list.queryOptions()); + const canSend = useQuery(trpc.sequences.canSend.queryOptions()); + const [view, setView] = useState({ kind: "list" }); + + if (view.kind === "create") { + return ( +
+ + setView({ kind: "detail", id })} + onCancel={() => setView({ kind: "list" })} + /> +
+ ); + } + + if (view.kind === "detail") { + return ( +
+ + +
+ ); + } + + return ( +
+ {canSend.data && !canSend.data.canSend ? ( +

+ Outlook Mail.Send is not granted yet. You can build sequences, but + enrolling contacts needs a Microsoft reconnect that includes send + access (Entra must also grant admin consent for Mail.Send). +

+ ) : null} + +
+

+ {list.data?.rows.length ?? 0} sequence + {(list.data?.rows.length ?? 0) === 1 ? "" : "s"} +

+ +
+ + {list.isLoading ? ( +
+ +
+ ) : (list.data?.rows.length ?? 0) === 0 ? ( + + + + + + No sequences yet + + Create a multi-step cadence, then enroll contacts to send from + your Outlook mailbox. + + + + ) : ( +
+ + + + Name + Status + Steps + Enrolled + Sent + Replied + + + + {(list.data?.rows ?? []).map((row) => ( + setView({ kind: "detail", id: row.id })} + /> + ))} + +
+
+ )} +
+ ); +} + +function SequenceListRow({ + row, + onOpen, +}: { + row: SequenceRow; + onOpen: () => void; +}) { + return ( + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onOpen(); + } + }} + tabIndex={0} + > + {row.name} + + {row.status.toLowerCase()} + + {row._count.steps} + + {row.stats.enrolled} + + {row.stats.sent} + + {row.stats.replied} + + + ); +} diff --git a/apps/app/app/(app)/settings/microsoft-connection.tsx b/apps/app/app/(app)/settings/microsoft-connection.tsx new file mode 100644 index 000000000..999691ef7 --- /dev/null +++ b/apps/app/app/(app)/settings/microsoft-connection.tsx @@ -0,0 +1,308 @@ +"use client"; + +import { authClient } from "@crm/auth/client"; +import { MS_ALL_SCOPES } from "@crm/auth/scopes"; +import MicrosoftLogo from "@crm/ui/components/brand-logos/microsoft"; +import { Button } from "@crm/ui/components/button"; +import { Label } from "@crm/ui/components/label"; +import { Spinner } from "@crm/ui/components/spinner"; +import { StatusIndicator } from "@crm/ui/components/status-indicator"; +import { Switch } from "@crm/ui/components/switch"; +import { relativeTimeFromIso } from "@crm/ui/lib/format"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import { isSyncing, SYNC_POLL_MS } from "@/components/crm/sync-status"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; + +/** + * What each source contributes, in the rep's terms. + * + * The toggle copy says what a record *becoming* real depends on, because that + * is the only decision on this page with a consequence. + */ +const SOURCES = { + "outlook-calendar": { + label: "Meetings", + autoCreate: "Add the company and contact when you meet someone new", + }, + outlook: { + label: "Email", + autoCreate: "Add the company and contact when you reply to someone new", + }, +} as const; + +export function MicrosoftConnection() { + const trpc = useTRPC(); + const cache = useCrmCache(); + const [reconnecting, setReconnecting] = useState(false); + + const status = useQuery({ + ...trpc.microsoft.status.queryOptions(), + // A sync is background work no client action caused, so per the API rules + // it is polled, not invalidated — and the poll stops the moment it settles. + refetchInterval: (query) => + query.state.data?.sources.some((source) => isSyncing(source.status)) + ? SYNC_POLL_MS + : false, + }); + + const purge = useMutation( + trpc.microsoft.purgeSyncedData.mutationOptions({ + onSuccess: async (result) => { + await cache.microsoft(); + toast.success(`Removed ${result.purged} synced items.`); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const revoke = useMutation( + trpc.microsoft.revokeAccess.mutationOptions({ + // Land on /grant-access so the rep can re-link immediately. Settings + // alone used to leave them with no reconnect button. + onSuccess: () => window.location.assign("/grant-access"), + onError: (error) => toast.error(error.message), + }), + ); + + const setAutoCreate = useMutation( + trpc.microsoft.setAutoCreate.mutationOptions({ + onSuccess: () => cache.microsoft({ settle: "record" }), + onError: (error) => toast.error(error.message), + }), + ); + + const setDailyTaskPush = useMutation( + trpc.microsoft.setDailyTaskPush.mutationOptions({ + onSuccess: () => cache.microsoft({ settle: "record" }), + onError: (error) => toast.error(error.message), + }), + ); + + const syncNow = useMutation( + trpc.microsoft.syncNow.mutationOptions({ + onSuccess: () => cache.microsoft(), + onError: (error) => toast.error(error.message), + }), + ); + + async function reconnect() { + setReconnecting(true); + const origin = window.location.origin; + const { error } = await authClient.linkSocial({ + provider: "microsoft", + scopes: [...MS_ALL_SCOPES], + callbackURL: `${origin}/settings`, + errorCallbackURL: `${origin}/settings`, + }); + if (error) { + toast.error(error.message ?? "Could not reach Microsoft."); + setReconnecting(false); + } + } + + if (!status.data) return null; + + const { sources, hasRefreshToken } = status.data; + + const anyConnected = sources.some((source) => source.connected); + const needsReconnect = !hasRefreshToken || !anyConnected; + + const broken = sources.find( + (source) => source.status === "NEEDS_RECONNECT" || source.lastError, + ); + const lastSyncedAt = sources + .map((source) => source.lastSyncedAt) + .filter((at): at is string => at !== null) + .sort() + .at(-1); + + const healthy = !broken && hasRefreshToken && anyConnected; + + return ( +
+
+
+
+

Microsoft 365

+ +
+ + {needsReconnect ? ( + + ) : ( + + )} +
+ +

+ New meetings and email threads are added to the matching company as + they happen. Nothing from before you connected is imported. + Sequences can send from your Outlook mailbox when Mail.Send is + granted. +

+ +

+ {needsReconnect + ? "Reconnect Microsoft to restore Outlook sync and sequence sending." + : broken?.lastError + ? broken.lastError + : lastSyncedAt + ? `Last checked ${relativeTimeFromIso(lastSyncedAt)}` + : "Waiting for the first check"} +

+
+ + {!needsReconnect ? ( +
+ {sources.map((source) => { + const copy = SOURCES[source.source]; + + return ( +
+ + + + setAutoCreate.mutate({ + source: source.source, + enabled, + }) + } + /> +
+ ); + })} + +
+ + + { + if (enabled && !status.data.canSendMail) { + toast.error( + "Reconnect Microsoft with Mail.Send to enable Daily Task Push.", + ); + return; + } + setDailyTaskPush.mutate({ enabled }); + }} + /> +
+
+ ) : null} + +
+ + + {!needsReconnect ? ( + + ) : null} + + + Manage in your Microsoft account + +
+
+ ); +} diff --git a/apps/app/app/(app)/settings/page.tsx b/apps/app/app/(app)/settings/page.tsx index f2c23916b..d72572e45 100644 --- a/apps/app/app/(app)/settings/page.tsx +++ b/apps/app/app/(app)/settings/page.tsx @@ -11,19 +11,77 @@ import { requireSession } from "@/lib/session"; import { HydrateClient } from "@/lib/trpc/hydrate"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; import { GoogleConnection } from "./google-connection"; +import { MicrosoftConnection } from "./microsoft-connection"; export const metadata: Metadata = { title: "Settings", }; +const microsoftEnabled = Boolean( + process.env.MICROSOFT_CLIENT_ID && + process.env.MICROSOFT_CLIENT_SECRET && + process.env.MICROSOFT_TENANT_ID, +); +const googleEnabled = Boolean( + process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET, +); + export default async function SettingsPage() { await requireSession(); const trpc = getServerTrpc(); const queryClient = getServerQueryClient(); - // Awaited: the whole page is this one query, and rendering "Not connected" - // for a beat before flipping to "Connected" is worse than waiting for it. + // Microsoft replaces Google as the real provider. Prefer that card when + // Entra is configured; keep the Google card only when Google OAuth is on + // and Microsoft is not (legacy / optional). + if (microsoftEnabled) { + // Awaited: the whole page is this one query, and rendering "Not connected" + // for a beat before flipping to "Connected" is worse than waiting for it. + await queryClient.prefetchQuery(trpc.microsoft.status.queryOptions()); + + return ( + + + + Settings + + Your meetings and email, on the companies they belong to. + + + + + + + + + + + ); + } + + if (!googleEnabled) { + return ( + + + + Settings + + Your meetings and email, on the companies they belong to. + + + + + +

+ No mailbox provider is configured. Set the Microsoft Entra variables + in `.env` to connect Outlook, or Google OAuth for Gmail. +

+
+
+ ); + } + await queryClient.prefetchQuery(trpc.google.status.queryOptions()); return ( diff --git a/apps/app/app/(app)/tasks/create-task-sheet.tsx b/apps/app/app/(app)/tasks/create-task-sheet.tsx new file mode 100644 index 000000000..1ba503a64 --- /dev/null +++ b/apps/app/app/(app)/tasks/create-task-sheet.tsx @@ -0,0 +1,239 @@ +"use client"; + +import Add from "@carbon/icons-react/es/Add"; +import { Button } from "@crm/ui/components/button"; +import { DatePicker } from "@crm/ui/components/date-picker"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@crm/ui/components/field"; +import { Icon } from "@crm/ui/components/icon"; +import { Input } from "@crm/ui/components/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@crm/ui/components/select"; +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@crm/ui/components/sheet"; +import { Spinner } from "@crm/ui/components/spinner"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { parseAsBoolean, useQueryState } from "nuqs"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +import { CompanyPicker } from "@/components/crm/company-picker"; +import { + PRIORITY_NONE, + PRIORITY_OPTIONS, + type PriorityValue, +} from "@/components/crm/priority"; +import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; + +const UNSET = "none"; + +/** + * Global "New task" entry. A task still has to attach to a company (and + * optionally a deal on that company) — the composer on a record sheet already + * knows its anchor; this sheet asks for one. + */ +export function CreateTaskSheet() { + const openRecord = useOpenRecord(); + const trpc = useTRPC(); + const cache = useCrmCache(); + + const [open, setOpen] = useQueryState( + "new", + parseAsBoolean.withDefault(false), + ); + const [subject, setSubject] = useState(""); + const [companyId, setCompanyId] = useState(null); + const [dealId, setDealId] = useState(UNSET); + const [dueAt, setDueAt] = useState(""); + const [priority, setPriority] = useState(PRIORITY_NONE); + + const subjectId = useId(); + const dueAtId = useId(); + + const deals = useQuery({ + ...trpc.deals.list.queryOptions({ + q: "", + sort: "name", + dir: "asc", + page: 1, + pageSize: 50, + status: "open", + owner: "all", + stage: "all", + closing: "all", + company: companyId ?? "all", + priority: "all", + }), + enabled: Boolean(companyId), + }); + + const create = useMutation( + trpc.activities.create.mutationOptions({ + onSuccess: async (task) => { + await cache.activity(); + toast.success("Task added."); + await setOpen(null); + setSubject(""); + setCompanyId(null); + setDealId(UNSET); + setDueAt(""); + setPriority(PRIORITY_NONE); + if (task.deal) { + openRecord({ kind: "deal", id: task.deal.id }); + } else if (task.company) { + openRecord({ kind: "company", id: task.company.id }); + } + }, + onError: (error) => toast.error(error.message), + }), + ); + + const ready = subject.trim() !== "" && companyId !== null; + + return ( + setOpen(next || null)}> + + + + + + New task + + Every task is about a company. Attach a deal when the work sits on + one. + + + +
{ + event.preventDefault(); + if (!companyId) return; + create.mutate({ + type: "TASK", + subject, + companyId, + dealId: dealId === UNSET ? undefined : dealId, + dueAt: dueAt || null, + priority: + priority === PRIORITY_NONE ? null : (priority as PriorityValue), + }); + }} + > + + + Task + setSubject(event.target.value)} + placeholder="Send proposal follow-up" + autoComplete="off" + required + /> + + + + Company + { + setCompanyId(id); + setDealId(UNSET); + }} + placeholder="Choose a company" + /> + + + + Deal + + + Optional. Open deals for the company you picked. + + + + + Due date + + + + + Priority + + + +
+ + + + + + + +
+
+ ); +} diff --git a/apps/app/app/(app)/tasks/page.tsx b/apps/app/app/(app)/tasks/page.tsx new file mode 100644 index 000000000..0397c8308 --- /dev/null +++ b/apps/app/app/(app)/tasks/page.tsx @@ -0,0 +1,59 @@ +import type { Metadata } from "next"; +import type { SearchParams } from "nuqs/server"; +import { + PageShell, + PageShellActions, + PageShellContent, + PageShellDescription, + PageShellHeader, + PageShellHeading, + PageShellTitle, +} from "@/components/page-shell"; +import { requireSession } from "@/lib/session"; +import { HydrateClient } from "@/lib/trpc/hydrate"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; +import { CreateTaskSheet } from "./create-task-sheet"; +import { parseTasksSearchParams, tasksQueryInput } from "./tasks-search-params"; +import { TasksTable } from "./tasks-table"; + +export const metadata: Metadata = { + title: "Tasks", +}; + +export default async function TasksPage({ + searchParams, +}: { + searchParams: Promise; +}) { + await requireSession(); + + const values = await parseTasksSearchParams(searchParams); + const trpc = getServerTrpc(); + const queryClient = getServerQueryClient(); + + await queryClient.prefetchQuery( + trpc.activities.myTasks.queryOptions(tasksQueryInput(values)), + ); + + return ( + + + + Tasks + + What you still have to do, ordered by priority and due date. + + + + + + + + + + + + + + ); +} diff --git a/apps/app/app/(app)/tasks/tasks-search-params.ts b/apps/app/app/(app)/tasks/tasks-search-params.ts new file mode 100644 index 000000000..b112d4166 --- /dev/null +++ b/apps/app/app/(app)/tasks/tasks-search-params.ts @@ -0,0 +1,50 @@ +import { Priority } from "@crm/db/enums"; +import { + createLoader, + parseAsStringLiteral, + type SearchParams, +} from "nuqs/server"; + +/** + * Priority filter values live here (not in `components/crm/priority`) so this + * server module never imports a client boundary — `priority.tsx` is `"use + * client"` because it renders UI. + */ +const PRIORITIES = [ + Priority.LOW, + Priority.MEDIUM, + Priority.HIGH, + Priority.HIGHEST, +] as const; +const PRIORITY_NONE = "none" as const; + +const STATUSES = ["open", "done", "all"] as const; +const WINDOWS = ["all", "overdue", "upcoming"] as const; +const PRIORITY_FILTERS = ["all", PRIORITY_NONE, ...PRIORITIES] as const; + +export const tasksParsers = { + status: parseAsStringLiteral(STATUSES).withDefault("open"), + window: parseAsStringLiteral(WINDOWS).withDefault("all"), + priority: parseAsStringLiteral(PRIORITY_FILTERS).withDefault("all"), +}; + +export const loadTasksSearchParams = createLoader(tasksParsers); + +export type TasksSearchValues = Awaited< + ReturnType +>; + +export function tasksQueryInput(values: TasksSearchValues) { + return { + status: values.status, + window: values.window, + priority: values.priority, + limit: 100, + }; +} + +export async function parseTasksSearchParams( + searchParams: Promise, +) { + return loadTasksSearchParams(searchParams); +} diff --git a/apps/app/app/(app)/tasks/tasks-table.tsx b/apps/app/app/(app)/tasks/tasks-table.tsx new file mode 100644 index 000000000..6f0011718 --- /dev/null +++ b/apps/app/app/(app)/tasks/tasks-table.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { Checkbox } from "@crm/ui/components/checkbox"; +import { EmptyCellValue } from "@crm/ui/components/empty-cell"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@crm/ui/components/select"; +import { + SimpleTable, + type SimpleTableColumn, + SimpleTableRow, +} from "@crm/ui/components/simple-table"; +import { StatusIndicator } from "@crm/ui/components/status-indicator"; +import { TableCell } from "@crm/ui/components/table"; +import { ToggleGroup, ToggleGroupItem } from "@crm/ui/components/toggle-group"; +import { relativeTimeFromIso } from "@crm/ui/lib/format"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQueryStates } from "nuqs"; +import { toast } from "sonner"; +import { + PRIORITY_FACET_OPTIONS, + PRIORITY_NONE, + PRIORITY_OPTIONS, + PriorityBadge, + type PriorityValue, +} from "@/components/crm/priority"; +import { RecordLink } from "@/components/crm/record-sheet/record-link"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@/lib/trpc/types"; +import { tasksParsers } from "./tasks-search-params"; + +type TaskRow = RouterOutputs["activities"]["myTasks"][number]; + +const CELL = "px-3 py-2.5 align-middle"; + +const COLUMNS: SimpleTableColumn[] = [ + { srLabel: "Done", width: "w-8" }, + { header: "Task" }, + { header: "Priority", width: "w-36" }, + { header: "Due", width: "w-28", align: "right" }, +]; + +/** + * The working list of the signed-in user's tasks. + * + * Filters live in the URL (status / due window / priority) so a shareable view + * survives a refresh. Completing and re-prioritising stay on the row. + */ +export function TasksTable() { + const trpc = useTRPC(); + const cache = useCrmCache(); + const [params, setParams] = useQueryStates(tasksParsers); + + const tasks = useQuery({ + ...trpc.activities.myTasks.queryOptions({ + status: params.status, + window: params.window, + priority: params.priority, + limit: 100, + }), + placeholderData: (previous) => previous, + }); + + const complete = useMutation( + trpc.activities.complete.mutationOptions({ + onSuccess: () => cache.activity(), + onError: (error) => toast.error(error.message), + }), + ); + + const setPriority = useMutation( + trpc.activities.setPriority.mutationOptions({ + onSuccess: () => cache.activity(), + onError: (error) => toast.error(error.message), + }), + ); + + const rows = tasks.data ?? []; + const now = Date.now(); + + return ( +
+
+ { + if (next) void setParams({ status: next as typeof params.status }); + }} + aria-label="Task status" + > + Open + Done + All + + + { + if (next) void setParams({ window: next as typeof params.window }); + }} + aria-label="Due window" + > + Any due + Overdue + Upcoming + + + +
+ + {rows.length === 0 ? ( +

+ {params.status === "open" + ? "Nothing open. You are caught up." + : "No tasks match this view."} +

+ ) : ( + + {rows.map((task) => ( + + complete.mutate({ + id: task.id, + completed: task.completedAt === null, + }) + } + onPriority={(next) => + setPriority.mutate({ + id: task.id, + priority: + next === PRIORITY_NONE ? null : (next as PriorityValue), + }) + } + /> + ))} + + )} +
+ ); +} + +function TaskRowView({ + task, + now, + busy, + onComplete, + onPriority, +}: { + task: TaskRow; + now: number; + busy: boolean; + onComplete: () => void; + onPriority: (next: string) => void; +}) { + const done = task.completedAt !== null; + const overdue = Boolean( + !done && task.dueAt && new Date(task.dueAt).getTime() < now, + ); + + return ( + + + + + + + + {task.subject} + + + {task.deal ? ( + + {task.deal.name} + + ) : task.company ? ( + + {task.company.name} + + ) : task.contact ? ( + + {[task.contact.firstName, task.contact.lastName] + .filter(Boolean) + .join(" ")} + + ) : null} + + + + + + + + {task.dueAt ? ( + + ) : ( + + )} + + + ); +} diff --git a/apps/app/app/(app)/trip-planner/page.tsx b/apps/app/app/(app)/trip-planner/page.tsx new file mode 100644 index 000000000..73058f1bf --- /dev/null +++ b/apps/app/app/(app)/trip-planner/page.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next"; +import { + PageShell, + PageShellContent, + PageShellDescription, + PageShellHeader, + PageShellHeading, + PageShellTitle, +} from "@/components/page-shell"; +import { requireSession } from "@/lib/session"; +import { HydrateClient } from "@/lib/trpc/hydrate"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; +import { TripPlannerClient } from "./trip-planner-client"; + +export const metadata: Metadata = { + title: "Trip Planner", +}; + +export default async function TripPlannerPage() { + await requireSession(); + + const trpc = getServerTrpc(); + const queryClient = getServerQueryClient(); + await queryClient.prefetchQuery(trpc.tripPlans.list.queryOptions()); + + return ( + + + + Trip Planner + + Plan multi-day client visits from a hub city — brief the agent, + build the itinerary, download a PDF. + + + + + + + + + + ); +} diff --git a/apps/app/app/(app)/trip-planner/trip-planner-client.tsx b/apps/app/app/(app)/trip-planner/trip-planner-client.tsx new file mode 100644 index 000000000..8f515e96c --- /dev/null +++ b/apps/app/app/(app)/trip-planner/trip-planner-client.tsx @@ -0,0 +1,870 @@ +"use client"; + +import Add from "@carbon/icons-react/es/Add"; +import ArrowLeft from "@carbon/icons-react/es/ArrowLeft"; +import DocumentExport from "@carbon/icons-react/es/DocumentExport"; +import Plane from "@carbon/icons-react/es/Plane"; +import TrashCan from "@carbon/icons-react/es/TrashCan"; +import { Alert, AlertDescription, AlertTitle } from "@crm/ui/components/alert"; +import { Button } from "@crm/ui/components/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardPanel, + CardPanelEmpty, + CardTitle, +} from "@crm/ui/components/card"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@crm/ui/components/empty"; +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSeparator, + FieldSet, +} from "@crm/ui/components/field"; +import { Icon } from "@crm/ui/components/icon"; +import { Input } from "@crm/ui/components/input"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, +} from "@crm/ui/components/input-group"; +import { Spinner } from "@crm/ui/components/spinner"; +import { StatusIndicator } from "@crm/ui/components/status-indicator"; +import { Textarea } from "@crm/ui/components/textarea"; +import { ToggleGroup, ToggleGroupItem } from "@crm/ui/components/toggle-group"; +import { formatCount, relativeTimeFromIso } from "@crm/ui/lib/format"; +import { cn } from "@crm/ui/lib/utils"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { parseAsString, useQueryStates } from "nuqs"; +import { useId, useState } from "react"; +import { TripAgentPanel } from "@/components/crm/agent-panel"; +import { CompanyMultiPicker } from "@/components/crm/company-multi-picker"; +import { downloadTripPdf } from "@/lib/trip-pdf"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@/lib/trpc/types"; + +type TripListRow = RouterOutputs["tripPlans"]["list"][number]; +type TripPlan = RouterOutputs["tripPlans"]["get"]; + +type FormState = { + hubCity: string; + hubStateCode: string; + dayCount: number; + radiusMiles: number; + activityMode: "ACTIVE" | "SALVAGE"; + activityYears: number; + mustVisitCompanyIds: string[]; + maxVisitsPerDay: string; + notes: string; +}; + +const EMPTY_FORM: FormState = { + hubCity: "", + hubStateCode: "", + dayCount: 3, + radiusMiles: 200, + activityMode: "ACTIVE", + activityYears: 3, + mustVisitCompanyIds: [], + maxVisitsPerDay: "", + notes: "", +}; + +function formFromPlan(plan: TripPlan): FormState { + return { + hubCity: plan.hubCity, + hubStateCode: plan.hubStateCode, + dayCount: plan.dayCount, + radiusMiles: plan.radiusMiles, + activityMode: plan.activityMode, + activityYears: plan.activityYears, + mustVisitCompanyIds: plan.mustVisitCompanyIds, + maxVisitsPerDay: + plan.maxVisitsPerDay != null ? String(plan.maxVisitsPerDay) : "", + notes: plan.notes ?? "", + }; +} + +function statusTone(status: TripListRow["status"]): "neutral" | "success" { + return status === "PLANNED" ? "success" : "neutral"; +} + +function statusLabel(status: TripListRow["status"]): string { + return status === "PLANNED" ? "Planned" : "Draft"; +} + +function activityLabel(mode: "ACTIVE" | "SALVAGE", years: number): string { + return mode === "ACTIVE" + ? `Active · ${years}y` + : `Salvage · ${years}y`; +} + +function tripMeta(row: Pick< + TripListRow, + "dayCount" | "radiusMiles" | "activityMode" | "activityYears" | "mustVisitCount" | "hasItinerary" +>): string { + const parts = [ + formatCount(row.dayCount, "day"), + `${row.radiusMiles} mi`, + activityLabel(row.activityMode, row.activityYears), + ]; + if (row.mustVisitCount > 0) { + parts.push(formatCount(row.mustVisitCount, "must-visit")); + } + if (row.hasItinerary) { + parts.push("itinerary"); + } + return parts.join(" · "); +} + +export function TripPlannerClient() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [{ trip }, setParams] = useQueryStates({ + trip: parseAsString, + }); + const [creating, setCreating] = useState(false); + const mode: "list" | "new" | "edit" = creating + ? "new" + : trip + ? "edit" + : "list"; + const [error, setError] = useState(null); + + const list = useQuery(trpc.tripPlans.list.queryOptions()); + const selected = useQuery({ + ...trpc.tripPlans.get.queryOptions({ id: trip ?? "" }), + enabled: Boolean(trip) && mode === "edit", + refetchInterval: trip && mode === "edit" ? 8_000 : false, + }); + + const invalidate = async (id?: string | null) => { + await queryClient.invalidateQueries({ + queryKey: trpc.tripPlans.list.queryKey(), + }); + if (id) { + await queryClient.invalidateQueries({ + queryKey: trpc.tripPlans.get.queryKey({ id }), + }); + } + }; + + const create = useMutation( + trpc.tripPlans.create.mutationOptions({ + onSuccess: async (plan) => { + setError(null); + setCreating(false); + await invalidate(plan.id); + void setParams({ trip: plan.id }); + }, + onError: (err) => setError(err.message), + }), + ); + + const update = useMutation( + trpc.tripPlans.update.mutationOptions({ + onSuccess: async (plan) => { + setError(null); + await invalidate(plan.id); + }, + onError: (err) => setError(err.message), + }), + ); + + const remove = useMutation( + trpc.tripPlans.delete.mutationOptions({ + onSuccess: async () => { + setCreating(false); + await setParams({ trip: null }); + await invalidate(); + }, + onError: (err) => setError(err.message), + }), + ); + + function startNew() { + setCreating(true); + void setParams({ trip: null }); + setError(null); + } + + function openTrip(id: string) { + setCreating(false); + void setParams({ trip: id }); + setError(null); + } + + function backToList() { + setCreating(false); + void setParams({ trip: null }); + setError(null); + } + + function saveForm(form: FormState) { + setError(null); + const payload = { + hubCity: form.hubCity.trim(), + hubStateCode: form.hubStateCode.trim().toUpperCase(), + dayCount: form.dayCount, + radiusMiles: form.radiusMiles, + activityMode: form.activityMode, + activityYears: form.activityYears, + mustVisitCompanyIds: form.mustVisitCompanyIds, + maxVisitsPerDay: form.maxVisitsPerDay + ? Number(form.maxVisitsPerDay) + : null, + notes: form.notes.trim() || null, + }; + if (!payload.hubCity || payload.hubStateCode.length !== 2) { + setError("Enter a hub city and a 2-letter state code."); + return; + } + if (mode === "edit" && trip) { + update.mutate({ id: trip, ...payload }); + } else { + create.mutate(payload); + } + } + + function handleDownload() { + const plan = selected.data; + if (!plan?.itinerary) { + setError("Ask the agent to finalize the itinerary first."); + return; + } + try { + downloadTripPdf({ + hubCity: plan.hubCity, + hubStateCode: plan.hubStateCode, + dayCount: plan.dayCount, + radiusMiles: plan.radiusMiles, + activityMode: plan.activityMode, + activityYears: plan.activityYears, + itinerary: plan.itinerary, + }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + const saving = create.isPending || update.isPending; + const rows = list.data ?? []; + + if (mode === "new") { + return ( +
+ + +
+ ); + } + + if (mode === "edit" && trip) { + if (selected.isLoading && !selected.data) { + return ( +
+ +
+ ); + } + + if (!selected.data) { + return ( +
+ + + Trip not found + + This trip may have been deleted. Pick another from your list. + + +
+ ); + } + + const plan = selected.data; + + return ( +
+
+
+ +
+
+

+ {plan.hubCity}, {plan.hubStateCode} +

+ +
+

+ {tripMeta({ + dayCount: plan.dayCount, + radiusMiles: plan.radiusMiles, + activityMode: plan.activityMode, + activityYears: plan.activityYears, + mustVisitCount: plan.mustVisitCompanyIds.length, + hasItinerary: Boolean(plan.itinerary), + })} +

+
+
+
+ + +
+
+ + {error ? ( + + Could not save + {error} + + ) : null} + +
+ + +
+ {plan.itinerary ? ( + + ) : ( + + + Itinerary + + Ask the agent to rank stops and write a day-by-day plan. + + + + + No itinerary yet. Open the chat below to start planning. + + + + )} + + + + Plan with the agent + + Search nearby accounts, set the day order, then finalize for + PDF. + + + + + + +
+
+
+ ); + } + + return ( +
+
+

+ {formatCount(rows.length, "trip")} +

+ +
+ + {list.isLoading ? ( +
+ +
+ ) : rows.length === 0 ? ( + + + + + + No trips yet + + Set a hub city, pick must-visit clients, and let the agent build + a day-by-day route you can download as a PDF. + + + + + + + ) : ( + + + Your trips + + Open a draft to keep planning, or a planned trip to export the + PDF. + + + +
    + {rows.map((row) => ( +
  • + +
  • + ))} +
+
+
+ )} +
+ ); +} + +function ItineraryCard({ + itinerary, +}: { + itinerary: NonNullable; +}) { + const stopTotal = itinerary.days.reduce( + (sum, day) => sum + day.stops.length, + 0, + ); + + return ( + + + Itinerary + + {formatCount(itinerary.days.length, "day")} ·{" "} + {formatCount(stopTotal, "stop")} + + + + {itinerary.summary ? ( +

{itinerary.summary}

+ ) : null} +
+ {itinerary.days.map((day) => ( +
+
+
+ Day {day.day} + {day.label ? ` — ${day.label}` : ""} +
+
+ {formatCount(day.stops.length, "stop")} +
+
+
    + {day.stops.map((stop, index) => ( +
  1. + + {index + 1}. + + + {stop.companyName} + + {[ + [stop.city, stop.stateCode].filter(Boolean).join(", "), + stop.milesFromHub != null + ? `${Math.round(stop.milesFromHub)} mi` + : null, + stop.sage100CustomerNo + ? `#${stop.sage100CustomerNo}` + : null, + ] + .filter(Boolean) + .join(" · ") || "—"} + + {stop.notes ? ( + + {stop.notes} + + ) : null} + +
  2. + ))} +
+
+ ))} +
+
+
+ ); +} + +function TripBriefForm({ + initial, + hubLatitude, + hubLongitude, + error, + saving, + isNew, + compact = false, + onSave, + onCancel, +}: { + initial: FormState; + hubLatitude?: number; + hubLongitude?: number; + error: string | null; + saving: boolean; + isNew: boolean; + compact?: boolean; + onSave: (form: FormState) => void; + onCancel?: () => void; +}) { + const [form, setForm] = useState(initial); + const hubCityId = useId(); + const hubStateId = useId(); + const dayCountId = useId(); + const radiusId = useId(); + const yearsId = useId(); + const maxVisitsId = useId(); + const notesId = useId(); + + return ( + + + {isNew ? "New trip" : "Trip brief"} + + {isNew + ? "Hub city, days, and who you must see — then the agent fills the route." + : "Update the brief anytime. The agent reads these fields on each turn."} + + +
{ + event.preventDefault(); + onSave(form); + }} + > + + +
+ Hub +
+ + City + + setForm((f) => ({ ...f, hubCity: e.target.value })) + } + placeholder="Chicago" + autoComplete="address-level2" + required + /> + + + State + + setForm((f) => ({ + ...f, + hubStateCode: e.target.value.toUpperCase().slice(0, 2), + })) + } + placeholder="IL" + maxLength={2} + autoComplete="address-level1" + required + /> + +
+ + We geocode the hub when you save so nearby accounts can be + ranked by distance. + +
+ + + +
+ Schedule +
+ + Days + + setForm((f) => ({ + ...f, + dayCount: Number(e.target.value) || 1, + })) + } + /> + + + Radius + + + setForm((f) => ({ + ...f, + radiusMiles: Number(e.target.value) || 200, + })) + } + /> + + mi + + + +
+ + Max visits / day + + setForm((f) => ({ + ...f, + maxVisitsPerDay: e.target.value, + })) + } + placeholder="Optional — e.g. 4" + /> + +
+ + + +
+ Accounts + + Activity mode + { + if (value === "ACTIVE" || value === "SALVAGE") { + setForm((f) => ({ ...f, activityMode: value })); + } + }} + aria-label="Activity mode" + className="w-full" + > + + Active + + + Salvage + + + + {form.activityMode === "ACTIVE" + ? "Companies with a deal created or closed in the window." + : "Quiet accounts — little or no deal activity in the window."} + + + + Look-back years + + setForm((f) => ({ + ...f, + activityYears: Number(e.target.value) || 3, + })) + } + /> + + + Must-visit clients + + setForm((f) => ({ ...f, mustVisitCompanyIds: ids })) + } + hubLatitude={hubLatitude} + hubLongitude={hubLongitude} + radiusMiles={form.radiusMiles} + /> + + Always included, even outside the radius. Prefer nearby once + the hub is saved. + + +
+ + + + + Notes +