Skip to main content

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:
    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 internalMutations.
  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: findValidUploadTokenconsumeUploadAllowancerecordClientUploadForAlert, all inside uploadDocument. A token with no maxUploads is unbounded by design, not uncountedconsumeUploadAllowance 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.

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.

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. 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

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. 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. 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.