Skip to content

Add document content-change blocks to the digest email #201

Description

@HMarzban

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:

export interface DigestDocument {
  name: string
  slug: string
  url: string
  channels: DigestChannel[]
}

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 name and url out of the chat notification row. See apps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:126-128:

    name: ws.name,
    slug: ws.slug,
    url: `${appUrl}/${ws.slug}`,

Those two values come from the workspaces row, through the digest SQL. See packages/supabase/scripts/07-5-email-notifications-pgmq.sql:633-634:

                'workspace_name', coalesce(w.name, c.slug),
                'workspace_slug', coalesce(w.slug, c.slug),

The workspaces row is written once, when a person first opens a document. That is the only INSERT INTO public.workspaces in packages/supabase/scripts. See packages/supabase/scripts/10-functions.sql:858-859:

        INSERT INTO public.workspaces (id, name, slug, created_by)
        VALUES (_workspace_id, _workspace_id, lower(_workspace_id), user_id);

_workspace_id is the documentId, and a documentId is 19 characters. apps/hocuspocus.server/src/api/services/documents.service.ts:140 is const documentId = uid.stamp(19), and apps/hocuspocus.server/src/lib/documentId.ts:7 is const ID_LENGTH = 19. So name is that raw id, and slug is 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:163 declares getDocumentBySlug, and line 172 is where: { slug: normalizedSlug } against DocumentMetadata.

The fix: resolve DocumentMetadata.title and DocumentMetadata.slug during enrichment, and build every link from .slug.

title is nullable. apps/hocuspocus.server/prisma/schema.prisma:35 is title 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:

  const appUrl = process.env.APP_URL || 'https://docs.plus'
  const documentUrl = `${appUrl}/${params.slug}`

A bare ?id= is enough for a section link. See apps/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:72 and line 76:

  const id = url.searchParams.get('id')

    const el = document.querySelector(`[data-toc-id="${id}"]`)

The h parameter is not read there.

What to do

The worker is a separate operating-system process. apps/hocuspocus.server/package.json:11 is "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 imports computeDocumentChanges by deep path from modules/document-changes/domain/computeDocumentChanges. That mirrors the existing module shape, for example apps/hocuspocus.server/src/modules/document-versions/domain.

This work adds no HTTP self-call. Today the only fetch( calls under apps/hocuspocus.server/src/lib/email are 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 is apps/hocuspocus.server/src/config/env.schema.ts:149, DOC_AUTOSAVE_RETENTION_DAYS: numericString('30'),.
  • flattenChangedSections(tree, docUrl) — pure. Depth-first, document order, drop unchanged nodes. The breadcrumb is the two deepest ancestor heading texts. A section link is ${docUrl}?id=${tocId} when the toc id is not null, else docUrl.
  • enrichDigestDocuments(...) — resolve title and slug. Read the reader's last visit. Call compute once per document. Attach a content_changes block only when changed is true. Cap the section list at 8 and carry a moreCount.

The only last-visit value in the tree is workspace_members.updated_at. join_workspace refreshes it on every call. See packages/supabase/scripts/10-functions.sql:867-870:

        -- Refresh "last visit" so the member roster shows a real last-seen time.
        UPDATE public.workspace_members
        SET updated_at = timezone('utc', now())
        WHERE workspace_id = _workspace_id AND member_id = user_id;

That column is already exposed as last_visit_at. packages/supabase/scripts/10-8-func-workspace_members.sql:238 is coalesce(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_id through buildDigestDocuments in pgmqConsumer.ts. Line 55 is workspace_id: string | null, so the payload already carries it. The builder drops it: the map value at pgmqConsumer.ts:82-89 holds only name, slug and channels.

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:

        left join public.channels c on c.id = n.channel_id
        left join public.workspaces w on w.id = c.workspace_id

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_id and workspace_slug together. pgmqConsumer.ts:92 is const wsKey = n.workspace_slug || 'unknown', and lines 96-97 fall back to wsKey for both name and slug. So such a notification lands in one synthetic bucket named unknown. Leave that bucket unenriched.

Add the optional content_changes field in four places:

  • apps/hocuspocus.server/src/types/email.types.ts, on DigestDocument.
  • renderDigestEmail in packages/email-templates/src/engine.ts:56.
  • One guarded block in the digest template, packages/email-templates/templates/digest.eta.
  • buildDigestEmailText in apps/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

  • A test stubs the compute call to throw, and the digest still sends, with its documents unenriched.
  • One message holds an unknown bucket plus an enrichable document. The digest delivers a change block for the second. The unknown bucket's chat block is untouched.
  • A digest rendered with no content_changes field matches the stored snapshot renderDigestEmail snapshot 1 in packages/email-templates/src/__tests__/__snapshots__/engine.test.ts.snap, with no snapshot update.
  • Every section link in a rendered digest uses the human slug from DocumentMetadata, never a 19-character id.
  • A document whose DocumentMetadata.title is null renders its slug as the name, never an empty string.
  • A document whose compute result is changed: false renders no change block.
  • Nine changed sections render eight rows plus a "+1 more" line.
  • bun run test in apps/hocuspocus.server is green.

Notes

Attribution is per version row, so it is whole-document. apps/hocuspocus.server/prisma/schema.prisma:18-20 holds trigger, triggeredBy and contributors on the Documents model. 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-4 records the path: Supabase email_queue → pg_cron → pgmq → pgmqConsumer → BullMQ → SMTP.

Activity

  1. HMarzban commented on Sep 1, 2026

    @HMarzban
    CollaboratorAuthor

    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 title

    Severity: high. The digest document header comes from workspaces.name. The only live writer of that row is join_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 makes content_change rows 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-known docUrl gap: that one is the link, this one is the name.

    Fix. Fix it inside Part D's enrichment, from a read it already performs. computeDocumentChanges must load the DocumentMetadata row anyway for its 404 gate, and that row carries both title and slug (/Users/macbook/workspace/docsy/apps/hocuspocus.server/prisma/schema.prisma:34-35). Return { title, slug } from the compute result, then have enrichDigestDocuments overwrite doc.name with the title and build docUrl from the slug. One read fixes the header and the known missing docUrl together.

    D3-3 — retentionDays = 0 collapses the digest window to zero and silently kills every content email

    Severity: medium. resolveDigestSince clamps since to now − retentionDays·24h. The only such value in the process is DOC_AUTOSAVE_RETENTION_DAYS, documented as the switch that turns thinning OFF at 0. At 0 the clamp floor becomes now, so since equals until, D-1's same-anchor fast path returns changed: 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 pinning retentionDays = 0 to 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.ts is 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.ts from 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.channels alone. sender.ts:119 builds the subject, and templates.ts:87 and :120 build the plain-text header. Part E-2 fixes only the HTML header inside digest.eta, and Part D-4 lists sender.ts under "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.ts and the templates.ts header line to Part E-2's touch list. Derive one count that includes changed sections, for example totalNotifications + sum(content_changes.summary.sectionsChanged), and word the subject from it, such as "Your daily digest - 8 changes". Guard the templates.ts:120 header 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-4 applied — Every digest names the document by its raw 19-character documentId, not its title
    • D3-3 applied — retentionDays = 0 collapses the digest window to zero and silently kills every content email
    • P5 applied — A content-only digest is sent with the subject "Your daily digest - 0 notifications"
    • EMAIL-1 applied — Subject line and plain-text header count only chat, so a content-change digest says "0 notifications"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions