Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
85 commits
Select commit Hold shift + click to select a range
421e556
WIP: Microsoft 365 expansion (Phases 0-2) + auth fallback + CompanyPi…
jj-mobilemark Aug 2, 2026
d0c9ac6
Merge branch 'main' into m365-expansion
jj-mobilemark Aug 2, 2026
5045aa0
docs: record upstream sync to comp design system (288d41a) in HANDOFF
jj-mobilemark Aug 2, 2026
8d025d2
Microsoft 365 expansion: Phases 3-5 (backfill, Screening Room, Follow…
jj-mobilemark Aug 2, 2026
0882f18
docs: record the fork to jj-mobilemark/crm and new main-based workflow
jj-mobilemark Aug 2, 2026
6bc3451
Add Follow-ups priority prefs and document Phase 5 smoke gaps.
jj-mobilemark Aug 2, 2026
32b581c
Sage CRM sync: Phase 7 foundation (schema, SOAP client, mappings)
jj-mobilemark Aug 2, 2026
9a0d06c
Sage CRM sync: test-slice pull, person-email fix, and Sage-ID UI.
jj-mobilemark Aug 3, 2026
a21f1df
Sage CRM sync: import opportunities as Deals with forecasting fields.
jj-mobilemark Aug 3, 2026
4a1e962
Sage CRM sync: full pull backfill, owner mapping, and forecast fixes
jj-mobilemark Aug 3, 2026
5ff4384
Add email sequences, task priority, and Sage push write-back.
jj-mobilemark Aug 3, 2026
99fbb61
Add Dockerfiles for Railway Bun deploys.
jj-mobilemark Aug 3, 2026
b670a7d
Provide build-time DATABASE_URL for Prisma generate in Docker.
jj-mobilemark Aug 3, 2026
e373e97
Fix Railway API start with an explicit entrypoint script.
jj-mobilemark Aug 3, 2026
655cf55
Start Railway API from the Bun dist bundle.
jj-mobilemark Aug 3, 2026
c7ca583
Add pipeline pulse feed and overview pipeline agent.
jj-mobilemark Aug 3, 2026
7dcfb42
Pass API_URL into the app Docker build for production.
jj-mobilemark Aug 3, 2026
8deb388
Add a /map page for city-level company pins.
jj-mobilemark Aug 3, 2026
5592e59
Polish /map: viewport list, fly-to selection, in-place company sheet.
jj-mobilemark Aug 3, 2026
8c30558
Note map UX polish is on origin/main after push.
jj-mobilemark Aug 3, 2026
fb4f3eb
Show unweighted due-this-month on the overview KPI strip.
jj-mobilemark Aug 3, 2026
57bacab
Brand the app as Mobile Mark CRM with official mark and wordmark.
jj-mobilemark Aug 3, 2026
9f0b65c
Fix prod tRPC proxy hairpin through Cloudflare.
jj-mobilemark Aug 3, 2026
610caf8
Note the Cloudflare tRPC proxy fix in HANDOFF.
jj-mobilemark Aug 3, 2026
fcb9eed
Fix map filters flashing empty while refetching.
jj-mobilemark Aug 3, 2026
9396eff
Filter the companies map by Sage 100, not CRM id.
jj-mobilemark Aug 3, 2026
142a4d4
Lock the welcome page to existing-account sign-in only.
jj-mobilemark Aug 3, 2026
49eddfa
Deploy the research agent on Railway with a Node 24 start path.
jj-mobilemark Aug 3, 2026
184e47b
Keep local Sage stage in sync after deal stage changes.
jj-mobilemark Aug 4, 2026
ebad51f
Give the pipeline agent month and stage report tools.
jj-mobilemark Aug 4, 2026
ad36a44
Scope Screening to the logged-in user's mailbox.
jj-mobilemark Aug 4, 2026
3ae45a5
Add primary contact and email copy to company and contact lists.
jj-mobilemark Aug 4, 2026
6fa4f14
Ask before creating a company that looks like one we already have.
jj-mobilemark Aug 4, 2026
d689559
Put pulse KPIs in the overview strip and tie counts to the date range.
jj-mobilemark Aug 4, 2026
10e93c5
Record Sage module probe results and give the pipeline agent more room.
jj-mobilemark Aug 4, 2026
8edf480
Count overview Won and Lost from closed deals, not the change log.
jj-mobilemark Aug 4, 2026
00998d2
Add Hide empty filter to the companies list.
jj-mobilemark Aug 4, 2026
7b99557
Stop Screening from inventing domain-orphan companies.
jj-mobilemark Aug 4, 2026
bee13d4
Stop treating Sage credit notes as company websites.
jj-mobilemark Aug 4, 2026
35e060f
Speed up the Sage website repair script for remote Postgres.
jj-mobilemark Aug 4, 2026
9be7b24
Note prod Sage website repair in the handoff log.
jj-mobilemark Aug 4, 2026
5c3f34b
Add a one-shot script to invite CRM users when signup is disabled.
jj-mobilemark Aug 4, 2026
ea0b5c2
Note Robert Johnson prod invite in the handoff log.
jj-mobilemark Aug 4, 2026
f794eae
Ship sales-rep sheet, deal stages, street addresses, and nav badges.
jj-mobilemark Aug 4, 2026
e94ebe7
Note prod street address backfill and Railway deploy in the handoff.
jj-mobilemark Aug 4, 2026
0c115ef
Compact map sidebar filters into a 2x2 select grid.
jj-mobilemark Aug 4, 2026
28b6ba9
Fill company state/country from Sage snapshots and drop junk emails.
jj-mobilemark Aug 4, 2026
2ee0da1
Note prod state/country backfill and junk email clear in the handoff.
jj-mobilemark Aug 4, 2026
bf0d69b
Show Sage 100 ID and contact count in company pickers.
jj-mobilemark Aug 4, 2026
72550a1
Show only the Sage customer number in company picker meta.
jj-mobilemark Aug 4, 2026
929d9bb
Limit forecast-by-close-month to last, this, and next 12 months.
jj-mobilemark Aug 4, 2026
1cc5953
Add deal amount to the Recent deal moves pulse feed.
jj-mobilemark Aug 4, 2026
a424bfa
Add Everyone certainty-by-rep grid and lock certainty to stage.
jj-mobilemark Aug 4, 2026
b54ec76
Allow @mobilemark.com Microsoft SSO to create accounts on first sign-in.
jj-mobilemark Aug 4, 2026
c8f07c1
Default Deals to the signed-in owner, allow multi-stage filters, and …
jj-mobilemark Aug 4, 2026
9fbf2ec
Rename Deal Maturity UI, switch the rep grid to historical windows, a…
jj-mobilemark Aug 4, 2026
00cb9ea
Add Trip Planner end-to-end and polish the /trip-planner UI.
jj-mobilemark Aug 4, 2026
2d51cf6
Prioritize nearby open-deal accounts by size in Trip Planner.
jj-mobilemark Aug 4, 2026
cbfc184
Resolve must-visit company names when reloading a trip.
jj-mobilemark Aug 4, 2026
0f1a8e4
Ship webform Screening intake, daily task push, and territory routing.
jj-mobilemark Aug 5, 2026
3bad68c
Document webform Screening prod wiring and Railway cron-webform.
jj-mobilemark Aug 5, 2026
5bb4ce0
Restart Sage incremental walk after a lost SOAP session
jj-mobilemark Aug 7, 2026
0bf5c30
Add order-defaults endpoint for the PO-processing sister app
jj-mobilemark Aug 7, 2026
a9cd8b5
Return terms_code on order-defaults from Sage snapshots
jj-mobilemark Aug 7, 2026
318fac0
Add POST /company/resolve for incomplete PO extracts
jj-mobilemark Aug 7, 2026
1f90c01
Disable sync-driven agent research unless AGENT_AUTO_ENRICH is on.
jj-mobilemark Aug 7, 2026
abf01cf
Retry Sage SOAP logon and restart walks on timeouts.
jj-mobilemark Aug 18, 2026
a336cca
fix KPIs
jj-mobilemark Aug 18, 2026
327d213
Count Closed won through month-end and stop Sage echo-guard from free…
jj-mobilemark Aug 19, 2026
716c487
Hide other reps' synced mail from the Everyone overview feed.
jj-mobilemark Aug 21, 2026
b2761aa
Map Sage deal currency id 1 to USD so Power BI no longer slices on "1".
jj-mobilemark Sep 9, 2026
81ae743
Note Sage currency remap is live on prod (USD only).
jj-mobilemark Sep 9, 2026
5aba1ec
Restart Sage incremental walks on pagination next faults.
jj-mobilemark Sep 11, 2026
752439a
Stop copying Sage targetclose into closedAt so forecasts stay off act…
jj-mobilemark Sep 12, 2026
bd6e7b5
Store canonical quote numbers on deals so Analytics can exact-join.
jj-mobilemark Sep 12, 2026
d431bf1
Show overdue open deals on a locked KPI board so the date range only …
jj-mobilemark Sep 12, 2026
d896ec5
Keep one primary per colliding quote number so Analytics can join.
jj-mobilemark Sep 12, 2026
a96ba7c
Note api, app, and agent are live on d896ec5.
jj-mobilemark Sep 12, 2026
ad88b53
Lock deal stage changes to the assigned owner so admins cannot move s…
jj-mobilemark Sep 12, 2026
4df95bb
Keep Everyone recent activity off the mailbox so the team list stays …
jj-mobilemark Sep 12, 2026
5607262
Note Everyone should hard-refresh after the CRM-only activity push.
jj-mobilemark Sep 12, 2026
5b37dce
Read and write Sage times as Central so the nightly pull stops skippi…
jj-mobilemark Oct 1, 2026
dff0909
Give the Sage backfill script the CRM module so its pull service can …
jj-mobilemark Oct 1, 2026
08b0e19
Log the Sage time zone fix, catch-up, and recent prod checks in the h…
jj-mobilemark Oct 1, 2026
be4e6c9
Route Central US and MCE to Bryant Cloud on the 2026-11-10 territory map
jj-mobilemark Nov 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
102 changes: 102 additions & 0 deletions .cursor/plans/in-app_email_feasibility_8886f2b9.plan.md
Original file line number Diff line number Diff line change
@@ -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.
85 changes: 85 additions & 0 deletions .cursor/plans/microsoft_365_expansion_2913c2cf.plan.md
Original file line number Diff line number Diff line change
@@ -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:<addr>"`,
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`.
120 changes: 120 additions & 0 deletions .cursor/plans/sage_crm_soap_sync_66743908.plan.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading