# Buddy CRM — Architecture Overview

Last updated: 2026-05-06

## What is Buddy CRM?

Buddy CRM is the standalone Sales CRM for **Now NZ** (a New Zealand telco), extracted from the original internal Buddy platform. It provides lead management, opportunity tracking, quoting, email & calendar integration, audience sync, reporting, and admin dashboards — all behind Microsoft SSO.

**Production URL:** see `ALLOWED_ORIGINS` in the Netlify site env vars (the deployed URL is intentionally not committed to source).

**Owner:** Marc Burborough — `marc.burborough@nownz.co.nz` (super_admin).

> The `docs/handover-2026-05-06.md` snapshot is the source of truth for what's shipped, what's open, and what's parked. This document covers the architecture only — read the handover for state-of-play.

---

## System Diagram

```
                         ┌─────────────────────────────────────────┐
                         │              USER'S BROWSER             │
                         │                                         │
                         │  Desktop: ~22 HTML pages + Alpine.js    │
                         │  Mobile:  PWA at /mobile/* (6 pages)    │
                         │  Auth:    MSAL.js 2.x (Microsoft SSO)   │
                         │  State:   localStorage / sessionStorage │
                         └──────────────┬──────────────────────────┘
                                        │
                            HTTPS (Bearer JWT in Authorization header)
                                        │
                         ┌──────────────▼──────────────────────────┐
                         │         NETLIFY (Hosting Layer)         │
                         │                                         │
                         │  Static:    HTML + shared/ + mobile/    │
                         │  Functions: 36 Netlify Functions        │
                         │  Config:    netlify.toml, _redirects    │
                         │  Env vars:  ~13 secrets (see env doc)   │
                         │  Deploy:    git push main → auto-deploy │
                         └──┬────────┬────────┬────────┬───────────┘
                            │        │        │        │
                  ┌─────────▼─┐  ┌───▼──────┐ ┌▼──────┐ ┌▼──────────┐
                  │ SUPABASE  │  │MICROSOFT │ │GOOGLE │ │ MAILCHIMP │
                  │           │  │          │ │       │ │           │
                  │ ~28 tables│  │ Entra ID │ │Gemini │ │ Audiences │
                  │ RPC funcs │  │ + Graph  │ │  AI   │ │ Campaigns │
                  │ no storage│  │   API    │ │       │ │           │
                  └───────────┘  └──────────┘ └───────┘ └───────────┘
                                        │
                                  ┌─────▼──────┐
                                  │   GMAIL    │
                                  │   SMTP     │
                                  │ (notifs)   │
                                  └────────────┘
```

The Buddy CRM Supabase **project is shared** with the original Buddy app — same `leads`, `opportunities`, `salespeople`, `pricebook`, `feature_flags`, `user_permissions`, `skill_queue_*` tables. CRM-specific tables (email sync, calendar sync, sales-home targets, saved reports, opp line items, etc.) were added by the migration files in the repo root.

---

## Tech Stack

| Layer | Technology | Version / source |
|---|---|---|
| Frontend | Vanilla JS + HTML/CSS | — |
| UI reactivity | Alpine.js 3.x | CDN |
| Auth (browser) | MSAL.js 2.x | CDN |
| Backend | Netlify Functions (Node.js) | esbuild bundled |
| Database | Supabase (PostgreSQL) | `@supabase/supabase-js ^2.49.1` |
| JWT verification | jsonwebtoken + jwks-rsa | `^9.0.3` / `^4.0.1` |
| Quote PDF | jspdf + jspdf-autotable, pdf-lib | `^4.2.1` / `^5.0.7` / `^1.17.1` |
| AI | Google Gemini 2.5 Flash | REST |
| Email — outbound | Microsoft Graph (`/me/sendMail` + `createReply`) | REST |
| Email — inbound | Microsoft Graph (poller + change-notification webhook) | REST |
| Calendar | Microsoft Graph `/me/calendarView` (15-min poller) | REST |
| Mailchimp | REST API v3.0 | — |
| Notifications (email) | nodemailer (Gmail SMTP) | `^8.0.4` |
| HTTP client | node-fetch | `^2.7.0` |

There is no build step. The repo root is the publish dir; functions are bundled by Netlify on deploy.

---

## Repo / File Structure

```
buddy-crm/
├── index.html                          # Sales-home dashboard + app launcher
├── lead-crm.html                       # Leads list + detail
├── opportunity-buddy.html              # Opportunities list + detail
├── sales-buddy.html                    # AI lead researcher (Gemini)
├── quote-buddy.html                    # Quote builder + PDF
├── email-buddy.html                    # Templated email composer
├── audience-buddy.html                 # Mailchimp/Salesforce sync UI
├── calendar.html                       # Week-view calendar (read-only) + outcomes
├── outlook-sync.html                   # Email/meeting Needs-Review queues
├── lead-reports.html, opportunity-reports.html, bdm-review.html, report-builder.html
├── manage-*.html                       # 7 admin pages (salespeople, pricebook, features, regions, sources, lost-reasons, products)
├── skill-queue.html, buddy-docs.html
│
├── shared/                             # Loaded by every desktop page
│   ├── auth.js                         # MSAL bootstrap; must load first
│   ├── features.js                     # Feature-flag client + caching
│   ├── nav.js                          # Sidebar + theme + auto-save
│   ├── styles.css                      # Global theme
│   ├── utils.js                        # Shared helpers
│   ├── anzsic-prompt.js                # ANZSIC reference list (used by Sales Buddy desktop + mobile)
│   ├── anzsic.js                       # ANZSIC code data
│   └── sales-buddy-prompt.js           # Pre-call brief Gemini prompt
│
├── mobile/                             # PWA — installed via Add-to-Home-Screen
│   ├── index.html, leads.html, lead-edit.html, research.html, scan.html, my-card.html
│   ├── manifest.webmanifest, sw.js
│   ├── css/, js/, icons/
│
├── netlify/functions/                  # 34 endpoints + 2 helpers
│   ├── supabase-client.js              # Helper: DB client, JWT verify, CORS, perms
│   ├── _token-crypto.js                # Helper: AES-256-GCM for refresh tokens
│   ├── auth-outlook.js                 # Outlook OAuth start/callback (Graph scopes)
│   ├── calendar-sync.js                # 15-min poller for /me/calendarView
│   ├── email-sync.js                   # Email poller (Layer 1) + folder sync (Layer 2)
│   ├── email-messages.js               # GET email message body for timeline expand
│   ├── email-review-queue.js           # CRUD on email_review_queue
│   ├── meeting-events.js               # GET list / GET pending-outcome / PATCH outcome
│   ├── meeting-review-queue.js         # CRUD on meeting_review_queue
│   ├── graph-webhook.js                # Microsoft Graph subscription notifications
│   ├── notifications.js                # Bell-badge unread counter
│   ├── leads.js, leads-report.js
│   ├── opportunities.js, opportunities-report.js
│   ├── lead-sources.js, lead-regions.js, lead-products.js, lead-lost-reasons.js
│   ├── salespeople.js, pricebook.js
│   ├── quote-generate.js, quote-save.js, quote-assets.js
│   ├── quote-signing.js                # E-sign portal + dashboard backend
│   ├── contract-upload.js              # Manual signed-contract upload (opp-level)
│   ├── reports.js, saved-reports.js
│   ├── sales-home.js                   # Dashboard tiles + targets
│   ├── sales-tv.js                     # Real-time sales floor dashboard backend
│   ├── account-summary.js              # Customer 360 fan-out by now_account_id
│   ├── global-search.js                # Multi-entity search (auto-detects type)
│   ├── feature-flags.js
│   ├── gemini.js                       # Server-side Gemini proxy
│   ├── mailchimp.js                    # Server-side Mailchimp proxy
│   ├── mrr-data.js                     # Legacy MRR module
│   ├── skill-queue.js
│   └── send-notification.js            # Gmail SMTP for skill-queue results
│
├── netlify.toml                        # Bundler, headers, CSP, function timeouts
├── _redirects                          # 16 clean URL rewrites (/leads, /calendar, /search, /account, etc.)
├── package.json                        # 8 npm dependencies
├── README.md, CLAUDE.md
├── docs/                               # This directory
└── supabase-migration-*.sql            # 21 idempotent migrations (run via Supabase SQL Editor)
```

### Page categories

- **CRM:** index (dashboard), sales-buddy, lead-crm, lead-reports, opportunity-buddy, opportunity-reports, bdm-review, report-builder, sales-tv, search, account
- **Quoting & email:** quote-buddy, email-buddy
- **Calendar / Outlook:** calendar, outlook-sync
- **Marketing:** audience-buddy
- **Automation:** skill-queue
- **Admin:** manage-salespeople, manage-pricebook, manage-features, manage-regions, manage-lead-sources, manage-closed-lost-reasons, manage-interested-products, buddy-docs
- **Mobile PWA:** `/mobile/*`

### Page load order (every authenticated desktop page)

1. MSAL.js (CDN) — Microsoft auth library
2. `shared/auth.js` — runs in `<head>`, hides page until auth resolves
3. `shared/features.js` — fetches flags, gates UI elements via `data-feature="key"`
4. `shared/styles.css`
5. `shared/nav.js` — sidebar, theme, auto-save hooks
6. `shared/utils.js` — helpers
7. Page-specific `<script>` — Alpine components and page logic

Mobile pages skip the desktop nav and use `mobile/js/shell.js` instead. Inline page scripts must wait for `DOMContentLoaded` before reaching for `window.Mobile` (see comments in mobile pages).

---

## Authentication Flow

### Browser

```
Page load
    │
    ▼
shared/auth.js executes immediately in <head>
    │
    ├── localhost? → set Dev User, return 'dev-token' for getAuthToken()
    │
    └── Production:
         ├── Hide page (display: none on html:not(.authenticated))
         ├── Initialize MSAL PublicClientApplication
         ├── handleRedirectPromise() — handles return from Microsoft
         ├── Existing account in sessionStorage?
         │     ├── YES → add .authenticated class, expose globals,
         │     │         restore deep-link from authReturnUrl if any
         │     └── NO  → save URL, msalInstance.loginRedirect(...)
         └── Globals exposed:
              window.ultimateBuddyUser = { name, email, account }
              window.getAuthToken()    → Microsoft ID token (for Buddy API)
              window.getGraphToken()   → Microsoft Graph access token
              window.ultimateBuddyLogout()
```

`getAuthToken()` silently acquires an ID token via MSAL and force-refreshes if it's within 5 minutes of expiry. On failure it triggers `loginRedirect`.

`getGraphToken()` requests scopes `User.Read`, `Mail.ReadWrite`, `Calendars.Read`, `offline_access` ([shared/auth.js:178](../shared/auth.js#L178)). `Mail.ReadWrite` covers send + look up the just-sent message in Sent Items. `offline_access` enables longer-lived silent refresh.

MSAL cache is `sessionStorage`. **Don't change this** — `localStorage` was rejected because it broke the working desktop + PWA paths. As a consequence, mobile sign-in is PWA-only by design (Safari-tab login on iPhone fails because MSAL session is partitioned between Safari and the standalone PWA window).

### Server

Each Netlify Function calls `getUserFromToken(authHeader)` from [netlify/functions/supabase-client.js](../netlify/functions/supabase-client.js):

1. Decode JWT header → get `kid`
2. Fetch Microsoft public key from JWKS (cached 10 min)
3. Verify signature (RS256), issuer (`login.microsoftonline.com/{TENANT_ID}/v2.0`), audience (`CLIENT_ID`), expiry
4. Extract email from `preferred_username` / `upn` / `email` claim
5. Return `{ email, name }` lowercased, or `null` on failure

Two non-JWT paths:
- `BUDDY_SERVICE_KEY` env var matches the bearer token → returns `{ email: 'mcp-service@nownz.co.nz' }` (used by external integrations).
- The literal `dev-token` is rejected on production but passes the localhost branch in the browser.

The default response shape and CORS are produced by `respond(...)` and `setOrigin(event)`. CORS reads `ALLOWED_ORIGINS` (comma-separated; falls back to the legacy single `ALLOWED_ORIGIN`) and echoes the matching request origin.

---

## Outlook Integration (Email + Calendar)

There are two distinct flows the Outlook subsystem uses.

### 1. Server-side OAuth (refresh-token storage)

[netlify/functions/auth-outlook.js](../netlify/functions/auth-outlook.js) runs the auth-code OAuth flow per BDM and stores the refresh token encrypted in `user_graph_tokens`. Encryption is AES-256-GCM with `EMAIL_TOKEN_KEY` (32-byte hex) — see [netlify/functions/_token-crypto.js](../netlify/functions/_token-crypto.js). The DB never sees plaintext tokens.

Scopes requested: `offline_access`, `Mail.ReadWrite`, `Calendars.Read`, `User.Read`.

### 2. Layer 1 — pollers

- `email-sync.js` runs on a schedule (15 min). For each BDM with a stored refresh token, it polls `/me/messages` filtered by Buddy-tagged conversations and inserts new messages into `email_messages`. Activity rows on Leads/Opps reference `email_message_id` for lazy expand.
- `calendar-sync.js` runs on a schedule (15 min). Polls `/me/calendarView`, applies the **0/1/2+ attendee match** policy:
  - 0 customer attendees → drop entirely (privacy guardrail; pure-internal meetings never persist)
  - 1 match → insert into `meeting_events` linked to that lead/opp
  - 2+ → insert into `meeting_review_queue` for the BDM to disambiguate

Both pollers maintain `email_sync_log` / their own state on `user_graph_tokens.last_sync_at`.

### 3. Layer 2 — `@Buddy/Auto-Log` folder + change webhooks

For inbound mail outside Buddy-started threads, the BDM moves a message into a Microsoft folder named `@Buddy/Auto-Log`. A Microsoft Graph subscription on that folder calls [graph-webhook.js](../netlify/functions/graph-webhook.js); the webhook validates `clientState`, then defers to `email-sync.js` to import. Subscriptions expire every ~3 days; the poller renews any row in `email_subscriptions` with `expires_at` within 24h.

Messages that don't auto-match a Lead/Opp land in `email_review_queue` and are surfaced on `outlook-sync.html`.

### 4. Activity timeline

`lead_activities` and `opportunity_activities` carry `message_id`, `conversation_id`, `internet_message_id`, `received_at`, `email_message_id`, `meeting_event_id` columns. The timeline renderer branches on which is set, so a single "expand body" surface handles both emails and meetings.

---

## Feature Flag System

```
1. After auth, features.js fetches /.netlify/functions/feature-flags
2. Server resolves the user's permission (user_permissions table) and
   department (salespeople table)
3. For each row in feature_flags:
     - !enabled                                         → false
     - userPermission < min_permission                  → false
     - departments != [] AND user is not super_admin
       AND userDept ∉ departments                       → false
     - else                                             → true
4. Returns { permission, department, features: {...} }
5. Client caches in sessionStorage (15-minute TTL)
6. window.hasFeature(key) used to gate UI
7. DOM elements with data-feature="key" are auto-hidden/shown
```

Permission hierarchy: `user` → `manager` → `admin` → `super_admin`. Default is `user` if not in `user_permissions`.

> Use **"permission"** (not "role") in code and conversation — this is a project convention.

Notable flag: `report_builder` is `min_permission='manager'` so reps can't pull the full pipeline.

---

## Skill Queue

Async task execution that lets the app offload work to a Claude Code agent.

```
1. UI (or another function) POSTs a task → /.netlify/functions/skill-queue
2. Row inserted into skill_queue_tasks with status='pending'
3. A skill-queue-worker (Claude Code agent, not in this repo) polls for pending
4. Worker calls the atomic claim RPC → status='in_progress'
5. Worker runs the matching skill (sales_research, lead_enrichment,
   quote_generation, salesforce_order, audience_buddy)
6. Worker reports result → status='complete' or 'failed'
7. send-notification.js emails the submitter (success) or super_admin (failure)
```

Priority levels: `1` Critical, `2` High, `3` Normal, `4` Low, `5` Lowest.
Status enum: `pending`, `in_progress`, `complete`, `failed`, `cancelled`.

Atomic claim: `skill_queue_claim_task(p_task_id UUID)` RPC — sets `in_progress` only if the row is still `pending`, eliminating worker races.

The worker itself lives outside this repo; it authenticates with `BUDDY_SERVICE_KEY` so its calls bypass JWT verification and appear as `mcp-service@nownz.co.nz`.

---

## Sales Home Dashboard

[index.html](../index.html) renders 8 tiles backed by [netlify/functions/sales-home.js](../netlify/functions/sales-home.js) in a single round trip:

- Leads created this month vs target
- New MRR booked this month vs target
- Qualified MRR this month vs target
- Pipeline coverage
- Stale leads (no activity in N days)
- Recently closed-won
- Recently closed-lost
- Greeting + tab-focus refresh

Targets per BDM live in `salesperson_targets` keyed by `(email, month)`. If no row exists, the function falls back to schema defaults (40 leads / $4000 MRR / $4000 qualified MRR), so the table can be empty on day one and the dashboard still renders.

`opportunities.js` accepts three filter params used for tile drill-throughs: `closed_from`, `qualified_from`, `stale=true`.

`sales-home.js` also accepts `?email=<bdm>` but only honours it when caller is `super_admin` or `manager` — the foundation for a manager view.

---

## Calendar v1 (read-only week view)

[calendar.html](../calendar.html) is a custom CSS-Grid week view (no FullCalendar.js) reading from `meeting_events` and `meeting_review_queue` via [meeting-events.js](../netlify/functions/meeting-events.js). Visual states: green pill (linked to lead/opp), amber pill (in review). Clicking an event opens a side panel with details — or a match picker if it's still in the review queue.

Below the grid, an **Outcome needed** panel surfaces past meetings where `outcome IS NULL AND end_at < now()`. Four pill buttons (Completed / No-show / Cancelled / Rescheduled) PATCH `meeting-events` and write a `meeting_outcome` activity row on the linked lead/opp.

Phase 3 (Buddy → Outlook write-back), drag-reschedule, and day/month views are deliberately parked — see the handover for trigger conditions and the recurring-meeting refactor cost they imply.

---

## Mobile PWA

`/mobile/*` is a PWA installed via Add-to-Home-Screen. Same MSAL redirect flow and same Entra app as desktop; backend functions and `shared/*` are fully shared.

Phases shipped:
1. Shell scaffold (manifest, service worker, mobile.css, shell.js, UA-sniff redirect from desktop home)
2. Research → Lead flow
3. Photo → Lead via Gemini multimodal (always-on review screen)
4. My Leads list (active-only, owner = me, edit existing leads via `?id=`)
4b. My Card — vCard-encoded QR business card (4th tab)

Phase 5 (polish — iOS transitions, pull-to-refresh, dark-mode tuning, haptics) is parked until BDMs use it for a couple of weeks.

**Don't change** `shared/auth.js` `cacheLocation` to `localStorage` to "fix" Safari-tab login. That regresses the desktop + PWA paths. PWA-only sign-in is the v1 stance.

---

## Key Architectural Decisions

1. **No framework** — vanilla JS + Alpine.js. Low complexity, fast page loads, no build step. Each page is self-contained.
2. **SSO-only authentication** — Microsoft Entra ID is the only auth method. No password storage.
3. **Service-key pattern** — all **database** access goes through Netlify Functions using the Supabase service key; the browser never touches the DB directly. The sole browser↔Supabase traffic is **Storage** via short-lived signed URLs minted server-side: the e-sign portal's pdf.js read (`sign-contract.html`) and the manual signed-contract upload's `PUT` (`contract-upload.js`). These carry no service key — the signed URL itself is the capability — and require `https://*.supabase.co` in the per-route `connect-src` CSP.
4. **Feature flags as first-class** — every nav item and tool can be independently enabled/disabled per permission level and department without code changes.
5. **localStorage auto-save** — pages opt in via `collectAutoState()` / `restoreAutoState()` hooks in `nav.js`. Reduces server calls and provides resilience.
6. **Minimal RLS** — all access control is enforced in application code via `checkFeatureAccess()` and per-function ownership checks. RLS is only used where Supabase Realtime requires it (currently nothing in the CRM fork uses Realtime).
7. **Privacy guardrail in Outlook sync** — internal meetings (zero customer attendees) are dropped at sync time and never persist.
8. **Idempotent migrations** — every `supabase-migration-*.sql` in repo root is re-runnable. Migrations are applied manually through the Supabase SQL Editor.

---

## What's NOT in this repo

The CRM fork was extracted from the original Buddy platform. Things that exist in original Buddy but **not here**:

- Project Buddy (`project_buddy_*` tables, project-management functions)
- Learning Buddy / LMS (`learn_*` tables, course functions)
- Quote e-signature workflow (signing audit trail + storage bucket)
- The MCP server (`mcp-server/`) and Order Buddy skill — they live in the original Buddy repo

The shared Supabase project still has those tables for the original app's use; this CRM fork simply doesn't read or write them.
