> ## Documentation Index
> Fetch the complete documentation index at: https://docs.myfundingmachine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Client upload operator alerts

# Client Upload → Operator Alerts — Subsystem Reference & Status Tracker

**This file is the ONLY status tracker for the upload-alerts subsystem.** It documents what the four delivered phases actually build, not what was once planned for them. Companion surface: the operator-facing settings page at `/settings/operator-alerts`.

**What the subsystem does:** when a client uploads documents through a secure upload link, tell the operator who created that link. A batch of files becomes one alert, and that alert fans out to up to five channels the location controls independently.

**Source of truth for this document:** `origin/main`, read 2026-08-03, with the counter removal pinned at `058be5c1` (#1391). All four phases plus that follow-up are merged and live.

> **Read this subsystem on `origin/main`, not on the phase branches.** The four `feat/upload-alerts-*` branches were squash-merged as PRs #1376–#1379 and now exist only as stale local copies — they are behind `main` and were never pushed to `origin`. Their merge-base predates the squashes, so `git diff origin/main...feat/upload-alerts-4-email-sms` misleadingly renders the entire subsystem as unmerged additions. Use a two-dot tip comparison (`git diff origin/main feat/…`) if you need to compare at all — and expect real drift now, not just none: #1391 landed after those branches and removed `incrementTokenUploadCount` from `convex/documents.ts`.

***

## 0. How to read this file

1. Read §1 (rules) and §2 (landmines) in full before touching any file this subsystem owns. Do not skim §2 — every entry is a bug that was written, caught, and fixed during these four phases, and every one is re-introducible by an innocent-looking edit.
2. §3 is the status. The Done table says what each phase landed; the Remaining table is the only place new work belongs.
3. §4 is the channel matrix — the fastest way to answer "why did this operator not hear about an upload?"
4. §5–§8 are reference: the alert lifecycle, the data model, recipient resolution, and the test map.

**Interpretation rules:**

* Code anchors here are **symbol names, never line numbers**. Locate them with `rg -n "<symbol>" <path>`. If a named symbol does not exist at the stated path, STOP and report it rather than picking a similar-looking one.
* This doc describes behavior that exists on `origin/main`. If the code and this doc disagree, **the code wins** — fix the doc in the same PR that revealed the drift.
* Do not add future phases to §3. The Remaining table's TODO placeholders are deliberate: nobody has scoped that work, and inventing a phase here makes it look decided.

## 1. Non-negotiable rules

1. **bun only.** `bun install`, `bunx`, `bun test`. Never npm/npx/yarn.
2. **The full gate** must pass before any change here is done, in this order:
   ```
   bun run convex:codegen     # wraps `worktree:init` + `bunx convex codegen`; the raw
                              # command fails in a fresh worktree with no .env.local
   bunx tsc --noEmit          # expected: no output, exit 0
   bun run lint               # expected: exit 0 (warnings OK, errors not)
   bun test                   # expected: 0 fail
   ```
   Do NOT stage `convex/_generated/api.d.ts` drift produced by codegen.
3. **One branch + one PR per change.** The original four phases shipped as separate stacked PRs (#1376–#1379) rather than one; keep follow-up work similarly narrow. **Never commit to `main`.**
4. **Convex auto-deploys to prod** when `main` receives pushes touching `convex/**`. A merged schema change here is live within minutes.
5. **Read `convex/_generated/ai/guidelines.md` before editing any `convex/` file.**
6. **User-facing copy says "CRM", never "GHL"/"GoHighLevel"/"HighLevel".** Backend code and comments may say GHL. This is pinned by a test — `tests/documentUploadNotify.test.ts` → `'copy never says GHL or HighLevel'` joins every builder's output and asserts none of the three strings appear.
7. **`mock.module()` is process-global in `bun test`.** Both alert test files re-install the real `convex/_generated/api` proxy (`mock.module('../convex/_generated/api', () => ({ api: anyApi, internal: anyApi }))`) because other suites stub it process-wide. Removing those two lines breaks the suites in full-run order but not in isolation.
8. **Every public function here carries `args` + `returns` validators**, and every scheduled function is an `internal*`. The fan-out is an `internalAction`; the finalize and suppression jobs are `internalMutation`s.
9. **No unbounded `.collect()`.** Both table scans in this subsystem are bounded: `MAX_PENDING_ALERTS_PER_LOCATION` (20) for the banner query and `SUPPRESSION_BATCH_SIZE` (100) per suppression pass.

***

## 2. Landmines (do-NOT list)

Each entry names the fix commit that encodes it. These are failure semantics, not style preferences — the tests listed are the ones that fail if you undo them.

> The short SHAs below are the **pre-squash** commits from the phase branches. Each phase was squash-merged, so those SHAs resolve on no ref you have — `git show 5ddd8d16` will fail. They identify the fix and its message; to read the landed change, use the phase PR in §3 instead.

1. **Do NOT report the upload allowance from the client, and do NOT coalesce on `contactId`.** (`5ddd8d16 fix(documents): spend the upload allowance and coalesce per link`)
   * *Allowance:* `uploadsUsed` used to be incremented by a separate client-called mutation (`documents.incrementTokenUploadCount`), so anyone calling the public `documents.uploadDocument` directly never spent it and `maxUploads` never actually bound. Validation and spending must close in one mutation: `findValidUploadToken` → `consumeUploadAllowance` → `recordClientUploadForAlert`, all inside `uploadDocument`. A token with no `maxUploads` is unbounded **by design, not uncounted** — `consumeUploadAllowance` still runs.
   * *Coalescing:* the window is keyed on the upload **link**, not the contact. Two live links for one contact can have different creators, and merging them credits every upload to whoever opened the window first — the wrong operator gets told. Pinned by `'two live links for the same contact do not merge into one alert'`.
   * **History:** the old counter, `documents.incrementTokenUploadCount`, survived phase 1 as an orphaned public mutation — its last caller went away when `DocumentUploadForm` stopped calling it, but the mutation stayed exported and would have double-spent the allowance against a token `uploadDocument` had already charged. It was removed in #1391 (`058be5c1`), so `rg -n "incrementTokenUploadCount" convex/` now returns nothing on `origin/main`; a hit means either someone has re-added it or your tree predates #1391 — check before assuming the former. The rule: **`uploadsUsed` has exactly one write path, inside `uploadDocument`.** Do not add a second.

2. **Do NOT find the open alert by scanning the contact's alerts.** (`d4a8cf3c fix(documents): index the collecting alert by token instead of scanning`)
   Matching happens through the `by_uploadToken_status` index on `documentUploadAlerts`, keyed `["uploadTokenId", "status"]`. It used to be a bounded scan of the contact's open alerts, so a contact holding more live links than the bound had the right window truncated away and the upload opened a duplicate alert instead of joining. Scoping by contact would require a bounded scan across every live link that contact holds — which is exactly the bug. Pinned by `'a contact holding many live links still joins the right window'` (25 links).

3. **Do NOT schedule the fan-out from the finalize job.** (`0c5ebd1c fix(documents): fan out from the state transition, not the finalize job`)
   `ctx.scheduler.runAfter(0, internal.documentUploadNotify.dispatchUploadAlert, …)` lives inside `closeCollectingAlert`, the single function every route out of `collecting` passes through. Scheduling it from `finalizeUploadAlert` instead missed the orphan-recovery path: an alert whose finalize job died and was rescued by the next upload reached the banner but never got its note or task, and nothing came along later to notice. Pinned by `'a rescued orphan alert still reaches the CRM channels'`. The same placement gives idempotency for free — `finalizeUploadAlertInDb` no-ops unless the row is still `collecting`, so a late or duplicated job run dispatches nothing (`'normal finalization dispatches exactly once'`).

4. **Do NOT let one channel's exception abort the fan-out.** (`5b7eadb5 fix(documents): keep one channel's exception from aborting the fan-out`)
   Every channel runs through `attemptChannel(channel, deliver)`, which converts a throw into a recorded `failed` delivery. The CRM helpers return a result object for an HTTP error, but a network fault, an aborted fetch, or a bad payload still throws — and an unhandled throw meant sibling channels never ran *and* the outcomes already gathered were never written. Two structural pieces enforce this and must both stay: `attemptChannel` around each channel, and the `finally` block in `dispatchUploadAlert` that calls `recordDeliveries` regardless of how the body exits. A 403 on the task must not cost the operator their note.

5. **Do NOT guess a recipient when the CRM user directory is unreadable.** (`71cdfa89`, `17e134b3 fix(ghl): let the user directory report a failed read instead of guessing`, `ba041091`)
   A location with no users and a directory that could not be read both produce an empty list. `fetchUsersByLocation` returns `{ ok, users, detail }` precisely so callers can tell them apart; `getUsersByLocation` is now a thin wrapper for callers that only display or scan. Treating a failed read as "that operator is gone" is worse than not sending: the task channel skips with a misleading "configure a fallback" message, and email and SMS quietly go to the free-form fallback contact — a different person than the one who asked for the documents. So `resolveRecipientForDispatch` returns `{ ok: false }` on a failed read and every addressed channel records a `failed` delivery explaining why.
   Two refinements ride with this and are easy to undo by accident:
   * A location that **genuinely has no users** is a successful read and still falls through to the configured fallback. `ok: false` must mean "the read failed", never "the list was empty".
   * When neither `notifyUserId` nor `fallbackUserId` is set, the directory read is **skipped entirely** (`ba041091`) — there is no user id to look up, so the CRM has no say in the answer and an outage must not block a purely free-form fallback.

6. **Do NOT leave the pending backlog behind when the banner is switched off.** (`60456a34 fix(settings): retire the pending backlog when the banner is switched off`)
   Nothing can dismiss a row nobody can see. Rows that went `pending` before the operator turned the banner off keep that status, and left alone they all reappear the moment the channel comes back on. `operatorNotificationSettings.saveForLocation` detects the on→off edge (comparing against `DEFAULT_UPLOAD_CHANNELS.inApp` when no row existed yet) and schedules `suppressPendingAlertsForLocation`. That job drains in batches of `SUPPRESSION_BATCH_SIZE` and **reschedules itself** while a full batch keeps coming back, so no single mutation scans the whole table. It also **re-reads the setting each pass** — an operator who switches the banner back on mid-drain keeps whatever is left.

7. **Do NOT finalize into `pending` when the banner is off — and do NOT call it `acknowledged`.** (`904b3661 fix(settings): seed alert settings per location, suppress unshowable alerts`)
   `closeCollectingAlert` reads the location's settings and lands on `pending` or `suppressed` accordingly. `suppressed` is a distinct terminal status on purpose: `acknowledged` would claim an operator saw it, and nobody did. Critically, **a suppressed alert is still dispatched** — the row is the event the CRM, email, and SMS channels fan out from, so suppressing the banner must not discard it (`'a suppressed alert is dispatched too, since the other channels still run'`). The banner query defends the other direction too: `getPendingAlertsByLocation` short-circuits to `[]` when `inApp` is off, covering rows that went `pending` beforehand.

8. **Do NOT skip the CRM task on an undecodable token.** `readAccessTokenScopes` returns `string[] | null`, and null and `[]` must stay distinguishable. The task channel skips only on a scope list that **provably lacks** `locations/tasks.write`; an undecodable token means "cannot tell", so the request goes out and a real 403 is recorded. Pinned by `'returns null rather than an empty list when it cannot tell'`.

9. **Do NOT count the note/email remainder off the file-names array.** `loadAlertContext` deliberately reads only `MAX_FILE_NAMES_IN_NOTE + 1` documents, so `fileNames` arrives shorter than `fileCount`. The "…and N more" line is computed as `fileCount - shown.length`. Counting off the array under-reports the batch — a 30-file upload would claim 0 more. Pinned by `'the remainder counts real files, not the names the query bothered to load'`.

10. **Do NOT interpolate file names or contact names into the email unescaped.** `buildUploadEmailHtml` runs every interpolated value through `escapeHtml`. File names are client-supplied. Pinned by `'a file name carrying markup cannot break out into the email body'`.

11. **Do NOT emit a review link without a configured app URL.** `buildReviewUrl` returns `undefined` when `APP_URL`/`NEXT_PUBLIC_APP_URL` is unset or blank, and both the email and the SMS omit the link rather than putting a localhost URL in a real operator's inbox.

12. **Do NOT let the SMS fall through without `toNumber`.** Without it the message goes to the **client**, not the operator. A missing `recipient.phone` must produce a `skipped` delivery. Same shape for email: `emailTo` redirects delivery away from the contact's primary address, and the message is still anchored on the client's conversation — so a copy of the operator-bound email is visible in that client's CRM conversation history. That side effect is documented in the settings page copy, not hidden.

13. **Do NOT key the settings form's seeding on "the form is still empty".** The page does not remount when the active location changes, so keying on emptiness left one location's toggles loaded and then saved them onto the next location. Seeding is keyed on `seededLocationId === locationId`.

***

## 3. Status

### Done — the four phases and the counter-removal follow-up (all merged and live)

Each of the four phases was developed on a branch stacked on the previous one, with earlier phases' fixes merged forward, then squash-merged to `main`. The final row is the #1391 follow-up, which was not part of that stack. Because `convex/**` auto-deploys on merge (§1.4), all of this is in production.

| Phase                           | PR                 | What landed                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          | Fix commits folded in                                                                                                |
| ------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
| **1 — core**                    | #1376              | The alert record and the in-app banner. `documentUploadAlerts` table (+ `by_location_status`, `by_uploadToken_status`); `documents.uploadTokenId` and `documentUploadTokens.contactName` columns. `documents.uploadDocument` gains an optional `uploadToken` arg and, when it validates, spends the allowance and folds the upload into an alert. Debounce window `DOCUMENT_UPLOAD_ALERT_DEBOUNCE_MS` = 120s with a scheduled `finalizeUploadAlert`; orphan-recovery for a window whose job died. `getPendingAlertsByLocation` + `acknowledgeAlert`; `DocumentUploadAlertBanner` mounted in `app/(main)/layout.tsx`. `DocumentUploadForm` stops calling `incrementTokenUploadCount`. | `5ddd8d16` allowance + per-link coalescing; `d4a8cf3c` index by token; `18f88c44` test typing                        |
| **2 — settings**                | #1377              | Per-location control. `operatorNotificationSettings` table (+ `by_locationId`), `DEFAULT_UPLOAD_CHANNELS`, `resolveOperatorNotificationSettings`, `getForLocation`, `saveForLocation`. New `/settings/operator-alerts` page (five toggles + fallback user/email/phone) linked from the settings index. Adds the `suppressed` alert status and the self-rescheduling `suppressPendingAlertsForLocation` drain.                                                                                                                                                                                                                                                                        | `904b3661` per-location seeding + suppress unshowable alerts; `60456a34` retire the pending backlog                  |
| **3 — CRM channels**            | #1378              | The fan-out. New `convex/documentUploadNotify.ts`: `loadAlertContext` (internalQuery), `recordDeliveries` (internalMutation), `dispatchUploadAlert` (internalAction). `deliveries[]` + `dispatchedAt` on the alert row. CRM note and CRM task channels; new `convex/lib/ghlApi/tasks.ts` (`createContactTask`, `locations/tasks.write`) and `readAccessTokenScopes` for the pre-flight scope check. `GhlUserRecord.phone` added.                                                                                                                                                                                                                                                     | `5b7eadb5` per-channel exception isolation; `0c5ebd1c` fan out from the state transition                             |
| **4 — email + SMS**             | #1379              | Email and SMS channels via `sendConversationMessage` with the new `emailTo` / `toNumber` redirects. Copy builders `buildUploadEmailSubject`/`buildUploadEmailHtml`/`buildUploadSmsBody` plus `buildReviewUrl` and `escapeHtml`. `fetchUsersByLocation` returns `{ ok, users, detail }`; `getUsersByLocation` becomes a wrapper over it. `resolveRecipientForDispatch` gates the addressed channels. Settings page grows amber caution copy for email and SMS.                                                                                                                                                                                                                        | `71cdfa89` + `17e134b3` refuse to guess a recipient; `ba041091` skip the directory read when nothing needs resolving |
| **Follow-up — counter removal** | #1391 (`058be5c1`) | Deletes the orphaned public mutation `documents.incrementTokenUploadCount`, leaving `uploadDocument` as the single write path for `uploadsUsed` (§2.1). It had no remaining callers, but it was a public mutation and `convex/**` auto-deploys (§1.4), so this removed a live API surface: the landmine is now enforced by absence rather than by convention.                                                                                                                                                                                                                                                                                                                        | —                                                                                                                    |

### Remaining

Nothing below is scoped. These are placeholders recording known open ends, not a planned phase sequence — fill one in only after a human decides it is real.

| # | Item                                  | What is actually known                                                                                                                                                                                                         | Owner / decision needed |
| - | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------- |
| 1 | Verify SMS against a live send        | The settings page says so in its own caution copy: "Not yet confirmed against a live send." No test can cover this — it needs one real upload with the channel on, confirming delivery landed with the team and not the client | TODO                    |
| 2 | Delete the stale local phase branches | `feat/upload-alerts-{1-core,2-settings,3-crm-channels,4-email-sms}` are merged and now only mislead branch comparisons (see the note under the title)                                                                          | TODO                    |
| 3 | Whatever else review turns up         | —                                                                                                                                                                                                                              | TODO                    |

***

## 4. Channel matrix

Five independent channels off one alert. "Default" is the value in `DEFAULT_UPLOAD_CHANNELS`, which applies to any location that has never saved a settings row — absence of a row means defaults, so no location needs backfilling to start receiving alerts. The two channels on by default are the two that **cannot reach anyone outside the app**; the three that push a real notification to a person are opt-in.

| Channel           | Default | What it needs                                                                                                                                             | Who it resolves as recipient                                                                                                                        | How it degrades                                                                                                                                                                                                                                           |
| ----------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **In-app banner** | **on**  | `uploadChannels.inApp`; an alert in `pending`; a GHL installation for the location (`requireGHLInstallation`)                                             | Nobody in particular — it renders for anyone viewing that location in the app. Not addressed to a person                                            | Off ⇒ new alerts finalize straight to `suppressed`, the pending backlog is retired (§2.6), and `getPendingAlertsByLocation` returns `[]`. Capped at the 20 most recent (`MAX_PENDING_ALERTS_PER_LOCATION`, `.order("desc")`)                              |
| **CRM note**      | **on**  | A refreshable CRM access token; `contactId`                                                                                                               | Nobody — it is a timeline entry on the contact. It is a record of what happened and notifies no one                                                 | HTTP error ⇒ `failed` delivery carrying `status: error`. Throw ⇒ `failed` via `attemptChannel`. Token unresolvable ⇒ every enabled channel is marked `failed` with that reason and nothing is attempted                                                   |
| **CRM task**      | off     | Token; `locations/tasks.write` scope; a resolved `recipient.userId`. This is the only channel that produces a CRM push notification on desktop and mobile | Link creator (`notifyUserId`) → configured `fallbackUserId`. Must be a **live CRM user** — a bare fallback email or phone cannot be assigned a task | Scope provably absent ⇒ `skipped` ("Reconnect the app to enable task alerts"). No `userId` ⇒ `skipped` ("Set a fallback user under Settings → Operator Alerts"). Undecodable token ⇒ attempt anyway (§2.8). Directory unreadable ⇒ `failed`, nothing sent |
| **Email**         | off     | Token; `recipient.email`; the client's conversation (the message is anchored there with `emailTo` redirecting delivery)                                   | Link creator's CRM email → fallback user's email (or `fallbackEmail` if the user record has none) → bare `fallbackEmail`                            | No email ⇒ `skipped` with a pointer to the settings page. No `APP_URL` ⇒ sent without the review link. Directory unreadable ⇒ `failed`. **Side effect, not a failure:** a copy appears in the client's CRM conversation history                           |
| **SMS**           | off     | Token; `recipient.phone` (a mobile number on the CRM profile)                                                                                             | Link creator's CRM phone → fallback user's phone (or `fallbackPhone`) → bare `fallbackPhone`                                                        | No phone ⇒ **must** `skip` — without `toNumber` the text goes to the client (§2.12). Directory unreadable ⇒ `failed`. Unverified against a live send                                                                                                      |

Two cross-cutting rules: the recipient is resolved **once** and shared by the three addressed channels (task, email, SMS), and if no addressed channel is enabled the directory is never read at all. Every outcome — including a deliberate skip — is written to `deliveries[]`, because without that a silent channel is indistinguishable from one that was switched off.

***

## 5. Alert lifecycle

```
                    uploadDocument (valid token)
                              │
              ┌───────────────┴───────────────┐
              │ open window on THIS link?     │
              │ (by_uploadToken_status)       │
              └───────┬───────────────┬───────┘
                 yes, job pending     no / job dead
                      │                    │
              append documentId       (dead ⇒ rescue the
              fileCount += 1           orphan via
                      │                closeCollectingAlert)
                      │                    │
                      │              insert alert `collecting`
                      │              + schedule finalize (120s)
                      └────────────────────┘
                              │
                    finalizeUploadAlert (120s later)
                              │
                    finalizeUploadAlertInDb
                    (no-op unless still `collecting`)
                              │
                      closeCollectingAlert
                              │
                ┌─────────────┴─────────────┐
          inApp on                     inApp off
              │                             │
          `pending`                   `suppressed`
              │                             │
              └──────────┬──────────────────┘
                         │
        schedule dispatchUploadAlert (runAfter 0) — ALWAYS
                         │
        crmNote │ crmTask │ email │ sms  (each via attemptChannel)
                         │
              recordDeliveries (in `finally`)

  `pending` ──operator dismisses──▶ `acknowledged`
  `pending` ──banner switched off──▶ `suppressed`  (batched drain)
```

The four statuses are `collecting`, `pending`, `acknowledged`, `suppressed`. `collecting` is the only non-terminal one, and it doubles as the coalescing buffer — the row *is* the debounce window. The window's liveness is read from the scheduled job itself (`ctx.db.system.get(scheduledJobId)`, `state.kind === "pending"`), not from a timestamp, which is what makes orphan recovery possible: a job that already ran, failed, or was cancelled while the row stayed `collecting` is detectable and the stranded alert gets closed out rather than lost.

Why 120 seconds: a client clicking Upload on six files fires six sequential `uploadDocument` mutations seconds apart, so the window only has to outlast one batch. Two minutes covers a slow connection re-uploading a large statement without making the operator wait meaningfully longer.

***

## 6. Data model

Both tables live in `convex/schemas/processing.ts`.

**`documentUploadAlerts`** — one operator-facing alert per burst of uploads through one link.

| Field                                                          | Notes                                                                                                                                   |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `locationId`, `contactId`, `contactName?`                      | `contactName` is captured on the **token** at generation time so the notification path never needs a live CRM lookup to name the client |
| `uploadTokenId`                                                | The coalescing key (§2.2)                                                                                                               |
| `notifyUserId?`                                                | CRM user id of the operator who generated the link, copied from `documentUploadTokens.createdBy`                                        |
| `documentIds[]`, `fileCount`                                   | `fileCount` is authoritative for copy; `documentIds` is what `loadAlertContext` samples for file names                                  |
| `status`                                                       | `collecting` \| `pending` \| `acknowledged` \| `suppressed`                                                                             |
| `scheduledJobId?`                                              | `Id<"_scheduled_functions">`, used to test window liveness                                                                              |
| `deliveries[]?`                                                | One entry per channel attempted, `{ channel, status, detail?, at }`, including deliberate skips                                         |
| `dispatchedAt?`, `createdAt`, `notifiedAt?`, `acknowledgedAt?` |                                                                                                                                         |

Indexes: `by_location_status` (`["locationId","status"]`) for the banner query and the suppression drain; `by_uploadToken_status` (`["uploadTokenId","status"]`) for coalescing.

**`operatorNotificationSettings`** — per-location operator preferences, indexed `by_locationId`. Holds `uploadChannels` (the five booleans) plus `fallbackUserId` / `fallbackEmail` / `fallbackPhone` and `updatedAt`. **This is distinct from the client-facing automation settings behind `/settings/notifications`, which send to the borrower — everything here notifies the operator.** A missing row means defaults.

Two columns were added to existing tables: `documents.uploadTokenId` (set only for link uploads, after validation, tying the file back to the operator who generated the link) and `documentUploadTokens.contactName`.

***

## 7. Recipient resolution

`resolveAlertRecipient` is a pure function over `{ notifyUserId, users, fallbackUserId, fallbackEmail, fallbackPhone }` returning `{ userId?, name?, email?, phone?, source }` where `source` is one of `link_creator` | `fallback_user` | `fallback_contact` | `none`. Preference order:

1. **`link_creator`** — the operator who created the link, if they are a live user in the directory.
2. **`fallback_user`** — the location's configured fallback user, if live. Their CRM email/phone win, falling back to the configured free-form values.
3. **`fallback_contact`** — bare `fallbackEmail`/`fallbackPhone`, with no `userId`. Enough for email and SMS; **not** enough for the CRM task.
4. **`none`** — nothing configured. Every addressed channel skips.

"Live" means present in the directory **and** not `deleted === true`. A creator who has been deleted from the location falls through rather than silently addressing a dead account, and so does one who has simply left (their id no longer appears).

`resolveRecipientForDispatch` is the impure wrapper that decides whether the directory even needs reading, and refuses to guess when the read fails — see §2.5. `source` is recorded as the `detail` on a successful task/email/SMS delivery, so the alert row says which rule matched.

***

## 8. Test map

Four suites, all `bun test`. None of them needs a Convex deployment: the mutation-side tests run against a hand-rolled in-memory `db`/`scheduler` fake implementing only indexed equality lookups, insert/get/patch, scheduled-job state, and `runAfter` (the repo has no `convex-test` harness). The HTTP-side tests stub `globalThis.fetch` and restore it in `afterEach`.

| Suite                                        | Covers                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | Landmines it pins                          |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ |
| `tests/documentUploadAlerts.test.ts`         | `findValidUploadToken` (revoked / expired / unknown / replayed across contact or location / allowance exhausted), `consumeUploadAllowance`, `recordClientUploadForAlert` (6-file coalescing, creator + contact name carry-over, second window after close, 25 live links, two links one contact, two contacts at once, orphan rescue), `finalizeUploadAlertInDb`, fan-out scheduling, `suppressPendingAlertsInDb` (drain, no-op while on, 100/50 batching, location isolation) | §2.1, §2.2, §2.3, §2.6, §2.7               |
| `tests/documentUploadNotify.test.ts`         | `resolveAlertRecipient` (all four sources, deleted creator, departed creator), all five copy builders, `buildReviewUrl`, `attemptChannel`, `describeDeliveryFailure` (non-Error throws, 500-char truncation), `readAccessTokenScopes`, `resolveRecipientForDispatch`                                                                                                                                                                                                           | §1.6, §2.4, §2.5, §2.8, §2.9, §2.10, §2.11 |
| `tests/ghlUsersByLocation.test.ts`           | `fetchUsersByLocation` success / genuinely-empty / HTTP 500 / HTTP 401, and `getUsersByLocation` still collapsing a failed read to `[]` for its scan-only callers                                                                                                                                                                                                                                                                                                              | §2.5                                       |
| `tests/operatorNotificationSettings.test.ts` | `resolveOperatorNotificationSettings` defaults, saved-row precedence, cross-location isolation, and an explicit assertion on the exact shape of `DEFAULT_UPLOAD_CHANNELS`                                                                                                                                                                                                                                                                                                      | §4 defaults                                |

The `DEFAULT_UPLOAD_CHANNELS` assertion is deliberately a hard-coded literal rather than a reference to the constant: it exists so that flipping a channel on by default is a test failure that someone has to consciously accept, not a silent enrollment of every location by deploy.
