diff --git a/apps/api/prisma/migrations/20260712010000_digest_frequency/migration.sql b/apps/api/prisma/migrations/20260712010000_digest_frequency/migration.sql new file mode 100644 index 0000000..aa2e596 --- /dev/null +++ b/apps/api/prisma/migrations/20260712010000_digest_frequency/migration.sql @@ -0,0 +1,2 @@ +-- E-mail digest cadence per user (issue #95). +ALTER TABLE "users" ADD COLUMN "digest_frequency" TEXT NOT NULL DEFAULT 'hourly'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 30c5b46..0ed5b58 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -40,6 +40,8 @@ model User { /// Auto-watch preferences (issue #93): watch pages I create / comment on. autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages") 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) emailVerifiedAt DateTime? @map("email_verified_at") createdAt DateTime @default(now()) @map("created_at") diff --git a/apps/api/src/auth/auth.guard.ts b/apps/api/src/auth/auth.guard.ts index b6c87a0..3f850e1 100644 --- a/apps/api/src/auth/auth.guard.ts +++ b/apps/api/src/auth/auth.guard.ts @@ -42,6 +42,10 @@ export function toCurrentUser(user: User): CurrentUserShape { isSiteAdmin: user.isSiteAdmin, autoWatchOwnPages: user.autoWatchOwnPages, autoWatchOnComment: user.autoWatchOnComment, + digestFrequency: + user.digestFrequency === 'daily' || user.digestFrequency === 'off' + ? user.digestFrequency + : 'hourly', }; } diff --git a/apps/api/src/mail/mail.service.ts b/apps/api/src/mail/mail.service.ts index 89387d7..43c190a 100644 --- a/apps/api/src/mail/mail.service.ts +++ b/apps/api/src/mail/mail.service.ts @@ -18,13 +18,13 @@ export class MailService { locale: 'de' | 'en', ): Promise { 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 { await this.prisma.mailOutbox.create({ - data: { - toAddress: to, - subject: rendered.subject, - textBody: rendered.text, - htmlBody: rendered.html, - }, + data: { toAddress: to, subject, textBody: text, htmlBody: html }, }); } } diff --git a/apps/api/src/notifications/digest.e2e.db.test.ts b/apps/api/src/notifications/digest.e2e.db.test.ts new file mode 100644 index 0000000..d86e81b --- /dev/null +++ b/apps/api/src/notifications/digest.e2e.db.test.ts @@ -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 = {}; + const cookies: Record = {}; + let pondId: string; + let pageId: string; + + const api = () => request(app.getHttpServer()); + + async function makeUser(handle: string): Promise { + 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 { + 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); + }); +}); diff --git a/apps/api/src/notifications/digest.service.ts b/apps/api/src/notifications/digest.service.ts new file mode 100644 index 0000000..1b2ca6c --- /dev/null +++ b/apps/api/src/notifications/digest.service.ts @@ -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; +} + +/** + * 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 { + 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 { + 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 { + const verdicts = new Map(); + 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 => + apiI18n.t(`mails:digest.${key}`, { lng: locale, ...options }); + + // Group per pond → per page (issue #95): actors and counts, no bodies. + const groups = new Map>(); + for (const notification of batch) { + const payload = notification.payload as unknown as NotificationPayload; + const pond = groups.get(payload.pondName) ?? new Map(); + const page = pond.get(payload.pageId) ?? { + pageTitle: payload.pageTitle, + pondName: payload.pondName, + changed: 0, + comments: 0, + actorNames: new Set(), + }; + 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 = `
${escapeHtml(text)}
`; + 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('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} diff --git a/apps/api/src/notifications/notifications.controller.ts b/apps/api/src/notifications/notifications.controller.ts index 2df963e..60c12c3 100644 --- a/apps/api/src/notifications/notifications.controller.ts +++ b/apps/api/src/notifications/notifications.controller.ts @@ -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 { notificationListQuerySchema, type NotificationListQuery, @@ -6,7 +16,15 @@ import { type NotificationView, } 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 { AuthenticatedOnly } from '../permissions/permission.decorators'; import { NotificationsService } from './notifications.service'; @@ -14,7 +32,42 @@ import { NotificationsService } from './notifications.service'; /** The in-app notification center's API (issue #94). */ @Controller('notifications') 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 { + 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: `

${t('unsubscribedTitle')}

${t('unsubscribedBody')}

`, + }), + ); + } @Get() @AuthenticatedOnly() diff --git a/apps/api/src/notifications/notifications.module.ts b/apps/api/src/notifications/notifications.module.ts index 0c35410..0a46947 100644 --- a/apps/api/src/notifications/notifications.module.ts +++ b/apps/api/src/notifications/notifications.module.ts @@ -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 { SchedulerModule } from '../scheduler/scheduler.module'; +import { SchedulerService } from '../scheduler/scheduler.service'; +import { DigestService } from './digest.service'; import { NotificationsController } from './notifications.controller'; import { NotificationsService } from './notifications.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({ - imports: [PermissionsModule], + imports: [PermissionsModule, MailModule, SchedulerModule], controllers: [NotificationsController], - providers: [NotificationsService, VersionEventListener], - exports: [NotificationsService], + providers: [NotificationsService, DigestService, VersionEventListener], + 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), + }); + } +} diff --git a/apps/api/src/notifications/unsubscribe-token.ts b/apps/api/src/notifications/unsubscribe-token.ts new file mode 100644 index 0000000..adaa4e7 --- /dev/null +++ b/apps/api/src/notifications/unsubscribe-token.ts @@ -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; + } +} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index a8f7efc..2626d36 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -49,6 +49,7 @@ export class UsersController { locale?: 'de' | 'en'; autoWatchOwnPages?: boolean; autoWatchOnComment?: boolean; + digestFrequency?: 'hourly' | 'daily' | 'off'; }, @Req() request: AuthedRequest, ): Promise { diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 79d7a2b..55e5a8d 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -99,6 +99,7 @@ export class UsersService { locale?: string; autoWatchOwnPages?: boolean; autoWatchOnComment?: boolean; + digestFrequency?: string; }, ): Promise { return this.prisma.user.update({ where: { id: userId }, data }); diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 4e3cb77..8249ae5 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -90,6 +90,7 @@ function ProfileSection(): React.JSX.Element { locale?: 'de' | 'en'; autoWatchOwnPages?: boolean; autoWatchOnComment?: boolean; + digestFrequency?: 'hourly' | 'daily' | 'off'; }>({ resolver: zodResolver(updateProfileInputSchema), values: { @@ -97,6 +98,7 @@ function ProfileSection(): React.JSX.Element { locale: user?.locale, autoWatchOwnPages: user?.autoWatchOwnPages, autoWatchOnComment: user?.autoWatchOnComment, + digestFrequency: user?.digestFrequency, }, }); @@ -143,6 +145,13 @@ function ProfileSection(): React.JSX.Element { {t('watches:prefs.autoWatchOnComment')} + + + diff --git a/packages/shared/i18n/de/mails.json b/packages/shared/i18n/de/mails.json index 367b41a..1a8d8ba 100644 --- a/packages/shared/i18n/de/mails.json +++ b/packages/shared/i18n/de/mails.json @@ -30,5 +30,17 @@ "lastSuccess": "Letztes erfolgreiches Backup: {{finishedAt}}", "lastSuccessNever": "Letztes erfolgreiches Backup: noch keines", "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." } } diff --git a/packages/shared/i18n/de/notifications.json b/packages/shared/i18n/de/notifications.json index 7307d8a..7459b8b 100644 --- a/packages/shared/i18n/de/notifications.json +++ b/packages/shared/i18n/de/notifications.json @@ -6,5 +6,11 @@ "types": { "page_changed": "{{actor}} hat „{{page}}“ in {{pond}} geändert", "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" } } diff --git a/packages/shared/i18n/en/mails.json b/packages/shared/i18n/en/mails.json index cb0c3ad..f7b64fe 100644 --- a/packages/shared/i18n/en/mails.json +++ b/packages/shared/i18n/en/mails.json @@ -30,5 +30,17 @@ "lastSuccess": "Last successful backup: {{finishedAt}}", "lastSuccessNever": "Last successful backup: none yet", "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." } } diff --git a/packages/shared/i18n/en/notifications.json b/packages/shared/i18n/en/notifications.json index fa5f6bc..201bf7d 100644 --- a/packages/shared/i18n/en/notifications.json +++ b/packages/shared/i18n/en/notifications.json @@ -6,5 +6,11 @@ "types": { "page_changed": "{{actor}} changed “{{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" } } diff --git a/packages/shared/src/auth.ts b/packages/shared/src/auth.ts index 57648ef..5465014 100644 --- a/packages/shared/src/auth.ts +++ b/packages/shared/src/auth.ts @@ -74,6 +74,8 @@ export const updateProfileInputSchema = z.object({ /** Auto-watch preferences (issue #93). */ autoWatchOwnPages: 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({ currentPassword: z.string().min(1, 'validation.required'), @@ -90,4 +92,5 @@ export interface CurrentUser { isSiteAdmin: boolean; autoWatchOwnPages: boolean; autoWatchOnComment: boolean; + digestFrequency: 'hourly' | 'daily' | 'off'; } diff --git a/packages/shared/src/notifications.ts b/packages/shared/src/notifications.ts index 08b272f..5dc050f 100644 --- a/packages/shared/src/notifications.ts +++ b/packages/shared/src/notifications.ts @@ -41,3 +41,7 @@ export interface NotificationListView { page: 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];