Batch notifications into localized e-mail digests with unsubscribe (#95)
Some checks failed
CI / Lint, typecheck, test (push) Successful in 3m41s
CD / Build and push images (push) Successful in 3m44s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Failing after 2m53s
CI / Import/export fidelity gate (push) Has been skipped

New per-user digestFrequency (hourly default | daily | off) on the
profile and in the settings UI. A scheduler job (15 min cadence) mails a
user once their oldest unread, unmailed notification exceeds the cadence
window: one localized mail per batch, grouped per pond then per page
with actor names and change/comment counts, enqueued through the mail
outbox. Sending marks the batch mailed — never read — and re-checks page
read permission per entry at send time; entries the user can no longer
read are dropped from the mail but still marked handled, so revoked
content cannot queue forever. Every mail carries a signed, single-purpose
unsubscribe link: it only flips the setting to off, renders a session-free
confirmation page, and sets no cookie.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Fable 5 2026-07-11 23:37:26 +02:00
parent 67fb01fe2b
commit 9674c0bae2
18 changed files with 589 additions and 14 deletions

View File

@ -0,0 +1,2 @@
-- E-mail digest cadence per user (issue #95).
ALTER TABLE "users" ADD COLUMN "digest_frequency" TEXT NOT NULL DEFAULT 'hourly';

View File

@ -40,6 +40,8 @@ model User {
/// Auto-watch preferences (issue #93): watch pages I create / comment on. /// Auto-watch preferences (issue #93): watch pages I create / comment on.
autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages") autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages")
autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment") autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment")
/// E-mail digest cadence (issue #95): hourly | daily | off.
digestFrequency String @default("hourly") @map("digest_frequency")
status UserStatus @default(PENDING_VERIFICATION) status UserStatus @default(PENDING_VERIFICATION)
emailVerifiedAt DateTime? @map("email_verified_at") emailVerifiedAt DateTime? @map("email_verified_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")

View File

@ -42,6 +42,10 @@ export function toCurrentUser(user: User): CurrentUserShape {
isSiteAdmin: user.isSiteAdmin, isSiteAdmin: user.isSiteAdmin,
autoWatchOwnPages: user.autoWatchOwnPages, autoWatchOwnPages: user.autoWatchOwnPages,
autoWatchOnComment: user.autoWatchOnComment, autoWatchOnComment: user.autoWatchOnComment,
digestFrequency:
user.digestFrequency === 'daily' || user.digestFrequency === 'off'
? user.digestFrequency
: 'hourly',
}; };
} }

View File

@ -18,13 +18,13 @@ export class MailService {
locale: 'de' | 'en', locale: 'de' | 'en',
): Promise<void> { ): Promise<void> {
const rendered = renderMail(template, params, locale); const rendered = renderMail(template, params, locale);
await this.enqueueRaw(to, rendered.subject, rendered.text, rendered.html);
}
/** Pre-rendered mails (the #95 digests build their own body). */
async enqueueRaw(to: string, subject: string, text: string, html: string): Promise<void> {
await this.prisma.mailOutbox.create({ await this.prisma.mailOutbox.create({
data: { data: { toAddress: to, subject, textBody: text, htmlBody: html },
toAddress: to,
subject: rendered.subject,
textBody: rendered.text,
htmlBody: rendered.html,
},
}); });
} }
} }

View File

@ -0,0 +1,217 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient, User } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { DigestService } from './digest.service';
import { NotificationsService } from './notifications.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
const HOUR = 60 * 60 * 1000;
/**
* E-mail digests (issue #95): grouping into exactly one mail, the cadence
* windows with time travel, the session-free unsubscribe link, and the
* send-time permission re-check.
*/
describe.skipIf(!hasTestDb)('notification digests (e2e, issue #95)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let digest: DigestService;
const suffix = uniqueSuffix();
const password = 'gebuendelt statt einzeln 1';
const users: Record<string, User> = {};
const cookies: Record<string, string> = {};
let pondId: string;
let pageId: string;
const api = () => request(app.getHttpServer());
async function makeUser(handle: string): Promise<void> {
const service = app.get(UsersService);
const username = `di-${handle}-${suffix}`;
const user = await service.createUser({
username,
email: `${username}@example.org`,
displayName: `Di ${handle}`,
password,
locale: 'de',
});
await service.markEmailVerified(user.id);
users[handle] = user;
cookies[handle] = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
}
/** Fan out an event and backdate the resulting unmailed notifications. */
async function seedEvent(type: 'page_changed' | 'comment_added', ageMs: number): Promise<void> {
await app.get(NotificationsService).fanoutPageEvent(type, pageId, [users.owner!.id]);
await prisma.notification.updateMany({
where: { userId: users.watcher!.id, mailedAt: null },
data: { createdAt: new Date(Date.now() - ageMs) },
});
}
async function outboxFor(handle: string): Promise<{ subject: string; textBody: string }[]> {
return prisma.mailOutbox.findMany({
where: { toAddress: users[handle]!.email },
orderBy: { createdAt: 'asc' },
select: { subject: true, textBody: true },
});
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
digest = app.get(DigestService);
for (const handle of ['owner', 'watcher']) await makeUser(handle);
const pond = await prisma.pond.create({
data: {
slug: `di-pond-${suffix}`,
name: 'Digest Pond',
type: 'SHARED',
ownerId: users.owner!.id,
},
});
pondId = pond.id;
await prisma.roleGrant.create({
data: {
pondId,
subjectType: 'USER',
subjectId: users.owner!.id,
role: 'POND_ADMIN',
scopeType: 'POND',
effect: 'ALLOW',
createdBy: users.owner!.id,
},
});
const page = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', cookies.owner!)
.send({ title: 'Digest target' })
.expect(201);
pageId = (page.body as { id: string }).id;
// The watcher joins as a reader through the API (permission cache!).
await api()
.post(`/api/v1/ponds/${pondId}/members`)
.set('Cookie', cookies.owner!)
.send({ usernameOrEmail: `di-watcher-${suffix}`, role: 'reader' })
.expect(201);
await api().put(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.watcher!).expect(200);
});
afterAll(async () => {
const ids = Object.values(users).map((u) => u.id);
await prisma.mailOutbox.deleteMany({
where: { toAddress: { in: Object.values(users).map((u) => u.email) } },
});
await prisma.notification.deleteMany({ where: { userId: { in: ids } } });
await prisma.watch.deleteMany({ where: { userId: { in: ids } } });
await prisma.comment.deleteMany({ where: { page: { pondId } } });
await prisma.roleGrant.deleteMany({ where: { pondId } });
await prisma.page.deleteMany({ where: { pondId } });
await prisma.pond.deleteMany({ where: { id: pondId } });
await prisma.auditEntry.deleteMany({ where: { actorId: { in: ids } } });
await prisma.session.deleteMany({ where: { userId: { in: ids } } });
await prisma.userIdentity.deleteMany({ where: { userId: { in: ids } } });
await prisma.user.deleteMany({ where: { id: { in: ids } } });
await prisma.$disconnect();
await app.close();
});
it('groups two edits and a comment into exactly one localized mail', async () => {
await seedEvent('page_changed', 2 * HOUR);
await seedEvent('page_changed', 2 * HOUR);
await seedEvent('comment_added', 2 * HOUR);
expect(await digest.runOnce()).toBe(1);
const mails = await outboxFor('watcher');
expect(mails).toHaveLength(1);
expect(mails[0]!.subject).toContain('3');
expect(mails[0]!.textBody).toContain('Digest Pond');
expect(mails[0]!.textBody).toContain('Digest target');
expect(mails[0]!.textBody).toContain('2 Änderungen');
expect(mails[0]!.textBody).toContain('1 Kommentar');
expect(mails[0]!.textBody).toContain('Di owner');
expect(mails[0]!.textBody).toContain('/api/v1/notifications/unsubscribe?token=');
// Mailed, not read — and never mailed twice.
const rows = await prisma.notification.findMany({ where: { userId: users.watcher!.id } });
expect(rows.every((row) => row.mailedAt !== null && row.readAt === null)).toBe(true);
expect(await digest.runOnce()).toBe(0);
expect(await outboxFor('watcher')).toHaveLength(1);
});
it("respects 'off' and batches 'daily' across the day", async () => {
await api()
.patch('/api/v1/users/me')
.set('Cookie', cookies.watcher!)
.send({ digestFrequency: 'off' })
.expect(200);
await seedEvent('page_changed', 3 * HOUR);
expect(await digest.runOnce()).toBe(0);
expect(await outboxFor('watcher')).toHaveLength(1); // unchanged
// Daily: a 3 h old batch is not due yet, a > 24 h old one is.
await api()
.patch('/api/v1/users/me')
.set('Cookie', cookies.watcher!)
.send({ digestFrequency: 'daily' })
.expect(200);
expect(await digest.runOnce()).toBe(0);
expect(await digest.runOnce(new Date(Date.now() + 25 * HOUR))).toBe(1);
expect(await outboxFor('watcher')).toHaveLength(2);
});
it('unsubscribes via the signed link without creating a session', async () => {
await api()
.patch('/api/v1/users/me')
.set('Cookie', cookies.watcher!)
.send({ digestFrequency: 'hourly' })
.expect(200);
const token = digest.unsubscribeToken(users.watcher!.id);
const res = await api().get(`/api/v1/notifications/unsubscribe?token=${token}`).expect(200);
expect(res.headers['set-cookie']).toBeUndefined();
expect(res.text).toContain('Digest-Mails deaktiviert');
const user = await prisma.user.findUniqueOrThrow({ where: { id: users.watcher!.id } });
expect(user.digestFrequency).toBe('off');
// Tampered and garbage tokens are rejected.
await api().get(`/api/v1/notifications/unsubscribe?token=${token}x`).expect(400);
await api().get('/api/v1/notifications/unsubscribe?token=nonsense').expect(400);
await api().get('/api/v1/notifications/unsubscribe').expect(400);
});
it('re-checks permission at send time: revoked content never reaches the mail', async () => {
await api()
.patch('/api/v1/users/me')
.set('Cookie', cookies.watcher!)
.send({ digestFrequency: 'hourly' })
.expect(200);
await seedEvent('page_changed', 2 * HOUR);
// Revoke the watcher's membership through the API (cache-safe).
await api()
.delete(`/api/v1/ponds/${pondId}/members/${users.watcher!.id}`)
.set('Cookie', cookies.owner!)
.expect(204);
const before = await outboxFor('watcher');
expect(await digest.runOnce()).toBe(0);
expect(await outboxFor('watcher')).toHaveLength(before.length);
// The batch still counts as handled — no retry loop on revoked content.
const rows = await prisma.notification.findMany({
where: { userId: users.watcher!.id, mailedAt: null },
});
expect(rows).toHaveLength(0);
});
});

View File

@ -0,0 +1,171 @@
import { Injectable } from '@nestjs/common';
import type { NotificationPayload } from '@dorfteich/shared';
import { Notification, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { apiI18n } from '../i18n/api-i18n';
import { AppConfig } from '../config/app-config.service';
import { MailService } from '../mail/mail.service';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
import { signUnsubscribeToken } from './unsubscribe-token';
const WINDOW_MS = { hourly: 60 * 60 * 1000, daily: 24 * 60 * 60 * 1000 } as const;
interface PageGroup {
pageTitle: string;
pondName: string;
changed: number;
comments: number;
actorNames: Set<string>;
}
/**
* E-mail notification digests (issue #95): batched, never mail-per-edit.
* A user gets at most one mail per run, summarizing every unread, not-yet-
* mailed notification once the oldest of them is older than their cadence
* window (`hourly` default, `daily`, `off`). Sending marks the batch as
* mailed not read. Every notification is permission-re-checked at send
* time; entries the user can no longer read are dropped from the mail but
* still marked mailed (no retry loop on revoked content).
*/
@Injectable()
export class DigestService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
private readonly mail: MailService,
private readonly config: AppConfig,
private readonly logger: PinoLogger,
) {
this.logger.setContext(DigestService.name);
}
/** One scheduler pass; `now` is injectable for the time-travel tests. */
async runOnce(now: Date = new Date()): Promise<number> {
const pending = await this.prisma.notification.groupBy({
by: ['userId'],
where: { readAt: null, mailedAt: null },
_min: { createdAt: true },
});
let mails = 0;
for (const entry of pending) {
const user = await this.prisma.user.findUnique({ where: { id: entry.userId } });
if (!user) continue;
const frequency = user.digestFrequency === 'daily' ? 'daily' : 'hourly';
if (user.digestFrequency === 'off') continue;
const oldest = entry._min.createdAt;
if (!oldest || now.getTime() - oldest.getTime() < WINDOW_MS[frequency]) continue;
if (await this.sendDigest(user, now)) mails += 1;
}
return mails;
}
private async sendDigest(user: User, now: Date): Promise<boolean> {
const batch = await this.prisma.notification.findMany({
where: { userId: user.id, readAt: null, mailedAt: null },
orderBy: { createdAt: 'asc' },
});
if (batch.length === 0) return false;
const readable = await this.filterReadable(user, batch);
// The whole batch counts as handled — including entries dropped by the
// permission re-check, which must not queue up forever.
await this.prisma.notification.updateMany({
where: { id: { in: batch.map((notification) => notification.id) } },
data: { mailedAt: now },
});
if (readable.length === 0) return false;
const locale = user.locale === 'de' ? 'de' : 'en';
const rendered = this.render(readable, locale);
await this.mail.enqueueRaw(user.email, rendered.subject, rendered.text, rendered.html);
this.logger.info(
{ userId: user.id, notifications: readable.length },
'notification digest enqueued',
);
return true;
}
/** Send-time permission re-check, one page lookup per distinct page. */
private async filterReadable(user: User, batch: Notification[]): Promise<Notification[]> {
const verdicts = new Map<string, boolean>();
const readable: Notification[] = [];
for (const notification of batch) {
const payload = notification.payload as unknown as NotificationPayload;
if (!verdicts.has(payload.pageId)) {
const page = await this.prisma.page.findFirst({
where: { id: payload.pageId, deletedAt: null },
select: { id: true, pondId: true },
});
verdicts.set(
payload.pageId,
page !== null && (await this.permissions.canAccessPage(user, page, 'read')),
);
}
if (verdicts.get(payload.pageId)) readable.push(notification);
}
return readable;
}
private render(
batch: Notification[],
locale: 'de' | 'en',
): { subject: string; text: string; html: string } {
const t = (key: string, options: Record<string, string | number> = {}): string =>
apiI18n.t(`mails:digest.${key}`, { lng: locale, ...options });
// Group per pond → per page (issue #95): actors and counts, no bodies.
const groups = new Map<string, Map<string, PageGroup>>();
for (const notification of batch) {
const payload = notification.payload as unknown as NotificationPayload;
const pond = groups.get(payload.pondName) ?? new Map<string, PageGroup>();
const page = pond.get(payload.pageId) ?? {
pageTitle: payload.pageTitle,
pondName: payload.pondName,
changed: 0,
comments: 0,
actorNames: new Set<string>(),
};
if (notification.type === 'comment_added') page.comments += 1;
else page.changed += 1;
for (const name of payload.actorNames) page.actorNames.add(name);
pond.set(payload.pageId, page);
groups.set(payload.pondName, pond);
}
const lines: string[] = [t('intro', { count: batch.length })];
for (const [pondName, pages] of groups) {
lines.push('', `${pondName}:`);
for (const page of pages.values()) {
const parts: string[] = [];
if (page.changed > 0) parts.push(t('changes', { count: page.changed }));
if (page.comments > 0) parts.push(t('comments', { count: page.comments }));
lines.push(
` - ${page.pageTitle}: ${parts.join(', ')} (${[...page.actorNames].join(', ')})`,
);
}
}
lines.push('', t('openApp', { link: this.config.env.APP_BASE_URL }));
const unsubscribeUrl = `${this.config.env.APP_BASE_URL}/api/v1/notifications/unsubscribe?token=${this.unsubscribeToken(batch[0]!.userId)}`;
lines.push('', t('unsubscribe', { link: unsubscribeUrl }));
const text = lines.join('\n');
const html = `<!doctype html><html><body style="font-family: system-ui, sans-serif; max-width: 32rem; margin: 0 auto; padding: 1.5rem;"><pre style="font-family: inherit; white-space: pre-wrap;">${escapeHtml(text)}</pre></body></html>`;
return { subject: t('subject', { count: batch.length }), text, html };
}
unsubscribeToken(userId: string): string {
return signUnsubscribeToken(userId, this.config.env.COLLAB_TOKEN_SECRET);
}
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}

View File

@ -1,4 +1,14 @@
import { Controller, Get, HttpCode, Param, Post, Query, Req } from '@nestjs/common'; import {
BadRequestException,
Controller,
Get,
HttpCode,
Param,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import { import {
notificationListQuerySchema, notificationListQuerySchema,
type NotificationListQuery, type NotificationListQuery,
@ -6,7 +16,15 @@ import {
type NotificationView, type NotificationView,
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard'; import type { Response } from 'express';
import { AuthedRequest, Public } from '../auth/auth.guard';
import { AppConfig } from '../config/app-config.service';
import { htmlDocument } from '../public/html-shell';
import { apiI18n } from '../i18n/api-i18n';
import { PrismaService } from '../prisma/prisma.service';
import { SetupExempt } from '../setup/setup.guard';
import { verifyUnsubscribeToken } from './unsubscribe-token';
import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AuthenticatedOnly } from '../permissions/permission.decorators'; import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { NotificationsService } from './notifications.service'; import { NotificationsService } from './notifications.service';
@ -14,7 +32,42 @@ import { NotificationsService } from './notifications.service';
/** The in-app notification center's API (issue #94). */ /** The in-app notification center's API (issue #94). */
@Controller('notifications') @Controller('notifications')
export class NotificationsController { export class NotificationsController {
constructor(private readonly notifications: NotificationsService) {} constructor(
private readonly notifications: NotificationsService,
private readonly prisma: PrismaService,
private readonly config: AppConfig,
) {}
/**
* Digest unsubscribe (issue #95): the signed link from the mail flips the
* user's digest setting to `off`. Deliberately session-free — the token's
* only power is this switch, and the response sets no cookie.
*/
@Get('unsubscribe')
@Public()
@SetupExempt()
async unsubscribe(@Query('token') token: string, @Res() res: Response): Promise<void> {
const userId = token
? verifyUnsubscribeToken(token, this.config.env.COLLAB_TOKEN_SECRET)
: null;
if (!userId) throw new BadRequestException({ code: 'bad_request' });
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new BadRequestException({ code: 'bad_request' });
await this.prisma.user.update({ where: { id: userId }, data: { digestFrequency: 'off' } });
const lang = user.locale === 'de' ? 'de' : 'en';
const t = (key: string): string => apiI18n.t(`mails:digest.${key}`, { lng: lang });
res
.status(200)
.type('text/html; charset=utf-8')
.send(
htmlDocument({
lang,
title: t('unsubscribedTitle'),
bodyHtml: `<h1>${t('unsubscribedTitle')}</h1><p>${t('unsubscribedBody')}</p>`,
}),
);
}
@Get() @Get()
@AuthenticatedOnly() @AuthenticatedOnly()

View File

@ -1,15 +1,35 @@
import { Module } from '@nestjs/common'; import { Module, OnModuleInit } from '@nestjs/common';
import { MailModule } from '../mail/mail.module';
import { PermissionsModule } from '../permissions/permissions.module'; import { PermissionsModule } from '../permissions/permissions.module';
import { SchedulerModule } from '../scheduler/scheduler.module';
import { SchedulerService } from '../scheduler/scheduler.service';
import { DigestService } from './digest.service';
import { NotificationsController } from './notifications.controller'; import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service'; import { NotificationsService } from './notifications.service';
import { VersionEventListener } from './version-event-listener.service'; import { VersionEventListener } from './version-event-listener.service';
/** Every 15 minutes the digest job looks for due batches (issue #95). */
const DIGEST_CADENCE_SECONDS = 15 * 60;
@Module({ @Module({
imports: [PermissionsModule], imports: [PermissionsModule, MailModule, SchedulerModule],
controllers: [NotificationsController], controllers: [NotificationsController],
providers: [NotificationsService, VersionEventListener], providers: [NotificationsService, DigestService, VersionEventListener],
exports: [NotificationsService], exports: [NotificationsService, DigestService],
}) })
export class NotificationsModule {} export class NotificationsModule implements OnModuleInit {
constructor(
private readonly scheduler: SchedulerService,
private readonly digest: DigestService,
) {}
onModuleInit(): void {
this.scheduler.register({
name: 'notification-digest',
cadenceSeconds: DIGEST_CADENCE_SECONDS,
run: () => this.digest.runOnce().then(() => undefined),
});
}
}

View File

@ -0,0 +1,52 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* Single-purpose unsubscribe tokens for the digest mails (issue #95): a
* signed `{userId, exp}` blob whose only power is flipping that user's
* digest setting to `off` it never creates a session (ADR 0007's "signed
* tokens" pattern, purpose-bound so it cannot be replayed anywhere else).
*/
const PURPOSE = 'digest-unsubscribe';
const TTL_SECONDS = 90 * 24 * 60 * 60;
function signature(body: string, secret: string): Buffer {
return createHmac('sha256', secret).update(`${PURPOSE}.${body}`).digest();
}
export function signUnsubscribeToken(userId: string, secret: string, now = Date.now()): string {
const body = Buffer.from(
JSON.stringify({ userId, exp: Math.floor(now / 1000) + TTL_SECONDS }),
'utf8',
).toString('base64url');
return `${body}.${signature(body, secret).toString('base64url')}`;
}
/** The user id, or null for anything invalid or expired. Never throws. */
export function verifyUnsubscribeToken(
token: string,
secret: string,
now = Date.now(),
): string | null {
const [body, sig] = token.split('.');
if (!body || !sig) return null;
let provided: Buffer;
try {
provided = Buffer.from(sig, 'base64url');
} catch {
return null;
}
const expected = signature(body, secret);
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) return null;
try {
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as {
userId?: string;
exp?: number;
};
if (typeof payload.userId !== 'string' || typeof payload.exp !== 'number') return null;
if (payload.exp * 1000 < now) return null;
return payload.userId;
} catch {
return null;
}
}

View File

@ -49,6 +49,7 @@ export class UsersController {
locale?: 'de' | 'en'; locale?: 'de' | 'en';
autoWatchOwnPages?: boolean; autoWatchOwnPages?: boolean;
autoWatchOnComment?: boolean; autoWatchOnComment?: boolean;
digestFrequency?: 'hourly' | 'daily' | 'off';
}, },
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
): Promise<CurrentUserShape> { ): Promise<CurrentUserShape> {

View File

@ -99,6 +99,7 @@ export class UsersService {
locale?: string; locale?: string;
autoWatchOwnPages?: boolean; autoWatchOwnPages?: boolean;
autoWatchOnComment?: boolean; autoWatchOnComment?: boolean;
digestFrequency?: string;
}, },
): Promise<User> { ): Promise<User> {
return this.prisma.user.update({ where: { id: userId }, data }); return this.prisma.user.update({ where: { id: userId }, data });

View File

@ -90,6 +90,7 @@ function ProfileSection(): React.JSX.Element {
locale?: 'de' | 'en'; locale?: 'de' | 'en';
autoWatchOwnPages?: boolean; autoWatchOwnPages?: boolean;
autoWatchOnComment?: boolean; autoWatchOnComment?: boolean;
digestFrequency?: 'hourly' | 'daily' | 'off';
}>({ }>({
resolver: zodResolver(updateProfileInputSchema), resolver: zodResolver(updateProfileInputSchema),
values: { values: {
@ -97,6 +98,7 @@ function ProfileSection(): React.JSX.Element {
locale: user?.locale, locale: user?.locale,
autoWatchOwnPages: user?.autoWatchOwnPages, autoWatchOwnPages: user?.autoWatchOwnPages,
autoWatchOnComment: user?.autoWatchOnComment, autoWatchOnComment: user?.autoWatchOnComment,
digestFrequency: user?.digestFrequency,
}, },
}); });
@ -143,6 +145,13 @@ function ProfileSection(): React.JSX.Element {
<input type="checkbox" {...form.register('autoWatchOnComment')} /> <input type="checkbox" {...form.register('autoWatchOnComment')} />
{t('watches:prefs.autoWatchOnComment')} {t('watches:prefs.autoWatchOnComment')}
</label> </label>
<Field label={t('notifications:digest.label')}>
<select {...form.register('digestFrequency')}>
<option value="hourly">{t('notifications:digest.hourly')}</option>
<option value="daily">{t('notifications:digest.daily')}</option>
<option value="off">{t('notifications:digest.off')}</option>
</select>
</Field>
<button type="submit" className="button" disabled={form.formState.isSubmitting}> <button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('settings:profile.save')} {t('settings:profile.save')}
</button> </button>

View File

@ -30,5 +30,17 @@
"lastSuccess": "Letztes erfolgreiches Backup: {{finishedAt}}", "lastSuccess": "Letztes erfolgreiches Backup: {{finishedAt}}",
"lastSuccessNever": "Letztes erfolgreiches Backup: noch keines", "lastSuccessNever": "Letztes erfolgreiches Backup: noch keines",
"hint": "Prüfe die Sidecar-Logs (docker compose logs backup) und die status.json auf dem Backups-Volume." "hint": "Prüfe die Sidecar-Logs (docker compose logs backup) und die status.json auf dem Backups-Volume."
},
"digest": {
"subject": "Dorfteich: {{count}} Neuigkeiten für dich",
"intro": "Das ist auf von dir beobachteten Seiten passiert ({{count}} Neuigkeiten):",
"changes_one": "{{count}} Änderung",
"changes_other": "{{count}} Änderungen",
"comments_one": "{{count}} Kommentar",
"comments_other": "{{count}} Kommentare",
"openApp": "Dorfteich öffnen: {{link}}",
"unsubscribe": "Diese Digest-Mails abbestellen: {{link}}",
"unsubscribedTitle": "Digest-Mails deaktiviert",
"unsubscribedBody": "Du erhältst keine Benachrichtigungs-Digests mehr. In den Konto-Einstellungen kannst du sie nach der Anmeldung jederzeit wieder aktivieren."
} }
} }

View File

@ -6,5 +6,11 @@
"types": { "types": {
"page_changed": "{{actor}} hat „{{page}}“ in {{pond}} geändert", "page_changed": "{{actor}} hat „{{page}}“ in {{pond}} geändert",
"comment_added": "{{actor}} hat „{{page}}“ in {{pond}} kommentiert" "comment_added": "{{actor}} hat „{{page}}“ in {{pond}} kommentiert"
},
"digest": {
"label": "E-Mail-Digest",
"hourly": "Stündliche Zusammenfassung",
"daily": "Tägliche Zusammenfassung",
"off": "Keine Digest-Mails"
} }
} }

View File

@ -30,5 +30,17 @@
"lastSuccess": "Last successful backup: {{finishedAt}}", "lastSuccess": "Last successful backup: {{finishedAt}}",
"lastSuccessNever": "Last successful backup: none yet", "lastSuccessNever": "Last successful backup: none yet",
"hint": "Check the sidecar logs (docker compose logs backup) and status.json on the backups volume." "hint": "Check the sidecar logs (docker compose logs backup) and status.json on the backups volume."
},
"digest": {
"subject": "Dorfteich: {{count}} updates for you",
"intro": "Here is what happened on pages you watch ({{count}} updates):",
"changes_one": "{{count}} change",
"changes_other": "{{count}} changes",
"comments_one": "{{count}} comment",
"comments_other": "{{count}} comments",
"openApp": "Open Dorfteich: {{link}}",
"unsubscribe": "Stop these digest mails: {{link}}",
"unsubscribedTitle": "Digest mails disabled",
"unsubscribedBody": "You will no longer receive notification digests. You can re-enable them anytime in your account settings after signing in."
} }
} }

View File

@ -6,5 +6,11 @@
"types": { "types": {
"page_changed": "{{actor}} changed “{{page}}” in {{pond}}", "page_changed": "{{actor}} changed “{{page}}” in {{pond}}",
"comment_added": "{{actor}} commented on “{{page}}” in {{pond}}" "comment_added": "{{actor}} commented on “{{page}}” in {{pond}}"
},
"digest": {
"label": "E-mail digest",
"hourly": "Hourly summary",
"daily": "Daily summary",
"off": "No digest mails"
} }
} }

View File

@ -74,6 +74,8 @@ export const updateProfileInputSchema = z.object({
/** Auto-watch preferences (issue #93). */ /** Auto-watch preferences (issue #93). */
autoWatchOwnPages: z.boolean().optional(), autoWatchOwnPages: z.boolean().optional(),
autoWatchOnComment: z.boolean().optional(), autoWatchOnComment: z.boolean().optional(),
/** E-mail digest cadence (issue #95). */
digestFrequency: z.enum(['hourly', 'daily', 'off']).optional(),
}); });
export const changePasswordInputSchema = z.object({ export const changePasswordInputSchema = z.object({
currentPassword: z.string().min(1, 'validation.required'), currentPassword: z.string().min(1, 'validation.required'),
@ -90,4 +92,5 @@ export interface CurrentUser {
isSiteAdmin: boolean; isSiteAdmin: boolean;
autoWatchOwnPages: boolean; autoWatchOwnPages: boolean;
autoWatchOnComment: boolean; autoWatchOnComment: boolean;
digestFrequency: 'hourly' | 'daily' | 'off';
} }

View File

@ -41,3 +41,7 @@ export interface NotificationListView {
page: number; page: number;
pageCount: number; pageCount: number;
} }
/** E-mail digest cadence (issue #95). */
export const DIGEST_FREQUENCIES = ['hourly', 'daily', 'off'] as const;
export type DigestFrequency = (typeof DIGEST_FREQUENCIES)[number];