mirror of https://github.com/requarks/wiki
parent
ba12798381
commit
9e36597965
@ -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<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, any>) => Record<string, any> | 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<string, any>
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
@ -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<EventOrigin>()
|
||||
|
||||
/** 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<NotificationChannel, boolean>
|
||||
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<string, unknown>
|
||||
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<T>(origin: EventOrigin, work: () => Promise<T>): Promise<T> {
|
||||
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<void> {
|
||||
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<NotificationPref[]> {
|
||||
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<string, Partial<Record<NotificationChannel, boolean>>>
|
||||
): Promise<string[]> {
|
||||
const current = await this.getPrefs(userId, actor)
|
||||
const changed = new Set<string>()
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<UnsubscribeClaim | null> {
|
||||
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<string, unknown>
|
||||
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<string | null>`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<number> {
|
||||
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<boolean> {
|
||||
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<string[]> {
|
||||
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, any>): 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<string, any>): Promise<boolean> {
|
||||
const previous = WIKI.config.notifications
|
||||
const picked: Record<string, any> = {}
|
||||
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<string | null>`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<number> => {
|
||||
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()
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
@ -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<string, GroupAccess>()
|
||||
/**
|
||||
* 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<string, boolean>()
|
||||
emailDelaySeconds = DEFAULT_EMAIL_DELAY
|
||||
mailConfigured = false
|
||||
|
||||
async load(): Promise<void> {
|
||||
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<ClaimedEvent[]> {
|
||||
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<Recipient[]> {
|
||||
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<Map<string, { inApp: boolean; email: boolean }>> {
|
||||
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<Set<string>> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<NotificationEvent> {
|
||||
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<FanOutCursor | null> {
|
||||
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<void> {
|
||||
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')
|
||||
}
|
||||
}
|
||||
@ -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.<key>.title` / `.description`, an entry `notifications.messages.<key>.*`
|
||||
* and a mail `mail.notification.<key>.*` — 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<string, NotificationCategory>
|
||||
|
||||
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)
|
||||
}
|
||||
@ -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<string, any>
|
||||
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<Map<string, SiteInfo>> {
|
||||
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<string, any>
|
||||
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<DueRow[]> {
|
||||
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<Set<string>> {
|
||||
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<string>()
|
||||
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<void> {
|
||||
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<string, SiteInfo>
|
||||
): Promise<boolean> {
|
||||
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<string, any>)?.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<void> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@ -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<void> {
|
||||
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<void> {
|
||||
const stored = await WIKI.models.settings.getConfig()
|
||||
if (stored) {
|
||||
WIKI.config = toMerged(WIKI.config, stored)
|
||||
}
|
||||
}
|
||||
@ -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<string[]>
|
||||
): AsyncIterable<string[]> {
|
||||
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<string>, after: string | null): AsyncIterable<string[]> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
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<string>()
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
return fromList(id ? [id] : [], after)
|
||||
}
|
||||
@ -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<string, unknown> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@ -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<string, unknown>
|
||||
}
|
||||
|
||||
/** 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>.*`). */
|
||||
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<NotificationChannel, boolean>
|
||||
/**
|
||||
* 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<string[]>
|
||||
/**
|
||||
* 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 ?? [] }
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
import type { TaskContext } from '../../core/scheduler.ts'
|
||||
|
||||
export async function task(_payload: unknown, { signal }: TaskContext): Promise<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -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<void> {
|
||||
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))
|
||||
}
|
||||
@ -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<void> {
|
||||
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))
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,310 @@
|
||||
<template>
|
||||
<w-page class="admin-notifications">
|
||||
<div class="flex flex-wrap p-4 items-center">
|
||||
<div class="flex-none">
|
||||
<img
|
||||
class="admin-icon animated fadeInLeft"
|
||||
src="/_assets/icons/fluent-topic-push-notification.svg" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 pl-4">
|
||||
<div class="text-h5 admin-page-title animated fadeInLeft">
|
||||
{{ t('admin.notifications.title') }}
|
||||
</div>
|
||||
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
|
||||
{{ t('admin.notifications.subtitle') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<w-btn
|
||||
class="mr-2 ml-4 acrylic-btn"
|
||||
icon="la:question-circle"
|
||||
flat
|
||||
color="grey"
|
||||
:aria-label="t(`common.actions.viewDocs`)"
|
||||
:href="siteStore.docsBase + `/admin/notifications`"
|
||||
target="_blank">
|
||||
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
|
||||
</w-btn>
|
||||
<w-btn
|
||||
class="acrylic-btn mr-2"
|
||||
icon="la:redo-alt"
|
||||
flat
|
||||
color="secondary"
|
||||
:loading="state.loading > 0"
|
||||
:aria-label="t(`common.actions.refresh`)"
|
||||
@click="refresh">
|
||||
<w-tooltip>{{ t(`common.actions.refresh`) }}</w-tooltip>
|
||||
</w-btn>
|
||||
<w-btn
|
||||
unelevated
|
||||
icon="mdi:check"
|
||||
:label="t(`common.actions.apply`)"
|
||||
color="secondary"
|
||||
:loading="state.loading > 0"
|
||||
@click="save" />
|
||||
</div>
|
||||
</div>
|
||||
<w-separator inset />
|
||||
<div class="grid grid-cols-12 p-4 gap-4">
|
||||
<div class="col-span-12 lg:col-span-6">
|
||||
<!-- ----------------------- -->
|
||||
<!-- Settings -->
|
||||
<!-- ----------------------- -->
|
||||
<w-card class="pb-2">
|
||||
<w-card-header>{{ t('admin.notifications.settings') }}</w-card-header>
|
||||
<w-item>
|
||||
<blueprint-icon icon="timer" top />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.notifications.emailDelay`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.notifications.emailDelayHint`) }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section style="flex: 0 0 160px">
|
||||
<w-input
|
||||
v-model="state.config.emailDelay"
|
||||
outlined
|
||||
dense
|
||||
placeholder="3m"
|
||||
:aria-label="t(`admin.notifications.emailDelay`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="historical" top />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.notifications.retentionDays`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.notifications.retentionDaysHint`) }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section style="flex: 0 0 160px">
|
||||
<w-input
|
||||
v-model="state.config.retentionDays"
|
||||
type="number"
|
||||
outlined
|
||||
dense
|
||||
:suffix="t(`admin.notifications.days`)"
|
||||
:aria-label="t(`admin.notifications.retentionDays`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="email" top />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.notifications.mailBatchSize`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.notifications.mailBatchSizeHint`) }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section style="flex: 0 0 160px">
|
||||
<w-input
|
||||
v-model="state.config.mailBatchSize"
|
||||
type="number"
|
||||
outlined
|
||||
dense
|
||||
:aria-label="t(`admin.notifications.mailBatchSize`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-card>
|
||||
</div>
|
||||
<div class="col-span-12 lg:col-span-6">
|
||||
<!-- ----------------------- -->
|
||||
<!-- Status -->
|
||||
<!-- ----------------------- -->
|
||||
<w-card class="pb-2">
|
||||
<w-card-header>
|
||||
{{ t('admin.notifications.status') }}
|
||||
<template #hint>{{ t('admin.notifications.statusHint') }}</template>
|
||||
</w-card-header>
|
||||
<w-item>
|
||||
<blueprint-icon
|
||||
icon="email-open"
|
||||
:indicator="state.status.isMailConfigured ? `positive` : `negative`" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.notifications.mail`) }}</w-item-label>
|
||||
<w-item-label caption>
|
||||
{{
|
||||
state.status.isMailConfigured
|
||||
? t(`admin.notifications.mailConfigured`)
|
||||
: t(`admin.notifications.mailNotConfigured`)
|
||||
}}
|
||||
</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
icon="la:arrow-circle-right"
|
||||
color="primary"
|
||||
:label="t(`admin.notifications.configureMail`)"
|
||||
to="/_admin/mail" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="workflow" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.notifications.backlog`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.notifications.backlogHint`) }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<div class="text-right">
|
||||
<div class="text-h6">{{ state.status.pendingEvents }}</div>
|
||||
<div v-if="state.status.oldestPendingEventAt" class="text-caption text-grey">
|
||||
{{
|
||||
t('admin.notifications.oldest', {
|
||||
date: relativeDate(state.status.oldestPendingEventAt)
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="received" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.notifications.emails`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.notifications.emailsHint`) }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<div class="flex gap-6 text-center">
|
||||
<div>
|
||||
<div class="text-h6">{{ state.status.emailsPending }}</div>
|
||||
<div class="text-caption text-grey">{{ t('admin.notifications.pending') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-h6 text-positive">{{ state.status.emailsSent24h }}</div>
|
||||
<div class="text-caption text-grey">{{ t('admin.notifications.sent') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="text-h6"
|
||||
:class="state.status.emailsFailed24h > 0 ? `text-negative` : ``">
|
||||
{{ state.status.emailsFailed24h }}
|
||||
</div>
|
||||
<div class="text-caption text-grey">{{ t('admin.notifications.failed') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<template v-for="warning of state.status.warnings" :key="warning">
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<w-item-section>
|
||||
<div class="text-caption text-deep-orange flex items-start">
|
||||
<w-icon class="mr-1 mt-px" name="la:exclamation-triangle" size="xs" />
|
||||
<span>{{ t(`admin.notifications.warnings.${warning}`) }}</span>
|
||||
</div>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</template>
|
||||
</w-card>
|
||||
</div>
|
||||
</div>
|
||||
</w-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { loading } from '@/composables/loading'
|
||||
import { useMeta } from '@/composables/meta'
|
||||
import { notify } from '@/composables/notify'
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
import { relativeDate } from '@/helpers/datetime'
|
||||
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
/**
|
||||
* Admin → Notifications: the instance-wide settings of the notification system, and how delivery is
|
||||
* doing. Whether a SITE has notifications is under its General → Features; what each person receives
|
||||
* is theirs to choose, under Profile → Notifications.
|
||||
*/
|
||||
|
||||
// STORES
|
||||
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta(() => ({
|
||||
title: t('admin.notifications.title')
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
loading: 0,
|
||||
config: {
|
||||
retentionDays: 60,
|
||||
emailDelay: '3m',
|
||||
mailBatchSize: 100
|
||||
},
|
||||
status: {
|
||||
pendingEvents: 0,
|
||||
oldestPendingEventAt: null,
|
||||
emailsPending: 0,
|
||||
emailsSent24h: 0,
|
||||
emailsFailed24h: 0,
|
||||
isMailConfigured: true,
|
||||
warnings: []
|
||||
}
|
||||
})
|
||||
|
||||
// METHODS
|
||||
|
||||
async function load() {
|
||||
state.loading++
|
||||
loading.show()
|
||||
try {
|
||||
const resp = await API_CLIENT.get('system/notifications').json()
|
||||
state.config = { ...state.config, ...resp.settings }
|
||||
state.status = { ...state.status, ...resp.status }
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('admin.notifications.loadFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
loading.hide()
|
||||
state.loading--
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await load()
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: t('admin.notifications.refreshSuccess')
|
||||
})
|
||||
}
|
||||
|
||||
async function save() {
|
||||
state.loading++
|
||||
try {
|
||||
await API_CLIENT.put('system/notifications', {
|
||||
json: {
|
||||
retentionDays: Number(state.config.retentionDays),
|
||||
emailDelay: `${state.config.emailDelay ?? ''}`.trim(),
|
||||
mailBatchSize: Number(state.config.mailBatchSize)
|
||||
}
|
||||
})
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: t('admin.notifications.saveSuccess')
|
||||
})
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('admin.notifications.saveFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
state.loading--
|
||||
}
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<w-page class="py-4">
|
||||
<div class="w-section-header">{{ t('profile.notifications') }}</div>
|
||||
<div class="px-4 pt-4">
|
||||
<div class="text-body2">{{ t('profile.notificationsInfo') }}</div>
|
||||
</div>
|
||||
<!--
|
||||
Said rather than hidden: the preferences are the person's for every site, so they stay editable
|
||||
here, and what this site does with them is what the banner explains.
|
||||
-->
|
||||
<w-item v-if="!siteStore.features.notifications">
|
||||
<w-item-section>
|
||||
<w-banner rounded class="bg-warning text-white">
|
||||
{{ t('profile.notificationsSiteOff') }}
|
||||
</w-banner>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-item v-if="!state.emailAvailable && state.loaded">
|
||||
<w-item-section>
|
||||
<w-banner
|
||||
rounded
|
||||
:class="dark.isActive ? `bg-dark-4 text-grey-4` : `bg-grey-2 text-grey-8`">
|
||||
{{ t('profile.notificationsNoEmail') }}
|
||||
</w-banner>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
|
||||
<template v-for="section of sections" :key="section.key">
|
||||
<div class="w-section-header mt-6">{{ t(`notifications.sections.${section.key}`) }}</div>
|
||||
<template v-for="(pref, idx) of section.prefs" :key="pref.key">
|
||||
<w-separator v-if="idx > 0" inset spaced="sm" />
|
||||
<w-item>
|
||||
<blueprint-icon :icon="CATEGORY_ICONS[pref.key] ?? `inbox`" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`notifications.categories.${pref.key}.title`) }}</w-item-label>
|
||||
<w-item-label caption>
|
||||
{{ t(`notifications.categories.${pref.key}.description`) }}
|
||||
</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<div class="flex flex-nowrap items-center gap-4">
|
||||
<w-toggle
|
||||
v-model="pref.inApp"
|
||||
dense
|
||||
:label="t(`profile.notificationsInApp`)"
|
||||
:aria-label="`${t(`notifications.categories.${pref.key}.title`)}: ${t(`profile.notificationsInApp`)}`" />
|
||||
<w-toggle
|
||||
v-model="pref.email"
|
||||
dense
|
||||
:disable="!state.emailAvailable"
|
||||
:label="t(`profile.notificationsEmail`)"
|
||||
:aria-label="`${t(`notifications.categories.${pref.key}.title`)}: ${t(`profile.notificationsEmail`)}`" />
|
||||
</div>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div class="actions-bar mt-6">
|
||||
<w-btn
|
||||
class="acrylic-btn self-center"
|
||||
icon="la:envelope-open"
|
||||
flat
|
||||
size="sm"
|
||||
:label="t(`profile.notificationsStopEmail`)"
|
||||
color="pink"
|
||||
:disable="state.loading > 0 || !state.emailAvailable"
|
||||
@click="stopAllEmail" />
|
||||
<w-space />
|
||||
<w-btn
|
||||
icon="la:check"
|
||||
unelevated
|
||||
:label="t(`common.actions.saveChanges`)"
|
||||
color="secondary"
|
||||
:disable="state.loading > 0"
|
||||
@click="save" />
|
||||
</div>
|
||||
|
||||
<w-inner-loading :showing="state.loading > 0" />
|
||||
</w-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useDark } from '@/composables/dark'
|
||||
import { useMeta } from '@/composables/meta'
|
||||
import { notify } from '@/composables/notify'
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
/**
|
||||
* Profile → Notifications: what this person is told about, and how.
|
||||
*
|
||||
* One row per category the server offers them, each with an In-App and an Email switch; both off is
|
||||
* how somebody says "never". The categories, their headings and their defaults all come from the
|
||||
* server (`notifications/index.ts`), so a category added there appears here without a change — what
|
||||
* this file knows of them is a picture for each, and the strings, which are keyed by category.
|
||||
*
|
||||
* One set of preferences for every site, which is why this sits in the profile rather than in a site.
|
||||
*/
|
||||
|
||||
/** The picture beside each category. One that has none gets the inbox. */
|
||||
const CATEGORY_ICONS = {
|
||||
watchedPage: 'activity-feed',
|
||||
watchedPageComment: 'comments',
|
||||
commentReply: 'chat',
|
||||
mention: 'contact',
|
||||
reviewRequested: 'todo-list',
|
||||
pageCreated: 'new-document',
|
||||
pageDeleted: 'trash'
|
||||
}
|
||||
|
||||
// COMPOSABLES
|
||||
|
||||
const dark = useDark()
|
||||
|
||||
// STORES
|
||||
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta(() => ({
|
||||
title: t('profile.notifications')
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
prefs: [],
|
||||
emailAvailable: false,
|
||||
loaded: false,
|
||||
loading: 0
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
|
||||
/** The rows grouped under their headings, in the order the server sent them in. */
|
||||
const sections = computed(() => {
|
||||
const grouped = []
|
||||
for (const pref of state.prefs) {
|
||||
let section = grouped.find((s) => s.key === pref.section)
|
||||
if (!section) {
|
||||
section = { key: pref.section, prefs: [] }
|
||||
grouped.push(section)
|
||||
}
|
||||
section.prefs.push(pref)
|
||||
}
|
||||
return grouped
|
||||
})
|
||||
|
||||
// METHODS
|
||||
|
||||
function apply(resp) {
|
||||
state.prefs = resp.preferences ?? []
|
||||
state.emailAvailable = resp.emailAvailable ?? false
|
||||
state.loaded = true
|
||||
}
|
||||
|
||||
async function load() {
|
||||
state.loading++
|
||||
try {
|
||||
apply(await API_CLIENT.get('users/profile/notifications').json())
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('profile.notificationsLoadFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
state.loading--
|
||||
}
|
||||
|
||||
async function save() {
|
||||
state.loading++
|
||||
try {
|
||||
const preferences = Object.fromEntries(
|
||||
state.prefs.map((pref) => [pref.key, { inApp: pref.inApp, email: pref.email }])
|
||||
)
|
||||
apply(await API_CLIENT.put('users/profile/notifications', { json: { preferences } }).json())
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: t('profile.notificationsSaved')
|
||||
})
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('profile.notificationsSaveFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
state.loading--
|
||||
}
|
||||
|
||||
/** Every email switch off, and saved — the same thing the unsubscribe page offers. */
|
||||
async function stopAllEmail() {
|
||||
for (const pref of state.prefs) {
|
||||
pref.email = false
|
||||
}
|
||||
await save()
|
||||
}
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div class="unsubscribe">
|
||||
<div class="unsubscribe-card">
|
||||
<div class="unsubscribe-logo">
|
||||
<img :src="`/_site/current/logo`" :alt="siteStore.title" />
|
||||
</div>
|
||||
<h1 class="text-h6 mb-2">{{ t('unsubscribe.title') }}</h1>
|
||||
|
||||
<template v-if="state.status === 'loading'">
|
||||
<w-spinner size="32px" color="primary" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="state.status === 'invalid'">
|
||||
<p class="text-body2">{{ t('unsubscribe.invalid') }}</p>
|
||||
<div class="unsubscribe-actions">
|
||||
<w-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:label="t(`unsubscribe.manage`)"
|
||||
@click="goToSettings" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state.status === 'done'">
|
||||
<p class="text-body2">
|
||||
{{ state.scope === 'all' ? t('unsubscribe.doneAll') : t('unsubscribe.done') }}
|
||||
</p>
|
||||
<div class="unsubscribe-actions">
|
||||
<w-btn flat color="primary" :label="t(`unsubscribe.manage`)" @click="goToSettings" />
|
||||
<w-btn unelevated color="primary" :label="t(`unsubscribe.backToWiki`)" to="/" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
The page that asks. Nothing has happened by the time it is drawn: a mail scanner following the
|
||||
link gets this far and no further, which is the reason it exists.
|
||||
-->
|
||||
<template v-else>
|
||||
<p class="text-body2">{{ t('unsubscribe.intro') }}</p>
|
||||
<ul class="unsubscribe-list text-body2">
|
||||
<li v-for="category of state.categories" :key="category">
|
||||
{{ t(`notifications.categories.${category}.title`) }}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-caption opacity-70">{{ t('unsubscribe.inAppStays') }}</p>
|
||||
<div class="unsubscribe-actions">
|
||||
<w-btn
|
||||
flat
|
||||
color="negative"
|
||||
:label="t(`unsubscribe.all`)"
|
||||
:disable="state.busy"
|
||||
@click="unsubscribe('all')" />
|
||||
<w-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:label="t(`unsubscribe.confirm`)"
|
||||
:loading="state.busy"
|
||||
@click="unsubscribe('token')" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useMeta } from '@/composables/meta'
|
||||
import { notify } from '@/composables/notify'
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
/**
|
||||
* Where an unsubscribe link in a notification email lands — the one in the mail body, and the one in
|
||||
* the `List-Unsubscribe` header when it is opened rather than posted.
|
||||
*
|
||||
* It asks before it acts. A mail client's own unsubscribe button posts to the API directly and is done
|
||||
* (RFC 8058); this page is for a person who clicked, and for the scanners that fetch every link in a
|
||||
* message, which must not unsubscribe anybody by doing so. No session is needed: the token in the URL
|
||||
* says whose email it is, and all it can do is turn that email off.
|
||||
*/
|
||||
|
||||
// ROUTER
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// STORES
|
||||
|
||||
const siteStore = useSiteStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta(() => ({
|
||||
title: t('unsubscribe.title')
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
/** `loading`, `ready`, `invalid` or `done`. */
|
||||
status: 'loading',
|
||||
categories: [],
|
||||
scope: 'token',
|
||||
busy: false
|
||||
})
|
||||
|
||||
// METHODS
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const info = await API_CLIENT.get('notifications/unsubscribe/info', {
|
||||
searchParams: { t: route.query.t ?? '' }
|
||||
}).json()
|
||||
state.categories = info.categories ?? []
|
||||
state.status = info.valid ? 'ready' : 'invalid'
|
||||
} catch {
|
||||
state.status = 'invalid'
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribe(scope) {
|
||||
state.busy = true
|
||||
try {
|
||||
await API_CLIENT.post('notifications/unsubscribe', {
|
||||
json: { t: route.query.t ?? '', scope }
|
||||
})
|
||||
state.scope = scope
|
||||
state.status = 'done'
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('unsubscribe.failed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
state.busy = false
|
||||
}
|
||||
|
||||
/** The full settings, which need a session — the login screen comes first for somebody without one. */
|
||||
function goToSettings() {
|
||||
router.push(userStore.authenticated ? '/_profile/notifications' : '/login')
|
||||
}
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.unsubscribe {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background-color: $grey-2;
|
||||
color: var(--color-black);
|
||||
|
||||
@at-root .body--dark & {
|
||||
background-color: $dark-6;
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
&-card {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
padding: 32px;
|
||||
border-radius: 8px;
|
||||
background-color: #fff;
|
||||
box-shadow: $shadow-2;
|
||||
|
||||
@at-root .body--dark & {
|
||||
background-color: $dark-3;
|
||||
}
|
||||
}
|
||||
|
||||
&-logo {
|
||||
margin-bottom: 16px;
|
||||
|
||||
img {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
&-list {
|
||||
margin: 12px 0;
|
||||
padding-inline-start: 20px;
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in new issue