From 421e55636a67f042ac0d4739add4ff771534a146 Mon Sep 17 00:00:00 2001 From: Jordan Johnson Date: Sun, 2 Aug 2026 09:33:08 -0500 Subject: [PATCH 01/84] WIP: Microsoft 365 expansion (Phases 0-2) + auth fallback + CompanyPicker Snapshot of local work before merging new upstream (trycompai/crm @288d41a): - Phase 0: additive Outlook id columns + migration. - Phase 1: Microsoft SSO via Better Auth; email/password fallback; optional MICROSOFT_* env; sign-in and grant-access UI. - Phase 2: apps/api/src/microsoft/ mail + calendar sync, match, connection, router; timeline wiring; interim Microsoft settings panel. - CompanyPicker replaces the plain company dropdown in create sheets. - Docs/plans and .cursor project scaffolding. Co-authored-by: Cursor --- .../microsoft_365_expansion_2913c2cf.plan.md | 85 +++ .../plans/sage_crm_soap_sync_66743908.plan.md | 120 ++++ .cursor/rules/local-dev.mdc | 66 +++ .cursor/rules/project-overview.mdc | 44 ++ .cursor/rules/skills-and-docs.mdc | 27 + .cursor/skills/ai-elements | 1 + .cursor/skills/better-auth-best-practices | 1 + .cursor/skills/eve | 1 + .cursor/skills/nestjs-best-practices | 1 + .cursor/skills/nestjs-trpc | 1 + .cursor/skills/no-use-effect | 1 + .cursor/skills/nuqs | 1 + .cursor/skills/prisma-database-setup | 1 + .cursor/skills/shadcn | 1 + .cursor/skills/skill-creator | 1 + .cursor/skills/turborepo | 1 + .cursor/skills/typescript-advanced-types | 1 + .cursor/skills/vercel-composition-patterns | 1 + .cursor/skills/vercel-react-best-practices | 1 + .cursor/skills/web-design-guidelines | 1 + .env.example | 8 + AGENTS.md | 41 ++ HANDOFF.md | 369 ++++++++++++ apps/api/scripts/build-func.mjs | 5 +- apps/api/src/app.module.ts | 2 + apps/api/src/config/env.validation.ts | 35 +- apps/api/src/generated/server.ts | 24 + apps/api/src/google/conversation.service.ts | 5 + .../src/google/google-connection.service.ts | 5 +- apps/api/src/google/google-sync.service.ts | 4 +- apps/api/src/google/google.module.ts | 2 +- apps/api/src/google/sync-state.service.ts | 24 +- .../api/src/microsoft/conversation.service.ts | 137 +++++ apps/api/src/microsoft/graph-api.client.ts | 170 ++++++ .../microsoft/microsoft-connection.service.ts | 264 +++++++++ .../src/microsoft/microsoft-match.service.ts | 334 +++++++++++ .../microsoft/microsoft-sync.controller.ts | 87 +++ .../src/microsoft/microsoft-sync.service.ts | 133 +++++ .../src/microsoft/microsoft-token.service.ts | 166 ++++++ apps/api/src/microsoft/microsoft.constants.ts | 28 + apps/api/src/microsoft/microsoft.contracts.ts | 33 ++ apps/api/src/microsoft/microsoft.module.ts | 47 ++ apps/api/src/microsoft/microsoft.router.ts | 101 ++++ .../outlook-calendar-sync.service.ts | 490 ++++++++++++++++ .../src/microsoft/outlook-calendar.client.ts | 155 +++++ .../microsoft/outlook-mail-sync.service.ts | 469 +++++++++++++++ apps/api/src/microsoft/outlook-mail.client.ts | 96 ++++ apps/api/src/microsoft/outlook-message.ts | 123 ++++ apps/api/test/google-scopes.spec.ts | 35 ++ .../(app)/contacts/create-contact-sheet.tsx | 23 +- .../app/app/(app)/deals/create-deal-sheet.tsx | 20 +- apps/app/app/(app)/layout.tsx | 10 +- .../(app)/settings/microsoft-connection.tsx | 212 +++++++ apps/app/app/(app)/settings/page.tsx | 62 +- .../app/(auth)/grant-access/grant-access.tsx | 29 +- apps/app/app/(auth)/grant-access/page.tsx | 31 +- .../app/(auth)/sign-in/credentials-form.tsx | 111 ++++ .../app/(auth)/sign-in/microsoft-sign-in.tsx | 50 ++ apps/app/app/(auth)/sign-in/page.tsx | 28 +- apps/app/components/crm/company-picker.tsx | 209 +++++++ .../crm/timeline/email-thread-entry.tsx | 71 ++- .../components/crm/timeline/meeting-entry.tsx | 13 +- .../crm/timeline/timeline-entry.tsx | 8 + apps/app/lib/trpc/cache.ts | 15 + docs/local-setup.md | 116 ++++ docs/plans/m365-expansion.md | 533 ++++++++++++++++++ packages/auth/src/auth.ts | 38 +- packages/auth/src/client.ts | 2 +- packages/auth/src/env.ts | 20 + packages/auth/src/index.ts | 4 + packages/auth/src/scopes.ts | 33 ++ .../migration.sql | 5 + packages/db/prisma/schema.prisma | 6 +- .../src/components/brand-logos/microsoft.tsx | 17 + 74 files changed, 5303 insertions(+), 112 deletions(-) create mode 100644 .cursor/plans/microsoft_365_expansion_2913c2cf.plan.md create mode 100644 .cursor/plans/sage_crm_soap_sync_66743908.plan.md create mode 100644 .cursor/rules/local-dev.mdc create mode 100644 .cursor/rules/project-overview.mdc create mode 100644 .cursor/rules/skills-and-docs.mdc create mode 120000 .cursor/skills/ai-elements create mode 120000 .cursor/skills/better-auth-best-practices create mode 120000 .cursor/skills/eve create mode 120000 .cursor/skills/nestjs-best-practices create mode 120000 .cursor/skills/nestjs-trpc create mode 120000 .cursor/skills/no-use-effect create mode 120000 .cursor/skills/nuqs create mode 120000 .cursor/skills/prisma-database-setup create mode 120000 .cursor/skills/shadcn create mode 120000 .cursor/skills/skill-creator create mode 120000 .cursor/skills/turborepo create mode 120000 .cursor/skills/typescript-advanced-types create mode 120000 .cursor/skills/vercel-composition-patterns create mode 120000 .cursor/skills/vercel-react-best-practices create mode 120000 .cursor/skills/web-design-guidelines create mode 100644 HANDOFF.md create mode 100644 apps/api/src/microsoft/conversation.service.ts create mode 100644 apps/api/src/microsoft/graph-api.client.ts create mode 100644 apps/api/src/microsoft/microsoft-connection.service.ts create mode 100644 apps/api/src/microsoft/microsoft-match.service.ts create mode 100644 apps/api/src/microsoft/microsoft-sync.controller.ts create mode 100644 apps/api/src/microsoft/microsoft-sync.service.ts create mode 100644 apps/api/src/microsoft/microsoft-token.service.ts create mode 100644 apps/api/src/microsoft/microsoft.constants.ts create mode 100644 apps/api/src/microsoft/microsoft.contracts.ts create mode 100644 apps/api/src/microsoft/microsoft.module.ts create mode 100644 apps/api/src/microsoft/microsoft.router.ts create mode 100644 apps/api/src/microsoft/outlook-calendar-sync.service.ts create mode 100644 apps/api/src/microsoft/outlook-calendar.client.ts create mode 100644 apps/api/src/microsoft/outlook-mail-sync.service.ts create mode 100644 apps/api/src/microsoft/outlook-mail.client.ts create mode 100644 apps/api/src/microsoft/outlook-message.ts create mode 100644 apps/app/app/(app)/settings/microsoft-connection.tsx create mode 100644 apps/app/app/(auth)/sign-in/credentials-form.tsx create mode 100644 apps/app/app/(auth)/sign-in/microsoft-sign-in.tsx create mode 100644 apps/app/components/crm/company-picker.tsx create mode 100644 docs/local-setup.md create mode 100644 docs/plans/m365-expansion.md create mode 100644 packages/db/prisma/migrations/20260802135359_add_outlook_ids/migration.sql create mode 100644 packages/ui/src/components/brand-logos/microsoft.tsx 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/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..cf1f98b9f --- /dev/null +++ b/.cursor/rules/project-overview.mdc @@ -0,0 +1,44 @@ +--- +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. + +## 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 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/.env.example b/.env.example index 0fa8c1773..59d86634f 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,14 @@ ALLOWED_SIGN_IN="" 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`) +MICROSOFT_CLIENT_ID="" +MICROSOFT_CLIENT_SECRET="" +MICROSOFT_TENANT_ID="" + # ── Where things are ───────────────────────────────────────────────────────── # Only needed when you deploy. The defaults below are the localhost ones. diff --git a/AGENTS.md b/AGENTS.md index bd7369aaf..c7680dc6e 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 @@ -37,3 +54,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/HANDOFF.md b/HANDOFF.md new file mode 100644 index 000000000..378353482 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,369 @@ +# 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) + +- **Runs locally**: Bun 1.3.12 monorepo; Postgres via `docker compose up -d`; + `bun run dev` serves app on :3000 and API on :3001. Setup details in + `docs/local-setup.md`. +- **Auth**: email + password (fallback), allow-list + `ALLOWED_SIGN_IN=mobilemark.com`. **Microsoft SSO works end-to-end** for + `jjohnson@mobilemark.com`: `account` row `providerId=microsoft`, + `refreshToken` present, scope includes `Mail.Read` + `Calendars.Read` + (short names). Google OAuth still optional / unconfigured. + `MICROSOFT_*` set in root `.env` (secret never recorded here). +- **Settings**: full Microsoft 365 card via `microsoft.status` (Check now, + auto-create, purge, revoke). Google card only when Google is configured and + Microsoft is not. +- **Active plan**: `docs/plans/m365-expansion.md` (Phases 0–7). +- **Phase 0**: DONE. **Phase 1**: DONE. **Phase 2**: code DONE — needs human + smoke test (cron + real mail/meeting). +- **Next step**: Human smoke-test Phase 2 Definition of Done, then **Phase 3** + (backfill on contact add). See newest work-log entry. + +## Work log (newest first) + +### 2026-08-02 — Phase 2 Microsoft mail + calendar sync (agent: Grok via Cursor) + +**Plan / phase**: `docs/plans/m365-expansion.md` — Phase 2 (implementation). + +**What was completed** + +- Full Outlook sync module at `apps/api/src/microsoft/` (16 files), mirroring + `apps/api/src/google/`: + - Constants/contracts/token/Graph client/mail+calendar clients/message + parser/match/mail-sync/calendar-sync/orchestrator/connection/conversation/ + router/controller/module. + - Sources: `"outlook-calendar"` then `"outlook"` (Meetings first in UI). + - Cron: `GET|POST /internal/sync/microsoft` + entry in + `apps/api/scripts/build-func.mjs`. + - Registered `MicrosoftModule` in `app.module.ts`. + - Regenerated and committed path: `apps/api/src/generated/server.ts` + (10 routers / 47 procedures). +- Shared cursor store: `SyncStateService.due(now, sources?)` filters by + provider so Google and Microsoft ticks do not steal each other's rows; + Google revoke only clears Google sources; `SyncStateService` exported from + `GoogleModule` for Microsoft to import. +- Front end: + - Replaced interim Settings panel with full + `microsoft-connection.tsx` (mirrors Google card). + - `settings/page.tsx` prefetches `microsoft.status`. + - Timeline expand branches on `meta.source` (`outlook` / + `outlook-calendar` → `microsoft.thread` / `microsoft.event`); deep links + prefer Outlook URL when `outlookMessageId` is set. + - `useCrmCache().microsoft()` added. +- Docs: `docs/local-setup.md` Settings note; plan Phase 2 front-end note. + +**How and why** + +- Graph mail delta has no cheap "now" token, so the first mail pass drains + the inbox importing nothing, tagging the in-progress cursor with a + `baseline:` prefix until `@odata.deltaLink` arrives (forward-only, like + Gmail). Calendar uses `calendarView/delta` with a 180-day horizon. +- Microsoft token revoke clears stored DB tokens only (no simple public + Graph revoke endpoint) — documented in `microsoft-token.service.ts`. +- nestjs-trpc collapses identically named Zod exports across routers into + one import; Microsoft contracts use `ms*` names + (`msSetAutoCreateInput`, etc.) so Google and Microsoft input types stay + distinct in `generated/server.ts`. + +**Deviations** + +- Microsoft Zod contract export names are `ms*` (not identical to Google) + — required for correct tRPC codegen. +- Mail baseline walks the full inbox once with zero imports (Graph + limitation vs Gmail `historyId`); may take several cron ticks on a large + mailbox before steady-state delta begins. +- `CRON_SECRET` was not set in this machine's `.env` at handoff time — the + sync route fails closed without it. Human must set it before smoke test. + +**What's next (for the next agent / human)** + +1. **Human — Phase 2 smoke test** (plan Definition of Done): + - Ensure `CRON_SECRET` is set in root `.env`; restart `bun run dev`. + - `curl -X POST -H "Authorization: Bearer $CRON_SECRET" \ + http://localhost:3001/internal/sync/microsoft` — expect a run summary. + - First run(s): mail imports zero messages, records a cursor (baseline + may span ticks; watch `MailboxSync` for sources `outlook` / + `outlook-calendar`). + - Send NEW mail to/from a CRM contact → sync again → thread on timeline + with body on expand; stranger mail creates no rows. + - Calendar event with known contact → MEETING activity. + - Settings → Check now / toggles work. +2. When DoD is green, mark Phase 2 done in this file and start **Phase 3** + (`EmailBackfill` model + enqueue on contact create/update + worker at end + of Microsoft sync tick). Skills: `prisma-database-setup`, + `nestjs-best-practices`. + +### 2026-08-02 — Interim Microsoft Settings + Phase 1 closed (agent: Grok via Cursor) + +**Plan / phase**: Phase 1 DoD closed; Settings interim ahead of Phase 2. + +**What was completed** + +- Confirmed Phase 1 smoke test in DB: microsoft account linked, refresh + token present, scopes `Calendars.Read,email,Mail.Read,openid,profile,User.Read`. +- Settings no longer shows a broken Google "Needs attention" card when only + Microsoft is configured: + - New `apps/app/app/(app)/settings/microsoft-connection.tsx` — Connected / + Needs attention / Not connected from `account.scope` + refresh token; + Meetings/Email rows marked Coming soon; link to Microsoft account privacy. + - `apps/app/app/(app)/settings/page.tsx` — prefers Microsoft panel when + `MICROSOFT_*` set; Google card only if Google is configured and Microsoft + is not; empty-state copy if neither. +- `docs/plans/m365-expansion.md` Phase 2 Front end — note that interim Settings + exists and must be replaced (not duplicated) when `microsoft.status` lands. +- `docs/local-setup.md` — Settings note under Microsoft sign-in. + +**How and why** + +- Full Microsoft settings (sync now, auto-create, purge, revoke) are Phase 2 + work and need `microsoft.router` / MailboxSync. The Google card was actively + wrong for this install (no Google grant → fake "refresh token" warning). + An interim read-only Microsoft panel unblocks reps without starting Phase 2 + sync. + +**Deviations** + +- Shipped a partial Settings UI before Phase 2 — intentional, documented in + the plan so the next agent replaces it rather than adding a second card. + +**What's next (for the next agent)** + +1. Read `AGENTS.md`, this file, then `docs/plans/m365-expansion.md` § Phase 2. +2. Skills: `nestjs-best-practices`, `nestjs-trpc`, `prisma-database-setup` if + schema touches are needed (Phase 2 itself is mostly API mirroring; Phase 3+ + add models). +3. Create `apps/api/src/microsoft/` mirroring `apps/api/src/google/` + file-for-file (plan lists each mapping). Register module + cron + + `microsoft.router`; run `bun run --filter=api trpc:generate` and commit + `apps/api/src/generated/server.ts`. +4. Replace `MicrosoftConnection` interim with a full card driven by + `microsoft.status` (same UX as `google-connection.tsx`: Check now, + toggles, purge, revoke). Do not keep rendering the Google card when + Microsoft is the active provider. +5. Wire timeline expand for Outlook ids (`outlookMessageId` / + `outlookEventId` already on schema from Phase 0b). +6. Stop when Phase 2 Definition of Done checklist is green; update this file. + +### 2026-08-02 — Phase 1 fix: account_not_linked (agent: Grok via Cursor) + +**Plan / phase**: `docs/plans/m365-expansion.md` — Phase 1 smoke-test fix. + +**What was completed** + +- Diagnosed `?error=account_not_linked` after Microsoft OAuth: user + `jjohnson@mobilemark.com` existed as `credential` with + `emailVerified = false`. Better Auth defaults + `accountLinking.requireLocalEmailVerified` to `true`, so it refuses to + link Microsoft onto that row. +- `packages/auth/src/auth.ts`: + - `accountLinking.requireLocalEmailVerified: false` (allow-list is the + real door; no verification email flow). + - `databaseHooks.user.create.before` now returns `emailVerified: true` + for allow-listed signups so new credential users do not hit this again. +- DB one-shot: set `emailVerified = true` for `jjohnson@mobilemark.com`. + +**Deviations** + +- None vs plan intent. Extra config flag beyond Phase 1 file list, required + for email/password + Microsoft coexistence. + +**What's next** + +- Human: restart API/app (`bun run dev`), retry Microsoft sign-in. Confirm + `account` row with `providerId = microsoft`. Then Phase 2. + +### 2026-08-02 — Phase 0a collected + Phase 1 Microsoft SSO (agent: Grok via Cursor) + +**Plan / phase**: `docs/plans/m365-expansion.md` — Phase 0a (human) + Phase 1. + +**What was completed** + +- Phase 0a: human registered Entra app `MM-CRM` (single-tenant) and set + `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT_ID` in + root `.env`. Confirmed present (values not logged). +- Phase 1 auth core: + - `packages/auth/src/scopes.ts` — `MS_MAIL_SCOPE`, `MS_CALENDAR_SCOPE`, + `MS_SYNC_SCOPES`, `hasMsSyncScopes()` (normalises Graph URI prefix). + - `packages/auth/src/env.ts` — `microsoftCredentials()` (all three or none). + - `packages/auth/src/auth.ts` — `socialProviders.microsoft` with + `tenantId`, `scope: MS_SYNC_SCOPES`, `prompt: "select_account"`; + `trustedProviders` includes `"microsoft"`. + - `packages/auth/src/index.ts` — re-exports MS symbols. +- Phase 1 API / docs env: + - `apps/api/src/config/env.validation.ts` — optional `MICROSOFT_*`. + - `.env.example` — documents the three vars + redirect URI. + - `docs/local-setup.md` — Microsoft sign-in notes. +- Phase 1 UI: + - `packages/ui/src/components/brand-logos/microsoft.tsx` + - `apps/app/app/(auth)/sign-in/microsoft-sign-in.tsx` + - `apps/app/app/(auth)/sign-in/page.tsx` — Microsoft above Google when + configured. + - `apps/app/app/(auth)/grant-access/*` — prefers Microsoft when configured. +- Tests: `apps/api/test/google-scopes.spec.ts` — `hasMsSyncScopes` cases. +- Verification: `bun run check-types` pass; app lint pass; api tests pass + (including new MS scope tests). + +**How and why** + +- Mirrored the existing optional-Google pattern so missing `MICROSOFT_*` + hides the button and never throws. Better Auth already requests + `openid`/`profile`/`email`/`User.Read`/`offline_access`; we only add + `Mail.Read` + `Calendars.Read` for sync. +- `hasMsSyncScopes` accepts short names and + `https://graph.microsoft.com/...` forms so the first real grant does not + false-fail the gate. + +**Deviations** + +- None material. Grant-access still unused by the app shell (layout uses + `requireSession()`, not a Microsoft gate) — same as the Google fork + behaviour after email/password was enabled. Phase 2 sync will need a + connection check of its own. + +**What's next** + +1. **Human smoke test (Phase 1 DoD)**: restart `bun run dev` if already + running (so auth picks up `MICROSOFT_*`), open + http://localhost:3000/sign-in, click **Continue with Microsoft**, consent + to Mail + Calendar. Confirm: + - Landing in the app succeeds. + - Postgres `account` row: `providerId = 'microsoft'`, `refreshToken` set, + `scope` contains mail + calendar. + - Email/password sign-in still works. +2. **CHECKPOINT for next agent**: if stored scopes are unexpected, adjust + `hasMsSyncScopes` / `parseScopes` (plan Phase 1 notes this). +3. **Phase 2** (`docs/plans/m365-expansion.md` § Phase 2): create + `apps/api/src/microsoft/` mirroring `apps/api/src/google/` file-for-file. + Read each Google file first. Skills: `nestjs-best-practices`, + `nestjs-trpc`. Do not start until the human smoke test above succeeds. + +### 2026-08-02 — Phase 0b schema prep (agent: Grok via Cursor) + +**Plan / phase**: `docs/plans/m365-expansion.md` — Phase 0 (partial: 0b only). + +**What was completed** + +- Additive Prisma fields (no renames, no enum changes): + - `EmailMessage.outlookMessageId String?` in + `packages/db/prisma/schema.prisma` (mirrors `gmailMessageId`). + - `CalendarEvent.outlookEventId String?` in the same file (mirrors + `googleEventId`). +- Migration created and applied against local Postgres: + `packages/db/prisma/migrations/20260802135359_add_outlook_ids/migration.sql` + (`ALTER TABLE` only — two nullable TEXT columns). +- `bun run check-types` passed after migrate + generate. + +**How and why** + +- Followed Phase 0b exactly: keep `GoogleSyncStatus` and Google id columns; + only add Outlook deep-link ids so Phase 2 sync can store them without a + later migration. `MailboxSync.source` stays a plain string; Microsoft will + use `"outlook"` / `"outlook-calendar"` at write time (no schema change). + +**Deviations** + +- None for 0b. +- Phase 0a (Entra registration) was **not** done — it is a manual Azure + portal step. Asked the user whether the app is already registered or they + will supply `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, and + `MICROSOFT_TENANT_ID`. Until those exist, Phase 0 Definition of Done is + incomplete (migration + types are done; Entra + `.env` are not). + +**What's next** + +1. **Human — Phase 0a** (plan §0a): Azure portal → Entra ID → App + registrations → New registration named `MM-CRM`, single-tenant. + - Redirect URI (Web): + `http://localhost:3001/api/auth/callback/microsoft` + - Secret: create one, copy the **value** into root `.env` only (never into + HANDOFF or git). + - Delegated Graph permissions + admin consent: `User.Read`, `Mail.Read`, + `Calendars.Read`, `offline_access`, `openid`, `profile`, `email`. + - Put into root `.env` (and later `.env.example` docs in Phase 1): + `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT_ID`. + - Update this file's Current state: mark 0a as collected (do **not** paste + the secret here). +2. **Next agent — Phase 1** (`docs/plans/m365-expansion.md` § Phase 1): + Microsoft SSO via Better Auth. Read + `.agents/skills/better-auth-best-practices/SKILL.md` first. Touch in + order: `packages/auth/src/scopes.ts`, `env.ts`, `auth.ts`; then + `apps/api/src/config/env.validation.ts`; `.env.example`; sign-in + + grant-access UI. Mirror the existing optional Google pattern so missing + `MICROSOFT_*` hides the button and never throws. +3. Do **not** start Phase 2 until Phase 1 DoD is satisfied. + +### 2026-08-02 — Initial setup, auth fallback, UX fixes, M365 plan (agent: Fable via Cursor) + +**What was completed** + +- Cloned and set up `trycompai/crm` in this repo: installed Bun 1.3.12, + started the docker-compose Postgres, created the root `.env` from + `.env.example` (`BETTER_AUTH_SECRET` generated, `ALLOWED_SIGN_IN` set, + `DATABASE_URL` matching compose), ran migrations and the demo seed. +- Cursor project scaffolding: rules in `.cursor/rules/` (`project-overview`, + `local-dev`, `skills-and-docs`), setup tracking doc `docs/local-setup.md`, + and `.cursor/skills/` symlinks into the canonical `.agents/skills/` so the + bundled skills work in Cursor as well as Claude Code. +- Enabled email/password auth so the app is usable without a Google OAuth + client: + - `packages/auth/src/auth.ts` — `emailAndPassword.enabled: true`. + - `packages/auth/src/client.ts` — export `signUp`. + - `apps/api/src/config/env.validation.ts` — `GOOGLE_CLIENT_ID`/`SECRET` + made optional (were required). + - `apps/app/app/(app)/layout.tsx` — gate relaxed from + `requireGoogleAccess()` to `requireSession()`. + - `apps/app/app/(auth)/sign-in/credentials-form.tsx` — new sign-in / + register form; `page.tsx` renders it and shows the Google button only + when Google creds are configured. +- Replaced the plain company ` - - - - - No company - {(companies.data ?? []).map((option) => ( - - {option.name} - - ))} - - + setCompany(id ?? NONE)} + includeNone + noneLabel="No company" + placeholder="No company" + /> diff --git a/apps/app/app/(app)/deals/create-deal-sheet.tsx b/apps/app/app/(app)/deals/create-deal-sheet.tsx index a45132abd..1af905795 100644 --- a/apps/app/app/(app)/deals/create-deal-sheet.tsx +++ b/apps/app/app/(app)/deals/create-deal-sheet.tsx @@ -33,6 +33,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 { dealStageLabel, OPEN_STAGES } from "@/components/crm/deal-stage"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; import { useCrmCache } from "@/lib/trpc/cache"; @@ -67,7 +68,6 @@ export function CreateDealSheet({ const closeDateId = useId(); const users = useQuery(trpc.users.list.queryOptions()); - const companies = useQuery(trpc.companies.options.queryOptions({ q: "" })); const me = useQuery(trpc.users.me.queryOptions()); // Whoever is adding the deal is almost always the one working it. @@ -141,18 +141,12 @@ export function CreateDealSheet({ Company - + setCompany(id ?? UNSET)} + placeholder="Choose a company" + /> diff --git a/apps/app/app/(app)/layout.tsx b/apps/app/app/(app)/layout.tsx index 6facff828..21a11d5c8 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 email/password users entirely. + const { user } = await requireSession(); return ( 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..f8c821935 --- /dev/null +++ b/apps/app/app/(app)/settings/microsoft-connection.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { Button } from "@crm/ui/components/button"; +import { Label } from "@crm/ui/components/label"; +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 { 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 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({ + // A full navigation, not a router push: the tokens are gone, so every + // cached render is wrong and the gate has to re-evaluate. + onSuccess: () => window.location.assign("/"), + 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 syncNow = useMutation( + trpc.microsoft.syncNow.mutationOptions({ + onSuccess: () => cache.microsoft(), + onError: (error) => toast.error(error.message), + }), + ); + + if (!status.data) return null; + + const { sources, hasRefreshToken } = status.data; + + 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; + + return ( +
+
+
+
+

Microsoft 365

+ +
+ + +
+ +

+ New meetings and email threads are added to the matching company as + they happen. Nothing from before you connected is imported, and + nothing is ever sent on your behalf. +

+ +

+ {!hasRefreshToken + ? "Sign out and back in to finish setting up — Microsoft did not return a refresh token." + : broken?.lastError + ? broken.lastError + : lastSyncedAt + ? `Last checked ${relativeTimeFromIso(lastSyncedAt)}` + : "Waiting for the first check"} +

+
+ +
+ {sources.map((source) => { + const copy = SOURCES[source.source]; + + return ( +
+ + + + setAutoCreate.mutate({ source: source.source, enabled }) + } + /> +
+ ); + })} +
+ +
+ + + + + + 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/(auth)/grant-access/grant-access.tsx b/apps/app/app/(auth)/grant-access/grant-access.tsx index a6e81bac9..b315c4b2a 100644 --- a/apps/app/app/(auth)/grant-access/grant-access.tsx +++ b/apps/app/app/(auth)/grant-access/grant-access.tsx @@ -1,14 +1,17 @@ "use client"; import { authClient, signOut } from "@crm/auth/client"; -import { SYNC_SCOPES } from "@crm/auth/scopes"; +import { MS_SYNC_SCOPES, SYNC_SCOPES } from "@crm/auth/scopes"; import GoogleLogo from "@crm/ui/components/brand-logos/google"; +import MicrosoftLogo from "@crm/ui/components/brand-logos/microsoft"; import { Button } from "@crm/ui/components/button"; import { Spinner } from "@crm/ui/components/spinner"; import { useState } from "react"; import { toast } from "sonner"; -export function GrantAccess() { +type SyncProvider = "microsoft" | "google"; + +export function GrantAccess({ provider }: { provider: SyncProvider }) { const [pending, setPending] = useState(false); async function handleGrant() { @@ -21,18 +24,24 @@ export function GrantAccess() { // `linkSocial` rather than `signIn.social`: there is already a session and // an account row, and this is a scope upgrade on the existing grant. - // Google's incremental authorisation means the resulting token covers the - // union, so sign-in keeps working either way. + const scopes = + provider === "microsoft" ? [...MS_SYNC_SCOPES] : [...SYNC_SCOPES]; + const { error } = await authClient.linkSocial({ - provider: "google", - scopes: [...SYNC_SCOPES], + provider, + scopes, callbackURL: `${origin}/`, errorCallbackURL: `${origin}/grant-access`, }); - // On success the browser has already navigated to Google. + // On success the browser has already navigated to the provider. if (error) { - toast.error(error.message ?? "Could not reach Google."); + toast.error( + error.message ?? + (provider === "microsoft" + ? "Could not reach Microsoft." + : "Could not reach Google."), + ); setPending(false); } } @@ -48,6 +57,8 @@ export function GrantAccess() { window.location.assign("/sign-in"); } + const Logo = provider === "microsoft" ? MicrosoftLogo : GoogleLogo; + return (
diff --git a/apps/app/app/(auth)/grant-access/page.tsx b/apps/app/app/(auth)/grant-access/page.tsx index c68eeb937..ff573d6a1 100644 --- a/apps/app/app/(auth)/grant-access/page.tsx +++ b/apps/app/app/(auth)/grant-access/page.tsx @@ -1,4 +1,4 @@ -import { hasSyncScopes } from "@crm/auth"; +import { hasMsSyncScopes, hasSyncScopes } from "@crm/auth"; import { db } from "@crm/db"; import type { Metadata } from "next"; import { redirect } from "next/navigation"; @@ -10,38 +10,53 @@ export const metadata: Metadata = { title: "Grant access", }; +const microsoftEnabled = Boolean( + process.env.MICROSOFT_CLIENT_ID && + process.env.MICROSOFT_CLIENT_SECRET && + process.env.MICROSOFT_TENANT_ID, +); + /** * The re-consent wall. * * Outside the `(app)` group deliberately: the gate lives in that group's * layout, so a page inside it would redirect to itself. * - * Reached by someone who is signed in but has not granted Gmail and Calendar — - * either they unticked a scope on Google's granular consent screen, or their - * account predates those scopes being required. + * Reached by someone who is signed in but has not granted mail and calendar — + * either they declined a scope on the consent screen, or their account + * predates those scopes being required. */ export default async function GrantAccessPage() { const { user } = await requireSession(); + // Prefer Microsoft when configured; otherwise fall back to Google. + const provider = microsoftEnabled ? "microsoft" : "google"; + const hasScopes = provider === "microsoft" ? hasMsSyncScopes : hasSyncScopes; + const account = await db.account.findFirst({ - where: { userId: user.id, providerId: "google" }, + where: { userId: user.id, providerId: provider }, select: { scope: true }, }); // Already granted — nothing to ask for. Guards the back button and a stale // link as much as anything. - if (hasSyncScopes(account?.scope)) { + if (hasScopes(account?.scope)) { redirect("/"); } + const mailboxLabel = + provider === "microsoft" + ? "Outlook mail and calendar" + : "Gmail and Calendar"; + return ( - +

Only conversations with companies in the CRM are stored. Personal mail diff --git a/apps/app/app/(auth)/sign-in/credentials-form.tsx b/apps/app/app/(auth)/sign-in/credentials-form.tsx new file mode 100644 index 000000000..e6b29a3bc --- /dev/null +++ b/apps/app/app/(auth)/sign-in/credentials-form.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { signIn, signUp } from "@crm/auth/client"; +import { Button } from "@crm/ui/components/button"; +import { Field, FieldLabel } from "@crm/ui/components/field"; +import { Input } from "@crm/ui/components/input"; +import { Spinner } from "@crm/ui/components/spinner"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; + +type Mode = "sign-in" | "register"; + +export function CredentialsForm() { + const router = useRouter(); + const [mode, setMode] = useState("sign-in"); + const [pending, setPending] = useState(false); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + const isRegister = mode === "register"; + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setPending(true); + + const { error } = isRegister + ? await signUp.email({ + name: name.trim() || email.split("@")[0] || "User", + email: email.trim(), + password, + }) + : await signIn.email({ email: email.trim(), password }); + + if (error) { + toast.error( + error.message ?? + (isRegister ? "Could not create the account." : "Could not sign in."), + ); + setPending(false); + return; + } + + // The session cookie is set; land on the app. + router.push("/"); + router.refresh(); + } + + return ( +

+ {isRegister && ( + + Name + setName(event.target.value)} + /> + + )} + + + Email + setEmail(event.target.value)} + /> + + + + Password + setPassword(event.target.value)} + /> + + + + + +
+ ); +} diff --git a/apps/app/app/(auth)/sign-in/microsoft-sign-in.tsx b/apps/app/app/(auth)/sign-in/microsoft-sign-in.tsx new file mode 100644 index 000000000..ce219486c --- /dev/null +++ b/apps/app/app/(auth)/sign-in/microsoft-sign-in.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { signIn } from "@crm/auth/client"; +import MicrosoftLogo from "@crm/ui/components/brand-logos/microsoft"; +import { Button } from "@crm/ui/components/button"; +import { Spinner } from "@crm/ui/components/spinner"; +import { useState } from "react"; +import { toast } from "sonner"; + +export function MicrosoftSignIn() { + const [pending, setPending] = useState(false); + + async function handleClick() { + setPending(true); + + // The API owns /api/auth/*, so both URLs have to be absolute — a relative + // one would resolve against the API's origin, not this app's. Better Auth + // checks them against the origins in APP_URL. + const origin = window.location.origin; + + const { error } = await signIn.social({ + provider: "microsoft", + callbackURL: `${origin}/`, + errorCallbackURL: `${origin}/sign-in`, + }); + + // On success the client has already navigated to Microsoft. + if (error) { + toast.error(error.message ?? "Could not reach the sign-in service."); + setPending(false); + } + } + + return ( + + ); +} diff --git a/apps/app/app/(auth)/sign-in/page.tsx b/apps/app/app/(auth)/sign-in/page.tsx index 9fa279087..faea56c7f 100644 --- a/apps/app/app/(auth)/sign-in/page.tsx +++ b/apps/app/app/(auth)/sign-in/page.tsx @@ -1,13 +1,26 @@ +import { FieldSeparator } from "@crm/ui/components/field"; import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { AuthHeading, AuthShell } from "@/components/auth-shell"; import { getSession } from "@/lib/session"; +import { CredentialsForm } from "./credentials-form"; import { GoogleSignIn } from "./google-sign-in"; +import { MicrosoftSignIn } from "./microsoft-sign-in"; export const metadata: Metadata = { title: "Sign in", }; +// Social buttons only appear when a real OAuth client is configured. +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 SignInPage() { // The authoritative counterpart to the cookie sniff in `proxy.ts`, and the // reason that check is not in the proxy: only a session that verifies @@ -26,18 +39,25 @@ export default async function SignInPage() { redirect("/"); } + const hasSocial = microsoftEnabled || googleEnabled; + return ( - + + + {hasSocial && or} + + {microsoftEnabled && } + {googleEnabled && }

- Comp AI CRM is internal. If you cannot get in, ask an admin to check - your Google account. + Comp AI CRM is internal. If you cannot get in, ask an admin to check the + sign-in allow-list.

); diff --git a/apps/app/components/crm/company-picker.tsx b/apps/app/components/crm/company-picker.tsx new file mode 100644 index 000000000..dd0efc7bd --- /dev/null +++ b/apps/app/components/crm/company-picker.tsx @@ -0,0 +1,209 @@ +"use client"; + +import ChevronDown from "@carbon/icons-react/es/ChevronDown"; +import { Button } from "@crm/ui/components/button"; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "@crm/ui/components/command"; +import { + EntityLogo, + type EntityLogoTone, +} from "@crm/ui/components/entity-logo"; +import { Icon } from "@crm/ui/components/icon"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@crm/ui/components/popover"; +import { cn } from "@crm/ui/lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import { useRef, useState } from "react"; +import { useTRPC } from "@/lib/trpc/client"; + +/** How long to wait after a keystroke before asking the API. */ +const SEARCH_DEBOUNCE_MS = 200; + +type PickedCompany = { + id: string; + name: string; + iconUrl?: string | null; + iconDarkUrl?: string | null; + iconTone?: string | null; +}; + +/** + * A searchable company picker for forms. + * + * The API matches server-side (`companies.options`, capped at 100), so this + * scales to a CRM with thousands of imported companies where a plain dropdown + * would render every row. Typing narrows the list; `""` shows the first + * hundred alphabetically. + */ +export function CompanyPicker({ + id, + value, + onChange, + placeholder = "Choose a company", + includeNone = false, + noneLabel = "No company", +}: { + id?: string; + /** The selected company id, or `null` for none. */ + value: string | null; + onChange: (companyId: string | null) => void; + placeholder?: string; + /** Offer a "No company" option (contacts can be company-less). */ + includeNone?: boolean; + noneLabel?: string; +}) { + const trpc = useTRPC(); + + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [debounced, setDebounced] = useState(""); + // The last company chosen here, kept so the trigger can label the selection + // without another lookup once it is known. + const [picked, setPicked] = useState(null); + const timer = useRef | null>(null); + + const results = useQuery({ + ...trpc.companies.options.queryOptions({ q: debounced }), + enabled: open, + // Keep the previous list on screen while the next one loads, so the + // options don't blank out on every keystroke. + placeholderData: (previous) => previous, + }); + + // A value set from outside (or before anything was picked) needs its name + // resolved for the trigger — it may not be in the first hundred. + const needsLookup = value !== null && picked?.id !== value; + const lookup = useQuery({ + ...trpc.companies.byId.queryOptions({ id: value ?? "" }), + enabled: needsLookup, + }); + + const selected: PickedCompany | null = + value === null + ? null + : picked?.id === value + ? picked + : (lookup.data ?? null); + + function handleQueryChange(next: string) { + setQuery(next); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setDebounced(next), SEARCH_DEBOUNCE_MS); + } + + function handleOpenChange(next: boolean) { + setOpen(next); + if (!next) { + // Reset the search so reopening starts clean. + setQuery(""); + setDebounced(""); + if (timer.current) clearTimeout(timer.current); + } + } + + function choose(company: PickedCompany | null) { + setPicked(company); + onChange(company?.id ?? null); + handleOpenChange(false); + } + + const options = results.data ?? []; + + return ( + + + + + + {/* + * `shouldFilter={false}`: the API already matched on name and domain, + * including domains the row does not show. Re-filtering in cmdk would + * drop those hits. + */} + + + + + {results.isFetching ? "Searching…" : "No companies match."} + + + {includeNone ? ( + choose(null)} + className={cn(value === null && "text-foreground")} + > + {noneLabel} + + ) : null} + + {options.map((option) => ( + choose(option)} + > + + + {option.name} + {option.domain ? ( + + {option.domain} + + ) : null} + + + ))} + + + + + ); +} diff --git a/apps/app/components/crm/timeline/email-thread-entry.tsx b/apps/app/components/crm/timeline/email-thread-entry.tsx index dc4c1a595..80abb6655 100644 --- a/apps/app/components/crm/timeline/email-thread-entry.tsx +++ b/apps/app/components/crm/timeline/email-thread-entry.tsx @@ -20,27 +20,39 @@ const timeFormat = new Intl.DateTimeFormat(undefined, { }); /** - * A synced Gmail thread, collapsed to one line. + * A synced mail thread, collapsed to one line. * * The messages are fetched only when it is opened: the timeline payload carries * a snippet and a count, and email bodies are the one thing that must never * ride along on a list response. + * + * `provider` picks which tRPC surface to call. Both return the same shape + * (gmailUrl and outlookUrl); the deep-link that is set wins in the UI. */ export function EmailThreadEntry({ threadId, messageCount, + provider = "google", }: { threadId: string; messageCount: number; + provider?: "google" | "microsoft"; }) { const trpc = useTRPC(); const [opened, setOpened] = useState(false); - const thread = useQuery({ + const googleThread = useQuery({ ...trpc.google.thread.queryOptions({ threadId }), - enabled: opened, + enabled: opened && provider === "google", + }); + + const microsoftThread = useQuery({ + ...trpc.microsoft.thread.queryOptions({ threadId }), + enabled: opened && provider === "microsoft", }); + const thread = provider === "microsoft" ? microsoftThread : googleThread; + return ( ) : (
- {thread.data?.messages.map((message) => ( - - Open in Gmail - - ) : null - } - /> - ))} + {thread.data?.messages.map((message) => { + const openUrl = message.outlookUrl ?? message.gmailUrl ?? null; + const openLabel = message.outlookUrl + ? "Open in Outlook" + : message.gmailUrl + ? "Open in Gmail" + : null; + + return ( + + {openLabel} + + ) : null + } + /> + ); + })}
)} diff --git a/apps/app/components/crm/timeline/meeting-entry.tsx b/apps/app/components/crm/timeline/meeting-entry.tsx index 7206638f8..fbd51e8c9 100644 --- a/apps/app/components/crm/timeline/meeting-entry.tsx +++ b/apps/app/components/crm/timeline/meeting-entry.tsx @@ -35,6 +35,7 @@ export function MeetingEntry({ isAllDay, attendeeCount, conferenceUrl, + provider = "google", }: { eventId: string; startsAt: string; @@ -42,15 +43,23 @@ export function MeetingEntry({ isAllDay: boolean; attendeeCount: number; conferenceUrl: string | null; + provider?: "google" | "microsoft"; }) { const trpc = useTRPC(); // Only worth a request when there is somebody to show. - const event = useQuery({ + const googleEvent = useQuery({ ...trpc.google.event.queryOptions({ eventId }), - enabled: attendeeCount > 0, + enabled: attendeeCount > 0 && provider === "google", }); + const microsoftEvent = useQuery({ + ...trpc.microsoft.event.queryOptions({ eventId }), + enabled: attendeeCount > 0 && provider === "microsoft", + }); + + const event = provider === "microsoft" ? microsoftEvent : googleEvent; + return (
diff --git a/apps/app/components/crm/timeline/timeline-entry.tsx b/apps/app/components/crm/timeline/timeline-entry.tsx index 2362d59a8..286c94e72 100644 --- a/apps/app/components/crm/timeline/timeline-entry.tsx +++ b/apps/app/components/crm/timeline/timeline-entry.tsx @@ -89,6 +89,12 @@ export function TimelineEntry({ // A synced entry was not written by the person whose mailbox it came from, // so it is attributed to the source rather than to them. const synced = entry.meta?.synced === true; + const syncSource = + typeof entry.meta?.source === "string" ? entry.meta.source : null; + const mailboxProvider = + syncSource === "outlook" || syncSource === "outlook-calendar" + ? "microsoft" + : "google"; const author = synced ? entry.emailThread ? "via Gmail" @@ -185,6 +191,7 @@ export function TimelineEntry({ isAllDay={entry.calendarEvent.isAllDay} attendeeCount={entry.calendarEvent.attendeeCount} conferenceUrl={entry.calendarEvent.conferenceUrl} + provider={mailboxProvider} /> ) : null} @@ -192,6 +199,7 @@ export function TimelineEntry({ ) : null} diff --git a/apps/app/lib/trpc/cache.ts b/apps/app/lib/trpc/cache.ts index 213559192..9c87260f7 100644 --- a/apps/app/lib/trpc/cache.ts +++ b/apps/app/lib/trpc/cache.ts @@ -54,6 +54,8 @@ export type CrmCache = { * writes a rep triggers from the settings page. */ google(options?: Options): Promise; + /** Same invalidation shape as `google`, for the Outlook settings card. */ + microsoft(options?: Options): Promise; /** An import writes across every table, so nothing is assumed to survive. */ everything(): Promise; }; @@ -188,6 +190,19 @@ export function useCrmCache(): CrmCache { options, ), + microsoft: (options) => + run( + [trpc.microsoft.status.queryKey()], + [ + ...activityKeys(), + ...listKeys(), + trpc.companies.byId.queryKey(), + trpc.contacts.byId.queryKey(), + trpc.dashboard.summary.queryKey(), + ], + options, + ), + everything: () => queryClient.invalidateQueries(), }; } diff --git a/docs/local-setup.md b/docs/local-setup.md new file mode 100644 index 000000000..da8369a5c --- /dev/null +++ b/docs/local-setup.md @@ -0,0 +1,116 @@ +# Local setup — tracking + +Status log for the local install of this CRM on macOS. Update this as the +environment changes so anyone (human or agent) can see the current state. + +## Environment + +- **Repo**: cloned from https://github.com/trycompai/crm into `MM-CRM/` +- **OS**: macOS (darwin) +- **Node**: v24 (repo requires `>=22`) +- **Bun**: 1.3.x installed via the official installer to `~/.bun` + (`~/.bun/bin` added to `PATH` in `~/.zshrc`) +- **Docker**: Docker Desktop; Postgres 17 in container `crm-postgres` on `:5432` + +## Setup steps completed + +- [x] `git clone` the repo into the folder +- [x] Installed Bun (official installer) +- [x] Started Docker Desktop +- [x] Created `.env` from `.env.example` +- [x] Generated `BETTER_AUTH_SECRET` (`openssl rand -base64 32`) +- [x] `bun install` +- [x] `docker compose up -d` (Postgres) +- [x] `bun run db:deploy` — all migrations applied +- [x] `bun run db:seed` — demo data (15 companies, 45 contacts, 23 deals, 162 activities) +- [x] Added `.cursor/rules/` and this tracking doc + +## Signing in (email + password) + +Auth runs on **email + password** locally (see "Auth changes" below); Google +OAuth is optional. Microsoft SSO is enabled when `MICROSOFT_*` are set. The +required `.env` values are: + +- [x] `BETTER_AUTH_SECRET` — generated +- [x] `ALLOWED_SIGN_IN` — set to `mobilemark.com` (the whole authorisation + model: only addresses on this domain may register or sign in) +- [x] Microsoft Entra (`MICROSOFT_CLIENT_ID` / `MICROSOFT_CLIENT_SECRET` / + `MICROSOFT_TENANT_ID`) — set for local Microsoft sign-in +- Google OAuth (`GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`) — left empty; the + Google button is hidden until they are set. + +### First run + +```bash +bun run dev # app on http://localhost:3000, api on http://localhost:3001 +``` + +1. Open http://localhost:3000 → you land on `/sign-in`. +2. Prefer **Continue with Microsoft** (Entra SSO), or use email/password: + click **Need an account? Create one**, enter your name, an + `@mobilemark.com` email, and a password (min 8 chars), then **Create + account**. +3. Next time, use Microsoft again, or the same email + password. + +An email whose domain is not on `ALLOWED_SIGN_IN` is refused at registration +(HTTP 403) — that guard is unchanged. + +### Microsoft Entra redirect URI + +Web redirect URI in the Entra app registration: + +`http://localhost:3001/api/auth/callback/microsoft` + +### Settings + +With `MICROSOFT_*` set, Settings shows a **Microsoft 365** connection card +driven by `microsoft.status` (Check now, Meetings/Email auto-create toggles, +purge, revoke). Set `CRON_SECRET` in root `.env` so +`POST /internal/sync/microsoft` and the five-minute cron can run. + +### Turning Google sign-in back on later + +Fill in a real OAuth client and restart; the Google button reappears +automatically. + +1. Google Cloud console → **Credentials** → **Create credentials** → + **OAuth client ID** → **Web application**. +2. Authorised redirect URI: `http://localhost:3001/api/auth/callback/google`. +3. Enable the **Gmail API** and **Calendar API**. +4. Put the client ID/secret in `.env`. + +## Auth changes from upstream + +To run without a Google account, this install differs from upstream `trycompai/crm`: + +- `packages/auth/src/auth.ts` — `emailAndPassword.enabled: true`. +- `apps/api/src/config/env.validation.ts` — `GOOGLE_CLIENT_ID` / + `GOOGLE_CLIENT_SECRET` are now optional (were required). +- `apps/app/app/(app)/layout.tsx` — gate relaxed from `requireGoogleAccess()` + to `requireSession()`, since the Gmail/Calendar scope gate is Google-only and + would lock out email/password users. +- `apps/app/app/(auth)/sign-in/` — added `credentials-form.tsx`; the page shows + the email/password form and only renders the Google button when configured. + +The `ALLOWED_SIGN_IN` allow-list still governs who may register or sign in, so +the authorisation model is unchanged. + +## Agent harness config (Cursor / Claude Code / Codex) + +The repo shipped Claude Code / Codex config. It now works across harnesses: + +- **Skills** — canonical source is `.agents/skills/` (tracked by + `skills-lock.json`). Each harness symlinks into it, so there's one copy: + - `.cursor/skills/` → Cursor (auto-discovered) + - `.claude/skills/` → Claude Code + - Edit skills only in `.agents/skills/`; never in the symlink dirs. +- **Instructions** — `AGENTS.md` at the root is the shared entry point. Cursor + reads it natively; `CLAUDE.md` just re-exports it (`@AGENTS.md`). +- **Cursor rules** — `.cursor/rules/*.mdc` add Cursor-native, always-on + pointers to the architecture, local-dev commands, and the skills/docs above. + +## Optional capabilities (all off by default) + +Each key in `.env` unlocks one more thing the agent can do; the app runs fine +with none of them. See `.env.example` for the annotated list +(`PERPLEXITY_API_KEY`, `RAPIDAPI_KEY`, `CONTEXT_DEV_API_KEY`, `REDIS_URL`, …). diff --git a/docs/plans/m365-expansion.md b/docs/plans/m365-expansion.md new file mode 100644 index 000000000..5c0351451 --- /dev/null +++ b/docs/plans/m365-expansion.md @@ -0,0 +1,533 @@ +# Microsoft 365 Expansion — Execution Plan + +This is the canonical copy of the plan. It is written so that an agent with +limited context can execute it phase by phase with minimal guesswork. + +## How to use this plan (read this first, every session) + +1. Read `AGENTS.md` at the repo root, then `HANDOFF.md` at the repo root. + `HANDOFF.md` says exactly where the previous agent stopped and what is next. +2. Read the skill for the area you are touching (they live in + `.cursor/skills/`, canonical source `.agents/skills/`): + - Auth work → `better-auth-best-practices` + - Prisma schema / migrations → `prisma-database-setup` + - API work → `nestjs-best-practices` and `nestjs-trpc` + - Agent work → `eve` (full docs also in `apps/agent/node_modules/eve/docs`) + - Front end → `shadcn`, `nuqs`, `no-use-effect`, `vercel-react-best-practices` +3. Execute ONE phase at a time, in order. Do not start a phase until the + previous phase's "Definition of done" checklist is fully satisfied. +4. When you finish a phase — or stop for any reason — update `HANDOFF.md` + (protocol described in `AGENTS.md`). This is not optional. + +### Hard rules (violating these is a bug) + +- ONE `.env` at the repo root. Every new variable goes into `.env.example` + with a comment. Every variable the API reads is also declared in + `apps/api/src/config/env.validation.ts`. Never add a per-package `.env`. +- Missing optional config removes a capability; it must never throw. + (Pattern: `packages/auth/src/env.ts` `googleCredentials()` and + `apps/agent/agent/lib/capabilities.ts`.) +- Intelligence never lives in the API (`docs/api.md`). Mechanical work in + NestJS is fine (fetch tokens, call Microsoft Graph, match email addresses, + store rows). Deciding what data MEANS (identity, follow-ups, briefs) lives + in `apps/agent`. +- UI components come only from `packages/ui`. No style overrides at call sites. +- Never let `build` regenerate `apps/api/src/generated/server.ts`. After + changing/adding any `*.router.ts`, run `bun run --filter=api trpc:generate` + and commit the regenerated file. +- No real customer names/emails in fixtures, tests, screenshots or docs. +- Do not add an `organizationId`. Single tenant, deliberately. + +### Verification commands (run after every phase) + +```bash +bun run check-types # tsc everywhere — must pass +bun run lint # biome — must pass +bun run test # must pass +bun run dev # manual smoke test on http://localhost:3000 +``` + +The local dev environment (Bun, Docker Postgres, .env) is already set up — +see `docs/local-setup.md`. Sign in locally with email/password. + +## Decisions already made (do not relitigate) + +- Microsoft 365 REPLACES Google as the real identity/data provider. + Email + password stays enabled as the fallback (it already works today). +- Mail sync is FULL-BODY but MATCHED-ONLY: bodies are stored, but only for + threads that resolve to a Contact/Company already in the CRM. Unmatched + mail is never stored (participant metadata may go to the Screening Room). +- BACKFILL on contact-add: adding a contact you already email triggers a + targeted Graph import of that address's recent history. +- Agent-detected follow-ups are suggestions; ACCEPTING one creates a real + CRM task (`Activity` of type `TASK`) linked to the contact/deal. +- The Follow-ups panel is PER-REP (my mailbox, my deals, my follow-ups). + +## Why this is close to what already exists + +The Google pipeline in `apps/api/src/google/` already stores bodies in +`EmailMessage.body`, projects one `Activity` per thread, and only keeps +threads that `GoogleMatchService.resolve()` ties to a known Contact/Company. +Phase 2 is that pipeline re-pointed at Microsoft Graph. The genuinely new +pieces are backfill (Phase 3), the Screening Room (Phase 4), and the +Follow-ups panel (Phase 5). + +## Target architecture + +```mermaid +flowchart LR + rep[Rep browser] -->|Entra SSO| app[Next.js app] + app -->|session cookie| api[NestJS API] + api -->|Better Auth: microsoft| entra[Entra ID] + api -->|tokens| db[(Postgres)] + + cron[Cron] -->|/internal/sync/microsoft| api + api -->|delegated token| graph[MS Graph] + api -->|matched threads + bodies| db + + addcontact[Add contact] -->|backfill row| api + api -->|targeted Graph query by address| graph + + rep -->|Follow-ups panel| app + agent[eve agent] -->|reads synced mail + deals| db + agent -->|FollowUpSuggestion rows| db + rep -->|accept| task["Activity TASK"] +``` + +## Baseline: how this fork already differs from upstream + +(Recorded in detail in `HANDOFF.md`. Summary so nothing surprises you.) + +- Email/password auth is ENABLED (`packages/auth/src/auth.ts`), Google env + vars are OPTIONAL (`apps/api/src/config/env.validation.ts`), and the app + shell gate is `requireSession()` not `requireGoogleAccess()` + (`apps/app/app/(app)/layout.tsx`). +- The sign-in page has an email/password form; the Google button renders only + when Google creds are set (`apps/app/app/(auth)/sign-in/page.tsx`). +- A searchable `CompanyPicker` (`apps/app/components/crm/company-picker.tsx`) + replaced the plain company dropdown in the create-deal and create-contact + sheets. + +--- + +## Phase 0 — Entra app registration + additive schema prep + +Goal: credentials exist, and the schema can hold Microsoft identifiers. +No user-facing change. + +### 0a. Entra app registration (MANUAL — a human does this in the Azure portal) + +Record the resulting values in `HANDOFF.md` as "collected" (never paste the +secret itself into any file except `.env`). + +1. Azure portal → Microsoft Entra ID → App registrations → New registration. +2. Name: `MM-CRM`. Supported account types: "Accounts in this organizational + directory only" (single tenant). +3. Platform: Web. Redirect URI: `http://localhost:3001/api/auth/callback/microsoft` + (Better Auth mounts callbacks at `/api/auth/callback/{provider}` on the API, + which is port 3001 locally). Add the production API origin later. +4. Certificates & secrets → New client secret → copy the secret VALUE. +5. API permissions → Add → Microsoft Graph → Delegated: + `User.Read`, `Mail.Read`, `Calendars.Read`, `offline_access`, `openid`, + `profile`, `email`. Then "Grant admin consent" for the tenant. +6. Collect: Application (client) ID, Directory (tenant) ID, client secret value. + +### 0b. Schema changes (ADDITIVE ONLY — do not rename existing enums/columns) + +Renaming `GoogleSyncStatus` or `gmailMessageId` is cosmetic and risky; skip it. +Reuse the existing enum — its values (`IDLE/RUNNING/NEEDS_RECONNECT/FAILED`) +are provider-neutral. + +Edit `packages/db/prisma/schema.prisma`: + +- `EmailMessage`: add `outlookMessageId String?` (deep-link id, mirrors + `gmailMessageId`). +- `CalendarEvent`: add `outlookEventId String?` (mirrors `googleEventId`). +- `MailboxSync.source` is already a plain `String` — no schema change needed. + Microsoft rows will use the new source values `"outlook"` (mail) and + `"outlook-calendar"` (calendar) to avoid colliding with Google's + `"gmail"`/`"calendar"` rows. + +Then run `bun run db:migrate` (it creates and applies a migration; name it +`add_outlook_ids`). + +### Definition of done (Phase 0) + +- [ ] Entra values collected and placed in `.env` (see Phase 1 var names) +- [ ] Migration applied; `bun run check-types` passes +- [ ] `HANDOFF.md` updated + +--- + +## Phase 1 — Microsoft SSO replaces Google + +Goal: the team signs in with their Microsoft 365 account. + +### Files and edits + +- `packages/auth/src/scopes.ts` — add Microsoft scope constants alongside the + Google ones: + - `MS_MAIL_SCOPE = "Mail.Read"`, `MS_CALENDAR_SCOPE = "Calendars.Read"`, + `MS_SYNC_SCOPES = [MS_MAIL_SCOPE, MS_CALENDAR_SCOPE]`. + - Add `hasMsSyncScopes(scope)` mirroring `hasSyncScopes()`. + - CHECKPOINT: after your first real sign-in, inspect the `account.scope` + column for the microsoft row and make sure the stored strings match what + `hasMsSyncScopes` checks (Microsoft may return scopes as short names or + full `https://graph.microsoft.com/...` URIs — normalize in `parseScopes` + if needed). +- `packages/auth/src/env.ts` — add `microsoftCredentials()` mirroring + `googleCredentials()`: reads `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, + `MICROSOFT_TENANT_ID`; all three required together, else `undefined` + (half-set throws with a clear message). Export as `env.microsoft`. +- `packages/auth/src/auth.ts` — mirror the `if (env.google)` block: + +```ts +if (env.microsoft) { + socialProviders.microsoft = { + clientId: env.microsoft.clientId, + clientSecret: env.microsoft.clientSecret, + tenantId: env.microsoft.tenantId, + scope: [...MS_SYNC_SCOPES], // Better Auth adds identity scopes itself + // consult the better-auth skill/docs for exact option names on the + // microsoft provider (e.g. prompt, authority) — verify before writing + }; +} +``` + + Also add `"microsoft"` to `account.accountLinking.trustedProviders`. +- `apps/api/src/config/env.validation.ts` — add the three `MICROSOFT_*` vars + as `@IsOptional() @IsString()` (same as the Google ones are now). +- `.env.example` — document the three vars with the redirect URI note. +- `apps/app/app/(auth)/sign-in/microsoft-sign-in.tsx` — copy + `google-sign-in.tsx`, change provider to `"microsoft"` and the label to + "Continue with Microsoft". If `packages/ui/src/components/brand-logos/` has + no Microsoft logo, add `microsoft.tsx` there (4-square mark, plain SVG, + same component shape as `google.tsx`). +- `apps/app/app/(auth)/sign-in/page.tsx` — add + `const microsoftEnabled = Boolean(process.env.MICROSOFT_CLIENT_ID && process.env.MICROSOFT_CLIENT_SECRET && process.env.MICROSOFT_TENANT_ID)` + and render the Microsoft button (above the Google block, same + `FieldSeparator` pattern). +- `apps/app/app/(auth)/grant-access/grant-access.tsx` — support + `linkSocial({ provider: "microsoft", scopes: [...MS_SYNC_SCOPES] })` when + Microsoft is configured. + +### Definition of done (Phase 1) + +- [ ] With `MICROSOFT_*` set, "Continue with Microsoft" appears and a real + tenant user completes sign-in end-to-end locally +- [ ] `account` row exists with `providerId = "microsoft"`, a non-null + `refreshToken`, and `scope` containing the mail/calendar scopes +- [ ] With `MICROSOFT_*` unset, the button is hidden and nothing throws +- [ ] Email/password still works; allow-list still rejects other domains +- [ ] `check-types` / `lint` / `test` pass; `HANDOFF.md` updated + +--- + +## Phase 2 — Microsoft mail + calendar sync (full body, matched-only) + +Goal: email/meetings with known CRM contacts appear on record timelines, +with bodies, synced from Outlook. Mirror `apps/api/src/google/` file-for-file +in a new `apps/api/src/microsoft/` module. Read the google file first, then +write the microsoft twin. + +### Files to create (template → new file) + +- `google.constants.ts` → `microsoft.constants.ts`: + `MICROSOFT_PROVIDER_ID = "microsoft"`, + `SYNC_SOURCES = ["outlook", "outlook-calendar"]`, + `SCOPE_FOR_SOURCE = { outlook: MS_MAIL_SCOPE, "outlook-calendar": MS_CALENDAR_SCOPE }`. +- `google-token.service.ts` → `microsoft-token.service.ts`: identical logic; + `auth.api.getAccessToken({ providerId: "microsoft", userId })`; outcomes + `ok | not-connected | needs-reconnect`, never throw. +- `google-api.client.ts` → `graph-api.client.ts`: fetch wrapper for + `https://graph.microsoft.com/v1.0`; map status codes: 401 → needs-reconnect, + 410/`SyncStateNotFound` → cursor reset, 429 → honor `Retry-After`. +- `gmail.client.ts` → `outlook-mail.client.ts`: + - Delta: `GET /me/mailFolders/inbox/messages/delta` with + `$select=id,internetMessageId,subject,from,toRecipients,ccRecipients,receivedDateTime,bodyPreview,body,conversationId,internetMessageHeaders`. + - Persist the returned `@odata.deltaLink` as the cursor + (`MailboxSync.cursor`, source `"outlook"`); follow `@odata.nextLink` + pages within a tick. +- `calendar.client.ts` → `outlook-calendar.client.ts`: + `GET /me/calendarView/delta?startDateTime=&endDateTime=`; + cursor = deltaLink, source `"outlook-calendar"`. +- `mime.ts` → `outlook-message.ts`: Graph returns parsed JSON (no MIME + decoding needed). Extract: RFC id from `internetMessageId`; thread root + from `internetMessageHeaders` (`References` / `In-Reply-To`) exactly like + `mime.ts` does; plain-text body from `body.content` (strip HTML if + `body.contentType === "html"`; reuse the quote-stripping logic from + `mime.ts`). +- `google-match.service.ts` → `microsoft-match.service.ts`: copy nearly + verbatim — the participant→contact/company logic is provider-agnostic. + Reuse `participants.ts` AS-IS (import it, do not copy). +- `gmail-sync.service.ts` → `outlook-mail-sync.service.ts`: same structure: + forward-only first pass (record cursor, import NOTHING), incremental pass + builds `EmailThread` / `EmailMessage` (set `outlookMessageId`) / one + projected `Activity` per thread; the same two-way-engagement gate; + dedupe on unique `rfcMessageId`. Unmatched threads: DO NOT store bodies — + hand participants to Phase 4's screening harvest (see below; in Phase 2, + just drop them like Google does). +- `calendar-sync.service.ts` → `outlook-calendar-sync.service.ts`: key events + by `(iCalUid, originalStartTime)` (Graph field `iCalUId`); set + `outlookEventId`; attendees → `CalendarAttendee`. +- `google-sync.service.ts` → `microsoft-sync.service.ts`: orchestrator, + 60s tick budget, per-user runs. +- `sync.controller.ts`: add `GET|POST /internal/sync/microsoft` (same + `CRON_SECRET` bearer guard) calling `MicrosoftSyncService.runDue()`. +- `google-connection.service.ts` → `microsoft-connection.service.ts`: + reconcile `MailboxSync` rows from `account.scope` on connect; settings + status; revoke (token revocation for Microsoft: delete stored tokens; there + is no simple public revoke endpoint — document this in code). +- `google.router.ts` → `microsoft.router.ts` (alias `microsoft`): `status`, + `syncNow`, `thread`, `event` procedures mirroring `google.*`. Then run + `bun run --filter=api trpc:generate` and commit `src/generated/server.ts`. +- `conversation.service.ts`: generalize or mirror so timeline expansion can + serve Microsoft threads (deep link: + `https://outlook.office.com/mail/deeplink/read/{outlookMessageId}`). +- `microsoft.module.ts`: wire everything; register in `app.module.ts`. +- Cron registration: mirror the google entry in `apps/api/scripts/build-func.mjs` + (`*/5 * * * *` → `/internal/sync/microsoft`). + +### Front end + +- The timeline already renders `Activity` rows of type EMAIL/MEETING. Wire the + expand action for Microsoft-sourced threads to `microsoft.thread` / + `microsoft.event` (find where `google.thread` is called and branch on which + provider id field is set on the message). +- Settings page: mirror the Google connection card for Microsoft + (connect status, sync now, auto-create toggles) — copy the component that + uses `google.status`. **Done:** Settings prefers the full tRPC-backed + `MicrosoftConnection` when `MICROSOFT_*` is set + (`apps/app/app/(app)/settings/microsoft-connection.tsx`). + +### Definition of done (Phase 2) + +- [ ] With a connected Microsoft account and `CRON_SECRET` set: + `curl -X POST -H "Authorization: Bearer $CRON_SECRET" http://localhost:3001/internal/sync/microsoft` + returns a run summary and does not error +- [ ] First run imports zero messages (forward-only) and records a cursor +- [ ] Send a NEW email to/from an address that exists as a CRM contact, run + sync again: the thread appears on that contact's timeline; expanding it + shows the body +- [ ] An email with a stranger (not in CRM) creates NO rows +- [ ] A calendar event with a known contact appears as a MEETING activity +- [ ] `check-types` / `lint` / `test` pass; generated `server.ts` committed; + `HANDOFF.md` updated + +--- + +## Phase 3 — Backfill when a contact is added + +Goal: adding a contact you already email retroactively imports recent history +with that address (and only that address). + +### Design + +- New model in `packages/db/prisma/schema.prisma`: + +```prisma +model EmailBackfill { + id String @id @default(cuid()) + address String + requestedById String? + status String @default("PENDING") // PENDING | RUNNING | DONE | FAILED + error String? + createdAt DateTime @default(now()) + finishedAt DateTime? + @@unique([address]) + @@map("emailBackfill") +} +``` + +- Enqueue: in `apps/api/src/contacts/contacts.service.ts`, after `create()` + (and after `update()` when `email` is set or changed), upsert an + `EmailBackfill` row for the address. Mechanical — no agent involvement. +- Worker: at the end of each Microsoft sync tick (`microsoft-sync.service.ts`), + process up to N pending backfills within the remaining budget: + - For each mailbox-connected user: Graph + `GET /me/messages?$search="participants:
"` (quotes required), + page cap 5 pages x 50 messages, discard anything older than 180 days + (config constant, not an env var). + - Feed results through the SAME store path as Phase 2 (match, thread, + message, activity). `rfcMessageId` unique makes this idempotent. + - Mark DONE/FAILED with error text. + +### Definition of done (Phase 3) + +- [ ] Create a contact whose address you exchanged mail with BEFORE Phase 2's + baseline: after the next sync tick, those recent threads appear on the + new contact's timeline +- [ ] Running the same backfill twice creates no duplicates +- [ ] `check-types` / `lint` / `test` pass; `HANDOFF.md` updated + +--- + +## Phase 4 — Screening Room (approve unknown contacts) + +Goal: frequent external correspondents who are NOT in the CRM surface in a +review queue; approving creates the contact (and triggers backfill), rejecting +suppresses them. + +### Design + +- New model (metadata only — never bodies): + +```prisma +model PendingContact { + id String @id @default(cuid()) + email String @unique + displayName String? + domain String + direction String // INBOUND | OUTBOUND (first seen) + sampleSubject String? + messageCount Int @default(1) + firstSeenAt DateTime @default(now()) + lastSeenAt DateTime @default(now()) + status String @default("PENDING") // PENDING | APPROVED | REJECTED + decidedById String? + decidedAt DateTime? + @@index([status, lastSeenAt]) + @@map("pendingContact") +} +``` + +- Harvest INSIDE the Phase 2 sync (no extra Graph pass): where + `outlook-mail-sync.service.ts` drops an unmatched thread, first extract its + external participants (reuse `participants.ts`), filter out + `SuppressedDomain` rows and existing `Contact.email`s, and upsert + `PendingContact` (bump `messageCount` / `lastSeenAt`). +- API: new `screening.router.ts` (alias `screening`): `list` (PENDING, ranked + by messageCount desc then lastSeenAt desc), `decide({ id, decision, createContact?: { firstName, lastName, companyId? } })`: + - approve → create `Contact` (source `EMAIL`), enqueue `EmailBackfill`, + call `AgentTriggerService.contactCreated(id, "approved in screening room")`, + mark APPROVED. + - reject → mark REJECTED; optional flag `suppressDomain` also inserts the + domain into `SuppressedDomain`. + - Regenerate + commit `server.ts`. +- UI: new route `apps/app/app/(app)/screening/page.tsx` — table of pending + candidates (name/email/domain, count, last seen, sample subject) with + Approve / Reject buttons. Use `packages/ui` table + button components and + the existing list-page patterns (see `contacts/page.tsx`). Add it to the + app icon rail (`apps/app/components/app-icon-rail.tsx`). + +### Definition of done (Phase 4) + +- [ ] Mail from a stranger shows up in Screening (no thread/body stored) +- [ ] Approve → contact exists (source EMAIL), backfill runs, agent identify + task is enqueued, candidate disappears from the queue +- [ ] Reject with suppress → domain in `SuppressedDomain`; future mail from it + never reappears +- [ ] `check-types` / `lint` / `test` pass; `HANDOFF.md` updated + +--- + +## Phase 5 — Follow-ups / Sales Cockpit panel (per-rep) + +Goal: the agent reads the rep's recent synced mail (~30+ messages) plus their +open deals and proposes follow-ups; accepted ones become real CRM tasks. + +### Design + +- New model: + +```prisma +model FollowUpSuggestion { + id String @id @default(cuid()) + userId String // the rep this belongs to + contactId String? + companyId String? + dealId String? + kind String // commitment | reply-owed | deal-risk | next-step + summary String // one sentence, imperative ("Send Acme the revised quote") + quote String? // short cited excerpt (<= 300 chars), never a full body + dueHint DateTime? + evidence Json // [{ threadId, messageId, sentAt }] + status String @default("PROPOSED") // PROPOSED | ACCEPTED | DISMISSED | SNOOZED + activityId String? // the created TASK when accepted + createdAt DateTime @default(now()) + decidedAt DateTime? + @@index([userId, status, createdAt]) + @@map("followUpSuggestion") +} +``` + +- Agent side (`apps/agent` — read the `eve` skill first): + - New task kind `"followups"` handled like existing kinds: the API (or a + small agent schedule) enqueues one `AgentTask` per mailbox-connected rep + per day (dedupe: skip if an unfinished `followups` task exists for that + user — same pattern as `AgentTriggerService.enqueue`). + - New tool `apps/agent/agent/tools/propose_followups.ts`: input + `{ userId }`; reads the rep's recent synced threads (via `@crm/db`, the + same read patterns as `lib/crm.ts`) and their open deals (stage, + `lastActivityAt`); writes `FollowUpSuggestion` rows. Dedupe before insert: + skip if a PROPOSED suggestion with the same `(userId, dealId|contactId, kind)` + already exists. Never invent evidence — every suggestion cites real + message ids in `evidence`. + - Detection targets: explicit commitments in the rep's own sent mail + ("I'll send X by Friday"), inbound questions never answered (reply-owed), + deals with `stageChangedAt` long ago and no recent activity (deal-risk), + agreed next steps (next-step). +- API: new `followups.router.ts` (alias `followups`): `list` (mine, PROPOSED + + SNOOZED due), `decide({ id, decision, dueAt? })`: + - accept → create `Activity { type: TASK, subject: summary, dueAt: dueAt ?? dueHint ?? now+3d, contactId/companyId/dealId from suggestion, createdById: session user }`, + store `activityId`, mark ACCEPTED. + - dismiss → DISMISSED. snooze → SNOOZED with a new `dueHint`. + - Regenerate + commit `server.ts`. +- UI: new route `apps/app/app/(app)/follow-ups/page.tsx`, three sections: + 1. Suggested follow-ups (PROPOSED — accept / snooze / dismiss; accepting + optionally edits due date), + 2. My open tasks (`activities.myTasks`, overdue highlighted — this query + already exists), + 3. My active deals at a glance (my open deals by stage with + `lastActivityAt`; reuse deal-stage helpers from + `apps/app/components/crm/deal-stage.tsx`). + Add to the icon rail. Everything scoped to the signed-in rep. + +### Definition of done (Phase 5) + +- [ ] With synced mail present, the daily task produces suggestions with real + cited evidence (spot-check: each cites message ids that exist) +- [ ] Accept creates a TASK visible on the record timeline AND in "My open + tasks"; dismiss/snooze behave; nothing duplicates on the next run +- [ ] A rep sees only their own suggestions/tasks/deals +- [ ] `check-types` / `lint` / `test` pass; `HANDOFF.md` updated + +--- + +## Phase 6 — Optional extras (only when asked) + +- Outlook contacts import via Graph `/me/contacts` (`Contacts.Read`), routed + through the Screening Room rather than direct creation. +- Meeting prep: on calendar sync, call `AgentTriggerService.meetingSoon()` + for upcoming external meetings (mirror the Google calendar-sync hook). +- Teams: post a rep's daily follow-up digest to a channel (incoming webhook + is the simplest start). + +## Phase 7 — Sage CRM interface layer (separate track, own plan) + +Bidirectional connector (Sage REST/OData or DB-level) syncing +companies/contacts/deals so this app acts as the agentic front-end over Sage. +Do not start this without a dedicated plan document. + +--- + +## Security posture + +- Single-tenant Entra app; delegated, read-only scopes (`Mail.Read`, + `Calendars.Read`); admin consent. +- Bodies at rest but scoped: only threads matched to known contacts/companies + are stored; backfill targets a single address; `SuppressedDomain` excludes + vendors/newsletters; Screening Room stores metadata only. +- `FollowUpSuggestion.quote` is capped (<= 300 chars) — never a full body. +- Graph calls run in the API app runtime. The agent sandbox keeps deny-all + egress and never receives `DATABASE_URL` (see `docs/agent.md`). +- `EmailMessage.body` is already never logged and never included in list + payloads — keep it that way in the Microsoft module. + +## Open tradeoffs (decide at build time, record the decision in HANDOFF.md) + +- Backfill window (default 180 days) and page cap — cost vs completeness. +- Follow-up cadence: daily per-rep task vs after every sync (daily + recommended; avoids noise). +- Whether "active deals at a glance" stays in the Follow-ups panel or becomes + a dashboard tab. diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index e7ffdea3b..21e034e7f 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -3,7 +3,7 @@ import { type BetterAuthOptions, betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { APIError } from "better-auth/api"; import { env } from "./env"; -import { SYNC_SCOPES } from "./scopes"; +import { MS_SYNC_SCOPES, SYNC_SCOPES } from "./scopes"; import { hasSignInAllowList, isWorkspaceEmail, @@ -12,6 +12,19 @@ import { const socialProviders: NonNullable = {}; +if (env.microsoft) { + socialProviders.microsoft = { + clientId: env.microsoft.clientId, + clientSecret: env.microsoft.clientSecret, + // Single-tenant Entra app — never "common" for this CRM. + tenantId: env.microsoft.tenantId, + // Better Auth already requests openid/profile/email/User.Read/offline_access. + // Mail + Calendar are the CRM sync extras. + scope: [...MS_SYNC_SCOPES], + prompt: "select_account", + }; +} + if (env.google) { socialProviders.google = { ...env.google, @@ -59,7 +72,10 @@ export const auth = betterAuth({ }), emailAndPassword: { - enabled: false, + // Enabled so the CRM can be used without a Google OAuth client. The + // `databaseHooks.user.create.before` allow-list below still applies to + // registrations, so ALLOWED_SIGN_IN remains the whole authorisation model. + enabled: true, }, socialProviders, @@ -67,7 +83,13 @@ export const auth = betterAuth({ account: { accountLinking: { enabled: true, - trustedProviders: ["google"], + trustedProviders: ["microsoft", "google"], + // Credential sign-up does not run an email-verify loop in this CRM + // (ALLOWED_SIGN_IN is the door). Without this, Microsoft SSO cannot + // link onto an existing email/password user and returns + // account_not_linked. Prefer marking new users verified in the + // create hook below; this covers rows that already exist unverified. + requireLocalEmailVerified: false, }, }, @@ -132,7 +154,15 @@ export const auth = betterAuth({ }); } - return { data: user }; + // Allow-list is the only gate — there is no verification email. + // Mark verified so later OAuth linking (Microsoft) is not blocked + // by Better Auth's default requireLocalEmailVerified check. + return { + data: { + ...user, + emailVerified: true, + }, + }; }, }, }, diff --git a/packages/auth/src/client.ts b/packages/auth/src/client.ts index 93af55e85..feee021a6 100644 --- a/packages/auth/src/client.ts +++ b/packages/auth/src/client.ts @@ -10,6 +10,6 @@ export const authClient = createAuthClient({ baseURL: process.env.NEXT_PUBLIC_API_URL, }); -export const { getSession, signIn, signOut, useSession } = authClient; +export const { getSession, signIn, signOut, signUp, useSession } = authClient; export type AuthClient = typeof authClient; diff --git a/packages/auth/src/env.ts b/packages/auth/src/env.ts index 5eb184385..33bcdd3dc 100644 --- a/packages/auth/src/env.ts +++ b/packages/auth/src/env.ts @@ -38,6 +38,25 @@ const googleCredentials = (): return { clientId, clientSecret }; }; +const microsoftCredentials = (): + | { clientId: string; clientSecret: string; tenantId: string } + | undefined => { + const clientId = optional("MICROSOFT_CLIENT_ID"); + const clientSecret = optional("MICROSOFT_CLIENT_SECRET"); + const tenantId = optional("MICROSOFT_TENANT_ID"); + + if (!clientId || !clientSecret || !tenantId) { + if (clientId || clientSecret || tenantId) { + throw new Error( + "MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET, and MICROSOFT_TENANT_ID must be set together.", + ); + } + return undefined; + } + + return { clientId, clientSecret, tenantId }; +}; + /** * Where the API is — the origin that mints session cookies and serves * `/api/auth/*`. @@ -69,6 +88,7 @@ const appUrl = appUrls[0] ?? DEFAULT_APP_URL; export const env = { appUrl: apiUrl, google: googleCredentials(), + microsoft: microsoftCredentials(), cookieDomain: optional("AUTH_COOKIE_DOMAIN"), // The API's own origin is trusted too: Better Auth validates the // post-sign-in `callbackURL` against this list, and the OAuth round trip diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index cd93d0cad..b7900d791 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -2,8 +2,12 @@ export { type Auth, auth, type Session, type SessionUser } from "./auth"; export { CALENDAR_SCOPE, GMAIL_SCOPE, + hasMsSyncScopes, hasSyncScopes, IDENTITY_SCOPES, + MS_CALENDAR_SCOPE, + MS_MAIL_SCOPE, + MS_SYNC_SCOPES, parseScopes, REQUIRED_SCOPES, SYNC_SCOPES, diff --git a/packages/auth/src/scopes.ts b/packages/auth/src/scopes.ts index a678eec31..55353ab5c 100644 --- a/packages/auth/src/scopes.ts +++ b/packages/auth/src/scopes.ts @@ -42,6 +42,39 @@ export function hasSyncScopes(scope: string | null | undefined): boolean { return SYNC_SCOPES.every((needed) => granted.has(needed)); } +/** Microsoft Graph mail (delegated, read-only). */ +export const MS_MAIL_SCOPE = "Mail.Read"; +/** Microsoft Graph calendar (delegated, read-only). */ +export const MS_CALENDAR_SCOPE = "Calendars.Read"; + +/** + * Read-only Outlook mail and calendar. + * + * Better Auth's Microsoft provider already requests identity scopes + * (`openid`, `profile`, `email`, `User.Read`, `offline_access`). These two are + * the extras this CRM needs for sync. + */ +export const MS_SYNC_SCOPES = [MS_MAIL_SCOPE, MS_CALENDAR_SCOPE] as const; + +const GRAPH_SCOPE_PREFIX = "https://graph.microsoft.com/"; + +/** + * Whether a Microsoft grant covers mail and calendar sync. + * + * Microsoft may store scopes as short names (`Mail.Read`) or full Graph URIs + * (`https://graph.microsoft.com/Mail.Read`). Both forms count. + */ +export function hasMsSyncScopes(scope: string | null | undefined): boolean { + const granted = new Set( + [...parseScopes(scope)].map((entry) => + entry.startsWith(GRAPH_SCOPE_PREFIX) + ? entry.slice(GRAPH_SCOPE_PREFIX.length) + : entry, + ), + ); + return MS_SYNC_SCOPES.every((needed) => granted.has(needed)); +} + /** `Account.scope` as a set. Google returns it comma- or space-separated. */ export function parseScopes(scope: string | null | undefined): Set { return new Set( diff --git a/packages/db/prisma/migrations/20260802135359_add_outlook_ids/migration.sql b/packages/db/prisma/migrations/20260802135359_add_outlook_ids/migration.sql new file mode 100644 index 000000000..6f9aba807 --- /dev/null +++ b/packages/db/prisma/migrations/20260802135359_add_outlook_ids/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "calendarEvent" ADD COLUMN "outlookEventId" TEXT; + +-- AlterTable +ALTER TABLE "emailMessage" ADD COLUMN "outlookMessageId" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 89054c19b..a2d35f583 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -698,7 +698,9 @@ model EmailMessage { rfcMessageId String @unique /// Whose mailbox produced this copy, and its id there — for deep links. syncedByUserId String? - gmailMessageId String? + gmailMessageId String? + /// Outlook/Graph message id for deep links (mirrors gmailMessageId). + outlookMessageId String? direction EmailDirection fromEmail String @@ -743,6 +745,8 @@ model CalendarEvent { syncedByUserId String? googleEventId String? + /// Outlook/Graph event id for deep links (mirrors googleEventId). + outlookEventId String? attendees CalendarAttendee[] activity Activity? diff --git a/packages/ui/src/components/brand-logos/microsoft.tsx b/packages/ui/src/components/brand-logos/microsoft.tsx new file mode 100644 index 000000000..976a60fde --- /dev/null +++ b/packages/ui/src/components/brand-logos/microsoft.tsx @@ -0,0 +1,17 @@ +import type * as React from "react"; + +const MicrosoftLogo = (props: React.SVGProps) => ( + +); + +export default MicrosoftLogo; From 5045aa05bf76fc7616e52cabcd5c096c9c7f3bae Mon Sep 17 00:00:00 2001 From: Jordan Johnson Date: Sun, 2 Aug 2026 09:35:41 -0500 Subject: [PATCH 02/84] docs: record upstream sync to comp design system (288d41a) in HANDOFF Log the branch-first merge of upstream trycompai/crm @288d41a into m365-expansion (zero conflicts) and the post-merge verification. Note the new git layout: work on m365-expansion, main tracks pure upstream. Co-authored-by: Cursor --- HANDOFF.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/HANDOFF.md b/HANDOFF.md index 378353482..183e0bdcd 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -23,6 +23,21 @@ it before stopping. The rules for maintaining it live in `AGENTS.md` ## Current state (keep this section up to date) +- **Git layout (NEW)**: local work now lives on branch **`m365-expansion`**. + `main` tracks upstream `trycompai/crm` and was fast-forwarded to `288d41a` + (the "comp design system" release). Upstream was merged INTO + `m365-expansion` with zero conflicts. The pre-merge snapshot of all local + work is commit `421e556`. To keep updating in future: commit on + `m365-expansion`, `git checkout main && git merge --ff-only origin/main`, + then `git checkout m365-expansion && git merge main`. +- **Upstream delta absorbed**: the new release restyled ~40 `packages/ui` + components + `globals.css`, split the company overview, added a favicon + resolver, and added row-hover prefetch. This is cosmetic/mechanical and did + not touch the Microsoft module. **Visual pass still needed** on the + Microsoft settings panel and sign-in page against the new design system. +- **Verification after merge**: `check-types` 10/10, `lint` 7/7, `test` + 111 pass / 0 fail (incl. `hasMsSyncScopes`). `server.ts` regenerates + identical (10 routers, 47 procedures) — no drift. - **Runs locally**: Bun 1.3.12 monorepo; Postgres via `docker compose up -d`; `bun run dev` serves app on :3000 and API on :3001. Setup details in `docs/local-setup.md`. @@ -43,6 +58,53 @@ it before stopping. The rules for maintaining it live in `AGENTS.md` ## Work log (newest first) +### 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 ` + + + + + + {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)/screening/page.tsx b/apps/app/app/(app)/screening/page.tsx new file mode 100644 index 000000000..573a9c7da --- /dev/null +++ b/apps/app/app/(app)/screening/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 { 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 you email 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..20107ec9e --- /dev/null +++ b/apps/app/app/(app)/screening/screening-table.tsx @@ -0,0 +1,204 @@ +"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 { + 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 { + 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 { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; +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]; + +function PendingActions({ row }: { row: PendingRow }) { + const trpc = useTRPC(); + const cache = useCrmCache(); + const openRecord = useOpenRecord(); + const queryClient = useQueryClient(); + const [suppressDomain, setSuppressDomain] = useState(false); + const suppressId = useId(); + + const invalidate = async () => { + await queryClient.invalidateQueries({ + queryKey: trpc.screening.list.queryKey(), + }); + }; + + const decide = useMutation( + trpc.screening.decide.mutationOptions({ + onSuccess: async (result) => { + await invalidate(); + if (result.decision === "approve" && result.contactId) { + await cache.contact(result.contactId); + toast.success("Contact created."); + openRecord({ kind: "contact", id: result.contactId }); + return; + } + toast.success( + suppressDomain ? "Rejected — domain suppressed." : "Rejected.", + ); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const busy = decide.isPending; + + return ( + + + + + + + setSuppressDomain(checked === true)} + disabled={busy} + /> + + + + ); +} + +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 external mail will show up here after sync. + + + + ); + } + + return ( +
+ + + + Name + Email + Domain + Messages + + Sample subject + + + Last seen + + + Actions + + + + + {rows.map((row) => ( + + + {row.displayName ? ( + {row.displayName} + ) : ( + + {row.email} + + )} + + + {row.email} + + + {row.domain} + + {row.messageCount} + + {row.sampleSubject ? ( + {row.sampleSubject} + ) : ( + + )} + + + {relativeTimeFromIso(row.lastSeenAt)} + + + + + + ))} + +
+
+ ); +} diff --git a/apps/app/components/app-icon-rail.tsx b/apps/app/components/app-icon-rail.tsx index 659ae1df2..d4bac2717 100644 --- a/apps/app/components/app-icon-rail.tsx +++ b/apps/app/components/app-icon-rail.tsx @@ -5,6 +5,8 @@ import type { CarbonIconType } from "@carbon/icons-react/es/CarbonIcon"; import Dashboard from "@carbon/icons-react/es/Dashboard"; import Partnership from "@carbon/icons-react/es/Partnership"; import Settings from "@carbon/icons-react/es/Settings"; +import TaskStar from "@carbon/icons-react/es/TaskStar"; +import UserFollow from "@carbon/icons-react/es/UserFollow"; import UserMultiple from "@carbon/icons-react/es/UserMultiple"; import { Button } from "@crm/ui/components/button"; import { Icon } from "@crm/ui/components/icon"; @@ -41,6 +43,18 @@ const ITEMS: RailItem[] = [ match: "prefix", }, { title: "Deals", href: "/deals", icon: Partnership, match: "prefix" }, + { + title: "Follow-ups", + href: "/follow-ups", + icon: TaskStar, + match: "prefix", + }, + { + title: "Screening", + href: "/screening", + icon: UserFollow, + match: "prefix", + }, { title: "Settings", href: "/settings", icon: Settings, match: "prefix" }, ]; diff --git a/apps/app/components/crm/timeline/timeline.tsx b/apps/app/components/crm/timeline/timeline.tsx index f4e64bbf1..7b7e94c49 100644 --- a/apps/app/components/crm/timeline/timeline.tsx +++ b/apps/app/components/crm/timeline/timeline.tsx @@ -65,7 +65,7 @@ const EMPTY_STATES: Record< email: { title: "No email", description: - "Threads appear here as they are synced from Gmail. Nothing from before this mailbox was connected is imported.", + "Threads appear here as they are synced from your mailbox. Nothing from before this mailbox was connected is imported.", }, meetings: { title: "No meetings", diff --git a/apps/app/components/theme-provider.tsx b/apps/app/components/theme-provider.tsx index 179ae36e4..4d07eb5b9 100644 --- a/apps/app/components/theme-provider.tsx +++ b/apps/app/components/theme-provider.tsx @@ -3,6 +3,28 @@ import { ThemeProvider as NextThemesProvider } from "next-themes"; import type * as React from "react"; +// next-themes injects an inline