Repository navigation
Add document content-change blocks to the digest email #201
Description
Activity
Architecture review findings — 2026-09-01
A two-team architecture review of the Part D and Part E design filed 4 finding(s) on this issue. Each one survived adversarial verification; 19 of 30 ranked findings were refuted and are not listed. Full report and the refuted list:
REPORT-partDE-architecture-review.md.Apply these before writing the code they touch.
D5-4— Every digest names the document by its raw 19-character documentId, not its titleSeverity: high. The digest document header comes from
workspaces.name. The only live writer of that row isjoin_workspace, which stores the raw documentId in the name column. Nothing else ever updates it: the two webapp helpers that could are dead code with zero callers, and no SQL script updates the table. So the email header reads "📄 kX9mPq2vRt7nB4dLw3s". Today this rarely bites, because a document only enters a digest when it has chat traffic. Part E changes that: E-2 makescontent_changerows workspace-seeders, so documents with no chat at all start appearing. Part D's whole payoff — a readable summary of what changed — lands under a header nobody can identify. This is a different defect from the already-knowndocUrlgap: that one is the link, this one is the name.Fix. Fix it inside Part D's enrichment, from a read it already performs.
computeDocumentChangesmust load theDocumentMetadatarow anyway for its 404 gate, and that row carries bothtitleandslug(/Users/macbook/workspace/docsy/apps/hocuspocus.server/prisma/schema.prisma:34-35). Return{ title, slug }from the compute result, then haveenrichDigestDocumentsoverwritedoc.namewith the title and builddocUrlfrom the slug. One read fixes the header and the known missingdocUrltogether.D3-3—retentionDays = 0collapses the digest window to zero and silently kills every content emailSeverity: medium.
resolveDigestSinceclampssincetonow − retentionDays·24h. The only such value in the process isDOC_AUTOSAVE_RETENTION_DAYS, documented as the switch that turns thinning OFF at0. At0the clamp floor becomesnow, sosinceequalsuntil, D-1's same-anchor fast path returnschanged: false, no block attaches, and Part E's zero-content skip drops the document. An operator who disables retention thinning — a documented, supported action with no stated link to email — silently turns off the whole content-change digest. Nothing logs it and nothing fails.Fix. Apply the clamp only when
retentionDays > 0. One clause in the pure function, plus one unit case pinningretentionDays = 0to an unclamped window.P5— A content-only digest is sent with the subject "Your daily digest - 0 notifications"Severity: medium. The subject line counts chat notifications only. It reduces over
doc.channels, which content changes never populate. A digest carrying only content changes therefore ships with a subject that says the email is empty.That subject is not a cosmetic bug. It is the only surface that decides whether the email is opened. "0 notifications" reads as a system error or as spam. Repeatedly sending it damages sending reputation and trains readers to filter the address.
The design knows about the body header and plans to fix it. It does not fix the subject.
sender.tsis listed as untouched and verified in Part D's own touch list. So Part E's header fix lands, the subject does not, and every content-only email goes out mislabelled.On the second question — does the merged email read as one message? The body layout is coherent: the content block sits between the document header and the channel rows, under the same document. But the framing is not. The counter, the subject, the period label and the plain-text fallback all describe chat. Content changes have no counter of their own. The result reads as one product with a second one bolted under its title.
Fix. Add content changes to the digest count, or replace the subject with one that names the document: "docs.plus demo and 1 more changed this week". Remove
sender.tsfrom the untouched list. Treat the subject as part of the feature, not as an existing file.EMAIL-1— Subject line and plain-text header count only chat, so a content-change digest says "0 notifications"Severity: medium. Two places compute the digest headline from
doc.channelsalone.sender.ts:119builds the subject, andtemplates.ts:87and:120build the plain-text header. Part E-2 fixes only the HTML header insidedigest.eta, and Part D-4 listssender.tsunder "Untouched, verified". Nothing in either part touches the subject or the text header. So a follower with edits but no chat receives a message titled "Your daily digest - 0 notifications", whose text half opens "Here's your daily digest with 0 notifications:" and then lists a bare document name with nothing under it. A mixed digest is worse in a quieter way: 3 chat notifications plus 40 changed sections still ships "3 notifications". The subject is the one line every recipient reads before deciding to open. A zero in it teaches people the mail is empty.Fix. Add
sender.tsand thetemplates.tsheader line to Part E-2's touch list. Derive one count that includes changed sections, for exampletotalNotifications + sum(content_changes.summary.sectionsChanged), and word the subject from it, such as "Your daily digest - 8 changes". Guard thetemplates.ts:120header the same way E-2 guards the HTML header. Add one test that renders a channel-less document and asserts the subject carries a non-zero count.Acceptance
-
D5-4applied — Every digest names the document by its raw 19-character documentId, not its title -
D3-3applied —retentionDays = 0collapses the digest window to zero and silently kills every content email -
P5applied — A content-only digest is sent with the subject "Your daily digest - 0 notifications" -
EMAIL-1applied — Subject line and plain-text header count only chat, so a content-change digest says "0 notifications"
-
Problem
The digest email tells a reader about chat messages only. It says nothing about document edits.
A digest document carries a name, a slug, a url and channels. Nothing else. See
apps/hocuspocus.server/src/types/email.types.ts:73-78:Issue #169 plans a compute service. It will answer "what changed in this document since a date". That service is not in the tree yet.
Part of #169. Blocked by that compute service and its REST route.
The document name and link have no human source today
The digest builder makes each document's
nameandurlout of the chat notification row. Seeapps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:126-128:Those two values come from the
workspacesrow, through the digest SQL. Seepackages/supabase/scripts/07-5-email-notifications-pgmq.sql:633-634:The
workspacesrow is written once, when a person first opens a document. That is the onlyINSERT INTO public.workspacesinpackages/supabase/scripts. Seepackages/supabase/scripts/10-functions.sql:858-859:_workspace_idis the documentId, and a documentId is 19 characters.apps/hocuspocus.server/src/api/services/documents.service.ts:140isconst documentId = uid.stamp(19), andapps/hocuspocus.server/src/lib/documentId.ts:7isconst ID_LENGTH = 19. Sonameis that raw id, andslugis only its lowercased copy. Neither value is a human title, and neither is a human slug.The webapp resolves a document path by the human slug instead.
apps/hocuspocus.server/src/api/services/documents.service.ts:163declaresgetDocumentBySlug, and line 172 iswhere: { slug: normalizedSlug }againstDocumentMetadata.The fix: resolve
DocumentMetadata.titleandDocumentMetadata.slugduring enrichment, and build every link from.slug.titleis nullable.apps/hocuspocus.server/prisma/schema.prisma:35istitle String?. So fall back to the slug when the title is null. An empty name is the same failure as the raw id.The correct in-tree shape to copy is
apps/hocuspocus.server/src/lib/email/document-notification.ts:45-46:A bare
?id=is enough for a section link. Seeapps/webapp/src/utils/link-helpers.ts:66:/** Deep-link to a heading. `h` is the outline parent chain; `id` is the resolver. */The reader side confirms it. See
apps/webapp/src/components/TipTap/TipTap.tsx:72and line 76:The
hparameter is not read there.What to do
The worker is a separate operating-system process.
apps/hocuspocus.server/package.json:11is"start:rest": "NODE_ENV=production bun scripts/start-production.ts",and line 13 is"start:worker": "NODE_ENV=production bun src/hocuspocus.worker.ts",. So the worker cannot share a value with the REST host.The module factory therefore returns
{ router }only. The worker importscomputeDocumentChangesby deep path frommodules/document-changes/domain/computeDocumentChanges. That mirrors the existing module shape, for exampleapps/hocuspocus.server/src/modules/document-versions/domain.This work adds no HTTP self-call. Today the only
fetch(calls underapps/hocuspocus.server/src/lib/emailare the Resend and SendGrid providers, and both call an external host. Keep it that way.Add
apps/hocuspocus.server/src/lib/email/digestContentChanges.ts:resolveDigestSince(lastVisit, frequency, now, retentionDays)— pure. Use the reader's last visit, or a 24-hour or 7-day window when there is none. Clamp the result to the retention floor, so a months-stale reader gets a bounded digest. The retention setting isapps/hocuspocus.server/src/config/env.schema.ts:149,DOC_AUTOSAVE_RETENTION_DAYS: numericString('30'),.flattenChangedSections(tree, docUrl)— pure. Depth-first, document order, dropunchangednodes. The breadcrumb is the two deepest ancestor heading texts. A section link is${docUrl}?id=${tocId}when the toc id is not null, elsedocUrl.enrichDigestDocuments(...)— resolve title and slug. Read the reader's last visit. Call compute once per document. Attach acontent_changesblock only whenchangedis true. Cap the section list at 8 and carry amoreCount.The only last-visit value in the tree is
workspace_members.updated_at.join_workspacerefreshes it on every call. Seepackages/supabase/scripts/10-functions.sql:867-870:That column is already exposed as
last_visit_at.packages/supabase/scripts/10-8-func-workspace_members.sql:238iscoalesce(wm.updated_at, wm.created_at) as last_visit_at,, and the comment at line 211 says-- first, then by join order. last_visit_at falls back to join time.. So the value can be as old as join time. The retention clamp already bounds that. Use the frequency window only when the member row is missing.Thread
workspace_idthroughbuildDigestDocumentsinpgmqConsumer.ts. Line 55 isworkspace_id: string | null, so the payload already carries it. The builder drops it: the map value atpgmqConsumer.ts:82-89holds onlyname,slugandchannels.Filter out the null ones before any lookup. Two left joins produce them, and both must miss. See
packages/supabase/scripts/07-5-email-notifications-pgmq.sql:643-644:An unguarded lookup would throw. The message-level catch would then drop the change block for every document in that digest.
Note what a null actually renders today. A missing channel row nulls
workspace_idandworkspace_slugtogether.pgmqConsumer.ts:92isconst wsKey = n.workspace_slug || 'unknown', and lines 96-97 fall back towsKeyfor bothnameandslug. So such a notification lands in one synthetic bucket namedunknown. Leave that bucket unenriched.Add the optional
content_changesfield in four places:apps/hocuspocus.server/src/types/email.types.ts, onDigestDocument.renderDigestEmailinpackages/email-templates/src/engine.ts:56.packages/email-templates/templates/digest.eta.buildDigestEmailTextinapps/hocuspocus.server/src/lib/email/templates.ts:80.Wrap enrichment in a per-document catch and a message-level catch. Log and omit the block.
Leave the digest SQL, the queue and the cron alone. They stay content-agnostic.
Acceptance
unknownbucket plus an enrichable document. The digest delivers a change block for the second. Theunknownbucket's chat block is untouched.content_changesfield matches the stored snapshotrenderDigestEmail snapshot 1inpackages/email-templates/src/__tests__/__snapshots__/engine.test.ts.snap, with no snapshot update.DocumentMetadata, never a 19-character id.DocumentMetadata.titleis null renders its slug as the name, never an empty string.changed: falserenders no change block.bun run testinapps/hocuspocus.serveris green.Notes
Attribution is per version row, so it is whole-document.
apps/hocuspocus.server/prisma/schema.prisma:18-20holdstrigger,triggeredByandcontributorson theDocumentsmodel. Per-section "who changed it" is not derivable from that. The email says who edited the document, not who edited a section.The digest never reaches the REST router.
apps/hocuspocus.server/src/api/email.ts:2-4records the path:Supabase email_queue → pg_cron → pgmq → pgmqConsumer → BullMQ → SMTP.