Skip to content

Commit 9e36597

Browse files
committed
feat: notifications
1 parent ba12798 commit 9e36597

67 files changed

Lines changed: 14051 additions & 120 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CLAUDE.md‎

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ read, tested and reasoned about. This applies to db columns, API payloads, store
1111
config keys alike; only real migrations under `backend/db/migrations/` are exempt, because Drizzle
1212
needs the history to get a live dev database to the current schema.
1313

14+
The one sanctioned exception is a value an existing installation **cannot** have and no static
15+
default can stand in for — a secret generated per installation, say. That goes in a **startup check**
16+
(`core/startupChecks.ts`), which every boot runs once to fill in what is missing; it is the only place
17+
such a value is made, and nothing that reads it falls back on its own. A new setting with a fixed
18+
default needs no check: put the default in `base.yml`, which is merged under the stored settings on
19+
every boot.
20+
1421
Three independently-installed workspaces (each has its own `package.json` / `node_modules`, there is
1522
no root package or monorepo tooling):
1623

@@ -74,7 +81,10 @@ path in silence.
7481
and nothing else.
7582
- `core/` — long-lived singletons: `config.ts` (yml + db-backed settings), `db.ts` (pg pool, Drizzle
7683
instance, migrations, LISTEN/NOTIFY pubsub), `logger.ts`, `scheduler.ts` (poolifier thread pool +
77-
postgres-backed job queue).
84+
postgres-backed job queue), and `startupChecks.ts` — what `preBoot()` makes sure of right after the
85+
settings are loaded, on a fresh install and an upgraded one alike: idempotent, add-only, run in one
86+
transaction under an advisory lock so that the instances of an HA set booting together cannot each
87+
generate a different value.
7888
- `db/` — `schema.ts` (all Drizzle table definitions), `relations.ts`, `migrations/` (generated).
7989
- `models/` — data-access classes over Drizzle, aggregated by `models/index.ts` and exposed as
8090
`WIKI.models.*`. Business logic belongs here, not in route handlers. `types.ts` holds the shared
@@ -84,6 +94,9 @@ path in silence.
8494
`modules/authentication/local/`. `modules/storage/*` ships `db` and `disk` — see
8595
[Storage targets](#storage-targets). `modules/analytics/*` is the odd one out: a pair of YAML files
8696
and no implementation at all — see [Analytics](#analytics).
97+
- `notifications/` — the notification categories (`categories/*.ts`, registered in `index.ts`) and
98+
the worker-side halves of delivery: the fan-out and the mail drain. Not under `modules/`, which
99+
expects a `definition.yml` per directory. See [Notifications](#notifications).
87100
- `tasks/simple/` — jobs run in-process by the scheduler; each exports `task(payload, { signal })`.
88101
File name is kebab-case, the task key is its camelCase form. `scheduler.taskTimeout` applies here
89102
as it does to workers, but a promise cannot be killed: at the timeout `signal` is aborted, and a
@@ -92,7 +105,11 @@ path in silence.
92105
is abandoned — failed, left running, and no second copy of that task starts on the instance until
93106
it ends (`executeInProcess` in `core/scheduler.ts`).
94107
- `tasks/workers/` — CPU-bound jobs run in a worker thread via `worker.ts`, which boots a minimal
95-
`WIKI` global (config + logger + lazy `ensureDb()`) and dynamically imports the task.
108+
`WIKI` global (config + logger + lazy `ensureDb()`) and dynamically imports the task. **A worker's
109+
database pool has ONE connection** (`core/db.ts`), so a transaction held open in a worker while
110+
anything else queries deadlocks the task against itself until the pool aborts it. And a worker reads
111+
the settings once, when its thread first opens the database, and never hears `reloadConfig` — a task
112+
that depends on settings an admin can change re-reads them per run (`refreshWorkerConfig`).
96113
- `base.yml` — system defaults for every config key. Do not edit as a user-facing config; it defines
97114
the shape merged with `config.yml` and the db `settings` table.
98115
- `helpers/` — small pure utilities (`common.ts`, `config.ts`), plus two that are not: `storageFiles.ts`,
@@ -1269,8 +1286,11 @@ produce and has to describe.
12691286

12701287
### Emails
12711288

1272-
The wiki sends three — a registration confirmation, a forgotten password, and the admin area's test
1273-
button — and `models/mail.ts` is the only place nodemailer is used. `MailTemplateData` is the
1289+
The wiki sends a registration confirmation, a forgotten password, the admin area's test, and
1290+
notifications (one at a time or as a digest — see [Notifications](#notifications)), and
1291+
`models/mail.ts` is the only place nodemailer is used. `send()` takes a `MailSite` from its caller
1292+
(`mail.siteFor(siteId)`) rather than a site id, because the notification mails are sent from a worker
1293+
thread, which has no `WIKI.sites`. `MailTemplateData` is the
12741294
closed list, held as typed literals rather than rows in a table: nothing sends a mail this wiki did
12751295
not ask it to, so a template is part of the flow that uses it and a flow that gained one would gain
12761296
code there anyway. A wiki with no SMTP settings is the normal case, which is why `isConfigured` is
@@ -1333,6 +1353,43 @@ has not been built; if it is, the shape to keep is sparse overrides on top of th
13331353
rather than a replacement for them, so that a wiki that rewords one sentence keeps getting
13341354
translations and improvements for everything else.
13351355

1356+
### Notifications
1357+
1358+
Telling a person about something that happened: a change to a page they watch, a comment or a reply,
1359+
a mention, a suggestion to review, and — opt-in — every page created or deleted. In-app (the inbox
1360+
and the header badge) and by email, chosen per person and per category under **Profile →
1361+
Notifications**. **`dev/specs/notifications.md` is the design**, and says why each part is the shape
1362+
it is; what follows is what is easy to get wrong.
1363+
1364+
- **`notifications.emit()` is one INSERT and never throws**, called from the models (so imports and
1365+
git pulls emit too) after the action succeeded, like `hooks.emit`. Nothing is resolved on the request
1366+
path. Events go into an outbox (`notificationEvents`) that a worker fans out in batches — not one
1367+
scheduler job per event, nor per recipient.
1368+
- **A category is a file** under `notifications/categories/` plus a key in `NOTIFICATION_CATEGORIES`
1369+
and its strings (`notifications.categories.<key>.*`, `notifications.messages.<key>.<variant>`,
1370+
`mail.notification.actions.<key>`). Leaving the actor out, the access check, preferences,
1371+
deduplication and coalescing are the fan-out's, done once for every category.
1372+
- **Access is checked per group set**, against the rows the worker reads — `rulesAllow` on the pooled
1373+
rules of a set of groups, memoized — which is what makes an audience of every account affordable.
1374+
- **Origins.** `notifications.withOrigin('bulk' | 'import', work)` marks the events emitted inside it,
1375+
and `storage.importingFrom` counts as `import` on its own. A category's `origins` decides whether it
1376+
fires: `watchedPage` fires for all of them, `pageCreated` / `pageDeleted` only for `user`.
1377+
- **A deletion captures its watchers in the event**, before the page row goes and the watch rows with
1378+
it (`watchersOf` on `emit`). Any new path that deletes page rows has to emit the same way first.
1379+
- **One unread entry per person per `groupKey`** (a partial unique index), so forty saves are one entry
1380+
counting to forty, and a replayed event changes nothing (`lastEventId`).
1381+
- **Email is a window, then quiet until read.** `emailAfter` is the only thing the drain looks at, and
1382+
`emailAfterFor` in the fan-out the only place it is set — the seam a digest schedule would use.
1383+
Opening a page or its Talk tab marks its entries read (`viewer.unreadNotifications`, `markSeen`),
1384+
which is what lets the next change email again.
1385+
- **The drain claims rows with a lease** (`emailState = 'sending'`, `emailAfter` as the expiry) rather
1386+
than a transaction held across the send — see the note on worker connections under `tasks/workers/`.
1387+
- **Every mail carries RFC 8058 one-click unsubscribe.** The token is an HMAC with
1388+
`notifications.unsubscribeSecret`, generated by a startup check wherever it is missing, and
1389+
deliberately not `auth.secret` (which rotating the sessions replaces). A GET of the link only redirects to `/_unsubscribe`, which asks.
1390+
- **The site switch is `features.notifications`**; the instance settings (retention, email delay, mail
1391+
batch size) are **Admin → Notifications**, `GET`/`PUT /system/notifications`, behind `manage:system`.
1392+
13361393
### Audit log
13371394

13381395
Every action a **person** takes is one row in `auditLog` — `userId`, `clientIP`, `ts`, `kind`

‎backend/api/approvals.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -894,6 +894,8 @@ async function routes(app: FastifyInstance) {
894894
}
895895
}
896896

897+
// -> Whether this replaces one already waiting, which its reviewers are told as an update
898+
const previous = await WIKI.models.approvals.getOwnSubmission(page.id, actor?.id ?? null)
897899
const submission = await WIKI.models.approvals.saveSubmission({
898900
siteId: req.params.siteId,
899901
page: pageRef,
@@ -917,6 +919,17 @@ async function routes(app: FastifyInstance) {
917919
path: page.path,
918920
...(actor ? {} : { guestName, guestEmail })
919921
})
922+
await WIKI.models.notifications.emit('submission:new', {
923+
siteId: req.params.siteId,
924+
actorId: actor?.id ?? null,
925+
data: {
926+
variant: previous ? 'updated' : 'new',
927+
submissionId: submission.id,
928+
page: WIKI.models.notifications.pageSnapshot(page),
929+
// -> A guest has no account to look a name up from, only what they typed
930+
...(actor ? {} : { actorName: guestName })
931+
}
932+
})
920933

921934
return {
922935
ok: true,

‎backend/api/comments.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,23 @@ async function routes(app: FastifyInstance) {
418418
},
419419
content: comment.content
420420
})
421+
await WIKI.models.notifications.emit('comment:new', {
422+
siteId: req.params.siteId,
423+
actorId: comment.authorId,
424+
data: {
425+
variant: 'new',
426+
page: WIKI.models.notifications.pageSnapshot(page),
427+
commentId: comment.id,
428+
parentId: comment.parentId,
429+
parentAuthorId: comment.parentId
430+
? await WIKI.models.comments.authorOf(comment.parentId)
431+
: null,
432+
excerpt: WIKI.models.comments.excerptOf(comment.content),
433+
mentionHandles: WIKI.models.comments.mentionedHandles(comment.content),
434+
// -> A guest's name is what they typed; an account's is looked up when the event is sent
435+
...(comment.authorId ? {} : { actorName: comment.authorName })
436+
}
437+
})
421438

422439
reply.code(201)
423440
return comment
@@ -490,6 +507,30 @@ async function routes(app: FastifyInstance) {
490507
},
491508
content: updated.content
492509
})
510+
// -> Only the handles this edit added: re-saving a comment does not mention everybody in it again
511+
const before = new Set(WIKI.models.comments.mentionedHandles(comment.content))
512+
const added = WIKI.models.comments
513+
.mentionedHandles(updated.content)
514+
.filter((handle) => !before.has(handle))
515+
if (added.length > 0) {
516+
await WIKI.models.notifications.emit('comment:edit', {
517+
siteId: req.params.siteId,
518+
actorId: req.session?.user?.id ?? null,
519+
data: {
520+
variant: 'edited',
521+
page: WIKI.models.notifications.pageSnapshot({
522+
id: comment.pageId,
523+
title: comment.title,
524+
path: comment.path,
525+
locale: comment.locale,
526+
tags: comment.tags
527+
}),
528+
commentId: comment.id,
529+
excerpt: WIKI.models.comments.excerptOf(updated.content),
530+
mentionHandles: added
531+
}
532+
})
533+
}
493534
return updated
494535
}
495536
)

‎backend/api/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ async function routes(app: FastifyInstance) {
2222
await import('./schemas/locale.ts').then((m) => m.registerSchemas(app))
2323
await import('./schemas/mail.ts').then((m) => m.registerSchemas(app))
2424
await import('./schemas/metrics.ts').then((m) => m.registerSchemas(app))
25+
await import('./schemas/notifications.ts').then((m) => m.registerSchemas(app))
2526
await import('./schemas/page.ts').then((m) => m.registerSchemas(app))
2627
await import('./schemas/scheduler.ts').then((m) => m.registerSchemas(app))
2728
await import('./schemas/scim.ts').then((m) => m.registerSchemas(app))
@@ -49,6 +50,7 @@ async function routes(app: FastifyInstance) {
4950
app.register(import('./locales.ts'), { prefix: '/locales' })
5051
app.register(import('./mail.ts'), { prefix: '/mail' })
5152
app.register(import('./navigation.ts'))
53+
app.register(import('./notifications.ts'))
5254
app.register(import('./pages.ts'))
5355
app.register(import('./ratings.ts'))
5456
app.register(import('./scheduler.ts'), { prefix: '/scheduler' })

‎backend/api/mail.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ async function routes(app: FastifyInstance) {
208208
(await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname }))?.id ?? ''
209209
try {
210210
await WIKI.models.mail.send({
211-
siteId,
211+
site: WIKI.models.mail.siteFor(siteId),
212212
to: req.body.recipient,
213213
template: 'test',
214214
locale: req.body.locale,

0 commit comments

Comments
 (0)