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