diff --git a/CLAUDE.md b/CLAUDE.md index c13f3002e..13b251d1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,13 @@ read, tested and reasoned about. This applies to db columns, API payloads, store config keys alike; only real migrations under `backend/db/migrations/` are exempt, because Drizzle needs the history to get a live dev database to the current schema. +The one sanctioned exception is a value an existing installation **cannot** have and no static +default can stand in for — a secret generated per installation, say. That goes in a **startup check** +(`core/startupChecks.ts`), which every boot runs once to fill in what is missing; it is the only place +such a value is made, and nothing that reads it falls back on its own. A new setting with a fixed +default needs no check: put the default in `base.yml`, which is merged under the stored settings on +every boot. + Three independently-installed workspaces (each has its own `package.json` / `node_modules`, there is no root package or monorepo tooling): @@ -74,7 +81,10 @@ path in silence. and nothing else. - `core/` — long-lived singletons: `config.ts` (yml + db-backed settings), `db.ts` (pg pool, Drizzle instance, migrations, LISTEN/NOTIFY pubsub), `logger.ts`, `scheduler.ts` (poolifier thread pool + - postgres-backed job queue). + postgres-backed job queue), and `startupChecks.ts` — what `preBoot()` makes sure of right after the + settings are loaded, on a fresh install and an upgraded one alike: idempotent, add-only, run in one + transaction under an advisory lock so that the instances of an HA set booting together cannot each + generate a different value. - `db/` — `schema.ts` (all Drizzle table definitions), `relations.ts`, `migrations/` (generated). - `models/` — data-access classes over Drizzle, aggregated by `models/index.ts` and exposed as `WIKI.models.*`. Business logic belongs here, not in route handlers. `types.ts` holds the shared @@ -84,6 +94,9 @@ path in silence. `modules/authentication/local/`. `modules/storage/*` ships `db` and `disk` — see [Storage targets](#storage-targets). `modules/analytics/*` is the odd one out: a pair of YAML files and no implementation at all — see [Analytics](#analytics). +- `notifications/` — the notification categories (`categories/*.ts`, registered in `index.ts`) and + the worker-side halves of delivery: the fan-out and the mail drain. Not under `modules/`, which + expects a `definition.yml` per directory. See [Notifications](#notifications). - `tasks/simple/` — jobs run in-process by the scheduler; each exports `task(payload, { signal })`. File name is kebab-case, the task key is its camelCase form. `scheduler.taskTimeout` applies here 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. is abandoned — failed, left running, and no second copy of that task starts on the instance until it ends (`executeInProcess` in `core/scheduler.ts`). - `tasks/workers/` — CPU-bound jobs run in a worker thread via `worker.ts`, which boots a minimal - `WIKI` global (config + logger + lazy `ensureDb()`) and dynamically imports the task. + `WIKI` global (config + logger + lazy `ensureDb()`) and dynamically imports the task. **A worker's + database pool has ONE connection** (`core/db.ts`), so a transaction held open in a worker while + anything else queries deadlocks the task against itself until the pool aborts it. And a worker reads + the settings once, when its thread first opens the database, and never hears `reloadConfig` — a task + that depends on settings an admin can change re-reads them per run (`refreshWorkerConfig`). - `base.yml` — system defaults for every config key. Do not edit as a user-facing config; it defines the shape merged with `config.yml` and the db `settings` table. - `helpers/` — small pure utilities (`common.ts`, `config.ts`), plus two that are not: `storageFiles.ts`, @@ -1269,8 +1286,11 @@ produce and has to describe. ### Emails -The wiki sends three — a registration confirmation, a forgotten password, and the admin area's test -button — and `models/mail.ts` is the only place nodemailer is used. `MailTemplateData` is the +The wiki sends a registration confirmation, a forgotten password, the admin area's test, and +notifications (one at a time or as a digest — see [Notifications](#notifications)), and +`models/mail.ts` is the only place nodemailer is used. `send()` takes a `MailSite` from its caller +(`mail.siteFor(siteId)`) rather than a site id, because the notification mails are sent from a worker +thread, which has no `WIKI.sites`. `MailTemplateData` is the closed list, held as typed literals rather than rows in a table: nothing sends a mail this wiki did not ask it to, so a template is part of the flow that uses it and a flow that gained one would gain 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 rather than a replacement for them, so that a wiki that rewords one sentence keeps getting translations and improvements for everything else. +### Notifications + +Telling a person about something that happened: a change to a page they watch, a comment or a reply, +a mention, a suggestion to review, and — opt-in — every page created or deleted. In-app (the inbox +and the header badge) and by email, chosen per person and per category under **Profile → +Notifications**. **`dev/specs/notifications.md` is the design**, and says why each part is the shape +it is; what follows is what is easy to get wrong. + +- **`notifications.emit()` is one INSERT and never throws**, called from the models (so imports and + git pulls emit too) after the action succeeded, like `hooks.emit`. Nothing is resolved on the request + path. Events go into an outbox (`notificationEvents`) that a worker fans out in batches — not one + scheduler job per event, nor per recipient. +- **A category is a file** under `notifications/categories/` plus a key in `NOTIFICATION_CATEGORIES` + and its strings (`notifications.categories..*`, `notifications.messages..`, + `mail.notification.actions.`). Leaving the actor out, the access check, preferences, + deduplication and coalescing are the fan-out's, done once for every category. +- **Access is checked per group set**, against the rows the worker reads — `rulesAllow` on the pooled + rules of a set of groups, memoized — which is what makes an audience of every account affordable. +- **Origins.** `notifications.withOrigin('bulk' | 'import', work)` marks the events emitted inside it, + and `storage.importingFrom` counts as `import` on its own. A category's `origins` decides whether it + fires: `watchedPage` fires for all of them, `pageCreated` / `pageDeleted` only for `user`. +- **A deletion captures its watchers in the event**, before the page row goes and the watch rows with + it (`watchersOf` on `emit`). Any new path that deletes page rows has to emit the same way first. +- **One unread entry per person per `groupKey`** (a partial unique index), so forty saves are one entry + counting to forty, and a replayed event changes nothing (`lastEventId`). +- **Email is a window, then quiet until read.** `emailAfter` is the only thing the drain looks at, and + `emailAfterFor` in the fan-out the only place it is set — the seam a digest schedule would use. + Opening a page or its Talk tab marks its entries read (`viewer.unreadNotifications`, `markSeen`), + which is what lets the next change email again. +- **The drain claims rows with a lease** (`emailState = 'sending'`, `emailAfter` as the expiry) rather + than a transaction held across the send — see the note on worker connections under `tasks/workers/`. +- **Every mail carries RFC 8058 one-click unsubscribe.** The token is an HMAC with + `notifications.unsubscribeSecret`, generated by a startup check wherever it is missing, and + deliberately not `auth.secret` (which rotating the sessions replaces). A GET of the link only redirects to `/_unsubscribe`, which asks. +- **The site switch is `features.notifications`**; the instance settings (retention, email delay, mail + batch size) are **Admin → Notifications**, `GET`/`PUT /system/notifications`, behind `manage:system`. + ### Audit log Every action a **person** takes is one row in `auditLog` — `userId`, `clientIP`, `ts`, `kind` diff --git a/backend/api/approvals.ts b/backend/api/approvals.ts index a3f019676..0004ed6cc 100644 --- a/backend/api/approvals.ts +++ b/backend/api/approvals.ts @@ -894,6 +894,8 @@ async function routes(app: FastifyInstance) { } } + // -> Whether this replaces one already waiting, which its reviewers are told as an update + const previous = await WIKI.models.approvals.getOwnSubmission(page.id, actor?.id ?? null) const submission = await WIKI.models.approvals.saveSubmission({ siteId: req.params.siteId, page: pageRef, @@ -917,6 +919,17 @@ async function routes(app: FastifyInstance) { path: page.path, ...(actor ? {} : { guestName, guestEmail }) }) + await WIKI.models.notifications.emit('submission:new', { + siteId: req.params.siteId, + actorId: actor?.id ?? null, + data: { + variant: previous ? 'updated' : 'new', + submissionId: submission.id, + page: WIKI.models.notifications.pageSnapshot(page), + // -> A guest has no account to look a name up from, only what they typed + ...(actor ? {} : { actorName: guestName }) + } + }) return { ok: true, diff --git a/backend/api/comments.ts b/backend/api/comments.ts index f1453c533..203a96b01 100644 --- a/backend/api/comments.ts +++ b/backend/api/comments.ts @@ -418,6 +418,23 @@ async function routes(app: FastifyInstance) { }, content: comment.content }) + await WIKI.models.notifications.emit('comment:new', { + siteId: req.params.siteId, + actorId: comment.authorId, + data: { + variant: 'new', + page: WIKI.models.notifications.pageSnapshot(page), + commentId: comment.id, + parentId: comment.parentId, + parentAuthorId: comment.parentId + ? await WIKI.models.comments.authorOf(comment.parentId) + : null, + excerpt: WIKI.models.comments.excerptOf(comment.content), + mentionHandles: WIKI.models.comments.mentionedHandles(comment.content), + // -> A guest's name is what they typed; an account's is looked up when the event is sent + ...(comment.authorId ? {} : { actorName: comment.authorName }) + } + }) reply.code(201) return comment @@ -490,6 +507,30 @@ async function routes(app: FastifyInstance) { }, content: updated.content }) + // -> Only the handles this edit added: re-saving a comment does not mention everybody in it again + const before = new Set(WIKI.models.comments.mentionedHandles(comment.content)) + const added = WIKI.models.comments + .mentionedHandles(updated.content) + .filter((handle) => !before.has(handle)) + if (added.length > 0) { + await WIKI.models.notifications.emit('comment:edit', { + siteId: req.params.siteId, + actorId: req.session?.user?.id ?? null, + data: { + variant: 'edited', + page: WIKI.models.notifications.pageSnapshot({ + id: comment.pageId, + title: comment.title, + path: comment.path, + locale: comment.locale, + tags: comment.tags + }), + commentId: comment.id, + excerpt: WIKI.models.comments.excerptOf(updated.content), + mentionHandles: added + } + }) + } return updated } ) diff --git a/backend/api/index.ts b/backend/api/index.ts index c569285a7..f4dcd6b97 100644 --- a/backend/api/index.ts +++ b/backend/api/index.ts @@ -22,6 +22,7 @@ async function routes(app: FastifyInstance) { await import('./schemas/locale.ts').then((m) => m.registerSchemas(app)) await import('./schemas/mail.ts').then((m) => m.registerSchemas(app)) await import('./schemas/metrics.ts').then((m) => m.registerSchemas(app)) + await import('./schemas/notifications.ts').then((m) => m.registerSchemas(app)) await import('./schemas/page.ts').then((m) => m.registerSchemas(app)) await import('./schemas/scheduler.ts').then((m) => m.registerSchemas(app)) await import('./schemas/scim.ts').then((m) => m.registerSchemas(app)) @@ -49,6 +50,7 @@ async function routes(app: FastifyInstance) { app.register(import('./locales.ts'), { prefix: '/locales' }) app.register(import('./mail.ts'), { prefix: '/mail' }) app.register(import('./navigation.ts')) + app.register(import('./notifications.ts')) app.register(import('./pages.ts')) app.register(import('./ratings.ts')) app.register(import('./scheduler.ts'), { prefix: '/scheduler' }) diff --git a/backend/api/mail.ts b/backend/api/mail.ts index 21624f842..2d40d128d 100644 --- a/backend/api/mail.ts +++ b/backend/api/mail.ts @@ -208,7 +208,7 @@ async function routes(app: FastifyInstance) { (await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname }))?.id ?? '' try { await WIKI.models.mail.send({ - siteId, + site: WIKI.models.mail.siteFor(siteId), to: req.body.recipient, template: 'test', locale: req.body.locale, diff --git a/backend/api/notifications.ts b/backend/api/notifications.ts new file mode 100644 index 000000000..4e02a474f --- /dev/null +++ b/backend/api/notifications.ts @@ -0,0 +1,550 @@ +import { createHash } from 'node:crypto' +import { audit } from '../helpers/audit.ts' +import { NOTIFICATION_CATEGORY_KEYS } from '../notifications/index.ts' +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' + +/** + * The account an inbox or a set of preferences belongs to, or a refusal. + * + * Every route here but the unsubscribe pair is about the caller's own rows, so being signed in is the + * whole of the check — there is no permission for reading one's own inbox, and an API key, which acts + * for groups rather than for a person, has no inbox to read. + */ +function ownerOf(req: FastifyRequest, reply: FastifyReply): string | null { + const userId = req.session?.authenticated ? req.session.user?.id : null + if (!userId) { + reply.unauthorized('Notifications belong to a signed-in user.') + return null + } + return userId +} + +/** + * The site an inbox is read on, or a refusal. Nothing is shown on a site with notifications switched + * off, which is the same as having none. + */ +function siteOf(req: FastifyRequest<{ Params: { siteId: string } }>, reply: FastifyReply) { + if (!WIKI.sites[req.params.siteId]) { + reply.notFound('This site does not exist.') + return null + } + return req.params.siteId +} + +const siteParams = { + type: 'object', + properties: { siteId: { type: 'string', format: 'uuid' } }, + required: ['siteId'] +} + +const preferencesResponse = { + type: 'object', + properties: { + preferences: { type: 'array', items: { $ref: 'NotificationPreference#' } }, + emailAvailable: { + type: 'boolean', + description: + 'Whether this instance can send email at all. Without it, every email choice is stored but has no effect.' + } + } +} + +/** + * Notifications API Routes + * + * The inbox and its badge, the preferences behind Profile → Notifications, the one-click unsubscribe + * a notification email carries, and the instance settings of Admin → Notifications. What becomes a + * notification and who receives it is decided elsewhere — `models/notifications.ts` and the worker + * tasks it queues. + */ +async function routes(app: FastifyInstance) { + /** + * LIST NOTIFICATIONS + */ + app.get<{ + Params: { siteId: string } + Querystring: { cursor?: string; unread?: boolean; limit?: number } + }>( + '/sites/:siteId/notifications', + { + // -> No route-level permissions: the caller's own rows, and the session is the whole check + schema: { + summary: "List the caller's notifications", + description: + 'Newest activity first: an entry that absorbs another event moves back to the top. Includes the entries of this site and those that belong to no site.\n\nKeyset-paginated: pass the `next` of one page as the `cursor` of the next.', + tags: ['Notifications'], + params: siteParams, + querystring: { + type: 'object', + properties: { + cursor: { type: 'string', maxLength: 128 }, + unread: { type: 'boolean', description: 'Only entries not yet read.' }, + limit: { type: 'integer', minimum: 1, maximum: 100 } + } + }, + response: { + 200: { + description: 'One page of the inbox', + type: 'object', + properties: { + entries: { type: 'array', items: { $ref: 'Notification#' } }, + next: { type: 'string', nullable: true } + } + } + } + } + }, + async (req, reply) => { + reply.preventCache() + const userId = ownerOf(req, reply) + const siteId = userId && siteOf(req, reply) + if (!userId || !siteId) { + return reply + } + return WIKI.models.notifications.list(userId, siteId, req.query) + } + ) + + /** + * NOTIFICATIONS SUMMARY + */ + app.get<{ Params: { siteId: string } }>( + '/sites/:siteId/notifications/summary', + { + // -> No route-level permissions: see above + schema: { + summary: "Count the caller's unread notifications", + description: + 'What the badge polls. `unread` stops counting at 100. Answers with an `ETag` and `304 Not Modified` when nothing has changed, so a poll that finds nothing new costs no body.', + tags: ['Notifications'], + params: siteParams, + response: { + 200: { + description: 'The unread count, and when the inbox last changed', + type: 'object', + properties: { + unread: { type: 'integer' }, + latestAt: { type: 'string', format: 'date-time', nullable: true } + } + }, + 304: { description: 'Nothing has changed since the ETag sent', type: 'null' } + } + } + }, + async (req, reply) => { + const userId = ownerOf(req, reply) + const siteId = userId && siteOf(req, reply) + if (!userId || !siteId) { + return reply + } + const summary = await WIKI.models.notifications.summary(userId, siteId) + // -> Built from the answer AND who asked: the same URL answers differently for each person, and a + // tag naming only the count would let one session revalidate against another's + const etag = `"${createHash('sha1') + .update(`${userId}|${summary.unread}|${summary.latestAt ?? ''}`) + .digest('base64url')}"` + reply.header('Cache-Control', 'private, no-cache') + reply.header('ETag', etag) + if (req.headers['if-none-match'] === etag) { + return reply.code(304).send() + } + return summary + } + ) + + /** + * MARK NOTIFICATIONS READ + */ + app.put<{ + Params: { siteId: string } + Body: { ids?: string[]; pageId?: string; categories?: string[] } + }>( + '/sites/:siteId/notifications/read', + { + // -> No route-level permissions: see above + schema: { + summary: 'Mark notifications read', + description: + 'Everything, when the body names nothing. Otherwise the entries matching every filter given: particular entries by `ids`, or everything about one page by `pageId` — narrowed by `categories`, which is how opening a page clears what was said about its content while leaving its discussion for the Talk tab.\n\nAn entry read before its email has gone cancels the email.', + tags: ['Notifications'], + params: siteParams, + body: { + type: 'object', + properties: { + ids: { type: 'array', items: { type: 'string', format: 'uuid' }, maxItems: 500 }, + pageId: { type: 'string', format: 'uuid' }, + categories: { type: 'array', items: { type: 'string', maxLength: 64 }, maxItems: 20 } + } + }, + response: { + 200: { + description: 'How many entries were marked read', + type: 'object', + properties: { + ok: { type: 'boolean' }, + updated: { type: 'integer' } + } + } + } + } + }, + async (req, reply) => { + const userId = ownerOf(req, reply) + const siteId = userId && siteOf(req, reply) + if (!userId || !siteId) { + return reply + } + /* + Not audited. Marking one's own inbox read is bookkeeping, and a row per click would bury + everything the audit log is for — the reason page views are not recorded either. + */ + const updated = await WIKI.models.notifications.markRead(userId, siteId, req.body ?? {}) + return { ok: true, updated } + } + ) + + /** + * DISMISS A NOTIFICATION + */ + app.delete<{ Params: { siteId: string; notificationId: string } }>( + '/sites/:siteId/notifications/:notificationId', + { + // -> No route-level permissions: see above + schema: { + summary: 'Dismiss a notification', + description: + 'Removes one entry from the caller’s inbox. Not audited, as marking read is not.', + tags: ['Notifications'], + params: { + type: 'object', + properties: { + siteId: { type: 'string', format: 'uuid' }, + notificationId: { type: 'string', format: 'uuid' } + }, + required: ['siteId', 'notificationId'] + }, + response: { + 200: { + description: 'Dismissed', + type: 'object', + properties: { ok: { type: 'boolean' } } + } + } + } + }, + async (req, reply) => { + const userId = ownerOf(req, reply) + const siteId = userId && siteOf(req, reply) + if (!userId || !siteId) { + return reply + } + if (!(await WIKI.models.notifications.dismiss(userId, siteId, req.params.notificationId))) { + return reply.notFound('This notification does not exist.') + } + return { ok: true } + } + ) + + /** + * GET OWN NOTIFICATION PREFERENCES + */ + app.get( + '/users/profile/notifications', + { + // -> No route-level permissions: session-scoped like the rest of `/users/profile` + schema: { + summary: "Get the logged in user's notification preferences", + description: + 'Every category the caller is offered, with the channel choices in effect — their own where they made one, the category default where they did not. One set for every site.', + tags: ['Notifications'], + response: { 200: preferencesResponse } + } + }, + async (req, reply) => { + reply.preventCache() + const userId = ownerOf(req, reply) + if (!userId) { + return reply + } + return { + preferences: await WIKI.models.notifications.getPrefs(userId, { + permissions: req.session.permissions ?? [] + }), + emailAvailable: WIKI.models.mail.isConfigured + } + } + ) + + /** + * UPDATE OWN NOTIFICATION PREFERENCES + */ + app.put<{ Body: { preferences: Record } }>( + '/users/profile/notifications', + { + // -> No route-level permissions: see above + schema: { + summary: "Update the logged in user's notification preferences", + description: + 'Keyed by category. A category or a channel left out is left as it is, and a category the caller is not offered is ignored.', + tags: ['Notifications'], + body: { + type: 'object', + required: ['preferences'], + properties: { + preferences: { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + inApp: { type: 'boolean' }, + email: { type: 'boolean' } + } + } + } + } + }, + response: { 200: preferencesResponse } + } + }, + async (req, reply) => { + const userId = ownerOf(req, reply) + if (!userId) { + return reply + } + const actor = { permissions: req.session.permissions ?? [] } + const changed = await WIKI.models.notifications.setPrefs(userId, actor, req.body.preferences) + if (changed.length > 0) { + await audit(req, 'profile', 'updateNotificationPrefs', { categories: changed.sort() }) + } + return { + preferences: await WIKI.models.notifications.getPrefs(userId, actor), + emailAvailable: WIKI.models.mail.isConfigured + } + } + ) + + /** + * ONE-CLICK UNSUBSCRIBE (RFC 8058) + */ + app.post<{ + Querystring: { t?: string } + Body: { t?: string; scope?: 'token' | 'all'; 'List-Unsubscribe'?: string } + }>( + '/notifications/unsubscribe', + { + config: { + publicAccess: true + }, + schema: { + summary: 'Unsubscribe from notification email', + description: + 'What a mail client posts when somebody presses its own unsubscribe button: the URL in the `List-Unsubscribe` header, with `List-Unsubscribe=One-Click` as a form body (RFC 8058). Needs no session — the token in the URL says who, and all it can do is turn email off for them.\n\nTurns off EMAIL for the categories the mail was about, or for every category with `scope=all`, and cancels whatever email was waiting for them. In-app notifications carry on.', + tags: ['Notifications'], + consumes: ['application/x-www-form-urlencoded', 'application/json'], + querystring: { + type: 'object', + properties: { t: { type: 'string', maxLength: 2048 } } + }, + body: { + type: 'object', + additionalProperties: true, + properties: { + t: { type: 'string', maxLength: 2048, description: 'The token, when not in the URL.' }, + scope: { type: 'string', enum: ['token', 'all'] } + } + }, + response: { + 200: { + description: 'Unsubscribed', + type: 'object', + properties: { ok: { type: 'boolean' } } + } + } + } + }, + async (req, reply) => { + const scope = req.body?.scope === 'all' ? 'all' : 'token' + const claim = await WIKI.models.notifications.unsubscribe(req.query.t ?? req.body?.t, scope) + if (!claim) { + // -> Says nothing about which part was wrong + return reply.badRequest('This unsubscribe link is not valid.') + } + /* + Recorded as the account the token was issued to, like the auth events that record themselves: + there is no session, and the token is the only thing that identifies anybody. + */ + const user = await WIKI.models.users.getById(claim.userId) + await WIKI.models.auditLog.record({ + kind: 'profile', + action: 'unsubscribeNotifications', + actor: { + id: user?.id ?? null, + name: user?.name ?? null, + email: user?.email ?? null, + ip: req.ip + }, + meta: { scope, categories: claim.categories } + }) + return { ok: true } + } + ) + + /** + * UNSUBSCRIBE LINK, OPENED + */ + app.get<{ Querystring: { t?: string } }>( + '/notifications/unsubscribe', + { + config: { + publicAccess: true + }, + schema: { + summary: 'Open an unsubscribe link', + description: + 'Changes nothing, and sends the browser to the page that asks first. Mail scanners fetch every link in a message, so a GET that acted would unsubscribe people who never asked.', + tags: ['Notifications'], + querystring: { + type: 'object', + properties: { t: { type: 'string', maxLength: 2048 } } + }, + response: { + 302: { description: 'Redirect to the unsubscribe page', type: 'null' } + } + } + }, + async (req, reply) => { + return reply.redirect(`/_unsubscribe?t=${encodeURIComponent(req.query.t ?? '')}`) + } + ) + + /** + * DESCRIBE AN UNSUBSCRIBE LINK + */ + app.get<{ Querystring: { t?: string } }>( + '/notifications/unsubscribe/info', + { + config: { + publicAccess: true + }, + schema: { + summary: 'Describe an unsubscribe link', + description: + 'Which categories a link would unsubscribe from, for the page that asks before it does. Says nothing about whose it is.', + tags: ['Notifications'], + querystring: { + type: 'object', + properties: { t: { type: 'string', maxLength: 2048 } } + }, + response: { + 200: { + description: 'What the link would do', + type: 'object', + properties: { + valid: { type: 'boolean' }, + categories: { type: 'array', items: { type: 'string' } } + } + } + } + } + }, + async (req) => { + const claim = WIKI.models.notifications.readToken(req.query.t) + return { valid: Boolean(claim), categories: claim?.categories ?? [] } + } + ) + + /** + * GET NOTIFICATION SETTINGS + */ + app.get( + '/system/notifications', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Get the notification settings and delivery status', + description: + 'The instance-wide settings — retention, the email delay, the mail batch size — and how delivery is doing: the backlog of events still to be turned into notifications, and the emails of the last day.', + tags: ['Notifications'], + response: { + 200: { + description: 'Settings and status', + type: 'object', + properties: { + settings: { $ref: 'NotificationSettings#' }, + status: { $ref: 'NotificationStatus#' }, + categories: { + type: 'array', + items: { type: 'string' }, + description: 'Every category there is.' + } + } + } + } + } + }, + async (_req, reply) => { + reply.preventCache() + return { + settings: WIKI.models.notifications.getConfig(), + status: await WIKI.models.notifications.status(), + categories: NOTIFICATION_CATEGORY_KEYS + } + } + ) + + /** + * UPDATE NOTIFICATION SETTINGS + */ + app.put<{ Body: Record }>( + '/system/notifications', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Update the notification settings', + description: + 'Accepts any subset of the fields, and applies at once on every instance. A change to the email delay applies to notifications written from then on.', + tags: ['Notifications'], + body: { $ref: 'NotificationSettings#' }, + response: { + 200: { + description: 'Saved', + type: 'object', + properties: { + ok: { type: 'boolean' }, + message: { type: 'string' } + } + } + } + } + }, + async (req, reply) => { + const patch: Record = {} + for (const field of ['retentionDays', 'emailDelay', 'mailBatchSize']) { + if (req.body?.[field] !== undefined) { + patch[field] = req.body[field] + } + } + if (Object.keys(patch).length < 1) { + return reply.badRequest('No valid notification setting was provided.') + } + const invalid = WIKI.models.notifications.validate(patch) + if (invalid) { + return reply.badRequest(invalid) + } + if (!(await WIKI.models.notifications.updateConfig(patch))) { + return reply.internalServerError('Failed to save the notification settings.') + } + // -> Fields rather than values, as the other configuration routes do + await audit(req, 'admin', 'updateNotificationSettings', { + fields: Object.keys(patch).sort() + }) + return { ok: true, message: 'Notification settings saved successfully.' } + } + ) +} + +export default routes diff --git a/backend/api/pages.ts b/backend/api/pages.ts index 37ff8393a..9c1b36e71 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -701,6 +701,13 @@ async function routes(app: FastifyInstance) { const ratingMode = page.allowRatings ? WIKI.models.pageRatings.modeFor(req.params.siteId) : null + /* + Which of the reader's notifications are about this page and still unread, so that opening it + — or its Talk tab — can mark them read without asking first. One indexed lookup, none for a + guest, and the browser only writes anything when this is not empty. Started here so that it + runs alongside the rest. + */ + const unreadNotifications = WIKI.models.notifications.unreadCategoriesOnPage(page.id, actorId) const [approvalState, isWatching, commentsCount, blog, ownRating] = await Promise.all([ WIKI.models.approvals.pageViewerState(req, req.params.siteId, { id: page.id, @@ -747,7 +754,8 @@ async function routes(app: FastifyInstance) { permissions: pagePermissionsFor(req, page), ...approvalState, isWatching, - rating: ownRating + rating: ownRating, + unreadNotifications: await unreadNotifications } } } diff --git a/backend/api/schemas/notifications.ts b/backend/api/schemas/notifications.ts new file mode 100644 index 000000000..db09e5bdf --- /dev/null +++ b/backend/api/schemas/notifications.ts @@ -0,0 +1,143 @@ +import type { FastifyInstance } from 'fastify' + +export async function registerSchemas(app: FastifyInstance): Promise { + /** + * NOTIFICATION - One entry in somebody's inbox + */ + app.addSchema({ + $id: 'Notification', + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + category: { + type: 'string', + description: + 'What kind of notification this is: `watchedPage`, `watchedPageComment`, `commentReply`, `mention`, `reviewRequested`, `pageCreated` or `pageDeleted`.' + }, + variant: { + type: 'string', + description: + 'What happened, within the category — `edited`, `moved`, `published`, `unpublished`, `scheduled`, `deleted`, `new`, `updated`, `created`, `restored`. The latest one, for an entry that absorbed several events.' + }, + count: { + type: 'integer', + description: + 'How many events this entry stands for. A page saved ten times while nobody looked is one entry with a count of ten.' + }, + pageId: { + type: 'string', + format: 'uuid', + nullable: true, + description: 'The page it is about, while that page exists.' + }, + commentId: { + type: 'string', + format: 'uuid', + nullable: true, + description: 'The comment it is about, while that comment exists.' + }, + actorId: { + type: 'string', + format: 'uuid', + nullable: true, + description: + 'Who did it, while their account exists. Null for a guest, whose name is in `data.actorName`.' + }, + data: { + type: 'object', + additionalProperties: true, + description: + 'A snapshot of what the entry is about, taken when it happened, so that it can still be drawn after the page has moved or gone: `page` (`id`, `title`, `path`, `locale`), `actorName`, `variants` (every variant absorbed), `previousPath`, `submissionId`, `origin` (`import` or `bulk` when nobody did it by hand), and `excerpt` — a comment’s first lines, present only while the comment exists.' + }, + isRead: { type: 'boolean' }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { + type: 'string', + format: 'date-time', + description: 'When the entry last absorbed an event, which is what the inbox sorts by.' + } + } + }) + + /** + * NOTIFICATION PREFERENCE - One category, as Profile → Notifications offers it + */ + app.addSchema({ + $id: 'NotificationPreference', + type: 'object', + properties: { + key: { type: 'string' }, + section: { + type: 'string', + enum: ['watching', 'discussions', 'reviews', 'everything'], + description: 'The heading the Profile screen lists it under.' + }, + inApp: { type: 'boolean' }, + email: { type: 'boolean' }, + defaults: { + type: 'object', + properties: { + inApp: { type: 'boolean' }, + email: { type: 'boolean' } + } + } + } + }) + + /** + * NOTIFICATION SETTINGS - The instance-wide settings, used both ways + */ + app.addSchema({ + $id: 'NotificationSettings', + type: 'object', + properties: { + retentionDays: { + type: 'integer', + minimum: 1, + maximum: 3650, + description: 'How many days a notification is kept, read or not.' + }, + emailDelay: { + type: 'string', + maxLength: 16, + description: + 'How long a notification waits before it is emailed, e.g. `3m`. Whatever else happens to the same thing in the meantime goes into the same email; once emailed, an entry is not emailed about again until it has been read. At most an hour.' + }, + mailBatchSize: { + type: 'integer', + minimum: 1, + maximum: 1000, + description: 'How many people one run of the mail task sends to.' + } + } + }) + + /** + * NOTIFICATION STATUS - How delivery is doing, for the admin screen + */ + app.addSchema({ + $id: 'NotificationStatus', + type: 'object', + properties: { + pendingEvents: { + type: 'integer', + description: + 'Events not yet turned into notifications. Anything beyond a handful for long means the fan-out is falling behind.' + }, + oldestPendingEventAt: { type: 'string', format: 'date-time', nullable: true }, + emailsPending: { type: 'integer', description: 'Emails waiting to be sent.' }, + emailsSent24h: { type: 'integer' }, + emailsFailed24h: { + type: 'integer', + description: 'Emails given up on after repeated failures to hand them to the relay.' + }, + isMailConfigured: { type: 'boolean' }, + warnings: { + type: 'array', + items: { type: 'string', enum: ['plainHttp', 'wildcardHostname'] }, + description: + '`plainHttp`: the base URL links are built with is not HTTPS, which one-click unsubscribe needs to be honoured by Gmail. `wildcardHostname`: a site answers to any hostname and no base URL is set, so its emails have no address to link to and are not sent.' + } + } + }) +} diff --git a/backend/api/schemas/page.ts b/backend/api/schemas/page.ts index 4989a5f84..5e6e3171e 100644 --- a/backend/api/schemas/page.ts +++ b/backend/api/schemas/page.ts @@ -300,6 +300,12 @@ export async function registerSchemas(app: FastifyInstance): Promise { description: 'The requester’s own rating of this page on the site’s current scale, or 0 for none. Always 0 without an account, since a rating belongs to one.' }, + unreadNotifications: { + type: 'array', + items: { type: 'string' }, + description: + 'The notification categories the requester has unread entries in about this page, so that opening it can mark them read. Empty without an account.' + }, pendingSubmissions: { type: 'array', items: { $ref: 'PageEditSubmission#' }, diff --git a/backend/api/schemas/site.ts b/backend/api/schemas/site.ts index c1f7e1b51..cdf9d7361 100644 --- a/backend/api/schemas/site.ts +++ b/backend/api/schemas/site.ts @@ -112,6 +112,11 @@ export async function registerSchemas(app: FastifyInstance): Promise { description: 'Whether a page may show who last edited it, in its sidebar. A page can still opt out on its own with `showLastEditedBy`.' }, + notifications: { + type: 'boolean', + description: + 'Whether this site sends notifications at all — in-app or by email. Off, nothing that happens on the site is recorded for anybody, emails already waiting are dropped, and the inbox is hidden; entries already there are kept for when it is turned back on. What each person receives is theirs to choose, under Profile → Notifications.' + }, reasonForChange: { type: 'string', enum: ['off', 'optional', 'required'] diff --git a/backend/base.yml b/backend/base.yml index d0a40e47b..3c9914779 100644 --- a/backend/base.yml +++ b/backend/base.yml @@ -102,6 +102,16 @@ defaults: # about a dozen database queries. includeRuntime: true includeWiki: false + notifications: + # How many days a notification is kept, read or not. Entries older than this are deleted by the + # `purgeNotifications` task, which runs daily. + retentionDays: 60 + # How long a notification waits before it is emailed, so that a burst of activity on one page + # becomes one email rather than one per save. Once emailed, an entry is not emailed about again + # until it has been read. + emailDelay: '3m' + # How many people one run of the mail task sends to. Lower it for a relay that throttles. + mailBatchSize: 100 scim: # SCIM 2.0 provisioning, served at /_scim/v2. Off by default: it is a directory's write access # to the wiki's user and group lists, and nothing about it is useful until an administrator has diff --git a/backend/core/startupChecks.ts b/backend/core/startupChecks.ts new file mode 100644 index 000000000..193860a77 --- /dev/null +++ b/backend/core/startupChecks.ts @@ -0,0 +1,110 @@ +import crypto from 'node:crypto' +import { eq, sql } from 'drizzle-orm' +import { settings as settingsTable } from '../db/schema.ts' + +/** + * What every boot makes sure of before anything else reads the settings. + * + * A release sometimes needs a value that an installation created before it cannot have: a secret + * generated per installation, an identifier, a value derived from what is already stored. A key with + * a static default needs none of this — it belongs in `base.yml`, which is merged under the stored + * settings on every boot. A check is for what cannot be written down in advance, and it is the one + * place such a value is filled in: nothing that reads a setting falls back on its own, and nothing + * at install time duplicates what a check already does, since checks run after the first-run seed + * as well. + * + * **Checks must be idempotent** — they run on every boot, and on every instance of an HA set — and + * should only ever ADD what is missing. A check that changes a value somebody set is a migration of + * meaning, not a startup check, and deserves more thought than this file gives it. + */ + +/** Advisory lock held for the length of the checks' transaction. See `runStartupChecks`. */ +const STARTUP_CHECKS_LOCK_KEY = 4210002 + +/** One thing every boot makes sure of. */ +interface StartupCheck { + /** What it ensures, as the boot log names it. */ + name: string + /** + * Do it, inside the checks' transaction. + * + * @returns Whether anything was written + */ + run: (trx: any) => Promise +} + +/** + * The common case: a key inside one of the settings blobs. + * + * `fill` is handed the blob as stored (an empty object when there is no row for it yet) and returns + * the fields to add, or nothing when nothing is missing. What it returns is merged over the stored + * blob and written back whole, so the fields it does not name are kept as they were. + */ +function settingsCheck( + name: string, + key: string, + fill: (stored: Record) => Record | null +): StartupCheck { + return { + name, + async run(trx) { + const [row] = await trx + .select({ value: settingsTable.value }) + .from(settingsTable) + .where(eq(settingsTable.key, key)) + const stored = (row?.value ?? {}) as Record + const added = fill(stored) + if (!added || Object.keys(added).length < 1) { + return false + } + const value = { ...stored, ...added } + await trx + .insert(settingsTable) + .values({ key, value }) + .onConflictDoUpdate({ target: settingsTable.key, set: { value } }) + return true + } + } +} + +/** + * The checks, in the order they run. Add to the end; never remove one that a release has shipped + * while an installation from before it could still be upgraded. + */ +const STARTUP_CHECKS: StartupCheck[] = [ + /* + What notification unsubscribe links are signed with (`notifications/unsubscribe.ts`). Its own + secret rather than `auth.secret`, which rotating the sessions replaces — and every unsubscribe + link already in somebody's mailbox with it. Generated here rather than seeded at install, so that + an installation from before notifications existed gets one too. + */ + settingsCheck('notification unsubscribe secret', 'notifications', (stored) => + stored.unsubscribeSecret ? null : { unsubscribeSecret: crypto.randomBytes(32).toString('hex') } + ) +] + +/** + * Run every startup check, and say whether the settings need reading again. + * + * All of them in one transaction under an advisory lock, because the instances of an HA set boot + * together: without it, two would each find the secret missing, each generate one, and the second + * write would silently replace the first — after the first instance had already started signing + * links with it. Under the lock, the second finds the first's value and has nothing to do. + * + * @returns Whether anything was written, in which case the caller reloads the settings + */ +export async function runStartupChecks(): Promise { + const applied: string[] = [] + await WIKI.db.transaction(async (trx: any) => { + await trx.execute(sql`SELECT pg_advisory_xact_lock(${STARTUP_CHECKS_LOCK_KEY}::bigint)`) + for (const check of STARTUP_CHECKS) { + if (await check.run(trx)) { + applied.push(check.name) + } + } + }) + if (applied.length > 0) { + WIKI.logger.info(`Startup checks filled in: ${applied.join(', ')} [ OK ]`) + } + return applied.length > 0 +} diff --git a/backend/db/migrations/20261002010437_notifications/migration.sql b/backend/db/migrations/20261002010437_notifications/migration.sql new file mode 100644 index 000000000..6d67ee04f --- /dev/null +++ b/backend/db/migrations/20261002010437_notifications/migration.sql @@ -0,0 +1,62 @@ +CREATE TABLE "notificationEvents" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "kind" varchar(64) NOT NULL, + "origin" varchar(16) DEFAULT 'user' NOT NULL, + "siteId" uuid, + "actorId" uuid, + "data" jsonb DEFAULT '{}' NOT NULL, + "recipients" uuid[], + "cursor" jsonb, + "claimedAt" timestamp, + "claimedBy" varchar(255), + "processedAt" timestamp, + "createdAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "notifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "siteId" uuid, + "category" varchar(64) NOT NULL, + "variant" varchar(32) NOT NULL, + "groupKey" varchar(255) NOT NULL, + "pageId" uuid, + "commentId" uuid, + "actorId" uuid, + "data" jsonb DEFAULT '{}' NOT NULL, + "count" integer DEFAULT 1 NOT NULL, + "lastEventId" uuid NOT NULL, + "inApp" boolean DEFAULT true NOT NULL, + "emailState" varchar(16) DEFAULT 'none' NOT NULL, + "emailAfter" timestamp, + "emailedAt" timestamp, + "readAt" timestamp, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "userNotificationPrefs" ( + "userId" uuid, + "category" varchar(64), + "channel" varchar(16), + "enabled" boolean NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "userNotificationPrefs_pkey" PRIMARY KEY("userId","category","channel") +); +--> statement-breakpoint +CREATE INDEX "notificationEvents_pending_idx" ON "notificationEvents" ("createdAt") WHERE "processedAt" IS NULL;--> statement-breakpoint +CREATE INDEX "notificationEvents_processed_idx" ON "notificationEvents" ("processedAt") WHERE "processedAt" IS NOT NULL;--> statement-breakpoint +CREATE INDEX "notifications_user_updated_idx" ON "notifications" ("userId","updatedAt");--> statement-breakpoint +CREATE INDEX "notifications_unread_idx" ON "notifications" ("userId","siteId") WHERE "readAt" IS NULL AND "inApp";--> statement-breakpoint +CREATE INDEX "notifications_email_idx" ON "notifications" ("emailAfter") WHERE "emailState" IN ('pending', 'sending');--> statement-breakpoint +CREATE UNIQUE INDEX "notifications_user_group_unread_idx" ON "notifications" ("userId","groupKey") WHERE "readAt" IS NULL;--> statement-breakpoint +CREATE INDEX "notifications_pageId_idx" ON "notifications" ("pageId");--> statement-breakpoint +CREATE INDEX "notifications_commentId_idx" ON "notifications" ("commentId");--> statement-breakpoint +CREATE INDEX "notifications_createdAt_idx" ON "notifications" ("createdAt");--> statement-breakpoint +CREATE INDEX "userNotificationPrefs_optin_idx" ON "userNotificationPrefs" ("category","userId") WHERE "enabled";--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_pageId_pages_id_fkey" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_commentId_comments_id_fkey" FOREIGN KEY ("commentId") REFERENCES "comments"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_actorId_users_id_fkey" FOREIGN KEY ("actorId") REFERENCES "users"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "userNotificationPrefs" ADD CONSTRAINT "userNotificationPrefs_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE; \ No newline at end of file diff --git a/backend/db/migrations/20261002010437_notifications/snapshot.json b/backend/db/migrations/20261002010437_notifications/snapshot.json new file mode 100644 index 000000000..a8236db9c --- /dev/null +++ b/backend/db/migrations/20261002010437_notifications/snapshot.json @@ -0,0 +1,8231 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "51df9571-5562-4eef-817f-75a0533bb4cf", + "prevIds": [ + "5f4ecf6d-668d-405e-a03e-8b038be23d80" + ], + "ddl": [ + { + "values": [ + "document", + "image", + "other" + ], + "name": "assetKind", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "pending", + "success", + "error" + ], + "name": "hookState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "open", + "finished", + "failed" + ], + "name": "importSessionState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "active", + "completed", + "failed", + "interrupted" + ], + "name": "jobHistoryState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "draft", + "published", + "scheduled" + ], + "name": "pagePublishState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "inherit", + "override", + "overrideExact", + "hide", + "hideExact" + ], + "name": "treeNavigationMode", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "folder", + "page", + "asset" + ], + "name": "treeType", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "apiKeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "approvalRules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "assets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "auditLog", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "authentication", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "blocks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "groups", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "hooks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "iconSets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "icons", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "importIdMap", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "importSessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobHistory", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobLock", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobSchedule", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "locales", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "notificationEvents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageEditSubmissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageHistory", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageLinks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageRatings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageRenderQueue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageWatching", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "rateLimits", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "siteAssets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sites", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "storage", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tree", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userAvatars", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userGroups", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userKeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userNotificationPrefs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "users", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "varchar(8)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "keyShort", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "groups", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "expiration", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isRevoked", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'START'", + "generated": null, + "identity": null, + "name": "match", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "submitterGroups", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "reviewerGroups", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileName", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileExt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "assetKind", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'other'", + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'application/octet-stream'", + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileSize", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "ts", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "clientIP", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "displayName", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "registration", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "allowedEmailRegex", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "autoEnrollGroups", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "block", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isCustom", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "definition", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "packageData", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "checksum", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorName", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "authorEmail", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "authorIP", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rules", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "redirectOnLogin", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "redirectOnFirstLogin", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "redirectOnLogout", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "externalId", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isProvisioned", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "events", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "includeMetadata", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "includeContent", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "acceptUntrusted", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authHeader", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "hookState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastErrorMessage", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "info", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refreshedAt", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "16", + "generated": null, + "identity": null, + "name": "width", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "16", + "generated": null, + "identity": null, + "name": "height", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "left", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "top", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "rotate", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hFlip", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "vFlip", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sessionId", + "entityType": "columns", + "schema": "public", + "table": "importIdMap" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity", + "entityType": "columns", + "schema": "public", + "table": "importIdMap" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceId", + "entityType": "columns", + "schema": "public", + "table": "importIdMap" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "importIdMap" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "namespace", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "sites", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "includes", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "overwrite", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'markdown'", + "generated": null, + "identity": null, + "name": "htmlConversion", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "importSessionState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'open'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "progress", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "warnings", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "jobHistoryState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "useWorker", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "wasScheduled", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastErrorMessage", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "executedBy", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastCheckedBy", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastCheckedAt", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cron", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "useWorker", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "retries", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "waitUntil", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isScheduled", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nativeName", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(8)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(3)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "region", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(4)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "script", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isRTL", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isInstalled", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "customCode", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "customName", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "strings", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "completeness", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "items", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'user'", + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 1, + "default": null, + "generated": null, + "identity": null, + "name": "recipients", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cursor", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claimedAt", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claimedBy", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "processedAt", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variant", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "commentId", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "count", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastEventId", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "inApp", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'none'", + "generated": null, + "identity": null, + "name": "emailState", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailAfter", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailedAt", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "patch", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseHash", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guestName", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guestEmail", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'updated'", + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "versionDate", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetSiteId", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetLocale", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetPath", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetRef", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageRatings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "pageRatings" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "pageRatings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "pageRatings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageRatings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pageRatings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowScripts", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowStyles", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "requestedById", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "alias", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "pagePublishState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "publishState", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishStartDate", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishEndDate", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "relations", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localeGroupId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "render", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "searchContent", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ts", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "toc", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "editor", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentType", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isBrowsable", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isSearchable", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": { + "as": "\"pages\".\"publishState\" != 'draft' AND \"pages\".\"isSearchable\"", + "type": "stored" + }, + "identity": null, + "name": "isSearchableComputed", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "ratings", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "scripts", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "historyData", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "creatorId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ownerId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "hits", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "windowStartedAt", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bannedUntil", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "settings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hostname", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "contentTypes", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "assetDelivery", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tag", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "usageCount", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "ltree", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folderPath", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileName", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "treeType", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tree", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "treeNavigationMode", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'inherit'", + "generated": null, + "identity": null, + "name": "navigationMode", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "navigationId", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "userAvatars" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "userAvatars" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "userGroups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupId", + "entityType": "columns", + "schema": "public", + "table": "userGroups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "validUntil", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "handle", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "externalId", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isProvisioned", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "auth", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "passkeys", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "prefs", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hasAvatar", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isActive", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isVerified", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastLoginAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "approvalRules_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "approvalRules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "assets_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_ts_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_kind_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_action_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blocks_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "blocks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "comments_page_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "comments_parentId_idx", + "entityType": "indexes", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "comments_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "externalId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "groups_externalId_idx", + "entityType": "indexes", + "schema": "public", + "table": "groups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "importSessions_createdAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "importSessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "locales_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "locales" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "navigation_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "navigation_siteId_locale_key", + "entityType": "indexes", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"processedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "notificationEvents_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "notificationEvents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "processedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"processedAt\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "notificationEvents_processed_idx", + "entityType": "indexes", + "schema": "public", + "table": "notificationEvents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_user_updated_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"readAt\" IS NULL AND \"inApp\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "emailAfter", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailState\" IN ('pending', 'sending')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_email_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"readAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_user_group_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "commentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_commentId_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_createdAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"authorId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_page_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "versionDate", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "path", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "versionDate", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "targetSiteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetLocale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_target_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "targetSiteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetRef", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_targetRef_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "href", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_pageId_href_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageRatings_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageRatings" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageRenderQueue_createdAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageWatching_user_site_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageWatching_page_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "creatorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_creatorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ownerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_ownerId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ts", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "pages_ts_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tags", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "pages_tags_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isSearchableComputed", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_isSearchableComputed_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "localeGroupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_localeGroupId_locale_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "path", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_siteId_locale_path_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "rateLimits_updatedAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "rateLimits" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "module", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "storage_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "storage" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "tag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "folderPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_folderpath_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "folderPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gist", + "concurrently": false, + "name": "tree_folderpath_gist_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_fileName_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_hash_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tree", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_locale_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "navigationMode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_navigationMode_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "navigationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_navigationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tags", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "tree_tags_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "groupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_groupId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userKeys_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userKeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"enabled\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "userNotificationPrefs_optin_idx", + "entityType": "indexes", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastLoginAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_lastLoginAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "externalId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_externalId_idx", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lower(\"handle\")", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_handle_idx", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "approvalRules_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "approvalRules" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "assets_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "assets_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "auditLog_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "blocks_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blocks" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "comments_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "comments", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "comments_parentId_comments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "comments_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": false, + "columns": [ + "prefix" + ], + "schemaTo": "public", + "tableTo": "iconSets", + "columnsTo": [ + "prefix" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "icons_prefix_iconSets_prefix_fkey", + "entityType": "fks", + "schema": "public", + "table": "icons" + }, + { + "nameExplicit": false, + "columns": [ + "sessionId" + ], + "schemaTo": "public", + "tableTo": "importSessions", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "importIdMap_sessionId_importSessions_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "importIdMap" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "importSessions_actorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "importSessions" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "navigation_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notifications_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notifications_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "notifications_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "commentId" + ], + "schemaTo": "public", + "tableTo": "comments", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "notifications_commentId_comments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "notifications_actorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageEditSubmissions_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageEditSubmissions_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageEditSubmissions_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "pageHistory_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageHistory_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": false, + "columns": [ + "targetSiteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageLinks_targetSiteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageLinks_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageLinks_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageRatings_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRatings" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageRatings_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRatings" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageRenderQueue_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageRenderQueue_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "requestedById" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "pageRenderQueue_requestedById_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageWatching_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageWatching_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageWatching_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "creatorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_creatorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "ownerId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_ownerId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "sessions_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "siteAssets_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "siteAssets" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "storage_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "storage" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "tags_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "tree_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "userGroups_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": false, + "columns": [ + "groupId" + ], + "schemaTo": "public", + "tableTo": "groups", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "userGroups_groupId_groups_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "userKeys_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userKeys" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "userNotificationPrefs_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "columns": [ + "prefix", + "name" + ], + "nameExplicit": false, + "name": "icons_pkey", + "entityType": "pks", + "schema": "public", + "table": "icons" + }, + { + "columns": [ + "sessionId", + "entity", + "sourceId" + ], + "nameExplicit": false, + "name": "importIdMap_pkey", + "entityType": "pks", + "schema": "public", + "table": "importIdMap" + }, + { + "columns": [ + "pageId", + "userId" + ], + "nameExplicit": false, + "name": "pageRatings_pkey", + "entityType": "pks", + "schema": "public", + "table": "pageRatings" + }, + { + "columns": [ + "siteId", + "kind" + ], + "nameExplicit": false, + "name": "siteAssets_pkey", + "entityType": "pks", + "schema": "public", + "table": "siteAssets" + }, + { + "columns": [ + "userId", + "groupId" + ], + "nameExplicit": false, + "name": "userGroups_pkey", + "entityType": "pks", + "schema": "public", + "table": "userGroups" + }, + { + "columns": [ + "userId", + "category", + "channel" + ], + "nameExplicit": false, + "name": "userNotificationPrefs_pkey", + "entityType": "pks", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "apiKeys_pkey", + "schema": "public", + "table": "apiKeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "approvalRules_pkey", + "schema": "public", + "table": "approvalRules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "assets_pkey", + "schema": "public", + "table": "assets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "auditLog_pkey", + "schema": "public", + "table": "auditLog", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "authentication_pkey", + "schema": "public", + "table": "authentication", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blocks_pkey", + "schema": "public", + "table": "blocks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "comments_pkey", + "schema": "public", + "table": "comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "groups_pkey", + "schema": "public", + "table": "groups", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "hooks_pkey", + "schema": "public", + "table": "hooks", + "entityType": "pks" + }, + { + "columns": [ + "prefix" + ], + "nameExplicit": false, + "name": "iconSets_pkey", + "schema": "public", + "table": "iconSets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "importSessions_pkey", + "schema": "public", + "table": "importSessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobHistory_pkey", + "schema": "public", + "table": "jobHistory", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "jobLock_pkey", + "schema": "public", + "table": "jobLock", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobSchedule_pkey", + "schema": "public", + "table": "jobSchedule", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobs_pkey", + "schema": "public", + "table": "jobs", + "entityType": "pks" + }, + { + "columns": [ + "code" + ], + "nameExplicit": false, + "name": "locales_pkey", + "schema": "public", + "table": "locales", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "navigation_pkey", + "schema": "public", + "table": "navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "notificationEvents_pkey", + "schema": "public", + "table": "notificationEvents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "notifications_pkey", + "schema": "public", + "table": "notifications", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageEditSubmissions_pkey", + "schema": "public", + "table": "pageEditSubmissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageHistory_pkey", + "schema": "public", + "table": "pageHistory", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageLinks_pkey", + "schema": "public", + "table": "pageLinks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageRenderQueue_pkey", + "schema": "public", + "table": "pageRenderQueue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageWatching_pkey", + "schema": "public", + "table": "pageWatching", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pages_pkey", + "schema": "public", + "table": "pages", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "rateLimits_pkey", + "schema": "public", + "table": "rateLimits", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "public", + "table": "sessions", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "settings_pkey", + "schema": "public", + "table": "settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sites_pkey", + "schema": "public", + "table": "sites", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "storage_pkey", + "schema": "public", + "table": "storage", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tags_pkey", + "schema": "public", + "table": "tags", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tree_pkey", + "schema": "public", + "table": "tree", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userAvatars_pkey", + "schema": "public", + "table": "userAvatars", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userKeys_pkey", + "schema": "public", + "table": "userKeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "users_pkey", + "schema": "public", + "table": "users", + "entityType": "pks" + }, + { + "nameExplicit": false, + "columns": [ + "customCode" + ], + "nullsNotDistinct": false, + "name": "locales_customCode_key", + "schema": "public", + "table": "locales", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "nullsNotDistinct": false, + "name": "pageRenderQueue_pageId_key", + "schema": "public", + "table": "pageRenderQueue", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "hostname" + ], + "nullsNotDistinct": false, + "name": "sites_hostname_key", + "schema": "public", + "table": "sites", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "users_email_key", + "schema": "public", + "table": "users", + "entityType": "uniques" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/backend/db/schema.ts b/backend/db/schema.ts index 94f7ebd47..e1d686173 100644 --- a/backend/db/schema.ts +++ b/backend/db/schema.ts @@ -630,6 +630,130 @@ export const navigation = pgTable( ] ) +// NOTIFICATION EVENTS ----------------- +/** + * The outbox: one row per thing that happened that somebody may need to be told about. + * + * Written by `notifications.emit()` from the request that caused it, and nothing more — working out + * who to tell happens later, in a worker, which claims these in batches (`FOR UPDATE SKIP LOCKED`). + * One row per EVENT rather than one scheduler job per event, because a job costs a `jobs` row and a + * `jobHistory` row each, and a busy wiki saves pages faster than that is worth paying for. + * + * No foreign keys: an event is a record of a moment, and the page, the comment or the actor it names + * may well be gone by the time it is processed — a deletion is the event that guarantees it. + */ +export const notificationEvents = pgTable( + 'notificationEvents', + { + // -> Also the idempotency key: a fan-out replayed after a crash writes this id onto every entry + // it touches, and an entry that already carries it is left alone + id: uuid().primaryKey().defaultRandom(), + kind: varchar({ length: 64 }).notNull(), + // -> 'user', 'import' or 'bulk', which a category may decline to fire for + origin: varchar({ length: 16 }).notNull().default('user'), + // -> Null for an event that belongs to no site + siteId: uuid(), + actorId: uuid(), + data: jsonb().notNull().default({}), + /** + * Candidates resolved when the event was written rather than when it is processed. Only a + * deletion needs it: the page's watchers are removed with the page by the foreign key's cascade, + * so they have to be read before the page goes or there is nobody left to tell. + */ + recipients: uuid().array(), + // -> How far an interrupted fan-out got, so that the next run carries on from there + cursor: jsonb(), + claimedAt: timestamp(), + claimedBy: varchar({ length: 255 }), + processedAt: timestamp(), + createdAt: timestamp().notNull().defaultNow() + }, + (table) => [ + // -> The fan-out's own query: what is still to do, oldest first + index('notificationEvents_pending_idx') + .on(table.createdAt) + .where(sql`"processedAt" IS NULL`), + index('notificationEvents_processed_idx') + .on(table.processedAt) + .where(sql`"processedAt" IS NOT NULL`) + ] +) + +// NOTIFICATIONS ----------------------- +/** + * What one person has been told about: the inbox, and the email queue, in one row. + * + * **A row covers both channels.** `inApp` says whether the inbox shows it and `emailState` where its + * email stands, so a person who only wants email still has a row — hidden from the inbox, and closed + * (`readAt` set) once the email has gone, since there is nowhere for them to read it. One row is what + * gives coalescing and the email cadence a single thing to work on. + * + * **A row coalesces.** Every entry has a `groupKey` (`watchedPage:`, `mention:`, + * …) and a person has at most one UNREAD entry per key, by the partial unique index below. Forty saves + * to a watched page are one entry with a `count` of forty; reading it frees the key, and the next save + * starts a new one. + * + * **A row carries a snapshot** (`data`) of what it is about — the title, the path, who did it — since + * the page may be renamed or deleted before anybody looks. The ids beside it are `set null` rather + * than cascaded for the same reason: a notification that a page was deleted has to outlive the page. + * A comment's excerpt is shown only while `commentId` is still set, so deleting a comment takes its + * text out of every inbox it reached. + */ +export const notifications = pgTable( + 'notifications', + { + id: uuid().primaryKey().defaultRandom(), + userId: uuid() + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + // -> Null for an entry that belongs to no site, which every site's inbox shows. Cascaded: an + // inbox is not content, and a site's entries mean nothing once the site has gone + siteId: uuid().references(() => sites.id, { onDelete: 'cascade' }), + category: varchar({ length: 64 }).notNull(), + // -> The latest one: an entry absorbing an edit and then a move says it was moved + variant: varchar({ length: 32 }).notNull(), + groupKey: varchar({ length: 255 }).notNull(), + pageId: uuid().references(() => pages.id, { onDelete: 'set null' }), + commentId: uuid().references(() => comments.id, { onDelete: 'set null' }), + actorId: uuid().references(() => users.id, { onDelete: 'set null' }), + data: jsonb().notNull().default({}), + count: integer().notNull().default(1), + lastEventId: uuid().notNull(), + inApp: boolean().notNull().default(true), + // -> 'none', 'pending', 'sending', 'sent', 'failed' or 'skipped'. A varchar rather than an enum + // so that a state added later does not need a migration. `sending` is a claim, and its + // `emailAfter` the lease — see `claim` in `notifications/mailer.ts` + emailState: varchar({ length: 16 }).notNull().default('none'), + emailAfter: timestamp(), + emailedAt: timestamp(), + readAt: timestamp(), + createdAt: timestamp().notNull().defaultNow(), + // -> Moved forward whenever the entry absorbs another event, and the inbox's sort key: an entry + // that is still collecting news belongs at the top + updatedAt: timestamp().notNull().defaultNow() + }, + (table) => [ + // -> The inbox + index('notifications_user_updated_idx').on(table.userId, table.updatedAt), + // -> The badge, which every page view of a signed-in reader polls for + index('notifications_unread_idx') + .on(table.userId, table.siteId) + .where(sql`"readAt" IS NULL AND "inApp"`), + // -> The mail drain: what is due, and what a run that died while sending left behind + index('notifications_email_idx') + .on(table.emailAfter) + .where(sql`"emailState" IN ('pending', 'sending')`), + // -> Coalescing, and idempotency with it: see the note above + uniqueIndex('notifications_user_group_unread_idx') + .on(table.userId, table.groupKey) + .where(sql`"readAt" IS NULL`), + index('notifications_pageId_idx').on(table.pageId), + index('notifications_commentId_idx').on(table.commentId), + // -> The purge, which goes by age alone + index('notifications_createdAt_idx').on(table.createdAt) + ] +) + // PAGES ------------------------------ export const pagePublishStateEnum = pgEnum('pagePublishState', ['draft', 'published', 'scheduled']) export const pages = pgTable( @@ -1309,3 +1433,42 @@ export const userGroups = pgTable( index('userGroups_composite_idx').on(table.userId, table.groupId) ] ) + +// USER NOTIFICATION PREFS ------------- +/** + * Which notifications one person has chosen to receive, and how. + * + * **Only a choice that differs from the category's default is stored.** No row means the default, + * which lives in the category's definition (`notifications/categories/`) and nowhere else, and setting + * a choice back to its default deletes the row rather than writing one that says the same thing. + * + * A table rather than a key in `users.prefs` because of the categories that are off by default: to + * tell everybody who asked about every page created, the question is "who has turned this on?" asked + * of every account on the instance, which is the partial index below rather than a scan of a JSONB + * column. The categories that are on by default ask the other way round — of these few watchers, + * who has turned it off — and that is the primary key. + * + * One row per channel rather than a column each, so that a third channel is a new value and not a + * migration. Global, not per site: a person has one set of preferences for every site they use. + */ +export const userNotificationPrefs = pgTable( + 'userNotificationPrefs', + { + userId: uuid() + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + category: varchar({ length: 64 }).notNull(), + // -> 'inApp' or 'email' + channel: varchar({ length: 16 }).notNull(), + enabled: boolean().notNull(), + updatedAt: timestamp().notNull().defaultNow() + }, + (table) => [ + primaryKey({ columns: [table.userId, table.category, table.channel] }), + // -> Who has turned a category on, read in userId order: the fan-out pages through it a thousand + // accounts at a time, and an opted-in audience can be every account on the instance + index('userNotificationPrefs_optin_idx') + .on(table.category, table.userId) + .where(sql`"enabled"`) + ] +) diff --git a/backend/index.ts b/backend/index.ts index 6673a0708..04d1f31a3 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -34,6 +34,7 @@ import configSvc from './core/config.ts' import dbManager from './core/db.ts' import logger from './core/logger.ts' import scheduler from './core/scheduler.ts' +import { runStartupChecks } from './core/startupChecks.ts' import { renderAppShell } from './helpers/appShell.ts' import { isPageUrl, @@ -165,6 +166,11 @@ async function preBoot() { throw new Error('Settings table is empty! Could not initialize [ ERROR ]') } } + // -> On a fresh install as much as an upgraded one: the seed leaves to these what has to be + // generated, so there is one place each such value is made + if (await runStartupChecks()) { + await WIKI.configSvc.loadFromDb() + } } catch (err: any) { WIKI.logger.error('Database Initialization Error: ' + err.message) if (WIKI.IS_DEBUG) { diff --git a/backend/locales/en.json b/backend/locales/en.json index f54e53f23..a191912b9 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -211,6 +211,7 @@ "admin.audit.actions.unassignUserFromGroup": "Removed a user from a group", "admin.audit.actions.unlockPage": "Unlocked a password-protected page", "admin.audit.actions.unratePage": "Withdrew a page rating", + "admin.audit.actions.unsubscribeNotifications": "Unsubscribed from notification email", "admin.audit.actions.unwatchPage": "Stopped watching a page", "admin.audit.actions.updateAnalytics": "Changed the analytics configuration", "admin.audit.actions.updateApiState": "Turned the API on or off", @@ -232,6 +233,8 @@ "admin.audit.actions.updateLocale": "Changed a locale alias", "admin.audit.actions.updateMailConfig": "Updated the mail configuration", "admin.audit.actions.updateMetricsState": "Turned the metrics endpoint on or off", + "admin.audit.actions.updateNotificationPrefs": "Changed their notification settings", + "admin.audit.actions.updateNotificationSettings": "Changed the notification settings", "admin.audit.actions.updatePage": "Edited a page", "admin.audit.actions.updatePageNavigation": "Changed the navigation of a page", "admin.audit.actions.updateProfile": "Updated their profile", @@ -494,6 +497,8 @@ "admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.", "admin.general.allowLastEditedBy": "Allow Last Edited By", "admin.general.allowLastEditedByHint": "Can the \"Last Edited By\" info be displayed on pages?", + "admin.general.allowNotifications": "Allow Notifications", + "admin.general.allowNotificationsHint": "Tell readers about the pages they watch, replies, mentions and suggestions to review. What each person receives is theirs to choose in their profile.", "admin.general.allowRatings": "Allow Ratings", "admin.general.allowRatingsHint": "Can logged in users rate the pages they can read, and on which scale? A page can still turn ratings off in its properties.", "admin.general.allowSearch": "Allow Search", @@ -878,6 +883,36 @@ "admin.nav.site": "Site", "admin.nav.system": "System", "admin.nav.users": "Users", + "admin.notifications.backlog": "Backlog", + "admin.notifications.backlogHint": "Events not yet turned into notifications. A backlog that keeps growing means delivery is falling behind.", + "admin.notifications.configureMail": "Configure", + "admin.notifications.days": "days", + "admin.notifications.emailDelay": "Email Delay", + "admin.notifications.emailDelayHint": "How long a notification waits before it is emailed, e.g. 3m. Whatever else happens to the same page in the meantime goes into the same email. Once emailed, nothing more is sent about it until it is read.", + "admin.notifications.emails": "Emails", + "admin.notifications.emailsHint": "Waiting to be sent, and sent or given up on in the last 24 hours.", + "admin.notifications.failed": "Failed", + "admin.notifications.loadFailed": "Failed to load the notification settings.", + "admin.notifications.mail": "Outgoing Mail", + "admin.notifications.mailBatchSize": "Mail Batch Size", + "admin.notifications.mailBatchSizeHint": "How many people one run of the mail task sends to. Lower it for a mail server that throttles.", + "admin.notifications.mailConfigured": "Configured. Notification emails are sent.", + "admin.notifications.mailNotConfigured": "Not configured. Only in-app notifications are delivered.", + "admin.notifications.oldest": "oldest {date}", + "admin.notifications.pending": "Pending", + "admin.notifications.refreshSuccess": "Notification settings refreshed.", + "admin.notifications.retentionDays": "Retention", + "admin.notifications.retentionDaysHint": "How long notifications are kept, read or not.", + "admin.notifications.saveFailed": "Failed to save the notification settings.", + "admin.notifications.saveSuccess": "Notification settings saved.", + "admin.notifications.sent": "Sent", + "admin.notifications.settings": "Settings", + "admin.notifications.status": "Status", + "admin.notifications.statusHint": "How delivery is doing on this instance", + "admin.notifications.subtitle": "How notifications are kept and emailed on this instance", + "admin.notifications.title": "Notifications", + "admin.notifications.warnings.plainHttp": "Links in emails are built from a base URL that is not HTTPS. Gmail ignores one-click unsubscribe on such links. Set an HTTPS base URL under Admin → Mail.", + "admin.notifications.warnings.wildcardHostname": "A site answers to any hostname and no base URL is set under Admin → Mail, so its notification emails have no address to link to and are not sent.", "admin.rendering.subtitle": "Configure the content rendering pipeline", "admin.rendering.title": "Rendering", "admin.scheduler.active": "Active", @@ -2876,8 +2911,18 @@ "iconPicker.selection": "Selected icon", "iconPicker.set": "Set", "iconPicker.setsFailed": "Failed to load the icon sets.", + "inbox.dismiss": "Dismiss", + "inbox.dismissFailed": "Failed to dismiss the notification.", "inbox.inbox": "Inbox", - "inbox.inboxInfo": "Nothing here yet.", + "inbox.loadFailed": "Failed to load your notifications.", + "inbox.loadMore": "Load more", + "inbox.markAllRead": "Mark all as read", + "inbox.markRead": "Mark as read", + "inbox.markReadFailed": "Failed to mark notifications as read.", + "inbox.none": "You have no notifications.", + "inbox.noneHint": "Watch a page with the bell in its header to hear about changes to it. Choose what else you are told about in your profile.", + "inbox.noneUnread": "You have no unread notifications.", + "inbox.notificationsOff": "Notifications are turned off on this site.", "inbox.pendingReview": "Pending Review", "inbox.pendingReviewInfo": "Edit suggestions waiting for your review, oldest first.", "inbox.reviewApprove": "Approve", @@ -2898,7 +2943,10 @@ "inbox.reviewSubmittedBy": "Suggested by {author} on {date}", "inbox.reviewUnknownAuthor": "Unknown", "inbox.reviewViewPage": "View Page", + "inbox.settings": "Notification settings", "inbox.title": "Inbox & Notifications", + "inbox.today": "Today", + "inbox.unreadOnly": "Unread only", "inbox.watching": "Watching", "inbox.watchingHint": "Open a page and press the bell in its header to start watching it.", "inbox.watchingInfo": "Pages you asked to be told about, most recently added first.", @@ -2909,6 +2957,7 @@ "inbox.watchingUnwatchFailed": "Could not stop watching this page.", "inbox.watchingUnwatched": "You are no longer watching {title}.", "inbox.watchingUpdated": "Last modified on {date}", + "inbox.yesterday": "Yesterday", "linkPicker.emptyFolder": "There are no pages in this folder.", "linkPicker.linkUrl": "Link URL", "linkPicker.loadFailed": "Failed to load the page tree.", @@ -2936,6 +2985,22 @@ "localeFetchDialog.resultUpdated": "No locale updated | {count} locale updated | {count} locales updated", "localeFetchDialog.title": "Fetch Updates", "mail.common.greeting": "Hi {name},", + "mail.notification.actions.commentReply": "Open the discussion", + "mail.notification.actions.mention": "Open the discussion", + "mail.notification.actions.pageCreated": "View the page", + "mail.notification.actions.pageDeleted": "Open your inbox", + "mail.notification.actions.reviewRequested": "Review the suggestion", + "mail.notification.actions.watchedPage": "View the page", + "mail.notification.actions.watchedPageComment": "Open the discussion", + "mail.notification.footer": "You are receiving this because of your notification settings on {siteName}.", + "mail.notification.manage": "Notification settings", + "mail.notification.subject": "{message} — {siteName}", + "mail.notification.unsubscribe": "Unsubscribe", + "mail.notificationDigest.action": "Open your inbox", + "mail.notificationDigest.body": "Here is what happened since your last notification ({count} in all).", + "mail.notificationDigest.more": "See {count} more in your inbox", + "mail.notificationDigest.subject": "{count} new notifications — {siteName}", + "mail.notificationDigest.title": "What's new on {siteName}", "mail.resetPwd.action": "Choose a new password", "mail.resetPwd.body": "Somebody asked to reset the password for your account on {siteName}.", "mail.resetPwd.expiry": "This link is valid for 24 hours and can only be used once. If you did not ask for this, nothing has changed and you can ignore this message.", @@ -3000,6 +3065,44 @@ "navEdit.visibilityAll": "Everyone", "navEdit.visibilityHint": "Whether to show the menu item to everyone or just selected groups.", "navEdit.visibilityLimited": "Selected Groups", + "notifications.categories.commentReply.description": "Somebody answers a comment you wrote.", + "notifications.categories.commentReply.title": "Replies to your comments", + "notifications.categories.mention.description": "Somebody mentions your handle in a comment.", + "notifications.categories.mention.title": "Mentions", + "notifications.categories.pageCreated.description": "A page is created anywhere you can read. This can be busy on a large wiki.", + "notifications.categories.pageCreated.title": "New pages", + "notifications.categories.pageDeleted.description": "A page is deleted anywhere you could read it.", + "notifications.categories.pageDeleted.title": "Deleted pages", + "notifications.categories.reviewRequested.description": "Somebody suggests an edit to a page your group reviews.", + "notifications.categories.reviewRequested.title": "Suggestions to review", + "notifications.categories.watchedPage.description": "A page you watch is edited, moved, published, unpublished, rescheduled or deleted.", + "notifications.categories.watchedPage.title": "Changes to watched pages", + "notifications.categories.watchedPageComment.description": "Somebody comments on a page you watch.", + "notifications.categories.watchedPageComment.title": "Comments on watched pages", + "notifications.count": "{count} updates", + "notifications.messages.commentReply.new": "{actor} replied to your comment on {page}", + "notifications.messages.mention.edited": "{actor} mentioned you in a comment on {page}", + "notifications.messages.mention.new": "{actor} mentioned you in a comment on {page}", + "notifications.messages.pageCreated.created": "{actor} created {page}", + "notifications.messages.pageCreated.restored": "{actor} restored {page}", + "notifications.messages.pageDeleted.deleted": "{actor} deleted {page}", + "notifications.messages.reviewRequested.new": "{actor} suggested an edit to {page}", + "notifications.messages.reviewRequested.updated": "{actor} updated their suggested edit to {page}", + "notifications.messages.watchedPage.deleted": "{actor} deleted {page}", + "notifications.messages.watchedPage.edited": "{actor} edited {page}", + "notifications.messages.watchedPage.moved": "{actor} moved {page}", + "notifications.messages.watchedPage.published": "{actor} published {page}", + "notifications.messages.watchedPage.scheduled": "{actor} changed when {page} is published", + "notifications.messages.watchedPage.unpublished": "{actor} unpublished {page}", + "notifications.messages.watchedPageComment.new": "{actor} commented on {page}", + "notifications.origin.bulk": "as part of a bulk action", + "notifications.origin.import": "via an import", + "notifications.sections.discussions": "Discussions", + "notifications.sections.everything": "Everything on the wiki", + "notifications.sections.reviews": "Reviews", + "notifications.sections.watching": "Pages you watch", + "notifications.someone": "Someone", + "notifications.unreadLabel": "{title} ({count} unread)", "pageDeleteDialog.confirm": "Are you sure you want to delete the page {name}?", "pageDeleteDialog.deleteSuccess": "Page deleted successfully.", "pageDeleteDialog.pageId": "Page ID {id}", @@ -3101,6 +3204,15 @@ "profile.locationHint": "Your city and country; shown on your profile page.", "profile.myInfo": "My Info", "profile.notifications": "Notifications", + "profile.notificationsEmail": "Email", + "profile.notificationsInApp": "In-App", + "profile.notificationsInfo": "Choose what you are told about, and how. These settings apply on every site of this wiki.", + "profile.notificationsLoadFailed": "Failed to load your notification settings.", + "profile.notificationsNoEmail": "This wiki cannot send email, so only in-app notifications are delivered.", + "profile.notificationsSaveFailed": "Failed to save your notification settings.", + "profile.notificationsSaved": "Notification settings saved.", + "profile.notificationsSiteOff": "Notifications are turned off on this site. Your settings still apply on the other sites of this wiki.", + "profile.notificationsStopEmail": "Turn off all emails", "profile.pages.emptyList": "No pages to display.", "profile.pages.headerCreatedAt": "Created", "profile.pages.headerPath": "Path", @@ -3200,6 +3312,17 @@ "tags.sortPopularity": "List tags by popularity", "tags.sortTags": "Sort tags", "tags.title": "Tags", + "unsubscribe.all": "Stop all notification email", + "unsubscribe.backToWiki": "Go to the wiki", + "unsubscribe.confirm": "Unsubscribe", + "unsubscribe.done": "You will no longer receive email about these notifications.", + "unsubscribe.doneAll": "You will no longer receive any notification email.", + "unsubscribe.failed": "Failed to unsubscribe.", + "unsubscribe.inAppStays": "Notifications in your inbox are not affected.", + "unsubscribe.intro": "Stop receiving email about:", + "unsubscribe.invalid": "This unsubscribe link is not valid. You can still change your notification settings from your profile.", + "unsubscribe.manage": "Notification settings", + "unsubscribe.title": "Unsubscribe", "userProfile.lastLogin": "Last Login", "userProfile.loadMore": "Load More", "userProfile.loadingFailed": "Failed to load user profile.", diff --git a/backend/models/auditLog.ts b/backend/models/auditLog.ts index 43c684ce0..3cca2e6ff 100644 --- a/backend/models/auditLog.ts +++ b/backend/models/auditLog.ts @@ -69,7 +69,9 @@ export const AUDIT_ACTIONS = { 'enableTfa', 'disableTfa', 'registerPasskey', - 'deletePasskey' + 'deletePasskey', + 'updateNotificationPrefs', + 'unsubscribeNotifications' ], admin: [ 'createApiKey', @@ -130,6 +132,7 @@ export const AUDIT_ACTIONS = { 'finishImport', 'updateApiState', 'updateMetricsState', + 'updateNotificationSettings', 'updateScimState', 'disconnectWebsockets', 'flushCache', diff --git a/backend/models/comments.ts b/backend/models/comments.ts index 936e1fcd1..ba68db5a0 100644 --- a/backend/models/comments.ts +++ b/backend/models/comments.ts @@ -711,6 +711,7 @@ class Comments { path: pagesTable.path, locale: pagesTable.locale, tags: pagesTable.tags, + title: pagesTable.title, allowComments: sql`coalesce((${pagesTable.config} ->> 'allowComments')::boolean, true)` }) .from(commentsTable) @@ -781,6 +782,35 @@ class Comments { return (result.rowCount ?? 0) > 0 ? 1 + Number(replies[0]?.total ?? 0) : 0 } + /** + * The handles written in a comment, folded to the case the unique index compares them in. + * + * Only what is written — whether each one names anybody is for whoever looks them up. + */ + mentionedHandles(content: string): string[] { + return [ + ...new Set([...content.matchAll(MENTION_PATTERN)].map((match) => match[1]!.toLowerCase())) + ] + } + + /** + * The first few lines of a comment, for a notification to quote. Markdown as typed, never HTML, with + * its whitespace run together so that a quote is one line. + */ + excerptOf(content: string, length = 240): string { + const flat = content.replace(/\s+/g, ' ').trim() + return flat.length > length ? `${flat.slice(0, length - 1).trimEnd()}…` : flat + } + + /** Who wrote a comment, or null for a guest or a comment that has gone. */ + async authorOf(commentId: string): Promise { + const [row] = await WIKI.db + .select({ authorId: commentsTable.authorId }) + .from(commentsTable) + .where(eq(commentsTable.id, commentId)) + return row?.authorId ?? null + } + /** * The users that the handles written in these comments point at. * diff --git a/backend/models/import.ts b/backend/models/import.ts index 02a1964f4..a580cf0f5 100644 --- a/backend/models/import.ts +++ b/backend/models/import.ts @@ -1763,23 +1763,27 @@ class Import { const source = editor === 'visual' ? htmlToMarkdown(body) : body try { - const page = await WIKI.models.pages.adoptStoredPage({ - siteId: target.siteId, - locale, - path: pagePath, - title: stringOf(record?.title, pagePath), - description: stringOf(record?.description), - editor, - tags: (Array.isArray(record?.tags) ? record.tags : []).map((tag: unknown) => - stringOf(tag) - ), - isPublished: record?.isPublished !== false, - content: editor === 'redirect' ? this.#redirectContent(source) : source, - createdAt: dateOf(record?.createdAt), - updatedAt: dateOf(record?.updatedAt), - authorId: await this.#authorFor(session, record?.authorId), - overwrite: session.overwrite - }) + const authorId = await this.#authorFor(session, record?.authorId) + // -> A restore, not a person writing pages: nobody who asked about new pages hears of these + const page = await WIKI.models.notifications.withOrigin('import', () => + WIKI.models.pages.adoptStoredPage({ + siteId: target.siteId, + locale, + path: pagePath, + title: stringOf(record?.title, pagePath), + description: stringOf(record?.description), + editor, + tags: (Array.isArray(record?.tags) ? record.tags : []).map((tag: unknown) => + stringOf(tag) + ), + isPublished: record?.isPublished !== false, + content: editor === 'redirect' ? this.#redirectContent(source) : source, + createdAt: dateOf(record?.createdAt), + updatedAt: dateOf(record?.updatedAt), + authorId, + overwrite: session.overwrite + }) + ) if (!page) { /* Already here and `overwrite` is off, so nothing was written — but the page IS what that diff --git a/backend/models/index.ts b/backend/models/index.ts index 73473fc45..ca0336a15 100644 --- a/backend/models/index.ts +++ b/backend/models/index.ts @@ -18,6 +18,7 @@ import { locales } from './locales.ts' import { mail } from './mail.ts' import { metrics } from './metrics.ts' import { navigation } from './navigation.ts' +import { notifications } from './notifications.ts' import { pageGraph } from './pageGraph.ts' import { pageHistory } from './pageHistory.ts' import { pageLinks } from './pageLinks.ts' @@ -60,6 +61,7 @@ export default { mail, metrics, navigation, + notifications, pageGraph, pageHistory, pageLinks, diff --git a/backend/models/jobs.ts b/backend/models/jobs.ts index f49282386..906b24dbb 100644 --- a/backend/models/jobs.ts +++ b/backend/models/jobs.ts @@ -43,6 +43,15 @@ export const SYSTEM_SCHEDULE: SystemScheduleEntry[] = [ // -> Same reasoning, and the cutoff is in hours but measured in days { task: 'purgeImportSessions', cron: '25 0 * * *' }, { task: 'purgeRateLimits', cron: '10 * * * *' }, + // -> Daily, for the same reason as the audit log: retention is counted in days + { task: 'purgeNotifications', cron: '30 0 * * *' }, + /* + Safety nets, not the schedule. Both are queued the moment there is work — an event written, an + email coming due — and each asks for its own next run; these only catch what an instance that + went down still owed, and a claim somebody abandoned. + */ + { task: 'dispatchNotifications', cron: '*/5 * * * *' }, + { task: 'sendNotificationMail', cron: '*/5 * * * *' }, { task: 'updateLocales', cron: '0 0 * * *' }, // -> Every minute, and the task decides which sites are actually due: the interval is a per-site // setting, so the tick has to be as fine as the shortest one anybody can ask for diff --git a/backend/models/mail.ts b/backend/models/mail.ts index 923c24860..33e560f79 100644 --- a/backend/models/mail.ts +++ b/backend/models/mail.ts @@ -1,12 +1,14 @@ import { createTransport } from 'nodemailer' import type { Transporter } from 'nodemailer' +import { locales } from './locales.ts' import type { Translator } from './locales.ts' /** * The templates this wiki sends, and what each one needs. * * Two of them belong to a flow — registration and a forgotten password; `test` is the admin area's - * button. Held as literals rather than rows in a table because nothing sends a mail this wiki did + * button; `notification` and `notificationDigest` are what the notification system sends (see + * `notifications/mailer.ts`). Held as literals rather than rows in a table because nothing sends a mail this wiki did * not ask it to — a template is part of the flow that uses it, and a flow that gained one would * have to gain code here anyway. * @@ -39,6 +41,38 @@ export interface MailTemplateData { test: { baseUrl: string } + /** One notification, on its own. */ + notification: MailNotificationData + /** Several, gathered into one mail — whatever came due for one person on one site at once. */ + notificationDigest: MailNotificationData +} + +/** One notification as a mail describes it. Everything here is already resolved to text and URLs. */ +export interface MailNotificationEntry { + /** A category key, which is what picks the strings. */ + category: string + variant: string + /** How many events the entry absorbed. */ + count: number + actorName: string | null + pageTitle: string + /** A comment's first lines, as typed. */ + excerpt?: string + /** Set when nobody did it by hand: `import` or `bulk`. */ + origin?: string + /** Where the entry leads. */ + url: string +} + +export interface MailNotificationData { + baseUrl: string + entries: MailNotificationEntry[] + /** How many more there were than a digest lists. */ + more: number + /** Profile → Notifications. */ + manageUrl: string + /** The page that asks before it unsubscribes, which is what a link in the body may point at. */ + unsubscribeUrl: string } /** A template key, i.e. one of the keys of `MailTemplateData`. */ @@ -59,8 +93,15 @@ interface MailContent { title: string /** Paragraphs, as plain text: escaping is the business of whichever body they end up in. */ body: string[] + /** + * A list of things, each leading somewhere — what a digest is made of. Drawn after the paragraphs + * and before the action. + */ + items?: { text: string; detail?: string; url: string }[] action?: { label: string; url: string } footer: string + /** Small links under the footer: where a notification mail says how to stop receiving it. */ + links?: { label: string; url: string }[] } /** @@ -86,10 +127,23 @@ interface MailConfig { dkimPrivateKey?: string } +/** + * The site a mail is about, as far as the mail needs to know it. + * + * Passed in rather than looked up, because the notification mails are sent from a worker thread, + * which has no `WIKI.sites`. Everything else builds it with `siteFor`. + */ +export interface MailSite { + /** What the mail calls the wiki. */ + name: string + /** The language to write in when nothing is known about the recipient's. */ + primaryLocale: string | null +} + /** One outgoing mail, as the models ask for it. */ export interface MailRequest { /** The site the mail is about, which is what names the wiki in it. */ - siteId: string + site: MailSite to: string template: K data: MailTemplateData[K] @@ -104,8 +158,50 @@ export interface MailRequest { * Left empty, the mail is written in the site's primary locale — see `localeFor`. */ locale?: string | null + /** Extra headers, e.g. `List-Unsubscribe`. */ + headers?: Record } +/** + * The headers a DKIM signature covers: nodemailer's own default list, which is RFC 4871's, plus + * `List-Unsubscribe-Post`. + * + * RFC 8058 requires both unsubscribe headers to be signed, and Gmail and Yahoo will not honour + * one-click unsubscribe without that — nodemailer's default covers `List-Unsubscribe` and not the + * second one. Setting the option replaces the default rather than adding to it, hence the full list. + */ +const DKIM_SIGNED_HEADERS = [ + 'From', + 'Sender', + 'Reply-To', + 'Subject', + 'Date', + 'Message-ID', + 'To', + 'Cc', + 'MIME-Version', + 'Content-Type', + 'Content-Transfer-Encoding', + 'Content-ID', + 'Content-Description', + 'Resent-Date', + 'Resent-From', + 'Resent-Sender', + 'Resent-To', + 'Resent-Cc', + 'Resent-Message-ID', + 'In-Reply-To', + 'References', + 'List-Id', + 'List-Help', + 'List-Unsubscribe', + 'List-Unsubscribe-Post', + 'List-Subscribe', + 'List-Post', + 'List-Owner', + 'List-Archive' +].join(':') + /** * Take a value out of the template language it is being put into. * @@ -132,7 +228,10 @@ function escapeHtml(str: string): string { * them with it — so a right-to-left mail read there would come out left-aligned, with its * punctuation at the wrong end, unless the cell that survives carries the direction itself. */ -function htmlShell({ title, body, action, footer }: MailContent, isRTL: boolean): string { +function htmlShell( + { title, body, items, action, footer, links }: MailContent, + isRTL: boolean +): string { const dir = isRTL ? 'rtl' : 'ltr' const align = isRTL ? 'right' : 'left' const paragraphs = body @@ -141,6 +240,21 @@ function htmlShell({ title, body, action, footer }: MailContent, isRTL: boolean) `

${escapeHtml(p)}

` ) .join('') + const list = items?.length + ? `` + + items + .map( + (item) => + `' + ) + .join('') + + '
` + + `${escapeHtml(item.text)}` + + (item.detail + ? `
${escapeHtml(item.detail)}
` + : '') + + '
' + : '' const button = action ? `

${escapeHtml(action.label)}

` + // -> The same link in full, for the client that will not render the button and for the reader @@ -155,8 +269,17 @@ function htmlShell({ title, body, action, footer }: MailContent, isRTL: boolean) ``, `

${escapeHtml(title)}

`, paragraphs, + list, button, `

${escapeHtml(footer)}

`, + links?.length + ? `

${links + .map( + (link) => + `${escapeHtml(link.label)}` + ) + .join(' · ')}

` + : '', '' ].join('') } @@ -174,8 +297,17 @@ function htmlShell({ title, body, action, footer }: MailContent, isRTL: boolean) * so that the two bodies say things in the same order — the button sits after the paragraphs in * the HTML one for the same reason. */ -function textBody({ title, body, action, footer }: MailContent): string { - return [title, ...body, ...(action ? [action.url] : []), footer].join('\n\n') +function textBody({ title, body, items, action, footer, links }: MailContent): string { + return [ + title, + ...body, + ...(items ?? []).map((item) => + [`- ${item.text}`, ...(item.detail ? [` ${item.detail}`] : []), ` ${item.url}`].join('\n') + ), + ...(action ? [action.url] : []), + footer, + ...(links ?? []).map((link) => `${link.label}: ${link.url}`) + ].join('\n\n') } /** @@ -270,7 +402,8 @@ class Mail { dkim: { domainName: conf.dkimDomainName ?? '', keySelector: conf.dkimKeySelector ?? '', - privateKey: conf.dkimPrivateKey + privateKey: conf.dkimPrivateKey, + headerFieldNames: DKIM_SIGNED_HEADERS } }) }) @@ -294,16 +427,22 @@ class Mail { * * @param req The request that triggered the mail, when there is one * @param siteId The site the mail is about, when it is about one + * @param hostname That site's hostname, for a caller with no `WIKI.sites` to look it up in */ baseUrl({ req, - siteId - }: { req?: { protocol: string; host: string }; siteId?: string } = {}): string { + siteId, + hostname: knownHostname + }: { + req?: { protocol: string; host: string } + siteId?: string + hostname?: string + } = {}): string { const configured = this.config.defaultBaseURL?.trim() if (configured) { return configured.replace(/\/+$/, '') } - const hostname = siteId ? WIKI.sites[siteId]?.hostname : null + const hostname = knownHostname ?? (siteId ? WIKI.sites[siteId]?.hostname : null) if (hostname && hostname !== '*') { // -> The scheme the caller was reached by, since the hostname alone does not carry one return `${req?.protocol ?? 'https'}://${hostname}` @@ -315,10 +454,13 @@ class Mail { } /** - * What to call this wiki in a mail. Per site, since that is what the reader was looking at. + * What a mail needs to know about a site, from the site configurations this process holds. + * + * The name is per site, since that is what the reader was looking at. */ - private siteName(siteId: string): string { - return WIKI.sites[siteId]?.config?.title || 'Wiki.js' + siteFor(siteId: string): MailSite { + const config = WIKI.sites[siteId]?.config + return { name: config?.title || 'Wiki.js', primaryLocale: config?.locales?.primary || null } } /** @@ -329,8 +471,8 @@ class Mail { * one addressed to a reader with a preference. `translator` takes it from there: a code naming a * locale that is not installed falls back to English rather than sending a mail full of keys. */ - private localeFor(locale: string | null | undefined, siteId: string): string | null { - return locale || WIKI.sites[siteId]?.config?.locales?.primary || null + private localeFor(locale: string | null | undefined, site: MailSite): string | null { + return locale || site.primaryLocale || null } /** @@ -384,6 +526,9 @@ class Mail { footer: t('mail.resetPwd.footer', { siteName }) } } + case 'notification': + case 'notificationDigest': + return this.renderNotification(t, siteName, data as MailTemplateData['notification']) default: { const d = data as MailTemplateData['test'] return { @@ -397,6 +542,74 @@ class Mail { } } + /** + * A notification mail: one entry told in full, or several as a list. + * + * The sentence describing an entry is the same one the inbox draws — + * `notifications.messages..` — so a person reads the same words in both places, + * and a translator translates them once. + */ + private renderNotification( + t: Translator['t'], + siteName: string, + d: MailNotificationData + ): MailContent { + const describe = (entry: MailNotificationEntry) => + t(`notifications.messages.${entry.category}.${entry.variant}`, { + actor: entry.actorName || t('notifications.someone'), + page: entry.pageTitle + }) + const detailOf = (entry: MailNotificationEntry) => + [ + ...(entry.count > 1 ? [t('notifications.count', { count: entry.count })] : []), + ...(entry.origin ? [t(`notifications.origin.${entry.origin}`)] : []) + ].join(' · ') + const footer = t('mail.notification.footer', { siteName }) + const links = [ + { label: t('mail.notification.manage'), url: d.manageUrl }, + { label: t('mail.notification.unsubscribe'), url: d.unsubscribeUrl } + ] + + if (d.entries.length === 1 && d.more < 1) { + const entry = d.entries[0]! + const message = describe(entry) + const detail = detailOf(entry) + return { + subject: t('mail.notification.subject', { siteName, message }), + title: message, + body: [...(entry.excerpt ? [`“${entry.excerpt}”`] : []), ...(detail ? [detail] : [])], + action: { + label: t(`mail.notification.actions.${entry.category}`), + url: entry.url + }, + footer, + links + } + } + + const total = d.entries.length + d.more + return { + subject: t('mail.notificationDigest.subject', { siteName, count: total }), + title: t('mail.notificationDigest.title', { siteName }), + body: [t('mail.notificationDigest.body', { count: total })], + items: d.entries.map((entry) => { + const detail = [entry.excerpt ? `“${entry.excerpt}”` : '', detailOf(entry)] + .filter(Boolean) + .join(' — ') + return { text: describe(entry), url: entry.url, ...(detail && { detail }) } + }), + action: { + label: + d.more > 0 + ? t('mail.notificationDigest.more', { count: d.more }) + : t('mail.notificationDigest.action'), + url: `${d.baseUrl}/_inbox` + }, + footer, + links + } + } + /** * Send one mail, and wait for the relay to have taken it. * @@ -408,18 +621,20 @@ class Mail { * nodemailer raises for a send that was attempted and failed */ async send({ - siteId, + site, to, template, data, - locale + locale, + headers }: MailRequest): Promise { if (!this.isConfigured) { throw new Error('ERR_MAIL_NOT_CONFIGURED') } const conf = this.config - const siteName = this.siteName(siteId) - const translator = await WIKI.models.locales.translator(this.localeFor(locale, siteId)) + const siteName = site.name + // -> The model itself rather than `WIKI.models.locales`, which a worker thread does not have + const translator = await locales.translator(this.localeFor(locale, site)) const content = this.render(translator, siteName, template, data) const { subject } = content const text = textBody(content) @@ -433,7 +648,8 @@ class Mail { to, subject, text, - html + html, + ...(headers && { headers }) }) WIKI.logger.info(`Sent ${template} email to <${to}>.`) } diff --git a/backend/models/notifications.ts b/backend/models/notifications.ts new file mode 100644 index 000000000..3cf23db03 --- /dev/null +++ b/backend/models/notifications.ts @@ -0,0 +1,731 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import { and, count, desc, eq, gte, inArray, isNotNull, isNull, lt, or, sql } from 'drizzle-orm' +import { + notificationEvents as eventsTable, + notifications as notificationsTable, + userNotificationPrefs as prefsTable +} from '../db/schema.ts' +import { durationToSeconds } from '../helpers/common.ts' +import { + NOTIFICATION_CATEGORIES, + NOTIFICATION_CATEGORY_KEYS, + NOTIFICATION_SECTIONS, + categoriesFor, + isCategoryKey +} from '../notifications/index.ts' +import type { NotificationCategoryKey } from '../notifications/index.ts' +import { enqueueOnce } from '../notifications/queue.ts' +import { readUnsubscribeToken } from '../notifications/unsubscribe.ts' +import type { UnsubscribeClaim } from '../notifications/unsubscribe.ts' +import type { + EventOrigin, + NotificationCategory, + NotificationChannel, + NotificationEventData, + NotificationEventKind, + NotificationSection, + PageSnapshot +} from '../notifications/types.ts' + +/** Fields stored in the `notifications` settings blob that the admin area may change. */ +export const NOTIFICATION_SETTINGS_FIELDS = [ + 'retentionDays', + 'emailDelay', + 'mailBatchSize' +] as const + +/** How long after the first emit in a burst the fan-out is asked for, in milliseconds. */ +const DISPATCH_DEBOUNCE = 1000 + +/** How long a processed event is kept in the outbox, which is long enough to debug one. */ +const PROCESSED_EVENT_RETENTION_HOURS = 24 + +/** How many rows the purge deletes at a time. */ +const PURGE_BATCH_SIZE = 10000 + +/** Badge counts stop here; the interface shows `99+` above 99. */ +const UNREAD_CAP = 100 + +/** The default inbox page size, and the ceiling a client may ask for. */ +const INBOX_PAGE_SIZE = 30 +const INBOX_PAGE_MAX = 100 + +/** How an event came about, when something further up the call chain has said. See `withOrigin`. */ +const originScope = new AsyncLocalStorage() + +/** A page, in whatever shape a model has it in hand. */ +interface PageLike { + id: string + title: string + path: string + locale: string + tags?: string[] | null + publishState?: string | null +} + +/** One category as Profile → Notifications offers it. */ +export interface NotificationPref { + key: NotificationCategoryKey + section: NotificationSection + defaults: Record + inApp: boolean + email: boolean +} + +/** One inbox entry, as the API answers with it. */ +export interface InboxEntry { + id: string + category: string + variant: string + count: number + pageId: string | null + commentId: string | null + actorId: string | null + data: Record + isRead: boolean + createdAt: Date + updatedAt: Date +} + +/** What `markRead` narrows by. Nothing at all marks every entry read. */ +export interface MarkReadFilter { + ids?: string[] + pageId?: string + categories?: string[] +} + +/** + * Notifications model + * + * The request process's side of notifications: writing events, the preferences behind Profile → + * Notifications, the inbox, the instance settings, and the one-click unsubscribe. Turning an event + * into entries happens in a worker (`notifications/fanout.ts`), and so does sending the mail + * (`notifications/mailer.ts`); neither goes through here, because neither has `WIKI.models`. + */ +class Notifications { + private dispatchTimer: NodeJS.Timeout | null = null + + // == ORIGIN ========================= + + /** + * Run some work whose events all came about the same way. + * + * Carried by `AsyncLocalStorage` for the reason `storage.importingFrom` is: the events are emitted + * several models away — a folder deletion reaches `deletePage` once per page — and none of those + * calls should need a parameter that says what is going on above them. + */ + withOrigin(origin: EventOrigin, work: () => Promise): Promise { + return originScope.run(origin, work) + } + + /** How the event being emitted now came about. */ + originNow(): EventOrigin { + return originScope.getStore() ?? (WIKI.models.storage.isImporting() ? 'import' : 'user') + } + + // == EMIT =========================== + + /** Whether a site has notifications at all — `features.notifications`, on unless switched off. */ + isEnabledFor(siteId: string | null | undefined): boolean { + return !siteId || WIKI.sites[siteId]?.config?.features?.notifications !== false + } + + /** A page as an event remembers it. */ + pageSnapshot(page: PageLike): PageSnapshot { + return { + id: page.id, + title: page.title, + path: page.path, + locale: page.locale, + tags: page.tags ?? [], + publishState: page.publishState ?? 'published' + } + } + + /** + * Record that something happened, for the fan-out to tell whoever it concerns. + * + * One INSERT and a debounced request for a fan-out run — nobody is looked up, no access is checked + * and nothing is sent from here, so the cost on the request that caused it is the same however many + * people end up being told. Like `hooks.emit`, it never throws: a notification problem must not fail + * the action that triggered it. Call it after the action has succeeded. + * + * Nothing is written for a site with notifications switched off, nor for an event no category fires + * for from this origin — a git pull's `page:create`, say — rather than writing it to throw away. + * + * @param watchersOf For a page about to be deleted: capture its watchers into the event now, + * because the watch rows go with the page + */ + async emit( + kind: NotificationEventKind, + { + siteId, + actorId, + data, + watchersOf + }: { + siteId: string + actorId: string | null + data: NotificationEventData + watchersOf?: string + } + ): Promise { + try { + if (!this.isEnabledFor(siteId)) { + return + } + const origin = this.originNow() + if (categoriesFor(kind, origin).length < 1) { + return + } + await WIKI.db.insert(eventsTable).values({ + kind, + origin, + siteId, + actorId, + data, + // -> In the same statement, so the ids never pass through here + ...(watchersOf && { + recipients: sql`ARRAY(SELECT "userId" FROM "pageWatching" WHERE "pageId" = ${watchersOf})` + }) + }) + this.requestDispatch() + } catch (err: any) { + WIKI.logger.warn(`Failed to record notification event ${kind}: ${err.message}`) + } + } + + /** + * Ask for a fan-out run, about a second from now. + * + * The first event of a burst arms the timer and the rest ride on it, so five hundred saves in a + * minute are a handful of runs rather than five hundred jobs. An instance that goes down with the + * timer armed owes a run, which the system schedule's safety net pays. + */ + private requestDispatch(): void { + if (this.dispatchTimer) { + return + } + this.dispatchTimer = setTimeout(() => { + this.dispatchTimer = null + void enqueueOnce('dispatchNotifications') + }, DISPATCH_DEBOUNCE) + this.dispatchTimer.unref?.() + } + + // == PREFERENCES ==================== + + /** The categories somebody is offered, in the order the Profile screen lists them. */ + visibleCategories(actor: { permissions: string[] }): NotificationCategoryKey[] { + return NOTIFICATION_CATEGORY_KEYS.filter((key) => { + const category: NotificationCategory = NOTIFICATION_CATEGORIES[key] + return !category.visibleTo || category.visibleTo(actor) + }).sort( + (a, b) => + NOTIFICATION_SECTIONS.indexOf(NOTIFICATION_CATEGORIES[a].section) - + NOTIFICATION_SECTIONS.indexOf(NOTIFICATION_CATEGORIES[b].section) + ) + } + + /** + * Somebody's choices, with the default standing in wherever they made none. + */ + async getPrefs(userId: string, actor: { permissions: string[] }): Promise { + const stored = await WIKI.db + .select({ + category: prefsTable.category, + channel: prefsTable.channel, + enabled: prefsTable.enabled + }) + .from(prefsTable) + .where(eq(prefsTable.userId, userId)) + return this.visibleCategories(actor).map((key) => { + const category = NOTIFICATION_CATEGORIES[key] + const chosen = (channel: NotificationChannel) => + stored.find((row) => row.category === key && row.channel === channel)?.enabled ?? + category.defaults[channel] + return { + key, + section: category.section, + defaults: { ...category.defaults }, + inApp: chosen('inApp'), + email: chosen('email') + } + }) + } + + /** + * Store somebody's choices. + * + * Only what differs from a category's default is kept: a choice set back to its default deletes its + * row rather than writing one that says the same thing, so that a default changed later reaches + * everybody who never chose otherwise. Categories the caller is not offered, or that the request + * does not mention, are left as they are. + * + * @returns The keys of the categories whose stored choice changed + */ + async setPrefs( + userId: string, + actor: { permissions: string[] }, + prefs: Record>> + ): Promise { + const current = await this.getPrefs(userId, actor) + const changed = new Set() + for (const pref of current) { + const wanted = prefs[pref.key] + if (!wanted) { + continue + } + for (const channel of ['inApp', 'email'] as const) { + if (typeof wanted[channel] !== 'boolean' || wanted[channel] === pref[channel]) { + continue + } + await this.storeChoice(userId, pref.key, channel, wanted[channel]) + changed.add(pref.key) + } + } + return [...changed] + } + + private async storeChoice( + userId: string, + category: NotificationCategoryKey, + channel: NotificationChannel, + enabled: boolean + ): Promise { + if (enabled === NOTIFICATION_CATEGORIES[category].defaults[channel]) { + await WIKI.db + .delete(prefsTable) + .where( + and( + eq(prefsTable.userId, userId), + eq(prefsTable.category, category), + eq(prefsTable.channel, channel) + ) + ) + return + } + await WIKI.db + .insert(prefsTable) + .values({ userId, category, channel, enabled }) + .onConflictDoUpdate({ + target: [prefsTable.userId, prefsTable.category, prefsTable.channel], + set: { enabled, updatedAt: sql`now()` } + }) + } + + /** + * Stop emailing somebody about these categories, and drop whatever email they had waiting for them. + * + * What an unsubscribe does, and nothing more: in-app entries carry on, since the person asked to + * stop receiving MAIL. + */ + async disableEmail(userId: string, categories: NotificationCategoryKey[]): Promise { + for (const category of categories) { + await this.storeChoice(userId, category, 'email', false) + } + if (categories.length > 0) { + await WIKI.db + .update(notificationsTable) + .set({ emailState: 'skipped' }) + .where( + and( + eq(notificationsTable.userId, userId), + eq(notificationsTable.emailState, 'pending'), + inArray(notificationsTable.category, categories) + ) + ) + } + } + + // == UNSUBSCRIBE ==================== + + /** + * Honour an unsubscribe link. + * + * @param scope `token` for the categories the mail was about, `all` for every category there is — + * which the page that asks offers, and which a token may ask for since all it can do is + * turn email off for the person it was issued to + * @returns What the token said, or null when it is not genuine + */ + async unsubscribe(token: unknown, scope: 'token' | 'all'): Promise { + const claim = readUnsubscribeToken(token) + if (!claim) { + return null + } + const categories = + scope === 'all' ? NOTIFICATION_CATEGORY_KEYS : claim.categories.filter(isCategoryKey) + await this.disableEmail(claim.userId, categories) + return { userId: claim.userId, categories } + } + + /** What a token says, without acting on it — for the page that asks first. */ + readToken(token: unknown): UnsubscribeClaim | null { + const claim = readUnsubscribeToken(token) + return claim ? { ...claim, categories: claim.categories.filter(isCategoryKey) } : null + } + + // == INBOX ========================== + + /** The entries one site's inbox shows: its own, and those that belong to no site. */ + private inboxScope(userId: string, siteId: string) { + return and( + eq(notificationsTable.userId, userId), + or(eq(notificationsTable.siteId, siteId), isNull(notificationsTable.siteId)), + eq(notificationsTable.inApp, true) + ) + } + + /** + * One page of somebody's inbox, newest activity first. + * + * Keyset-paginated on `(updatedAt, id)` — the cursor is the last entry of the previous page — since + * an entry that absorbs a new event moves to the top, and an offset would skip or repeat around it. + */ + async list( + userId: string, + siteId: string, + { cursor, unread, limit }: { cursor?: string; unread?: boolean; limit?: number } = {} + ): Promise<{ entries: InboxEntry[]; next: string | null }> { + const size = Math.min(Math.max(limit ?? INBOX_PAGE_SIZE, 1), INBOX_PAGE_MAX) + const conditions = [this.inboxScope(userId, siteId)] + if (unread) { + conditions.push(isNull(notificationsTable.readAt)) + } + const after = this.parseCursor(cursor) + if (after) { + conditions.push( + sql`(${notificationsTable.updatedAt}, ${notificationsTable.id}) < (${after.updatedAt}, ${after.id})` + ) + } + const rows = await WIKI.db + .select({ + id: notificationsTable.id, + category: notificationsTable.category, + variant: notificationsTable.variant, + count: notificationsTable.count, + pageId: notificationsTable.pageId, + commentId: notificationsTable.commentId, + actorId: notificationsTable.actorId, + data: notificationsTable.data, + readAt: notificationsTable.readAt, + createdAt: notificationsTable.createdAt, + updatedAt: notificationsTable.updatedAt + }) + .from(notificationsTable) + .where(and(...conditions)) + .orderBy(desc(notificationsTable.updatedAt), desc(notificationsTable.id)) + .limit(size + 1) + const page = rows.slice(0, size) + const last = page[page.length - 1] + return { + entries: page.map((row) => { + const { readAt, data, ...rest } = row + const { excerpt, ...snapshot } = (data ?? {}) as Record + return { + ...rest, + // -> A comment's text only while the comment exists: deleting it takes it out of every inbox + data: row.commentId && excerpt !== undefined ? { ...snapshot, excerpt } : snapshot, + isRead: readAt !== null + } + }), + next: rows.length > size && last ? `${last.updatedAt.toISOString()}|${last.id}` : null + } + } + + private parseCursor(cursor?: string): { updatedAt: string; id: string } | null { + if (!cursor) { + return null + } + const [updatedAt, id] = cursor.split('|') + if (!updatedAt || !id || Number.isNaN(Date.parse(updatedAt)) || !/^[0-9a-f-]{36}$/i.test(id)) { + return null + } + return { updatedAt, id } + } + + /** + * What the badge needs, and nothing more: how many unread entries (counted no further than + * `UNREAD_CAP`) and when the inbox last changed. + * + * What every open tab of a signed-in reader polls, so it is two index-only reads. The pair is also + * what the poll's ETag is built from: anything that changes the inbox moves one of them. + */ + async summary( + userId: string, + siteId: string + ): Promise<{ unread: number; latestAt: string | null }> { + const capped = WIKI.db + .select({ one: sql`1` }) + .from(notificationsTable) + .where(and(this.inboxScope(userId, siteId), isNull(notificationsTable.readAt))) + .limit(UNREAD_CAP) + .as('capped') + const [[unread], [latest]] = await Promise.all([ + WIKI.db.select({ total: count() }).from(capped), + WIKI.db + .select({ at: sql`max(${notificationsTable.updatedAt})` }) + .from(notificationsTable) + .where(this.inboxScope(userId, siteId)) + ]) + return { + unread: Number(unread?.total ?? 0), + latestAt: latest?.at ? new Date(latest.at).toISOString() : null + } + } + + /** + * Mark entries read. + * + * Reading an entry before its email has gone also cancels the email: the person has seen it, which + * is all the mail was for. An entry that is read stops holding its coalescing key, so the next event + * about the same thing starts a new entry — and, being new, a new email. + * + * @returns How many entries changed + */ + async markRead(userId: string, siteId: string, filter: MarkReadFilter = {}): Promise { + const conditions = [ + eq(notificationsTable.userId, userId), + or(eq(notificationsTable.siteId, siteId), isNull(notificationsTable.siteId)), + isNull(notificationsTable.readAt) + ] + if (filter.ids) { + if (filter.ids.length < 1) { + return 0 + } + conditions.push(inArray(notificationsTable.id, filter.ids)) + } + if (filter.pageId) { + conditions.push(eq(notificationsTable.pageId, filter.pageId)) + } + if (filter.categories) { + const categories = filter.categories.filter(isCategoryKey) + if (categories.length < 1) { + return 0 + } + conditions.push(inArray(notificationsTable.category, categories)) + } + const result = await WIKI.db + .update(notificationsTable) + .set({ + readAt: sql`now()`, + emailState: sql`CASE WHEN ${notificationsTable.emailState} = 'pending' THEN 'skipped' ELSE ${notificationsTable.emailState} END` + }) + .where(and(...conditions)) + return result.rowCount ?? 0 + } + + /** + * Remove one entry from somebody's inbox. + * + * @returns Whether there was one to remove + */ + async dismiss(userId: string, siteId: string, id: string): Promise { + const result = await WIKI.db + .delete(notificationsTable) + .where( + and( + eq(notificationsTable.id, id), + eq(notificationsTable.userId, userId), + or(eq(notificationsTable.siteId, siteId), isNull(notificationsTable.siteId)) + ) + ) + return (result.rowCount ?? 0) > 0 + } + + /** + * The categories somebody has unread entries in for one page — what the page view hands the browser + * so that opening the page, or its Talk tab, can mark them read without asking first. + * + * One indexed lookup, and none for a reader with no account. Never throws: it is a convenience on + * the page view, which is started ahead of the page's other lookups and must not take the page down + * — or go unhandled — if it fails. + */ + async unreadCategoriesOnPage(pageId: string, userId: string | null): Promise { + if (!userId) { + return [] + } + try { + const rows = await WIKI.db + .selectDistinct({ category: notificationsTable.category }) + .from(notificationsTable) + .where( + and( + eq(notificationsTable.userId, userId), + eq(notificationsTable.pageId, pageId), + isNull(notificationsTable.readAt) + ) + ) + return rows.map((row) => row.category) + } catch (err: any) { + WIKI.logger.warn(`Failed to read unread notifications for page ${pageId}: ${err.message}`) + return [] + } + } + + // == SETTINGS ======================= + + /** The instance settings as the admin area edits them. The unsubscribe secret is never among them. */ + getConfig(): Record<(typeof NOTIFICATION_SETTINGS_FIELDS)[number], any> { + const stored = WIKI.config.notifications ?? {} + return { + retentionDays: stored.retentionDays, + emailDelay: stored.emailDelay, + mailBatchSize: stored.mailBatchSize + } + } + + /** + * Check a patch against the settings it will be merged with. + * + * @returns The reason it is invalid, or null when it is fine + */ + validate(patch: Record): string | null { + const merged = { ...this.getConfig(), ...patch } + if ( + !Number.isInteger(merged.retentionDays) || + merged.retentionDays < 1 || + merged.retentionDays > 3650 + ) { + return 'Retention must be a whole number of days between 1 and 3650.' + } + const delay = durationToSeconds(merged.emailDelay, 0) + if (!/^\d+[smh]$/.test(String(merged.emailDelay ?? '')) || delay < 1 || delay > 3600) { + return 'The email delay must be a duration such as 30s, 3m or 1h, of at most an hour.' + } + if ( + !Number.isInteger(merged.mailBatchSize) || + merged.mailBatchSize < 1 || + merged.mailBatchSize > 1000 + ) { + return 'The mail batch size must be a whole number between 1 and 1000.' + } + return null + } + + /** + * Save a validated patch. The secret is carried over untouched, since the blob is written whole. + * + * @returns Whether the settings were saved + */ + async updateConfig(patch: Record): Promise { + const previous = WIKI.config.notifications + const picked: Record = {} + for (const field of NOTIFICATION_SETTINGS_FIELDS) { + if (patch[field] !== undefined) { + picked[field] = patch[field] + } + } + WIKI.config.notifications = { ...previous, ...picked } + if (!(await WIKI.configSvc.saveToDb(['notifications']))) { + WIKI.config.notifications = previous + return false + } + return true + } + + /** + * How the system is doing, for the admin screen: what is waiting to be fanned out, how email has + * gone over the last day, and anything about the configuration that stops email being sent. + */ + async status(): Promise<{ + pendingEvents: number + oldestPendingEventAt: string | null + emailsPending: number + emailsSent24h: number + emailsFailed24h: number + isMailConfigured: boolean + warnings: string[] + }> { + const dayAgo = sql`now() - interval '24 hours'` + const [[backlog], [pending], [sent], [failed]] = await Promise.all([ + WIKI.db + .select({ + total: count(), + oldest: sql`min(${eventsTable.createdAt})` + }) + .from(eventsTable) + .where(isNull(eventsTable.processedAt)), + WIKI.db + .select({ total: count() }) + .from(notificationsTable) + .where(inArray(notificationsTable.emailState, ['pending', 'sending'])), + WIKI.db + .select({ total: count() }) + .from(notificationsTable) + .where( + and(eq(notificationsTable.emailState, 'sent'), gte(notificationsTable.emailedAt, dayAgo)) + ), + WIKI.db + .select({ total: count() }) + .from(notificationsTable) + .where( + and( + eq(notificationsTable.emailState, 'failed'), + gte(notificationsTable.updatedAt, dayAgo) + ) + ) + ]) + + const warnings: string[] = [] + const baseUrl = (WIKI.config.mail?.defaultBaseURL ?? '').trim() + if (baseUrl.toLowerCase().startsWith('http://')) { + warnings.push('plainHttp') + } + if (!baseUrl && Object.values(WIKI.sites).some((site: any) => site.hostname === '*')) { + warnings.push('wildcardHostname') + } + return { + pendingEvents: Number(backlog?.total ?? 0), + oldestPendingEventAt: backlog?.oldest ? new Date(backlog.oldest).toISOString() : null, + emailsPending: Number(pending?.total ?? 0), + emailsSent24h: Number(sent?.total ?? 0), + emailsFailed24h: Number(failed?.total ?? 0), + isMailConfigured: WIKI.models.mail.isConfigured, + warnings + } + } + + // == HOUSEKEEPING =================== + + /** + * Delete entries past retention, read or not, and processed events past a day — in batches, checking + * between them whether the task has been asked to stop. + */ + async purge(signal?: AbortSignal): Promise<{ notifications: number; events: number }> { + const days = Number(WIKI.config.notifications?.retentionDays) || 60 + const purgeBatched = async (table: any, condition: any): Promise => { + let total = 0 + while (!signal?.aborted) { + const result = await WIKI.db.execute(sql` + DELETE FROM ${table} WHERE id IN ( + SELECT id FROM ${table} WHERE ${condition} LIMIT ${PURGE_BATCH_SIZE} + ) + `) + const deleted = result.rowCount ?? 0 + total += deleted + if (deleted < PURGE_BATCH_SIZE) { + break + } + } + return total + } + const notifications = await purgeBatched( + notificationsTable, + lt(notificationsTable.createdAt, sql`now() - make_interval(days => ${days})`) + ) + const events = await purgeBatched( + eventsTable, + and( + isNotNull(eventsTable.processedAt), + lt( + eventsTable.processedAt, + sql`now() - make_interval(hours => ${PROCESSED_EVENT_RETENTION_HOURS})` + ) + ) + ) + return { notifications, events } + } +} + +export const notifications = new Notifications() diff --git a/backend/models/pages.ts b/backend/models/pages.ts index 64882e3e0..c7d15fd04 100644 --- a/backend/models/pages.ts +++ b/backend/models/pages.ts @@ -1913,6 +1913,11 @@ class Pages { authorId: actor.id, metadata: { title: page.title, description: page.description, editor } }) + await WIKI.models.notifications.emit('page:create', { + siteId, + actorId: actor.id, + data: { variant: 'created', page: WIKI.models.notifications.pageSnapshot(page) } + }) /* Everything a document served to a client that will not run the app says about a page comes out @@ -2120,11 +2125,52 @@ class Pages { authorId: actor.id, metadata: { title: updated.title, description: updated.description } }) + // -> Only a save that changed something: re-publishing a page as it stood is not news + const variants = this.notificationVariants(changedFields, values) + if (variants.length > 0) { + await WIKI.models.notifications.emit('page:edit', { + siteId, + actorId: actor.id, + data: { + variant: variants[0]!, + variants, + page: WIKI.models.notifications.pageSnapshot(updated) + } + }) + } invalidateAppShellCache() return { page: updated, versionId } } + /** + * What a save did, as a page's watchers are told it — the publishing change first, since being + * published or taken down says more about a page than an edit to it does, and an edit after it when + * anything else changed too. + * + * @param changedFields What `pageHistory.changedFields` made of the save + */ + notificationVariants(changedFields: string[], values: Record): string[] { + const variants: string[] = [] + const publishing = new Set(['publishState', 'publishStartDate', 'publishEndDate']) + if (changedFields.includes('publishState')) { + // -> Back to a draft from either other state is the page being taken down + variants.push( + values.publishState === 'published' + ? 'published' + : values.publishState === 'scheduled' + ? 'scheduled' + : 'unpublished' + ) + } else if (changedFields.some((field) => publishing.has(field))) { + variants.push('scheduled') + } + if (changedFields.some((field) => !publishing.has(field))) { + variants.push('edited') + } + return variants + } + /** * Open a page with a different editor from now on. * @@ -2351,6 +2397,16 @@ class Pages { siteId, authorId: actor.id }) + await WIKI.models.notifications.emit('page:rename', { + siteId, + actorId: actor.id, + data: { + variant: 'moved', + page: WIKI.models.notifications.pageSnapshot(moved), + previousPath: page.path, + previousLocale: page.locale + } + }) invalidateAppShellCache() return { page: moved, versionId } } @@ -2599,6 +2655,18 @@ class Pages { authorId: actor.id }) + /* + Before the row goes, because its watchers go with it: the event captures them as it is written. + The fan-out checks the page really has gone before it tells anybody, so a delete that fails from + here on announces nothing. + */ + await WIKI.models.notifications.emit('page:delete', { + siteId, + actorId: actor.id, + data: { variant: 'deleted', page: WIKI.models.notifications.pageSnapshot(page) }, + watchersOf: id + }) + // -> Out of its set of translations first, which dissolves the set if this leaves one page in // it: a group of one is no group, and the survivor's locale picker would offer nothing await this.detachFromLocaleGroup(siteId, id) @@ -2835,6 +2903,11 @@ class Pages { authorId: actor.id, metadata: { title: page.title, description: page.description, editor } }) + await WIKI.models.notifications.emit('page:create', { + siteId, + actorId: actor.id, + data: { variant: 'restored', page: WIKI.models.notifications.pageSnapshot(page) } + }) invalidateAppShellCache() @@ -2862,11 +2935,14 @@ class Pages { .where(and(eq(pagesTable.siteId, siteId), sql`${pagesTable.tags} @> ${sql.param([tag])}`)) let deleted = 0 - for (const row of rows) { - if (await this.deletePage(siteId, row.id, actor)) { - deleted++ + // -> One action, however many pages: watchers hear about theirs, nobody else about every one + await WIKI.models.notifications.withOrigin('bulk', async () => { + for (const row of rows) { + if (await this.deletePage(siteId, row.id, actor)) { + deleted++ + } } - } + }) return deleted } @@ -2898,7 +2974,16 @@ class Pages { // and guessing would mean reaching for names that may belong to the assets beside them. The // aliases are for the links that name a page by one const doomed = await WIKI.db - .select({ id: pagesTable.id, contentType: pagesTable.contentType, alias: pagesTable.alias }) + .select({ + id: pagesTable.id, + contentType: pagesTable.contentType, + alias: pagesTable.alias, + title: pagesTable.title, + path: pagesTable.path, + locale: pagesTable.locale, + tags: pagesTable.tags, + publishState: pagesTable.publishState + }) .from(pagesTable) .where( inArray( @@ -2907,6 +2992,18 @@ class Pages { ) ) const contentTypes = new Map(doomed.map((row) => [row.id, row.contentType])) + // -> Before the rows go, as `deletePage` does, and as one bulk action: a folder's watchers hear + // about their pages, and nobody who asked about every deletion hears about each of them + await WIKI.models.notifications.withOrigin('bulk', async () => { + for (const row of doomed) { + await WIKI.models.notifications.emit('page:delete', { + siteId, + actorId: actor.id, + data: { variant: 'deleted', page: WIKI.models.notifications.pageSnapshot(row) }, + watchersOf: row.id + }) + } + }) // -> Out of their sets of translations first, for the same reason `deletePage` does it await this.detachFromLocaleGroups( siteId, diff --git a/backend/models/settings.ts b/backend/models/settings.ts index 3859bc69d..616b58270 100644 --- a/backend/models/settings.ts +++ b/backend/models/settings.ts @@ -130,6 +130,16 @@ class Settings { includeWiki: false } }, + { + key: 'notifications', + value: { + retentionDays: 60, + emailDelay: '3m', + // -> No `unsubscribeSecret`: generated by the startup checks, which run after this seed + // and on every boot after it, so that an installation from before it gets one too + mailBatchSize: 100 + } + }, { key: 'scim', value: { diff --git a/backend/models/sites.ts b/backend/models/sites.ts index 3113de0fb..9f6d82501 100644 --- a/backend/models/sites.ts +++ b/backend/models/sites.ts @@ -140,6 +140,9 @@ class Sites { // choice, which is only useful to somebody who has already made it. comments: true, lastEditedBy: true, + // -> On: a reader who watches a page or is mentioned expects to be told. What each person + // receives, and how, is theirs to choose under Profile → Notifications + notifications: true, reasonForChange: 'optional', search: true }, @@ -463,6 +466,7 @@ class Sites { ratingsMode: 'off', comments: true, lastEditedBy: true, + notifications: true, reasonForChange: 'optional', search: true }, diff --git a/backend/models/storage.ts b/backend/models/storage.ts index 0e232a155..89e607384 100644 --- a/backend/models/storage.ts +++ b/backend/models/storage.ts @@ -1351,6 +1351,15 @@ class Storage { return importSource.run(target.id, work) } + /** + * Whether anything at all is being imported in the current call chain — which is how a page saved by + * an import is told apart from one somebody saved, for the notifications that should not fire for + * the former. See `notifications.originNow`. + */ + isImporting(): boolean { + return importSource.getStore() !== undefined + } + /** Whether this target is the one an import in progress is reading from. */ private isImportSource(target: StorageTarget): boolean { return importSource.getStore() === target.id diff --git a/backend/models/users.ts b/backend/models/users.ts index ea7af6bbc..fdf865aaf 100644 --- a/backend/models/users.ts +++ b/backend/models/users.ts @@ -2223,7 +2223,7 @@ class Users { }) try { await WIKI.models.mail.send({ - siteId, + site: WIKI.models.mail.siteFor(siteId), to: address, template: 'welcome', locale, @@ -2262,7 +2262,7 @@ class Users { if (WIKI.models.mail.isConfigured) { try { await WIKI.models.mail.send({ - siteId, + site: WIKI.models.mail.siteFor(siteId), to: address, template: 'welcome', locale, @@ -2310,7 +2310,7 @@ class Users { (await WIKI.models.sites.getSiteByHostname({ hostname: req?.hostname ?? '*' }))?.id ?? '' await WIKI.models.mail.send({ - siteId: targetSiteId, + site: WIKI.models.mail.siteFor(targetSiteId), to: user.email, template: 'welcome', // -> Theirs, never the administrator's: the request that triggers this is somebody else's @@ -2411,7 +2411,7 @@ class Users { meta: { strategyId: strategy.id, siteId } }) await WIKI.models.mail.send({ - siteId, + site: WIKI.models.mail.siteFor(siteId), to: user.email, template: 'resetPwd', // -> Their own preference where they have one, and otherwise the language the wiki was being diff --git a/backend/notifications/categories/commentReply.ts b/backend/notifications/categories/commentReply.ts new file mode 100644 index 000000000..d34e1f934 --- /dev/null +++ b/backend/notifications/categories/commentReply.ts @@ -0,0 +1,30 @@ +import { only } from '../recipients.ts' +import { snapshotOf } from '../snapshot.ts' +import type { NotificationCategory } from '../types.ts' + +/** + * Somebody answered a comment the recipient wrote. + * + * Only the author of the comment that started the thread — replies are one level deep, so every + * answer in a thread is an answer to it. Others who replied in the thread are not told: following a + * whole discussion is what watching the page is for. A guest's comment has no account to tell. + */ +export const commentReply: NotificationCategory = { + key: 'commentReply', + section: 'discussions', + events: ['comment:new'], + scope: 'site', + origins: ['user'], + defaults: { inApp: true, email: true }, + priority: 30, + appliesTo: (event) => Boolean(event.data.parentId && event.data.parentAuthorId), + recipients: (event, after) => only(event.data.parentAuthorId, after), + access: 'read:comments', + groupKey: (event) => `reply:${event.data.parentId}`, + entry: (event) => ({ + variant: event.data.variant, + pageId: event.data.page!.id, + commentId: event.data.commentId ?? null, + data: snapshotOf(event) + }) +} diff --git a/backend/notifications/categories/mention.ts b/backend/notifications/categories/mention.ts new file mode 100644 index 000000000..4276ceffa --- /dev/null +++ b/backend/notifications/categories/mention.ts @@ -0,0 +1,31 @@ +import { mentionedIn } from '../recipients.ts' +import { snapshotOf } from '../snapshot.ts' +import type { NotificationCategory } from '../types.ts' + +/** + * Somebody wrote the recipient's `@handle` in a comment. + * + * On an edit, only a handle the comment did not already contain counts, so re-saving a comment does + * not mention everybody in it a second time. The recipient still needs `read:comments` on the page: + * a mention in a discussion somebody may not read would otherwise be a way of leaking it to them. + */ +export const mention: NotificationCategory = { + key: 'mention', + section: 'discussions', + events: ['comment:new', 'comment:edit'], + scope: 'site', + origins: ['user'], + defaults: { inApp: true, email: true }, + priority: 40, + appliesTo: (event) => (event.data.mentionHandles ?? []).length > 0, + recipients: mentionedIn, + access: 'read:comments', + // -> Per comment: two mentions in two comments are two things to answer + groupKey: (event) => `mention:${event.data.commentId}`, + entry: (event) => ({ + variant: event.kind === 'comment:edit' ? 'edited' : 'new', + pageId: event.data.page!.id, + commentId: event.data.commentId ?? null, + data: snapshotOf(event) + }) +} diff --git a/backend/notifications/categories/pageCreated.ts b/backend/notifications/categories/pageCreated.ts new file mode 100644 index 000000000..228b7bbc4 --- /dev/null +++ b/backend/notifications/categories/pageCreated.ts @@ -0,0 +1,29 @@ +import { optedInTo } from '../recipients.ts' +import { snapshotOf } from '../snapshot.ts' +import type { NotificationCategory } from '../types.ts' + +/** + * A page was created anywhere the recipient may read. Off by default, and busy when turned on. + * + * On creation whatever the page's publish state, which the entry says — a signed-in reader with + * `read:pages` sees an unpublished page, so being told about one tells them nothing they could not + * find. Silent for an import or a bulk operation: restoring five thousand pages is not news to + * everybody who asked about new ones. + */ +export const pageCreated: NotificationCategory = { + key: 'pageCreated', + section: 'everything', + events: ['page:create'], + scope: 'site', + origins: ['user'], + defaults: { inApp: false, email: false }, + priority: 5, + recipients: (_event, after) => optedInTo('pageCreated', after), + access: 'read:pages', + groupKey: (event) => `pageCreated:${event.data.page!.id}`, + entry: (event) => ({ + variant: event.data.variant, + pageId: event.data.page!.id, + data: snapshotOf(event) + }) +} diff --git a/backend/notifications/categories/pageDeleted.ts b/backend/notifications/categories/pageDeleted.ts new file mode 100644 index 000000000..809e2b65c --- /dev/null +++ b/backend/notifications/categories/pageDeleted.ts @@ -0,0 +1,28 @@ +import { optedInTo } from '../recipients.ts' +import { snapshotOf } from '../snapshot.ts' +import type { NotificationCategory } from '../types.ts' + +/** + * A page was deleted anywhere the recipient could read it. Off by default. + * + * Access is checked against the page as it was, from the event's snapshot, since there is no page + * left to check. A watcher who also turned this on gets the `watchedPage` entry instead, which + * outranks it. Silent for an import or a bulk operation, as `pageCreated` is. + */ +export const pageDeleted: NotificationCategory = { + key: 'pageDeleted', + section: 'everything', + events: ['page:delete'], + scope: 'site', + origins: ['user'], + defaults: { inApp: false, email: false }, + priority: 5, + recipients: (_event, after) => optedInTo('pageDeleted', after), + access: 'read:pages', + groupKey: (event) => `pageDeleted:${event.data.page!.id}`, + entry: (event) => ({ + variant: event.data.variant, + pageId: null, + data: snapshotOf(event) + }) +} diff --git a/backend/notifications/categories/reviewRequested.ts b/backend/notifications/categories/reviewRequested.ts new file mode 100644 index 000000000..70599d4ec --- /dev/null +++ b/backend/notifications/categories/reviewRequested.ts @@ -0,0 +1,29 @@ +import { reviewersOf } from '../recipients.ts' +import { snapshotOf } from '../snapshot.ts' +import type { NotificationCategory } from '../types.ts' + +/** + * An edit suggestion is waiting for review, on a page whose rules name one of the recipient's groups + * as a reviewer. + * + * No page permission is checked on top: the rule naming the group IS the grant, and an entry says no + * more than the reviewer's own queue already shows them. A submitter revising a suggestion that is + * still open bumps the entry they already have, since it is the same suggestion. + */ +export const reviewRequested: NotificationCategory = { + key: 'reviewRequested', + section: 'reviews', + events: ['submission:new'], + scope: 'site', + origins: ['user'], + defaults: { inApp: true, email: true }, + priority: 30, + recipients: reviewersOf, + access: null, + groupKey: (event) => `review:${event.data.submissionId}`, + entry: (event) => ({ + variant: event.data.variant, + pageId: event.data.page?.id ?? null, + data: snapshotOf(event) + }) +} diff --git a/backend/notifications/categories/watchedPage.ts b/backend/notifications/categories/watchedPage.ts new file mode 100644 index 000000000..4464314d4 --- /dev/null +++ b/backend/notifications/categories/watchedPage.ts @@ -0,0 +1,30 @@ +import { watchersOf } from '../recipients.ts' +import { snapshotOf } from '../snapshot.ts' +import type { NotificationCategory } from '../types.ts' + +/** + * A page somebody watches changed: it was edited, moved, published or unpublished, rescheduled, or + * deleted. + * + * One category for all of it rather than one per kind of change, because watching is one thing a + * reader asked for — "tell me about this page" — and the variants are how the entry says which. It + * fires for every origin: a page a git pull rewrote has changed exactly as much as one somebody saved. + */ +export const watchedPage: NotificationCategory = { + key: 'watchedPage', + section: 'watching', + events: ['page:edit', 'page:rename', 'page:delete'], + scope: 'site', + origins: ['user', 'import', 'bulk'], + defaults: { inApp: true, email: true }, + priority: 20, + recipients: watchersOf, + access: 'read:pages', + // -> Per page, so a page saved forty times while nobody looked is one entry counting to forty + groupKey: (event) => `watchedPage:${event.data.page!.id}`, + entry: (event) => ({ + variant: event.data.variant, + pageId: event.kind === 'page:delete' ? null : event.data.page!.id, + data: snapshotOf(event) + }) +} diff --git a/backend/notifications/categories/watchedPageComment.ts b/backend/notifications/categories/watchedPageComment.ts new file mode 100644 index 000000000..dc936532c --- /dev/null +++ b/backend/notifications/categories/watchedPageComment.ts @@ -0,0 +1,28 @@ +import { watchersOf } from '../recipients.ts' +import { snapshotOf } from '../snapshot.ts' +import type { NotificationCategory } from '../types.ts' + +/** + * Somebody commented on a page the recipient watches. + * + * Per page rather than per comment, so a lively discussion is one entry counting up rather than a + * stack of them — the Talk tab is where the comments are read, and opening it marks the entry read. + */ +export const watchedPageComment: NotificationCategory = { + key: 'watchedPageComment', + section: 'discussions', + events: ['comment:new'], + scope: 'site', + origins: ['user'], + defaults: { inApp: true, email: true }, + priority: 10, + recipients: watchersOf, + access: 'read:comments', + groupKey: (event) => `watchedComment:${event.data.page!.id}`, + entry: (event) => ({ + variant: event.data.variant, + pageId: event.data.page!.id, + commentId: event.data.commentId ?? null, + data: snapshotOf(event) + }) +} diff --git a/backend/notifications/fanout.ts b/backend/notifications/fanout.ts new file mode 100644 index 000000000..21dbe693c --- /dev/null +++ b/backend/notifications/fanout.ts @@ -0,0 +1,512 @@ +import { and, eq, inArray, isNull, sql } from 'drizzle-orm' +import { + groups as groupsTable, + notificationEvents as eventsTable, + notifications as notificationsTable, + pages as pagesTable, + userGroups as userGroupsTable, + userNotificationPrefs as prefsTable, + users as usersTable +} from '../db/schema.ts' +import { durationToSeconds } from '../helpers/common.ts' +import { rulesAllow } from '../helpers/pageRules.ts' +import { mail } from '../models/mail.ts' +import type { GroupRule } from '../models/groups.ts' +import { categoriesFor } from './index.ts' +import { enqueueOnce } from './queue.ts' +import { rulePageOf } from './types.ts' +import type { NotificationCategory, NotificationEvent } from './types.ts' + +/** + * The fan-out: turning what happened into who is told. + * + * Runs in a worker thread (`tasks/workers/dispatch-notifications.ts`), so it reads everything it + * needs from the database rather than from `WIKI.models` — the group rules included, which the + * request process keeps in memory and a worker does not have. + * + * For each event, each category that cares about it, in priority order; for each category, its + * candidates a batch at a time; for each batch, the same five steps whatever the category: leave the + * actor out, drop accounts that cannot receive anything, check access, apply preferences, and write + * the entries. Doing those here rather than in the categories is what stops one of them from getting + * one wrong. + */ + +/** How many events one claim takes. */ +const EVENT_BATCH_SIZE = 50 + +/** How many entries go into one INSERT. */ +const INSERT_CHUNK_SIZE = 500 + +/** The email delay when nothing is configured, in seconds. */ +const DEFAULT_EMAIL_DELAY = 180 + +/** How far an interrupted fan-out got: which category, and the last candidate written for it. */ +interface FanOutCursor { + category: string + after: string | null +} + +/** An event as a run holds it: the outbox row, with whatever an earlier run left of its progress. */ +type ClaimedEvent = NotificationEvent & { cursor: FanOutCursor | null } + +/** A group as an access check needs it. */ +interface GroupAccess { + rules: GroupRule[] + permissions: string[] +} + +/** A candidate who survived the first cut, with what the rest of the steps need to know. */ +interface Recipient { + id: string + isVerified: boolean + groupIds: string[] +} + +/** + * Everything one run reads once and every batch uses: the groups' rules and permissions, and the + * memo of access decisions made against them. + */ +class RunContext { + groups = new Map() + /** + * Access decisions, keyed by permission, page and the set of groups asking. + * + * The whole reason a large audience is cheap: a page permission depends on nothing about a user but + * the groups they are in, and ten thousand accounts on a wiki are usually a handful of combinations + * of groups. Each combination is evaluated once per event, however many people share it. + */ + decisions = new Map() + emailDelaySeconds = DEFAULT_EMAIL_DELAY + mailConfigured = false + + async load(): Promise { + const rows = await WIKI.db + .select({ + id: groupsTable.id, + rules: groupsTable.rules, + permissions: groupsTable.permissions + }) + .from(groupsTable) + for (const row of rows) { + this.groups.set(row.id, { + rules: (row.rules ?? []) as GroupRule[], + permissions: (row.permissions ?? []) as string[] + }) + } + this.emailDelaySeconds = durationToSeconds( + WIKI.config.notifications?.emailDelay, + DEFAULT_EMAIL_DELAY + ) + this.mailConfigured = mail.isConfigured + } + + /** + * Whether a set of groups may do this to the page the event is about — `groups.checkAccess`, asked + * of the rows this run read rather than of the request process's cache. + */ + mayAccess(event: NotificationEvent, permission: string, groupIds: string[]): boolean { + const page = rulePageOf(event) + if (!page) { + return false + } + const key = `${event.id}|${permission}|${[...groupIds].sort().join(',')}` + const known = this.decisions.get(key) + if (known !== undefined) { + return known + } + const pooled = groupIds.map((id) => this.groups.get(id)).filter(Boolean) as GroupAccess[] + const allowed = + pooled.some((group) => group.permissions.includes('manage:system')) || + rulesAllow( + pooled.flatMap((group) => group.rules), + permission, + page + ) + this.decisions.set(key, allowed) + return allowed + } +} + +/** + * Claim the next batch of events. + * + * `SKIP LOCKED` so that several instances can drain the outbox at once without two of them taking + * the same event, and a claim older than the task timeout counts as abandoned: whatever held it has + * stopped without saying so, and the cursor it left is where the next claim carries on. + */ +async function claimEvents(): Promise { + const staleSeconds = (WIKI.config.scheduler?.taskTimeout ?? 300) + 60 + const result = await WIKI.db.execute(sql` + UPDATE ${eventsTable} + SET "claimedAt" = now(), "claimedBy" = ${WIKI.INSTANCE_ID} + WHERE id IN ( + SELECT id FROM ${eventsTable} + WHERE "processedAt" IS NULL + AND ("claimedAt" IS NULL OR "claimedAt" < now() - make_interval(secs => ${staleSeconds})) + ORDER BY "createdAt" + LIMIT ${EVENT_BATCH_SIZE} + FOR UPDATE SKIP LOCKED + ) + RETURNING id, kind, origin, "siteId", "actorId", data, recipients, cursor, "createdAt" + `) + return (result.rows as any[]) + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + .map((row) => ({ + id: row.id, + kind: row.kind, + origin: row.origin, + siteId: row.siteId, + actorId: row.actorId, + data: row.data ?? {}, + recipients: row.recipients ?? null, + cursor: row.cursor ?? null + })) +} + +/** + * The candidates in a batch who can receive anything at all: active, not a system account, and not + * whoever caused the event — nobody is told about their own action. + */ +async function recipientsAmong(event: NotificationEvent, ids: string[]): Promise { + const candidates = ids.filter((id) => id !== event.actorId) + if (candidates.length < 1) { + return [] + } + const rows = await WIKI.db + .select({ + id: usersTable.id, + isVerified: usersTable.isVerified, + // -> As text: the driver parses a text[] into an array, but leaves a uuid[] as its literal + groupIds: sql< + string[] + >`coalesce(array_agg(${userGroupsTable.groupId}::text) filter (where ${userGroupsTable.groupId} is not null), '{}')` + }) + .from(usersTable) + .leftJoin(userGroupsTable, eq(userGroupsTable.userId, usersTable.id)) + .where( + and( + inArray(usersTable.id, candidates), + eq(usersTable.isActive, true), + eq(usersTable.isSystem, false) + ) + ) + .groupBy(usersTable.id) + return rows +} + +/** + * Each recipient's channels for this category: their stored choice where they made one, the + * category's default where they did not. + */ +async function channelsFor( + category: NotificationCategory, + userIds: string[] +): Promise> { + const stored = await WIKI.db + .select({ + userId: prefsTable.userId, + channel: prefsTable.channel, + enabled: prefsTable.enabled + }) + .from(prefsTable) + .where(and(eq(prefsTable.category, category.key), inArray(prefsTable.userId, userIds))) + const channels = new Map(userIds.map((id) => [id, { ...category.defaults }])) + for (const row of stored) { + const entry = channels.get(row.userId) + if (entry && (row.channel === 'inApp' || row.channel === 'email')) { + entry[row.channel] = row.enabled + } + } + return channels +} + +/** + * Who in this batch already has an entry for this very event. + * + * Covers both ways that happens. A higher-priority category of the same event got to them first — a + * comment that mentions somebody watching the page is their mention and not also a watched-page + * comment, which is why categories are processed highest first. And a fan-out that died after + * writing part of a batch is being replayed. Reading it back rather than holding a set in memory is + * what makes both survive a fan-out that is carried on by a different run. + */ +async function alreadyNotified(event: NotificationEvent, userIds: string[]): Promise> { + const rows = await WIKI.db + .select({ userId: notificationsTable.userId }) + .from(notificationsTable) + .where( + and(inArray(notificationsTable.userId, userIds), eq(notificationsTable.lastEventId, event.id)) + ) + return new Set(rows.map((row) => row.userId)) +} + +/** + * When a new entry's email may go out — the one place that is decided. + * + * Today it is the instance's email delay from now, the window in which whatever else happens to the + * same thing joins the same mail. It is also where a per-user digest schedule (hourly, daily) would + * attach: such a schedule is a later answer here, and nothing in the mail drain has to change for it, + * since the drain sends whatever `emailAfter` says is due and never asks why. + */ +function emailAfterFor(ctx: RunContext): Date { + return new Date(Date.now() + ctx.emailDelaySeconds * 1000) +} + +/** + * One batch of candidates for one category, written. + * + * @returns Whether anything was written with an email to send + */ +async function deliverBatch( + ctx: RunContext, + event: NotificationEvent, + category: NotificationCategory, + ids: string[] +): Promise { + let recipients = await recipientsAmong(event, ids) + if (category.access) { + recipients = recipients.filter((r) => ctx.mayAccess(event, category.access!, r.groupIds)) + } + if (recipients.length < 1) { + return false + } + const channels = await channelsFor( + category, + recipients.map((r) => r.id) + ) + recipients = recipients.filter((r) => { + const chosen = channels.get(r.id)! + return chosen.inApp || chosen.email + }) + if (recipients.length < 1) { + return false + } + const done = await alreadyNotified( + event, + recipients.map((r) => r.id) + ) + recipients = recipients.filter((r) => !done.has(r.id)) + if (recipients.length < 1) { + return false + } + + const entry = category.entry(event) + const groupKey = category.groupKey(event) + const siteId = category.scope === 'site' ? event.siteId : null + const emailAfter = emailAfterFor(ctx) + let anyEmail = false + + const values = recipients.map((r) => { + const chosen = channels.get(r.id)! + // -> Unverified: the address has not been confirmed, so nothing is sent to it yet + const email = chosen.email && ctx.mailConfigured && r.isVerified + anyEmail ||= email + return { + userId: r.id, + siteId, + category: category.key, + variant: entry.variant, + groupKey, + pageId: entry.pageId ?? null, + commentId: entry.commentId ?? null, + actorId: event.actorId, + data: entry.data, + lastEventId: event.id, + inApp: chosen.inApp, + emailState: email ? 'pending' : 'none', + emailAfter: email ? emailAfter : null + } + }) + + for (let i = 0; i < values.length; i += INSERT_CHUNK_SIZE) { + await WIKI.db + .insert(notificationsTable) + .values(values.slice(i, i + INSERT_CHUNK_SIZE)) + .onConflictDoUpdate({ + target: [notificationsTable.userId, notificationsTable.groupKey], + targetWhere: sql`"readAt" IS NULL`, + /* + An entry nobody has read yet absorbs the event instead of being joined by a second one. + + Email follows the cadence in the spec (§10.2): one already due stays due and takes this event + with it; one already SENT stays sent, so a page edited all afternoon is one email until it is + looked at. An entry with no email waiting — the channel was off, or the last attempt was + given up on — gets one if this event asks for it. + */ + set: { + count: sql`${notificationsTable.count} + 1`, + variant: sql`excluded.variant`, + pageId: sql`excluded."pageId"`, + commentId: sql`excluded."commentId"`, + actorId: sql`excluded."actorId"`, + data: sql`excluded.data || jsonb_build_object('variants', ( + SELECT coalesce(jsonb_agg(DISTINCT v), '[]'::jsonb) + FROM jsonb_array_elements( + coalesce(${notificationsTable.data} -> 'variants', '[]'::jsonb) || + coalesce(excluded.data -> 'variants', '[]'::jsonb) + ) AS v + ))`, + lastEventId: sql`excluded."lastEventId"`, + inApp: sql`excluded."inApp"`, + emailState: sql`CASE + WHEN ${notificationsTable.emailState} IN ('none', 'failed', 'skipped') AND excluded."emailState" = 'pending' + THEN 'pending' ELSE ${notificationsTable.emailState} END`, + emailAfter: sql`CASE + WHEN ${notificationsTable.emailState} IN ('none', 'failed', 'skipped') AND excluded."emailState" = 'pending' + THEN excluded."emailAfter" ELSE ${notificationsTable.emailAfter} END`, + updatedAt: sql`now()` + }, + // -> The same event a second time is a replay, and changes nothing + setWhere: sql`${notificationsTable.lastEventId} IS DISTINCT FROM excluded."lastEventId"` + }) + } + return anyEmail +} + +/** + * Whether an event still describes something that happened. + * + * A deletion is written before the page goes, because the watchers go with it. If the page is still + * there, the delete failed after the event was written, and nobody is told about a deletion that did + * not happen. (A page deleted and restored before this ran is indistinguishable, and is not told + * about either — the page is there.) + */ +async function stillHappened(event: NotificationEvent): Promise { + if (event.kind !== 'page:delete' || !event.data.page?.id) { + return true + } + const rows = await WIKI.db + .select({ id: pagesTable.id }) + .from(pagesTable) + .where(eq(pagesTable.id, event.data.page.id)) + .limit(1) + return rows.length < 1 +} + +/** + * The actor's name as it stands now, which is close enough to "at the time" — this runs seconds after + * the event. Looked up here rather than when the event is written so that writing one stays a single + * INSERT on the request path. + */ +async function withActorName(event: NotificationEvent): Promise { + if (event.data.actorName !== undefined || !event.actorId) { + return event + } + const rows = await WIKI.db + .select({ name: usersTable.name }) + .from(usersTable) + .where(eq(usersTable.id, event.actorId)) + .limit(1) + return { ...event, data: { ...event.data, actorName: rows[0]?.name ?? null } } +} + +/** + * Fan one event out, from wherever an earlier run left it. + * + * @returns The cursor to carry on from, or null when the event is done + */ +async function processEvent( + ctx: RunContext, + claimed: ClaimedEvent, + signal: AbortSignal, + onEmail: () => void +): Promise { + if (!(await stillHappened(claimed))) { + return null + } + const event = await withActorName(claimed) + const categories = categoriesFor(event.kind, event.origin).filter( + (category) => !category.appliesTo || category.appliesTo(event) + ) + let start = 0 + if (claimed.cursor) { + const index = categories.findIndex((category) => category.key === claimed.cursor!.category) + start = index < 0 ? categories.length : index + } + for (let i = start; i < categories.length; i++) { + const category = categories[i]! + const after = i === start ? (claimed.cursor?.after ?? null) : null + for await (const ids of category.recipients(event, after)) { + if (await deliverBatch(ctx, event, category, ids)) { + onEmail() + } + if (signal.aborted) { + return { category: category.key, after: ids[ids.length - 1]! } + } + } + } + return null +} + +/** + * Drain the outbox, as far as the run is allowed to go. + * + * Stops between batches when `signal` is aborted, leaving each event it had not finished with the + * cursor it reached and its claim released, so that the next run carries on rather than starting + * the event again. Asks for that next run itself, and for a mail run once there is something to send. + */ +export async function dispatchPending(signal: AbortSignal): Promise { + const ctx = new RunContext() + await ctx.load() + let anyEmail = false + let processed = 0 + + while (!signal.aborted) { + const events = await claimEvents() + if (events.length < 1) { + break + } + for (const event of events) { + if (signal.aborted) { + // -> Not started: let it go for the next run to claim + await WIKI.db + .update(eventsTable) + .set({ claimedAt: null, claimedBy: null }) + .where(eq(eventsTable.id, event.id)) + continue + } + let cursor: FanOutCursor | null + try { + cursor = await processEvent(ctx, event, signal, () => { + anyEmail = true + }) + } catch (err: any) { + // -> Released rather than marked done, so the next run tries it again from its cursor + WIKI.logger.warn(`Failed to dispatch notification event ${event.id}: ${err.message}`) + await WIKI.db + .update(eventsTable) + .set({ claimedAt: null, claimedBy: null }) + .where(eq(eventsTable.id, event.id)) + continue + } + await WIKI.db + .update(eventsTable) + .set( + cursor + ? { cursor, claimedAt: null, claimedBy: null } + : { cursor: null, processedAt: sql`now()` } + ) + .where(eq(eventsTable.id, event.id)) + if (!cursor) { + processed++ + } + } + } + + if (processed > 0) { + WIKI.logger.debug(`Dispatched ${processed} notification event(s).`) + } + if (anyEmail) { + await enqueueOnce('sendNotificationMail', emailAfterFor(ctx)) + } + // -> Something is still waiting and nobody holds it, which is either more than one run could take + // or what this one let go when it was told to stop. A claim somebody abandoned is picked up by + // the scheduled run once it has gone stale + const remaining = await WIKI.db + .select({ id: eventsTable.id }) + .from(eventsTable) + .where(and(isNull(eventsTable.processedAt), isNull(eventsTable.claimedAt))) + .limit(1) + if (remaining.length > 0) { + await enqueueOnce('dispatchNotifications') + } +} diff --git a/backend/notifications/index.ts b/backend/notifications/index.ts new file mode 100644 index 000000000..7d0c04489 --- /dev/null +++ b/backend/notifications/index.ts @@ -0,0 +1,66 @@ +import { commentReply } from './categories/commentReply.ts' +import { mention } from './categories/mention.ts' +import { pageCreated } from './categories/pageCreated.ts' +import { pageDeleted } from './categories/pageDeleted.ts' +import { reviewRequested } from './categories/reviewRequested.ts' +import { watchedPage } from './categories/watchedPage.ts' +import { watchedPageComment } from './categories/watchedPageComment.ts' +import type { + EventOrigin, + NotificationCategory, + NotificationEventKind, + NotificationSection +} from './types.ts' + +/** + * The notification categories, in full. + * + * Closed, the way `AUDIT_ACTIONS` is: `NotificationCategoryKey` is its union, so `npm run typecheck` + * refuses a key that is not here. Each key is also its translation prefix — the Profile screen reads + * `notifications.categories..title` / `.description`, an entry `notifications.messages..*` + * and a mail `mail.notification..*` — so a category added here is a set of strings added to + * `locales/en.json` as well. + * + * Adding one is a file under `categories/`, a line here, an `emit()` for any event that does not + * exist yet, and its strings. Nothing else reads a category by name. + */ +export const NOTIFICATION_CATEGORIES = { + watchedPage, + watchedPageComment, + commentReply, + mention, + reviewRequested, + pageCreated, + pageDeleted +} as const satisfies Record + +export type NotificationCategoryKey = keyof typeof NOTIFICATION_CATEGORIES + +export const NOTIFICATION_CATEGORY_KEYS = Object.keys( + NOTIFICATION_CATEGORIES +) as NotificationCategoryKey[] + +/** The order the Profile screen lists its headings in. */ +export const NOTIFICATION_SECTIONS: NotificationSection[] = [ + 'watching', + 'discussions', + 'reviews', + 'everything' +] + +export function isCategoryKey(key: string): key is NotificationCategoryKey { + return Object.hasOwn(NOTIFICATION_CATEGORIES, key) +} + +/** + * The categories an event concerns, highest priority first — which is the order the fan-out has to + * write them in for the higher one to win a person both would reach. + */ +export function categoriesFor( + kind: NotificationEventKind, + origin: EventOrigin +): NotificationCategory[] { + return Object.values(NOTIFICATION_CATEGORIES) + .filter((category) => category.events.includes(kind) && category.origins.includes(origin)) + .sort((a, b) => b.priority - a.priority) +} diff --git a/backend/notifications/mailer.ts b/backend/notifications/mailer.ts new file mode 100644 index 000000000..1b38a9348 --- /dev/null +++ b/backend/notifications/mailer.ts @@ -0,0 +1,390 @@ +import NodeCache from 'node-cache' +import { and, asc, eq, inArray, lte, min, sql } from 'drizzle-orm' +import { + notifications as notificationsTable, + sites as sitesTable, + userNotificationPrefs as prefsTable, + users as usersTable +} from '../db/schema.ts' +import { locales } from '../models/locales.ts' +import { mail } from '../models/mail.ts' +import type { MailNotificationEntry, MailSite } from '../models/mail.ts' +import { NOTIFICATION_CATEGORIES, isCategoryKey } from './index.ts' +import { enqueueOnce } from './queue.ts' +import { createUnsubscribeToken } from './unsubscribe.ts' + +/** + * The mail drain: notifications whose email is due, sent. + * + * Runs in a worker thread (`tasks/workers/send-notification-mail.ts`). What is due is decided by one + * column, `emailAfter`, and nothing else — the cadence (a short window, then quiet until the entry is + * read) is entirely in how the fan-out sets it, which is what lets an hourly or daily digest be added + * later as a different `emailAfter` without anything here changing. + * + * **One mail per person per site.** A mail names the wiki it comes from and its links use that site's + * hostname, so a person active on two sites gets two, each of which reads as coming from its own + * wiki. Everything due for that pair goes into the one mail — a single entry told in full, or a list. + */ + +/** Entries listed in one digest; above this it says how many more and links to the inbox. */ +const DIGEST_MAX_ENTRIES = 50 + +/** How many times an email is tried before it is given up on. */ +const MAX_ATTEMPTS = 3 + +/** How long a failed send waits before it is tried again, in seconds. */ +const RETRY_DELAY = 600 + +/** + * How long a claim on an email lasts, in seconds — longer than any relay takes to accept one, so that + * only a run that died mid-send ever lets it lapse. + */ +const SEND_LEASE = 600 + +/** How many recipients one run sends to when nothing is configured. */ +const DEFAULT_BATCH_SIZE = 100 + +interface SiteInfo { + hostname: string + isEnabled: boolean + mailSite: MailSite +} + +/** A due entry, with what drawing it in a mail needs. */ +interface DueRow { + id: string + category: string + variant: string + count: number + pageId: string | null + commentId: string | null + data: Record + inApp: boolean +} + +/** + * The sites a run may send for: what the mail calls each, where its links point, and whether its + * notifications are switched on. Read once per run — a worker has no `WIKI.sites`. + */ +async function loadSites(): Promise> { + const rows = await WIKI.db + .select({ id: sitesTable.id, hostname: sitesTable.hostname, config: sitesTable.config }) + .from(sitesTable) + return new Map( + rows.map((row) => { + const config = (row.config ?? {}) as Record + return [ + row.id, + { + hostname: row.hostname, + isEnabled: config.features?.notifications !== false, + mailSite: { + name: config.title || 'Wiki.js', + primaryLocale: config.locales?.primary || null + } + } + ] + }) + ) +} + +/** Where an entry leads, from the site the mail is about. */ +function urlOf(baseUrl: string, row: DueRow): string { + if (row.category === 'reviewRequested' && row.data.submissionId) { + return `${baseUrl}/_inbox/review/${row.data.submissionId}` + } + if (!row.pageId) { + // -> The page has gone: the inbox still says what it was + return `${baseUrl}/_inbox` + } + const section = NOTIFICATION_CATEGORIES[row.category as keyof typeof NOTIFICATION_CATEGORIES] + return `${baseUrl}/i/${row.pageId}${section?.section === 'discussions' ? '#talk' : ''}` +} + +function entryOf(baseUrl: string, row: DueRow): MailNotificationEntry { + return { + category: row.category, + variant: row.variant, + count: row.count, + actorName: row.data.actorName ?? null, + pageTitle: row.data.page?.title ?? '', + // -> Only while the comment exists, the same rule the inbox applies + ...(row.commentId && row.data.excerpt && { excerpt: row.data.excerpt }), + ...(row.data.origin && { origin: row.data.origin }), + url: urlOf(baseUrl, row) + } +} + +/** + * The (user, site) pairs that have something due, oldest first. + * + * Not locked: the rows themselves are claimed, one pair at a time, in `claim`. Two instances choosing + * the same pair is therefore harmless — whichever comes second finds the rows taken and moves on. + */ +async function duePairs(limit: number): Promise<{ userId: string; siteId: string | null }[]> { + return WIKI.db + .select({ userId: notificationsTable.userId, siteId: notificationsTable.siteId }) + .from(notificationsTable) + .where( + and( + inArray(notificationsTable.emailState, ['pending', 'sending']), + lte(notificationsTable.emailAfter, sql`now()`) + ) + ) + .groupBy(notificationsTable.userId, notificationsTable.siteId) + .orderBy(asc(min(notificationsTable.emailAfter))) + .limit(limit) +} + +/** + * Take what is due for one person on one site, so that nobody else sends it. + * + * One statement, and no transaction held open across the send: a worker thread has a single database + * connection (`core/db.ts`), so a transaction kept open while the mail is rendered and sent would + * leave nothing for the queries that rendering makes — the locale strings, the person's preferences — + * and the run would wait on itself until the pool aborted it. + * + * Claimed rows are marked `sending`, and their `emailAfter` becomes the lease: a run that dies mid-send + * leaves them `sending` with a lease that runs out, and the next run takes them again, which is what + * `duePairs` and this both look for. At least once rather than at most once, then: a duplicate + * notification is the better failure. + */ +async function claim(pair: { userId: string; siteId: string | null }): Promise { + const result = await WIKI.db.execute(sql` + UPDATE ${notificationsTable} + SET "emailState" = 'sending', "emailAfter" = now() + make_interval(secs => ${SEND_LEASE}) + WHERE id IN ( + SELECT id FROM ${notificationsTable} + WHERE "userId" = ${pair.userId} + AND "siteId" IS NOT DISTINCT FROM ${pair.siteId} + AND "emailState" IN ('pending', 'sending') + AND "emailAfter" <= now() + FOR UPDATE SKIP LOCKED + ) + RETURNING id, category, variant, count, "pageId", "commentId", data, "inApp", "updatedAt" + `) + return (result.rows as any[]) + .sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()) + .map((row) => ({ + id: row.id, + category: row.category, + variant: row.variant, + count: row.count, + pageId: row.pageId, + commentId: row.commentId, + data: row.data ?? {}, + inApp: row.inApp + })) +} + +/** + * The categories, among these, that the user has since turned email off for. + * + * Asked again at send time rather than trusted from when the entry was written: somebody who + * unsubscribes while a mail is waiting should not receive it. + */ +async function emailOffFor(userId: string, categories: string[]): Promise> { + const stored = await WIKI.db + .select({ category: prefsTable.category, enabled: prefsTable.enabled }) + .from(prefsTable) + .where( + and( + eq(prefsTable.userId, userId), + eq(prefsTable.channel, 'email'), + inArray(prefsTable.category, categories) + ) + ) + const off = new Set() + for (const category of categories) { + const row = stored.find((entry) => entry.category === category) + const enabled = row + ? row.enabled + : isCategoryKey(category) && NOTIFICATION_CATEGORIES[category].defaults.email + if (!enabled) { + off.add(category) + } + } + return off +} + +/** Give claimed rows up as not to be emailed, saying why in the log. */ +async function skip(userId: string, ids: string[], reason: string): Promise { + if (ids.length < 1) { + return + } + WIKI.logger.debug(`Skipped ${ids.length} notification email(s) for ${userId}: ${reason}`) + await WIKI.db + .update(notificationsTable) + .set({ emailState: 'skipped' }) + .where(inArray(notificationsTable.id, ids)) +} + +/** + * Send whatever is due for one person on one site, as one mail. + * + * @returns Whether a mail was sent + */ +async function sendTo( + pair: { userId: string; siteId: string | null }, + sites: Map +): Promise { + const rows = await claim(pair) + if (rows.length < 1) { + return false + } + const allIds = rows.map((row) => row.id) + + const [user] = await WIKI.db + .select({ + email: usersTable.email, + isActive: usersTable.isActive, + isVerified: usersTable.isVerified, + prefs: usersTable.prefs + }) + .from(usersTable) + .where(eq(usersTable.id, pair.userId)) + /* + An entry with no site belongs to no wiki in particular. None of the launch categories writes one; + when one does, it is sent as coming from the first site, which is as good a guess as any about + where this person reads. + */ + const site = pair.siteId ? sites.get(pair.siteId) : sites.values().next().value + if (!user?.isActive || !user.isVerified) { + await skip(pair.userId, allIds, 'the account cannot receive mail') + return false + } + if (!site?.isEnabled) { + await skip(pair.userId, allIds, 'notifications are switched off on the site') + return false + } + const baseUrl = mail.baseUrl({ hostname: site.hostname }) + if (!baseUrl) { + WIKI.logger.warn( + 'Notification emails cannot be sent for a site with no hostname unless a base URL is set under Admin → Mail.' + ) + await skip(pair.userId, allIds, 'there is no base URL to build links with') + return false + } + + const off = await emailOffFor(pair.userId, [...new Set(rows.map((row) => row.category))]) + await skip( + pair.userId, + rows.filter((row) => off.has(row.category)).map((row) => row.id), + 'email was turned off since' + ) + const sending = rows.filter((row) => !off.has(row.category)) + if (sending.length < 1) { + return false + } + const ids = sending.map((row) => row.id) + + const listed = sending.slice(0, DIGEST_MAX_ENTRIES) + const categories = [...new Set(sending.map((row) => row.category))] + const token = createUnsubscribeToken({ userId: pair.userId, categories }) + try { + await mail.send({ + site: site.mailSite, + to: user.email, + template: sending.length > 1 ? 'notificationDigest' : 'notification', + locale: (user.prefs as Record)?.locale, + data: { + baseUrl, + entries: listed.map((row) => entryOf(baseUrl, row)), + more: sending.length - listed.length, + manageUrl: `${baseUrl}/_profile/notifications`, + unsubscribeUrl: `${baseUrl}/_unsubscribe?t=${token}` + }, + /* + RFC 8058. The POST is what a mail client sends when somebody presses its own unsubscribe + button, and it acts at once; a GET of the same URL only redirects to the page that asks, + because mail scanners fetch every link in a message. + */ + headers: { + 'List-Unsubscribe': `<${baseUrl}/_api/notifications/unsubscribe?t=${token}>`, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + // -> So that an out-of-office reply is not sent back to the wiki + 'Auto-Submitted': 'auto-generated' + } + }) + } catch (err: any) { + WIKI.logger.warn(`Failed to send a notification email to <${user.email}>: ${err.message}`) + /* + Tried again later, up to a limit, rather than given up on at once: a relay that is down for ten + minutes should not cost everybody the notifications that came due in those ten minutes. + */ + await WIKI.db + .update(notificationsTable) + .set({ + data: sql`jsonb_set(${notificationsTable.data}, '{mailAttempts}', to_jsonb(coalesce((${notificationsTable.data} ->> 'mailAttempts')::int, 0) + 1))`, + emailState: sql`CASE WHEN coalesce((${notificationsTable.data} ->> 'mailAttempts')::int, 0) + 1 >= ${MAX_ATTEMPTS} THEN 'failed' ELSE 'pending' END`, + emailAfter: sql`now() + make_interval(secs => ${RETRY_DELAY})` + }) + .where(inArray(notificationsTable.id, ids)) + return false + } + + /* + Sent. An entry the inbox does not show is closed as well: there is nowhere for it to be read, and + while it stayed unread it would keep absorbing events without ever emailing about them again. + */ + await WIKI.db + .update(notificationsTable) + .set({ + emailState: 'sent', + emailedAt: sql`now()`, + readAt: sql`CASE WHEN ${notificationsTable.inApp} THEN ${notificationsTable.readAt} ELSE now() END` + }) + .where(inArray(notificationsTable.id, ids)) + return true +} + +/** + * Send everything that is due, as far as the run is allowed to go, then ask for the next run at the + * moment the next email comes due. + */ +export async function sendPendingMail(signal: AbortSignal): Promise { + if (!mail.isConfigured) { + // -> Nothing is written as pending while mail is not configured, so this is only what was + // waiting when it was switched off. It waits on: configuring mail again sends it + return + } + /* + A fresh cache each run, for the translator. A worker thread outlives many runs and never hears the + `reloadLocales` event the request process does, so a cache it kept would go on writing mails from + whatever strings were installed when the thread started. + */ + WIKI.cache = new NodeCache({ checkperiod: 0 }) + await locales.getLocales() + const sites = await loadSites() + const batchSize = Number(WIKI.config.notifications?.mailBatchSize) || DEFAULT_BATCH_SIZE + + /* + One pass over a batch of recipients, not a loop until nothing is left: a pair another instance is + in the middle of sending stays due until it is done, and a loop would keep finding it. Whatever is + still due afterwards is the next run's — asked for below. + */ + let sent = 0 + for (const pair of await duePairs(batchSize)) { + if (signal.aborted) { + break + } + try { + if (await sendTo(pair, sites)) { + sent++ + } + } catch (err: any) { + WIKI.logger.warn(`Failed to process notification emails for ${pair.userId}: ${err.message}`) + } + } + if (sent > 0) { + WIKI.logger.info(`Sent ${sent} notification email(s).`) + } + + const [next] = await WIKI.db + .select({ at: min(notificationsTable.emailAfter) }) + .from(notificationsTable) + .where(inArray(notificationsTable.emailState, ['pending', 'sending'])) + if (next?.at) { + await enqueueOnce('sendNotificationMail', new Date(next.at)) + } +} diff --git a/backend/notifications/queue.ts b/backend/notifications/queue.ts new file mode 100644 index 000000000..1c46c1171 --- /dev/null +++ b/backend/notifications/queue.ts @@ -0,0 +1,72 @@ +import { and, eq, isNull, lte, or } from 'drizzle-orm' +import { toMerged } from 'es-toolkit/object' +import { v4 as uuid } from 'uuid' +import { jobs as jobsTable } from '../db/schema.ts' + +/** The two worker tasks notifications are delivered by. */ +export type NotificationTask = 'dispatchNotifications' | 'sendNotificationMail' + +/** + * Ask for a task to run, unless a run already waiting will do. + * + * One pending job is enough: each of these tasks drains everything that is due when it runs and asks + * for its own next run before it stops, so a second copy queued behind the first would find nothing + * left to do. A pending job that runs no later than `waitUntil` therefore counts. Two instances + * deciding at the same moment can still both add one, which costs a run that finds nothing — the + * rows themselves are claimed with `SKIP LOCKED`. + * + * Called from the request process and from the worker threads alike, which is why it is not just + * `scheduler.addJob`: a worker has no scheduler. There the row is written directly and picked up on + * the next poll (`scheduler.pollingCheck`) rather than announced, which costs a few seconds on + * something that waits minutes on purpose. + */ +export async function enqueueOnce(task: NotificationTask, waitUntil?: Date): Promise { + try { + const pending = await WIKI.db + .select({ id: jobsTable.id }) + .from(jobsTable) + .where( + and( + eq(jobsTable.task, task), + waitUntil + ? or(isNull(jobsTable.waitUntil), lte(jobsTable.waitUntil, waitUntil)) + : isNull(jobsTable.waitUntil) + ) + ) + .limit(1) + if (pending.length > 0) { + return + } + // -> Only the request process has a scheduler — see the note on the worker's WIKI in `worker.ts` + if (typeof WIKI.scheduler?.addJob === 'function') { + await WIKI.scheduler.addJob({ task, waitUntil, notify: !waitUntil }) + return + } + await WIKI.db.insert(jobsTable).values({ + id: uuid(), + task, + useWorker: true, + payload: {}, + maxRetries: WIKI.config.scheduler.maxRetries, + waitUntil, + createdBy: WIKI.INSTANCE_ID + }) + } catch (err: any) { + WIKI.logger.warn(`Failed to queue ${task}: ${err.message}`) + } +} + +/** + * Bring a worker thread's copy of the settings up to date. + * + * A worker reads the settings table once, when its thread first opens the database, and never hears + * the `reloadConfig` event the request process does — and a thread lives for many runs. Without + * this, mail configured after the thread started would never be seen (nothing would be emailed), + * and a changed email delay or batch size would be ignored until a restart. One small query per run. + */ +export async function refreshWorkerConfig(): Promise { + const stored = await WIKI.models.settings.getConfig() + if (stored) { + WIKI.config = toMerged(WIKI.config, stored) + } +} diff --git a/backend/notifications/recipients.ts b/backend/notifications/recipients.ts new file mode 100644 index 000000000..3003853d2 --- /dev/null +++ b/backend/notifications/recipients.ts @@ -0,0 +1,198 @@ +import { and, asc, eq, gt, inArray, sql } from 'drizzle-orm' +import { + approvalRules as approvalRulesTable, + pageWatching as watchingTable, + userGroups as userGroupsTable, + userNotificationPrefs as prefsTable, + users as usersTable +} from '../db/schema.ts' +import { approvals } from '../models/approvals.ts' +import type { ApprovalRule } from '../models/approvals.ts' +import type { NotificationEvent } from './types.ts' + +/** + * Where categories find their candidates. + * + * Every function here yields user ids in ascending order, a batch at a time, starting strictly after + * `after` — the contract `NotificationCategory.recipients` states, and what lets a fan-out that ran + * out of time carry on from the last id it wrote. Keyset rather than OFFSET because the audience of + * an opt-in category can be every account on the instance, and an offset re-reads everything before it. + * + * Imported by the categories, which run in a worker thread, so nothing here reaches for `WIKI.models`. + */ + +/** How many candidates a category hands the fan-out at once. */ +export const RECIPIENT_BATCH_SIZE = 1000 + +/** + * Page through a query that answers ids in ascending order, given where to start. + */ +async function* keyset( + after: string | null, + fetch: (after: string | null, limit: number) => Promise +): AsyncIterable { + let cursor = after + while (true) { + const ids = await fetch(cursor, RECIPIENT_BATCH_SIZE) + if (ids.length > 0) { + yield ids + } + if (ids.length < RECIPIENT_BATCH_SIZE) { + return + } + cursor = ids[ids.length - 1]! + } +} + +/** The same contract, over a list already in hand. */ +async function* fromList(ids: Iterable, after: string | null): AsyncIterable { + const sorted = [...new Set(ids)].filter((id) => !after || id > after).sort() + for (let i = 0; i < sorted.length; i += RECIPIENT_BATCH_SIZE) { + yield sorted.slice(i, i + RECIPIENT_BATCH_SIZE) + } +} + +/** + * Everybody watching the page an event is about. + * + * For a deletion that is the list the event was written with: the watch rows were removed with the + * page, so the outbox row is the only place they still exist. + */ +export function watchersOf( + event: NotificationEvent, + after: string | null +): AsyncIterable { + if (event.kind === 'page:delete') { + return fromList(event.recipients ?? [], after) + } + const pageId = event.data.page?.id + if (!pageId) { + return fromList([], after) + } + return keyset(after, async (cursor, limit) => { + const rows = await WIKI.db + .select({ userId: watchingTable.userId }) + .from(watchingTable) + .where( + cursor + ? and(eq(watchingTable.pageId, pageId), gt(watchingTable.userId, cursor)) + : eq(watchingTable.pageId, pageId) + ) + .orderBy(asc(watchingTable.userId)) + .limit(limit) + return rows.map((row) => row.userId) + }) +} + +/** + * Everybody who has turned a category on, on either channel. + * + * Off the partial index on `(category, userId) WHERE enabled`, which is the whole reason the + * preferences are a table: the users table is never scanned. + */ +export function optedInTo(category: string, after: string | null): AsyncIterable { + return keyset(after, async (cursor, limit) => { + const rows = await WIKI.db + .selectDistinct({ userId: prefsTable.userId }) + .from(prefsTable) + .where( + and( + eq(prefsTable.category, category), + eq(prefsTable.enabled, true), + ...(cursor ? [gt(prefsTable.userId, cursor)] : []) + ) + ) + .orderBy(asc(prefsTable.userId)) + .limit(limit) + return rows.map((row) => row.userId) + }) +} + +/** + * The members of the reviewer groups of every enabled rule covering the page. + * + * Only the groups a rule names. Holding `review:pages` at the page, or `manage:system`, makes + * someone able to answer the queue but not a recipient: on a large wiki every administrator would + * otherwise hear about every suggestion, and an administrator who wants to is put in a reviewer group + * like anybody else. + * + * Read from the table rather than from the approvals model's cache, which a worker thread does not + * have. `matchesPage` itself is pure, so it is the model's own. + */ +export async function* reviewersOf( + event: NotificationEvent, + after: string | null +): AsyncIterable { + const page = event.data.page + if (!page || !event.siteId) { + return + } + const rules = (await WIKI.db + .select({ + id: approvalRulesTable.id, + name: approvalRulesTable.name, + isEnabled: approvalRulesTable.isEnabled, + match: approvalRulesTable.match, + path: approvalRulesTable.path, + submitterGroups: approvalRulesTable.submitterGroups, + reviewerGroups: approvalRulesTable.reviewerGroups + }) + .from(approvalRulesTable) + .where( + and(eq(approvalRulesTable.siteId, event.siteId), eq(approvalRulesTable.isEnabled, true)) + )) as ApprovalRule[] + const groupIds = new Set() + for (const rule of rules) { + if (approvals.matchesPage(rule, { path: page.path, tags: page.tags ?? [] })) { + for (const id of rule.reviewerGroups ?? []) { + groupIds.add(id) + } + } + } + if (groupIds.size < 1) { + return + } + yield* keyset(after, async (cursor, limit) => { + const rows = await WIKI.db + .selectDistinct({ userId: userGroupsTable.userId }) + .from(userGroupsTable) + .where( + and( + inArray(userGroupsTable.groupId, [...groupIds]), + ...(cursor ? [gt(userGroupsTable.userId, cursor)] : []) + ) + ) + .orderBy(asc(userGroupsTable.userId)) + .limit(limit) + return rows.map((row) => row.userId) + }) +} + +/** + * The accounts the handles written in a comment point at. + * + * The handles were taken out of the text by the request that posted it, so all that is left is the + * lookup — on the folded form the unique index is on, so `@Ana` and `@ana` are one person. + */ +export async function* mentionedIn( + event: NotificationEvent, + after: string | null +): AsyncIterable { + const handles = event.data.mentionHandles ?? [] + if (handles.length < 1) { + return + } + const rows = await WIKI.db + .select({ id: usersTable.id }) + .from(usersTable) + .where(inArray(sql`lower(${usersTable.handle})`, handles)) + yield* fromList( + rows.map((row) => row.id), + after + ) +} + +/** A single id, or nobody. */ +export function only(id: string | null | undefined, after: string | null): AsyncIterable { + return fromList(id ? [id] : [], after) +} diff --git a/backend/notifications/snapshot.ts b/backend/notifications/snapshot.ts new file mode 100644 index 000000000..01bcd7d07 --- /dev/null +++ b/backend/notifications/snapshot.ts @@ -0,0 +1,26 @@ +import type { NotificationEvent } from './types.ts' + +/** + * What an entry remembers about an event, so that it can still be drawn after the page has been + * renamed, moved or deleted and the actor's account has gone. + * + * The page is copied without its tags — they are what access is checked against, not something an + * inbox shows — and `variants` is what lets a coalesced entry say "edited 4 times and moved" rather + * than only what the last event did. + */ +export function snapshotOf(event: NotificationEvent): Record { + const { page, variants, variant, previousPath, previousLocale, actorName, excerpt } = event.data + return { + ...(page && { + page: { id: page.id, title: page.title, path: page.path, locale: page.locale } + }), + variants: variants ?? [variant], + ...(previousPath !== undefined && { previousPath, previousLocale }), + actorName: actorName ?? null, + ...(excerpt !== undefined && { excerpt }), + ...(event.data.submissionId && { submissionId: event.data.submissionId }), + // -> Said in the entry when nobody did it by hand, which is the case where the actor named is + // the account the sync runs as rather than whoever made the change + ...(event.origin !== 'user' && { origin: event.origin }) + } +} diff --git a/backend/notifications/types.ts b/backend/notifications/types.ts new file mode 100644 index 000000000..08cc1bc0c --- /dev/null +++ b/backend/notifications/types.ts @@ -0,0 +1,157 @@ +import type { RulePageRef } from '../helpers/pageRules.ts' + +/** + * The events a notification can be about. Every one of them has a `notifications.emit()` call + * somewhere in the server; add the call in the same change that adds a key here. + */ +export const NOTIFICATION_EVENT_KINDS = [ + 'page:create', + 'page:edit', + 'page:rename', + 'page:delete', + 'submission:new', + 'comment:new', + 'comment:edit' +] as const + +export type NotificationEventKind = (typeof NOTIFICATION_EVENT_KINDS)[number] + +/** + * How an event came about, which a category may decline to fire for. + * + * `import` is anything adopted from outside — a storage target's import, a git pull, a backup being + * restored. `bulk` is one action that removes many pages at once — a folder, a tag. `user` is + * everything else: somebody doing one thing to one page. + */ +export const EVENT_ORIGINS = ['user', 'import', 'bulk'] as const + +export type EventOrigin = (typeof EVENT_ORIGINS)[number] + +/** The channels a notification can be delivered on, which is also what a preference row names. */ +export const NOTIFICATION_CHANNELS = ['inApp', 'email'] as const + +export type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number] + +/** A page as an event remembers it: what an entry displays, and what access is checked against. */ +export interface PageSnapshot { + id: string + title: string + path: string + locale: string + tags: string[] + publishState: string +} + +/** + * What an event carries, beyond who and where. + * + * One shape for every kind rather than one per kind: an event is written by the request process and + * read by a worker thread, through a JSONB column, so nothing about it is checked on the way anyway. + * Each field says which kinds set it. + */ +export interface NotificationEventData { + /** What happened: `edited`, `moved`, `deleted`, `created`, `new`, … Every kind sets it. */ + variant: string + /** Every page event, and the page a comment or a suggestion is on. */ + page?: PageSnapshot + /** `page:edit`: everything the save did, of which `variant` is the one that leads. */ + variants?: string[] + /** `page:rename`: where the page was before. */ + previousPath?: string + previousLocale?: string + /** + * Who did it, as they were called at the time. Also the only name a guest has — a guest comment or + * suggestion has no actor id. + */ + actorName?: string | null + /** `comment:*` */ + commentId?: string + /** `comment:new`: the thread it answers, and who started it. */ + parentId?: string | null + parentAuthorId?: string | null + /** `comment:*`: the first few lines, as typed. Markdown, never HTML. */ + excerpt?: string + /** `comment:*`: the handles written in it, lowercased. On an edit, only the ones that are new. */ + mentionHandles?: string[] + /** `submission:new` */ + submissionId?: string +} + +/** An event as the fan-out reads it back out of the outbox. */ +export interface NotificationEvent { + id: string + kind: NotificationEventKind + origin: EventOrigin + siteId: string | null + actorId: string | null + data: NotificationEventData + recipients: string[] | null +} + +/** What one entry in somebody's inbox is made of, as a category describes it for an event. */ +export interface NotificationEntry { + variant: string + pageId?: string | null + commentId?: string | null + /** The snapshot the inbox and the email are drawn from. */ + data: Record +} + +/** The headings the Profile screen groups the categories under. */ +export type NotificationSection = 'watching' | 'discussions' | 'reviews' | 'everything' + +/** + * One kind of notification: who it goes to, what it says, and how it is offered. + * + * A category is a file under `notifications/categories/` and a key in `NOTIFICATION_CATEGORIES`. + * Everything that is the same for every category — leaving the actor out, checking access, applying + * preferences, deduplicating, coalescing, mailing — is done once by the fan-out, so a category only + * says what is particular to it. + */ +export interface NotificationCategory { + /** Also the preference key and the translation prefix (`notifications.categories..*`). */ + key: string + section: NotificationSection + events: readonly NotificationEventKind[] + /** + * `site` for something that happened on a site, `instance` for something that belongs to none (an + * account being created). An instance entry has no `siteId`, ignores the site switch, and is shown + * in every site's inbox. + */ + scope: 'site' | 'instance' + /** Which origins this fires for. A category that leaves `import` out is silent during an import. */ + origins: readonly EventOrigin[] + defaults: Record + /** + * Which category wins when one event reaches the same person more than once — a comment that + * mentions somebody watching the page. The higher one is sent and the lower one is not. + */ + priority: number + /** Whether the Profile screen offers this category to someone. Every launch category is offered. */ + visibleTo?: (actor: { permissions: string[] }) => boolean + /** Whether this particular event concerns the category at all, e.g. a reply needs a parent. */ + appliesTo?: (event: NotificationEvent) => boolean + /** + * The people who may need to hear about it, in batches, **sorted by id and each batch strictly + * after `after`**: the last id of a batch is the cursor an interrupted fan-out carries on from. + * + * Candidates only. The fan-out takes the actor out, checks access and applies preferences. + */ + recipients: (event: NotificationEvent, after: string | null) => AsyncIterable + /** + * The page permission a recipient must hold, at the time they are told, on the page the event is + * about. Null when being a candidate is itself the grant — a reviewer is named by the rule. + */ + access: string | null + groupKey: (event: NotificationEvent) => string + entry: (event: NotificationEvent) => NotificationEntry +} + +/** The page an event is about, in the form an access rule is checked against. */ +export function rulePageOf(event: NotificationEvent): RulePageRef | null { + const page = event.data.page + if (!page || !event.siteId) { + return null + } + return { siteId: event.siteId, path: page.path, locale: page.locale, tags: page.tags ?? [] } +} diff --git a/backend/notifications/unsubscribe.ts b/backend/notifications/unsubscribe.ts new file mode 100644 index 000000000..37886426a --- /dev/null +++ b/backend/notifications/unsubscribe.ts @@ -0,0 +1,78 @@ +import crypto from 'node:crypto' +import { timingSafeCompare } from '../helpers/common.ts' + +/** + * The token an unsubscribe link carries: who it is for, and which categories the mail was about. + * + * `payload.signature`, both base64url, with an HMAC-SHA256 over the payload. Stateless on purpose — + * there is nothing to store and nothing to look up, so the one-click endpoint answers a mail client + * without a session, a cookie or a query beyond the write it makes. + * + * **It does not expire.** A link in a mail from last year has to work, and the only thing it can ever + * do is turn email off for one person, which is not something anybody gains by forging. + * + * **Signed with its own secret**, `notifications.unsubscribeSecret`, generated on boot by the startup + * checks (`core/startupChecks.ts`) wherever it is missing. Not `auth.secret`: that is rotated + * whenever an administrator invalidates every session, and every unsubscribe link already sitting in + * somebody's mailbox would stop working with it — the same reason `models/apiKeys.ts` gives the + * signing certificates a passphrase of their own. + */ + +/** The format version, so that a token written by a later shape can be told apart. */ +const TOKEN_VERSION = 1 + +export interface UnsubscribeClaim { + userId: string + categories: string[] +} + +function secret(): string { + const value = WIKI.config.notifications?.unsubscribeSecret + if (!value) { + throw new Error('ERR_NOTIFICATIONS_NO_SECRET') + } + return value +} + +function sign(payload: string): string { + return crypto.createHmac('sha256', secret()).update(payload).digest('base64url') +} + +export function createUnsubscribeToken({ userId, categories }: UnsubscribeClaim): string { + const payload = Buffer.from( + JSON.stringify({ v: TOKEN_VERSION, u: userId, c: [...new Set(categories)].sort() }) + ).toString('base64url') + return `${payload}.${sign(payload)}` +} + +/** + * What a token says, if it is genuine. + * + * @returns Null for anything that was not signed here, without saying which part was wrong + */ +export function readUnsubscribeToken(token: unknown): UnsubscribeClaim | null { + if (typeof token !== 'string' || token.length > 2048) { + return null + } + const [payload, signature, ...rest] = token.split('.') + if (!payload || !signature || rest.length > 0) { + return null + } + if (!timingSafeCompare(signature, sign(payload))) { + return null + } + try { + const claim = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) + if ( + claim?.v !== TOKEN_VERSION || + typeof claim.u !== 'string' || + !Array.isArray(claim.c) || + !claim.c.every((key: unknown) => typeof key === 'string') + ) { + return null + } + return { userId: claim.u, categories: claim.c } + } catch { + return null + } +} diff --git a/backend/tasks/simple/purge-notifications.ts b/backend/tasks/simple/purge-notifications.ts new file mode 100644 index 000000000..ea977a59e --- /dev/null +++ b/backend/tasks/simple/purge-notifications.ts @@ -0,0 +1,17 @@ +import type { TaskContext } from '../../core/scheduler.ts' + +export async function task(_payload: unknown, { signal }: TaskContext): Promise { + WIKI.logger.info('Purging expired notifications...') + + try { + const { notifications, events } = await WIKI.models.notifications.purge(signal) + + WIKI.logger.info( + `Purged ${notifications} expired notification(s) and ${events} processed event(s): [ COMPLETED ]` + ) + } catch (err: any) { + WIKI.logger.error('Purging expired notifications: [ FAILED ]') + WIKI.logger.error(err.message) + throw err + } +} diff --git a/backend/tasks/workers/dispatch-notifications.ts b/backend/tasks/workers/dispatch-notifications.ts new file mode 100644 index 000000000..0c198bec4 --- /dev/null +++ b/backend/tasks/workers/dispatch-notifications.ts @@ -0,0 +1,26 @@ +import { dispatchPending } from '../../notifications/fanout.ts' +import { refreshWorkerConfig } from '../../notifications/queue.ts' + +/** + * Turn whatever has happened into notifications for the people it concerns. + * + * Queued by `notifications.emit()` — debounced, so a burst of saves is one run rather than one each — + * and by the system schedule as a safety net, for an instance that went down with a run still owed. + * Each run drains the outbox (`notificationEvents`) as far as it can; see `notifications/fanout.ts`. + * + * In a worker thread because the audience of one event can be every account on the instance: an + * access check per group set and a few thousand rows written is time the event loop would otherwise + * spend not serving pages. A worker starts with nothing but config and a logger, so the database is + * opened here and everything the fan-out needs is imported by it rather than taken off `WIKI.models`. + */ +export async function task(): Promise { + await WIKI.ensureDb!() + await refreshWorkerConfig() + /* + The pool aborts a worker at `scheduler.taskTimeout` without telling the task, so the task keeps + its own deadline a little inside that: the fan-out stops between batches when it passes, writes + down how far each event got, and the next run carries on from there. + */ + const timeout = Number(WIKI.config.scheduler?.taskTimeout) || 300 + await dispatchPending(AbortSignal.timeout(Math.max(timeout - 30, timeout / 2) * 1000)) +} diff --git a/backend/tasks/workers/send-notification-mail.ts b/backend/tasks/workers/send-notification-mail.ts new file mode 100644 index 000000000..b626c250b --- /dev/null +++ b/backend/tasks/workers/send-notification-mail.ts @@ -0,0 +1,20 @@ +import { sendPendingMail } from '../../notifications/mailer.ts' +import { refreshWorkerConfig } from '../../notifications/queue.ts' + +/** + * Send the notification emails that are due. + * + * Queued for the moment the next email comes due — by the fan-out when it writes one, and by each + * run for whatever it leaves pending — and by the system schedule as a safety net. See + * `notifications/mailer.ts`. + * + * In a worker thread, like webhook deliveries, because what it does is wait on somebody else's + * server: a relay may take seconds to accept each mail, and a digest run goes through a hundred. + */ +export async function task(): Promise { + await WIKI.ensureDb!() + await refreshWorkerConfig() + // -> Its own deadline inside the pool's, as `dispatch-notifications.ts` explains + const timeout = Number(WIKI.config.scheduler?.taskTimeout) || 300 + await sendPendingMail(AbortSignal.timeout(Math.max(timeout - 30, timeout / 2) * 1000)) +} diff --git a/dev/specs/notifications.md b/dev/specs/notifications.md index 0293f9138..41ef388ea 100644 --- a/dev/specs/notifications.md +++ b/dev/specs/notifications.md @@ -1,8 +1,8 @@ # Notifications -**Status:** a proposal. Nothing here is implemented yet. What already exists and is built on (page -watching, the inbox shell, the disabled Profile entry) is listed in [§2](#2-what-exists-today). Table, -column, route and file names are proposals until the first phase lands. +**Status:** implemented — phases 1 to 4 of [§16](#16-suggested-order); push, digests and the admin +categories are still to come. [§2](#2-what-exists-today) describes what the system was built on, as it +stood before. Where building it changed the design, this document was changed with it. **Covers:** what a user is notified about, how they choose which notifications they get and how, how an event becomes an inbox entry and an email without slowing the request that caused it, and how new kinds of notification are added later. @@ -238,7 +238,7 @@ holds the two opt-in categories, with a note that they can be busy. - A **Stop all email** action at the foot of the screen turns off email for every category in one go. It is the same thing the unsubscribe page offers. -`GET /_api/users/me/notifications` answers with every visible category, its effective +`GET /_api/users/profile/notifications` answers with every visible category, its effective `{ inApp, email }`, its defaults, and `emailAvailable`. `PUT` takes the full map back, stores only the rows that differ from the defaults, and audits `updateNotificationPrefs` (kind `profile`) with the categories that changed. @@ -433,7 +433,7 @@ data jsonb -- snapshot: title, path, locale, actor name, excerpt, va count integer default 1 lastEventId uuid inApp boolean -emailState varchar(16) default 'none' -- none | pending | sent | failed | skipped +emailState varchar(16) default 'none' -- none | pending | sending | sent | failed | skipped emailAfter timestamp null emailedAt timestamp null readAt timestamp null @@ -442,7 +442,7 @@ updatedAt timestamp -- bumped on coalescing, the inbox's so INDEX (userId, updatedAt DESC) -- the inbox INDEX (userId) WHERE readAt IS NULL AND inApp -- the badge -INDEX (emailAfter) WHERE emailState = 'pending' -- the mail drain +INDEX (emailAfter) WHERE emailState IN ('pending', 'sending') -- the mail drain UNIQUE (userId, groupKey) WHERE readAt IS NULL -- §8.3 ``` @@ -506,7 +506,7 @@ No route declares `config.permissions`. Each comments `No route-level permission | `GET /_api/sites/:siteId/notifications?cursor=&unread=` | Keyset-paginated on `(updatedAt, id)`, 30 per page. Includes the site's rows plus instance-scoped ones (`siteId IS NULL`) | | `GET /_api/sites/:siteId/notifications/summary` | `{ unread, latestAt }`. `unread` is counted from `SELECT 1 … LIMIT 100` on the badge index and shown as "99+" above 99. Answers with an `ETag` built from the two values, so a poll that finds nothing new is a 304 | | `PUT /_api/sites/:siteId/notifications/:id/read` | Marks one entry read | -| `PUT /_api/sites/:siteId/notifications/read` | Marks everything read, or `{ pageId }` for one page's entries (mark-read-on-view, [§10.2](#102-cadence)) | +| `PUT /_api/sites/:siteId/notifications/read` | Marks everything read, or what matches every filter given: `ids`, `pageId`, `categories` (mark-read-on-view, [§10.2](#102-cadence)). An entry read before its email has gone cancels the email | | `DELETE /_api/sites/:siteId/notifications/:id` | Dismisses an entry | Marking read and dismissing are **not audited**. They are bookkeeping on one's own inbox, and a @@ -567,12 +567,18 @@ for a comment the excerpt, one action button linking to the target, and a footer notifications** (linking to `/_profile/notifications`) and **Unsubscribe**. No page content is ever included. -**The mail model has to run in a worker.** `mail.ts` currently reads `WIKI.sites[siteId]` (hostname, -title, primary locale) and goes through `WIKI.models.locales`, and a worker has neither. `send()` is -changed to take a `MailSiteContext` (`{ baseUrl, title, primaryLocale }`) from its caller. The -existing callers build it from `WIKI.sites`; the drain builds it from the `sites` rows it loads -once per run. The translator is checked to work with the worker's lazily opened database, and -imported directly the way `dispatch-webhook.ts` imports `hooks`. +**The mail model runs in a worker.** It used to read `WIKI.sites[siteId]` (title, primary locale) +and go through `WIKI.models.locales`, and a worker has neither. `send()` now takes a `MailSite` +(`{ name, primaryLocale }`) from its caller instead of a site id — the existing callers build one +with `mail.siteFor(siteId)`, the drain from the `sites` rows it reads once per run — and +`baseUrl()` accepts a `hostname` for a caller with nothing to look one up in. The translator is the +locales model imported directly. It caches through `WIKI.cache`, which a worker does not have, so the +drain gives each run a fresh one: a worker thread outlives many runs and never hears `reloadLocales`. + +**A worker's settings go stale.** A worker thread reads the settings table once, when it first opens +the database, and does not hear `reloadConfig`. Both notification tasks therefore re-read it at the +start of every run (`refreshWorkerConfig`), or mail configured after the thread started would never +be seen and a changed email delay would be ignored until a restart. ### 10.2 Cadence @@ -596,17 +602,19 @@ entry reads "edited 12 times". Sending an email per event would have meant thirt window alone about ten, since Bob's pace keeps opening new windows. **This only works if entries get read in the ordinary course of things**, or it becomes "one email, -ever". So **opening a page marks that page's `watchedPage` entries read**, and opening its Talk tab -marks its `watchedPageComment` entries read. The page payload already answers `isWatching`, so it -also answers `hasUnreadNotifications`, and the browser calls `PUT …/notifications/read { pageId }` -only when that is true. A page view therefore writes nothing unless there was something to clear. +ever". So **opening a page marks its `watchedPage` and `pageCreated` entries read**, and opening its +Talk tab marks its `watchedPageComment`, `commentReply` and `mention` entries read. The page payload +answers `viewer.unreadNotifications`, the categories the reader has unread entries in about that +page, and the browser calls `PUT …/notifications/read { pageId, categories }` only for the ones it has +just shown (`markSeen` in `stores/notifications.js`). A page view therefore writes nothing unless +there was something to clear. **An email-only row closes when it is emailed** ([§8.2](#82-notifications-the-inbox-and-the-email-queue)), because it has no inbox to be read in. Its recipient therefore gets one email per window rather than one ever. -**`emailAfter` is computed in exactly one place**, `emailAfterFor(user, now)` in the notifications -model, and both the fan-out insert and the coalesce go through it. Today it answers +**`emailAfter` is computed in exactly one place**, `emailAfterFor` in `notifications/fanout.ts`, and +both the fan-out insert and the coalesce go through it. Today it answers `now + emailDelay`. It is the seam where hourly and daily digests attach later ([§10.5](#105-leaving-room-for-hourly-and-daily-digests)). @@ -614,19 +622,23 @@ model, and both the fan-out insert and the coalesce go through it. Today it answ `tasks/workers/send-notification-mail.ts`: -1. Claims up to `notifications.mailBatchSize` (default 100) **(user, site) pairs** with rows due, - using `SELECT DISTINCT "userId", "siteId" … WHERE "emailState" = 'pending' AND "emailAfter" <= - now() … FOR UPDATE SKIP LOCKED`. -2. For each pair, loads the due rows (capped at 50; above that, the digest says "and N more" and +1. Picks up to `notifications.mailBatchSize` (default 100) **(user, site) pairs** with rows due. +2. For each pair, **claims** the due rows with one `UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP + LOCKED)` that marks them `sending` and makes `emailAfter` a ten-minute lease. No transaction is held + across the send: a worker thread has a single database connection, and a transaction kept open + while the mail is rendered leaves none for the queries rendering makes — the run waits on itself. + A run that dies mid-send leaves rows `sending` with a lease that lapses, and the next run takes + them again; at least once, rather than at most once. +3. Renders the claimed rows (capped at 50; above that, the digest says "and N more" and links to the inbox), drops them as `skipped` if the site has notifications switched off, renders one mail and sends it. **One mail per site, not one per user**, because a mail names the wiki it comes from and its links use that site's hostname. A user active on two sites gets two digests, each of which reads as coming from its own wiki. Instance-scoped entries (`siteId` null) go out - with the site the recipient last signed in on ([§11](#11-the-site-switch)). -3. Marks the rows `sent` with `emailedAt`, or `failed` after the scheduler's retries are used up. A - failure on one user does not stop the batch. -4. Runs itself again while due rows remain. The safety net is the same minute-by-minute schedule as - fan-out. + as coming from the first site ([§11](#11-the-site-switch)). +4. Marks the rows `sent` with `emailedAt`. A send that fails puts them back to `pending` ten minutes + on, and after three attempts marks them `failed`. A failure on one user does not stop the batch. +5. Makes one pass and stops, then asks for the next run at the moment the next email comes due. A + five-minute schedule is the safety net, for fan-out as well. Every send goes through the one cached transporter. Turning on nodemailer's `pool` option for this task is worth measuring once it exists. @@ -645,8 +657,9 @@ List-Unsubscribe-Post: List-Unsubscribe=One-Click **The token.** `base64url(payload) . base64url(HMAC-SHA256(payload))`, where the payload is `{ v: 1, u: userId, c: [categories in this mail] }`. It is signed with -**`notifications.unsubscribeSecret`**, a 32-byte value seeded at install beside the other secrets in -`models/settings.ts`. It is deliberately not `auth.secret`: that one is rotated whenever an +**`notifications.unsubscribeSecret`**, a 32-byte value generated on boot wherever it is missing, by +the startup checks in `core/startupChecks.ts` — so an installation from before notifications gets +one as well as a new one. It is deliberately not `auth.secret`: that one is rotated whenever an administrator invalidates every session, which would break every unsubscribe link already sitting in somebody's mailbox (the reason `apiKeys.ts` gives for keeping the certificate passphrase apart). The token **does not expire**. A link in an email from last year still has to work, and the only @@ -666,15 +679,17 @@ thing it can do is turn email off for one person. **`GET`** on the same URL **does nothing** but redirect to the frontend page `/_unsubscribe?t=`. Mail scanners fetch every link in a message, so a GET that acted would unsubscribe people who never -asked. This is the same principle as the welcome mail's verify link. That page names the categories, -offers **Unsubscribe from these** and **Stop all notification email** (the same POST with +asked. This is the same principle as the welcome mail's verify link. That page names the categories +(from `GET /_api/notifications/unsubscribe/info`, which says what a token would do and nothing about +whose it is), offers **Unsubscribe from these** and **Stop all notification email** (the same POST with `scope=all`), and links to the Profile screen. The footer link in the mail body points to this page. -**Deliverability checks, in the implementing PR:** +**Deliverability:** -- Gmail and Yahoo require the `List-Unsubscribe` headers to be covered by the DKIM signature. - Check which headers nodemailer's DKIM signing covers, and add these two if they are not among - them. +- Gmail and Yahoo require both `List-Unsubscribe` headers to be covered by the DKIM signature. + Nodemailer's default list covers `List-Unsubscribe` and not `List-Unsubscribe-Post`, so the + transport signs its own list (`DKIM_SIGNED_HEADERS` in `models/mail.ts`), which is the default plus + the second header. - Gmail honours one-click only for an `https` URL. A site served over plain `http` still gets the header, which works as an ordinary link elsewhere. Admin → Notifications warns about it ([§12.2](#122-admin--notifications)). @@ -747,7 +762,8 @@ Turned off for a site: **Instance-scoped categories ignore it.** None ships at launch, but the admin categories planned ([§13](#13-adding-a-category)) include some that belong to no site (a registration, since users are per instance). Their entries have `siteId = null`, appear in every site's inbox, and their email -links use the site the recipient last signed in on, falling back to the first site. +links use the first site for now. Nothing records which site a person last used; the first +instance-scoped category is the one to decide whether that is worth recording. --- @@ -760,10 +776,10 @@ the per-site switch ([§11](#11-the-site-switch)). | Key | Default | What | Where | | --- | ------- | ---- | ----- | -| `notifications.retentionDays` | 90 | Entries older than this are purged, read or not | Settings blob, edited on Admin → Notifications | +| `notifications.retentionDays` | 60 | Entries older than this are purged, read or not | Settings blob, edited on Admin → Notifications | | `notifications.emailDelay` | `3m` | The window of [§10.2](#102-cadence) | Same | | `notifications.mailBatchSize` | 100 | Users per mail run. Raise it for a mail server that can take more, lower it for one that throttles. The API refuses anything outside 1–1000 | Same | -| `notifications.unsubscribeSecret` | generated at install | Signs unsubscribe tokens ([§10.4](#104-one-click-unsubscribe-rfc-8058)) | Settings blob, never returned by the API | +| `notifications.unsubscribeSecret` | generated on boot when missing | Signs unsubscribe tokens ([§10.4](#104-one-click-unsubscribe-rfc-8058)) | Settings blob, never returned by the API | ### 12.2 Admin → Notifications @@ -866,12 +882,12 @@ single entry, which the quiet-until-read cadence ([§10.2](#102-cadence)) then h 1. **Foundation**: the three tables and their migration (`npm run db-generate -- --name=notifications`), the registry with the seven categories, `userNotificationPrefs` with its API, `ProfileNotifications.vue`, `features.notifications` in General → Features, and the - `notifications` settings blob with `unsubscribeSecret` seeded. + `notifications` settings blob, with `unsubscribeSecret` generated by a startup check. 2. **In-app**: `emit()` with origins, the debounce and the emit sites, the deletion snapshot, `dispatch-notifications.ts`, the inbox API, `stores/notifications.js` with polling, the `HeaderNav` badge, and `InboxMessages.vue`. Usable on its own: an instance with no mail configured is complete at this point. -3. **Email**: the `MailSiteContext` refactor, both templates and their strings, +3. **Email**: the `MailSite` refactor, both templates and their strings, `send-notification-mail.ts` with the quiet-until-read cadence and mark-read-on-view, the unsubscribe token, routes and `/_unsubscribe` page, the DKIM check, and Admin → Notifications ([§12.2](#122-admin--notifications)). diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index 7368bf64b..499456b0c 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -5,7 +5,7 @@ never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or removing an icon; `check-icons.mjs` fails the build if this drifts. - 290 icons. + 300 icons. */ export const BUNDLED_ICONS = { "la:angle-down": {"body":"","width":32,"height":32}, @@ -57,6 +57,7 @@ export const BUNDLED_ICONS = { "la:ellipsis-h": {"body":"","width":32,"height":32}, "la:ellipsis-v": {"body":"","width":32,"height":32}, "la:envelope": {"body":"","width":32,"height":32}, + "la:envelope-open": {"body":"","width":32,"height":32}, "la:eraser": {"body":"","width":32,"height":32}, "la:exclamation-circle": {"body":"","width":32,"height":32}, "la:exclamation-triangle": {"body":"","width":32,"height":32}, @@ -181,6 +182,7 @@ export const BUNDLED_ICONS = { "mdi:alert-box-outline": {"body":"","width":24,"height":24}, "mdi:arrow-right": {"body":"","width":24,"height":24}, "mdi:arrow-vertical-lock": {"body":"","width":24,"height":24}, + "mdi:at": {"body":"","width":24,"height":24}, "mdi:basketball": {"body":"","width":24,"height":24}, "mdi:bell": {"body":"","width":24,"height":24}, "mdi:bell-off-outline": {"body":"","width":24,"height":24}, @@ -196,6 +198,7 @@ export const BUNDLED_ICONS = { "mdi:chevron-left": {"body":"","width":24,"height":24}, "mdi:chevron-right": {"body":"","width":24,"height":24}, "mdi:clear-circle-multiple-outline": {"body":"","width":24,"height":24}, + "mdi:clipboard-check-outline": {"body":"","width":24,"height":24}, "mdi:clipboard-text-outline": {"body":"","width":24,"height":24}, "mdi:clock-outline": {"body":"","width":24,"height":24}, "mdi:close": {"body":"","width":24,"height":24}, @@ -203,16 +206,22 @@ export const BUNDLED_ICONS = { "mdi:code-json": {"body":"","width":24,"height":24}, "mdi:code-tags": {"body":"","width":24,"height":24}, "mdi:cog": {"body":"","width":24,"height":24}, + "mdi:comment-text-outline": {"body":"","width":24,"height":24}, "mdi:database-refresh": {"body":"","width":24,"height":24}, "mdi:dog": {"body":"","width":24,"height":24}, "mdi:drag-horizontal": {"body":"","width":24,"height":24}, + "mdi:email-open-multiple-outline": {"body":"","width":24,"height":24}, + "mdi:email-open-outline": {"body":"","width":24,"height":24}, "mdi:emoticon-outline": {"body":"","width":24,"height":24}, "mdi:emoticon-plus-outline": {"body":"","width":24,"height":24}, "mdi:exclamation": {"body":"","width":24,"height":24}, "mdi:eye": {"body":"","width":24,"height":24}, "mdi:eye-off": {"body":"","width":24,"height":24}, "mdi:eye-off-outline": {"body":"","width":24,"height":24}, + "mdi:file-document-edit-outline": {"body":"","width":24,"height":24}, "mdi:file-document-outline": {"body":"","width":24,"height":24}, + "mdi:file-plus-outline": {"body":"","width":24,"height":24}, + "mdi:file-remove-outline": {"body":"","width":24,"height":24}, "mdi:file-search-outline": {"body":"","width":24,"height":24}, "mdi:file-tree": {"body":"","width":24,"height":24}, "mdi:file-tree-outline": {"body":"","width":24,"height":24}, @@ -271,6 +280,7 @@ export const BUNDLED_ICONS = { "mdi:power": {"body":"","width":24,"height":24}, "mdi:radar": {"body":"","width":24,"height":24}, "mdi:redo-variant": {"body":"","width":24,"height":24}, + "mdi:reply-outline": {"body":"","width":24,"height":24}, "mdi:seed-plus-outline": {"body":"","width":24,"height":24}, "mdi:sort-alphabetical-descending-variant": {"body":"","width":24,"height":24}, "mdi:sort-numeric-descending-variant": {"body":"","width":24,"height":24}, diff --git a/frontend/src/boot/notifications.js b/frontend/src/boot/notifications.js new file mode 100644 index 000000000..6c709c81e --- /dev/null +++ b/frontend/src/boot/notifications.js @@ -0,0 +1,77 @@ +import { watch } from 'vue' + +import { useNotificationsStore } from '@/stores/notifications' +import { useSiteStore } from '@/stores/site' +import { useUserStore } from '@/stores/user' + +/** How often a visible tab asks, in milliseconds. */ +const POLL_INTERVAL = 60_000 + +/** The least time between two refreshes a navigation may cause, in milliseconds. */ +const NAVIGATION_THROTTLE = 15_000 + +/** + * When the notification badge is brought up to date. + * + * Polling, and deliberately only while somebody can see it: a hundred tabs left open in the + * background should not be a hundred requests a minute, so the timer runs while the tab is visible + * and a tab that becomes visible again asks at once. A navigation asks too, at most every fifteen + * seconds, which is when somebody is most likely to look at the badge. + * + * This is the only part that would change if push is added: a socket that says "something changed" + * calls the same `refresh()`, and the timer here relaxes to a slow safety net while it is open. + */ +export function initializeNotifications(router) { + const notificationsStore = useNotificationsStore() + const siteStore = useSiteStore() + const userStore = useUserStore() + + let timer = null + let lastRefreshAt = 0 + + const refresh = () => { + lastRefreshAt = Date.now() + notificationsStore.refresh() + } + + const stop = () => { + clearInterval(timer) + timer = null + } + + const start = () => { + stop() + if (notificationsStore.isActive && document.visibilityState === 'visible') { + timer = setInterval(refresh, POLL_INTERVAL) + } + } + + // -> Signing in or out, or the site config arriving, changes whether there is anything to ask about + watch( + () => [userStore.authenticated, userStore.id, siteStore.id, siteStore.features.notifications], + () => { + notificationsStore.reset() + if (notificationsStore.isActive) { + refresh() + } + start() + } + ) + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + if (notificationsStore.isActive) { + refresh() + } + start() + } else { + stop() + } + }) + + router.afterEach(() => { + if (notificationsStore.isActive && Date.now() - lastRefreshAt > NAVIGATION_THROTTLE) { + refresh() + } + }) +} diff --git a/frontend/src/components/HeaderActionsMenu.vue b/frontend/src/components/HeaderActionsMenu.vue index 43e8f4f99..4c3f8c36d 100644 --- a/frontend/src/components/HeaderActionsMenu.vue +++ b/frontend/src/components/HeaderActionsMenu.vue @@ -6,6 +6,8 @@ dense icon="la:ellipsis-v" aria-label="More Actions"> + + + + {{ inboxLabel }} { const isSearchCollapsed = computed(() => !isAtLeastSm.value) +/** The inbox button's name, which says how many are unread so that a screen reader hears the badge. */ +const inboxLabel = computed(() => + notificationsStore.badge + ? t('notifications.unreadLabel', { title: t('inbox.title'), count: notificationsStore.badge }) + : t('inbox.title') +) + /** * Below 900px, where the five action buttons become the one overflow menu. * diff --git a/frontend/src/components/shared/WToggle.vue b/frontend/src/components/shared/WToggle.vue index b8d897400..42efc52f9 100644 --- a/frontend/src/components/shared/WToggle.vue +++ b/frontend/src/components/shared/WToggle.vue @@ -6,7 +6,10 @@ :aria-label="label ? undefined : ariaLabel" :disabled="isDisabled" class="w-toggle w-unstyled inline-flex flex-nowrap items-center gap-2 rounded outline-offset-2 focus-visible:outline-2" - :class="isDisabled ? 'w-toggle--disabled pointer-events-none' : 'cursor-pointer'" + :class="[ + isDisabled ? 'w-toggle--disabled pointer-events-none' : 'cursor-pointer', + { 'w-toggle--dark': dark } + ]" @click="toggle"> + + + + + {{ t('admin.notifications.title') }} + {{ navItem.label }} + + + @@ -36,6 +39,7 @@ import { useRouter, useRoute } from 'vue-router' import { useMeta } from '@/composables/meta' +import { useNotificationsStore } from '@/stores/notifications' import { useSiteStore } from '@/stores/site' import { useUserStore } from '@/stores/user' @@ -52,6 +56,7 @@ import MainOverlayDialog from '@/components/MainOverlayDialog.vue' // STORES +const notificationsStore = useNotificationsStore() const siteStore = useSiteStore() const userStore = useUserStore() @@ -88,7 +93,8 @@ const sidenav = computed(() => [ { key: 'messages', label: t('inbox.inbox'), - icon: 'mdi:inbox-full' + icon: 'mdi:inbox-full', + badge: notificationsStore.badge }, { key: 'watching', diff --git a/frontend/src/layouts/ProfileLayout.vue b/frontend/src/layouts/ProfileLayout.vue index bdc353ac8..cbbeb4ce8 100644 --- a/frontend/src/layouts/ProfileLayout.vue +++ b/frontend/src/layouts/ProfileLayout.vue @@ -147,8 +147,7 @@ const sidenav = computed(() => [ { key: 'notifications', label: t('profile.notifications'), - icon: 'la:bell', - disabled: true + icon: 'la:bell' }, // { // key: 'pages', diff --git a/frontend/src/main.js b/frontend/src/main.js index 4b775bc7c..1f6057dff 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -8,6 +8,7 @@ import { initializeExternals } from './boot/externals' import { initializeI18n } from './boot/i18n' import { initializeIconify } from './boot/iconify' import { initializeMonaco } from './boot/monaco' +import { initializeNotifications } from './boot/notifications' import { initializeTemporal } from './boot/temporal' import { initializeHairlines } from './helpers/hairline' @@ -43,6 +44,7 @@ initializeIconify() initializeMonaco() initializeExternals(router, store) initializeI18n(app, store) +initializeNotifications(router) // The server's copy of the page, for clients that never get this far -- see // `backend/helpers/appShell.ts`. It has done its job by now, and Vue is about to draw the real thing. document.getElementById('wiki-prerender')?.remove() diff --git a/frontend/src/pages/AdminGeneral.vue b/frontend/src/pages/AdminGeneral.vue index a6d6c77e2..8633c598c 100644 --- a/frontend/src/pages/AdminGeneral.vue +++ b/frontend/src/pages/AdminGeneral.vue @@ -223,6 +223,32 @@ + + + + {{ t(`admin.general.allowLastEditedBy`) }} + {{ t(`admin.general.allowLastEditedByHint`) }} + + + + + + + + + + {{ t(`admin.general.allowNotifications`) }} + {{ t(`admin.general.allowNotificationsHint`) }} + + + + + + @@ -240,19 +266,6 @@ - - - - {{ t(`admin.general.allowLastEditedBy`) }} - {{ t(`admin.general.allowLastEditedByHint`) }} - - - - - - @@ -701,6 +714,7 @@ function defaultConfig() { ratingsMode: 'off', comments: true, lastEditedBy: true, + notifications: true, reasonForChange: 'required' }, discoverable: false, @@ -842,6 +856,7 @@ async function save() { collaborativeEditing: state.config.features?.collaborativeEditing ?? false, comments: state.config.features?.comments ?? true, lastEditedBy: state.config.features?.lastEditedBy ?? true, + notifications: state.config.features?.notifications ?? true, ratingsMode: state.config.features?.ratingsMode ?? 'off', reasonForChange: state.config.features?.reasonForChange ?? 'required', search: state.config.features?.search ?? false diff --git a/frontend/src/pages/AdminNotifications.vue b/frontend/src/pages/AdminNotifications.vue new file mode 100644 index 000000000..a2b10310f --- /dev/null +++ b/frontend/src/pages/AdminNotifications.vue @@ -0,0 +1,310 @@ + + + diff --git a/frontend/src/pages/InboxMessages.vue b/frontend/src/pages/InboxMessages.vue index e9ed69b8d..ea972f6fd 100644 --- a/frontend/src/pages/InboxMessages.vue +++ b/frontend/src/pages/InboxMessages.vue @@ -1,16 +1,216 @@ + + diff --git a/frontend/src/pages/Index.vue b/frontend/src/pages/Index.vue index 4ae8bdc8d..df3caaf81 100644 --- a/frontend/src/pages/Index.vue +++ b/frontend/src/pages/Index.vue @@ -547,6 +547,7 @@ import { parseBlog } from '@/helpers/pageBlog' import { useCommonStore } from '@/stores/common' import { useEditorStore } from '@/stores/editor' import { useFlagsStore } from '@/stores/flags' +import { useNotificationsStore } from '@/stores/notifications' import { usePageStore } from '@/stores/page' import { useSiteStore } from '@/stores/site' import { useUserStore } from '@/stores/user' @@ -636,6 +637,7 @@ const editorComponents = { const commonStore = useCommonStore() const editorStore = useEditorStore() const flagsStore = useFlagsStore() +const notificationsStore = useNotificationsStore() const pageStore = usePageStore() const siteStore = useSiteStore() const userStore = useUserStore() @@ -1071,6 +1073,20 @@ watch( } ) +/* + Reading the page is reading what the reader was told about it: its content as soon as it is on + screen, its discussion once the Talk tab is open. This is what lets an email go out once and then + stay quiet until it is read, without anybody having to visit the inbox. It writes nothing unless the + page came with unread entries of that kind. +*/ +watch( + () => [pageStore.id, pageStore.unreadNotifications, activeView.value], + () => { + notificationsStore.markSeen({ inDiscussion: activeView.value === 'talk' }) + }, + { immediate: true } +) + /* A protected page asks for its password the moment it arrives: the reader followed a link to read it, and making them press a button first would only add a step. Keyed on the page rather than on the diff --git a/frontend/src/pages/ProfileNotifications.vue b/frontend/src/pages/ProfileNotifications.vue new file mode 100644 index 000000000..584a892fb --- /dev/null +++ b/frontend/src/pages/ProfileNotifications.vue @@ -0,0 +1,213 @@ + + + diff --git a/frontend/src/pages/Unsubscribe.vue b/frontend/src/pages/Unsubscribe.vue new file mode 100644 index 000000000..a08245e5f --- /dev/null +++ b/frontend/src/pages/Unsubscribe.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/frontend/src/router/routes.js b/frontend/src/router/routes.js index e82adcf52..5cbb5772e 100644 --- a/frontend/src/router/routes.js +++ b/frontend/src/router/routes.js @@ -20,7 +20,7 @@ const routes = [ beforeEnter: async (to) => { const pageStore = usePageStore() try { - return await pageStore.pageAlias(to.params.alias) + return { path: await pageStore.pageAlias(to.params.alias), hash: to.hash } } catch { return '/_error/notfound' } @@ -32,7 +32,9 @@ const routes = [ beforeEnter: async (to) => { const pageStore = usePageStore() try { - return await pageStore.pageById(to.params.pageId) + // -> The fragment survives the redirect: `#talk` is how a notification about a comment + // opens the page on its discussion + return { path: await pageStore.pageById(to.params.pageId), hash: to.hash } } catch { return '/_error/notfound' } @@ -46,9 +48,19 @@ const routes = [ { path: 'info', component: () => import('@/pages/ProfileInfo.vue') }, { path: 'avatar', component: () => import('@/pages/ProfileAvatar.vue') }, { path: 'auth', component: () => import('@/pages/ProfileAuth.vue') }, - { path: 'groups', component: () => import('@/pages/ProfileGroups.vue') } + { path: 'groups', component: () => import('@/pages/ProfileGroups.vue') }, + { path: 'notifications', component: () => import('@/pages/ProfileNotifications.vue') } ] }, + /* + Where an unsubscribe link in a notification email lands. Outside the profile because it needs no + session -- the token in the link is what says whose email it is. + */ + { + path: '/_unsubscribe', + component: () => import('@/layouts/AuthLayout.vue'), + children: [{ path: '', component: () => import('@/pages/Unsubscribe.vue') }] + }, { path: '/_inbox', component: () => import('@/layouts/InboxLayout.vue'), @@ -117,6 +129,7 @@ const routes = [ { path: 'mail', component: () => import('@/pages/AdminMail.vue') }, { path: 'mcp', component: () => import('@/pages/AdminMcp.vue') }, { path: 'metrics', component: () => import('@/pages/AdminMetrics.vue') }, + { path: 'notifications', component: () => import('@/pages/AdminNotifications.vue') }, { path: 'rendering', component: () => import('@/pages/AdminRendering.vue') }, { path: 'scheduler', component: () => import('@/pages/AdminScheduler.vue') }, { path: 'search', component: () => import('@/pages/AdminSearch.vue') }, diff --git a/frontend/src/stores/notifications.js b/frontend/src/stores/notifications.js new file mode 100644 index 000000000..c16fd44c6 --- /dev/null +++ b/frontend/src/stores/notifications.js @@ -0,0 +1,162 @@ +import { defineStore } from 'pinia' + +import { usePageStore } from './page' +import { useSiteStore } from './site' +import { useUserStore } from './user' + +/** What reading a page's content counts as having seen. */ +const CONTENT_CATEGORIES = ['watchedPage', 'pageCreated'] + +/** What reading a page's discussion counts as having seen. */ +const DISCUSSION_CATEGORIES = ['watchedPageComment', 'commentReply', 'mention'] + +/** + * The signed-in reader's notifications on this site: the badge count, and the inbox once it is opened. + * + * `refresh()` is the one way the count is brought up to date, and what decides WHEN it is called lives + * outside this store, in `boot/notifications.js`. That split is the point: today a timer and a few + * events call it; a push channel added later will only ever say "something changed" and call the same + * method, so the state a pushed update leaves behind is exactly the state a poll would have — the + * summary endpoint stays the single source of truth either way. + */ +export const useNotificationsStore = defineStore('notifications', { + state: () => ({ + /** Unread entries, counted by the server no further than 100. */ + unread: 0, + /** When the inbox last changed, which is how a refresh knows the list it holds is stale. */ + latestAt: null, + /** The loaded part of the inbox, newest activity first. */ + entries: [], + /** The cursor of the next page, or null at the end. */ + next: null, + /** Whether the inbox has been opened, so that a refresh knows there is a list to keep current. */ + listLoaded: false, + listLoading: false, + /** Only unread entries in the list. */ + unreadOnly: false + }), + getters: { + /** What the badge says: nothing at zero, `99+` once the count is capped. */ + badge: (state) => (state.unread > 99 ? '99+' : state.unread > 0 ? String(state.unread) : ''), + /** Whether there is anything to poll for: a signed-in reader, on a site that has notifications. */ + isActive: () => { + const userStore = useUserStore() + const siteStore = useSiteStore() + return Boolean(userStore.authenticated && siteStore.id && siteStore.features.notifications) + } + }, + actions: { + /** + * Bring the count up to date, and the list too if one is showing and the inbox has moved. + * + * The request revalidates against the ETag the server sent last time — the browser does that for + * a `no-cache` response on its own — so a poll that finds nothing new costs a 304 and no body. + */ + async refresh() { + if (!this.isActive) { + this.reset() + return + } + const siteStore = useSiteStore() + try { + const summary = await API_CLIENT.get(`sites/${siteStore.id}/notifications/summary`).json() + const moved = summary.latestAt !== this.latestAt + this.unread = summary.unread ?? 0 + this.latestAt = summary.latestAt ?? null + if (moved && this.listLoaded) { + await this.loadList() + } + } catch (err) { + // -> A missed poll is not worth interrupting anybody for: the next one tries again + console.warn(`Could not refresh notifications: ${err.message}`) + } + }, + /** + * Load the inbox from the top, or the next page of it. + */ + async loadList({ append = false } = {}) { + const siteStore = useSiteStore() + if (append && !this.next) { + return + } + this.listLoading = true + try { + const resp = await API_CLIENT.get(`sites/${siteStore.id}/notifications`, { + searchParams: { + ...(append && this.next ? { cursor: this.next } : {}), + ...(this.unreadOnly ? { unread: true } : {}) + } + }).json() + this.entries = append ? [...this.entries, ...resp.entries] : resp.entries + this.next = resp.next ?? null + this.listLoaded = true + } finally { + this.listLoading = false + } + }, + /** + * Mark entries read — particular ones, everything about a page, or everything. + * + * The list is updated in place rather than reloaded, since what changed is known; the count is + * asked for again, since the server is what knows it. + * + * @param {{ ids?: string[], pageId?: string, categories?: string[] }} filter + */ + async markRead(filter = {}) { + const siteStore = useSiteStore() + await API_CLIENT.put(`sites/${siteStore.id}/notifications/read`, { json: filter }) + const matches = (entry) => + (!filter.ids || filter.ids.includes(entry.id)) && + (!filter.pageId || entry.pageId === filter.pageId) && + (!filter.categories || filter.categories.includes(entry.category)) + for (const entry of this.entries) { + if (matches(entry)) { + entry.isRead = true + } + } + await this.refresh() + }, + /** + * Mark what the reader has now seen of the page in front of them as read: its content on opening + * it, its discussion on opening the Talk tab. + * + * This is what makes "emailed once, then quiet until read" work without anybody visiting the + * inbox — reading the page IS reading the notification about it. Nothing is written unless the + * page came with unread entries of the kind just seen, which is the page payload's + * `unreadNotifications`, so an ordinary page view costs no request. + */ + async markSeen({ inDiscussion = false } = {}) { + const pageStore = usePageStore() + const unread = pageStore.unreadNotifications ?? [] + const seen = unread.filter( + (category) => + CONTENT_CATEGORIES.includes(category) || + (inDiscussion && DISCUSSION_CATEGORIES.includes(category)) + ) + if (!pageStore.id || seen.length < 1 || !this.isActive) { + return + } + // -> Taken off first, so that whatever watches this does not ask a second time + pageStore.unreadNotifications = unread.filter((category) => !seen.includes(category)) + try { + await this.markRead({ pageId: pageStore.id, categories: seen }) + } catch (err) { + console.warn(`Could not mark notifications read: ${err.message}`) + } + }, + async dismiss(id) { + const siteStore = useSiteStore() + await API_CLIENT.delete(`sites/${siteStore.id}/notifications/${id}`) + this.entries = this.entries.filter((entry) => entry.id !== id) + await this.refresh() + }, + /** Forget everything: a different reader, a different site, or a site without notifications. */ + reset() { + this.unread = 0 + this.latestAt = null + this.entries = [] + this.next = null + this.listLoaded = false + } + } +}) diff --git a/frontend/src/stores/page.js b/frontend/src/stores/page.js index e1132e7fa..ac290fe56 100644 --- a/frontend/src/stores/page.js +++ b/frontend/src/stores/page.js @@ -176,6 +176,12 @@ export const usePageStore = defineStore('page', { * a watch belongs to an account, which is what a notification would eventually be sent to. */ isWatching: false, + /** + * The notification categories this reader has unread entries in about this page. Opening the page + * marks the ones about its content read, and opening its Talk tab the ones about its discussion — + * see `markSeen` in the notifications store. + */ + unreadNotifications: [], /** * How readers have rated this page, as `{ mode, count, average, up, down }` on the site's current * scale, or null when ratings are off for the site or for the page. @@ -436,6 +442,7 @@ export const usePageStore = defineStore('page', { canReview: viewer.canReview === true, pendingSubmissions: viewer.pendingSubmissions ?? [], isWatching: viewer.isWatching === true, + unreadNotifications: viewer.unreadNotifications ?? [], viewerRating: viewer.rating ?? 0 }) }, @@ -484,6 +491,7 @@ export const usePageStore = defineStore('page', { canReview: false, pendingSubmissions: [], isWatching: false, + unreadNotifications: [], rating: null, viewerRating: 0, blog: null, diff --git a/frontend/src/stores/site.js b/frontend/src/stores/site.js index 293209507..0a5219ccf 100644 --- a/frontend/src/stores/site.js +++ b/frontend/src/stores/site.js @@ -120,6 +120,9 @@ export const useSiteStore = defineStore('site', { browse: false, collaborativeEditing: false, lastEditedBy: true, + // -> On, for the reason `backlinks` is: the server reads a missing key as on, and the badge + // must not disagree with it + notifications: true, ratingsMode: 'off', reasonForChange: 'required', search: false