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 onorigin/main, not on the phase branches. The fourfeat/upload-alerts-*branches were squash-merged as PRs #1376–#1379 and now exist only as stale local copies — they are behindmainand were never pushed toorigin. Their merge-base predates the squashes, sogit diff origin/main...feat/upload-alerts-4-email-smsmisleadingly 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 removedincrementTokenUploadCountfromconvex/documents.ts.
0. How to read this file
- 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.
- §3 is the status. The Done table says what each phase landed; the Remaining table is the only place new work belongs.
- §4 is the channel matrix — the fastest way to answer “why did this operator not hear about an upload?”
- §5–§8 are reference: the alert lifecycle, the data model, recipient resolution, and the test map.
- 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
- bun only.
bun install,bunx,bun test. Never npm/npx/yarn. - The full gate must pass before any change here is done, in this order:
Do NOT stage
convex/_generated/api.d.tsdrift produced by codegen. - 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. - Convex auto-deploys to prod when
mainreceives pushes touchingconvex/**. A merged schema change here is live within minutes. - Read
convex/_generated/ai/guidelines.mdbefore editing anyconvex/file. - 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. mock.module()is process-global inbun test. Both alert test files re-install the realconvex/_generated/apiproxy (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.- Every public function here carries
args+returnsvalidators, and every scheduled function is aninternal*. The fan-out is aninternalAction; the finalize and suppression jobs areinternalMutations. - No unbounded
.collect(). Both table scans in this subsystem are bounded:MAX_PENDING_ALERTS_PER_LOCATION(20) for the banner query andSUPPRESSION_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.
-
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:
uploadsUsedused to be incremented by a separate client-called mutation (documents.incrementTokenUploadCount), so anyone calling the publicdocuments.uploadDocumentdirectly never spent it andmaxUploadsnever actually bound. Validation and spending must close in one mutation:findValidUploadToken→consumeUploadAllowance→recordClientUploadForAlert, all insideuploadDocument. A token with nomaxUploadsis unbounded by design, not uncounted —consumeUploadAllowancestill 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 whenDocumentUploadFormstopped calling it, but the mutation stayed exported and would have double-spent the allowance against a tokenuploadDocumenthad already charged. It was removed in #1391 (058be5c1), sorg -n "incrementTokenUploadCount" convex/now returns nothing onorigin/main; a hit means either someone has re-added it or your tree predates #1391 — check before assuming the former. The rule:uploadsUsedhas exactly one write path, insideuploadDocument. Do not add a second.
- Allowance:
-
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 theby_uploadToken_statusindex ondocumentUploadAlerts, 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). -
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 insidecloseCollectingAlert, the single function every route out ofcollectingpasses through. Scheduling it fromfinalizeUploadAlertinstead 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 —finalizeUploadAlertInDbno-ops unless the row is stillcollecting, so a late or duplicated job run dispatches nothing ('normal finalization dispatches exactly once'). -
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 throughattemptChannel(channel, deliver), which converts a throw into a recordedfaileddelivery. 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:attemptChannelaround each channel, and thefinallyblock indispatchUploadAlertthat callsrecordDeliveriesregardless of how the body exits. A 403 on the task must not cost the operator their note. -
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.fetchUsersByLocationreturns{ ok, users, detail }precisely so callers can tell them apart;getUsersByLocationis 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. SoresolveRecipientForDispatchreturns{ ok: false }on a failed read and every addressed channel records afaileddelivery 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: falsemust mean “the read failed”, never “the list was empty”. - When neither
notifyUserIdnorfallbackUserIdis 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.
- A location that genuinely has no users is a successful read and still falls through to the configured fallback.
-
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 wentpendingbefore the operator turned the banner off keep that status, and left alone they all reappear the moment the channel comes back on.operatorNotificationSettings.saveForLocationdetects the on→off edge (comparing againstDEFAULT_UPLOAD_CHANNELS.inAppwhen no row existed yet) and schedulessuppressPendingAlertsForLocation. That job drains in batches ofSUPPRESSION_BATCH_SIZEand 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. -
Do NOT finalize into
pendingwhen the banner is off — and do NOT call itacknowledged. (904b3661 fix(settings): seed alert settings per location, suppress unshowable alerts)closeCollectingAlertreads the location’s settings and lands onpendingorsuppressedaccordingly.suppressedis a distinct terminal status on purpose:acknowledgedwould 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:getPendingAlertsByLocationshort-circuits to[]wheninAppis off, covering rows that wentpendingbeforehand. -
Do NOT skip the CRM task on an undecodable token.
readAccessTokenScopesreturnsstring[] | null, and null and[]must stay distinguishable. The task channel skips only on a scope list that provably lackslocations/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'. -
Do NOT count the note/email remainder off the file-names array.
loadAlertContextdeliberately reads onlyMAX_FILE_NAMES_IN_NOTE + 1documents, sofileNamesarrives shorter thanfileCount. The “…and N more” line is computed asfileCount - 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'. -
Do NOT interpolate file names or contact names into the email unescaped.
buildUploadEmailHtmlruns every interpolated value throughescapeHtml. File names are client-supplied. Pinned by'a file name carrying markup cannot break out into the email body'. -
Do NOT emit a review link without a configured app URL.
buildReviewUrlreturnsundefinedwhenAPP_URL/NEXT_PUBLIC_APP_URLis 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. -
Do NOT let the SMS fall through without
toNumber. Without it the message goes to the client, not the operator. A missingrecipient.phonemust produce askippeddelivery. Same shape for email:emailToredirects 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. -
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 tomain. 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 inDEFAULT_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
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 inconvex/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:
link_creator— the operator who created the link, if they are a live user in the directory.fallback_user— the location’s configured fallback user, if live. Their CRM email/phone win, falling back to the configured free-form values.fallback_contact— barefallbackEmail/fallbackPhone, with nouserId. Enough for email and SMS; not enough for the CRM task.none— nothing configured. Every addressed channel skips.
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, allbun 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.
