feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste
- E-Mail-Planer: Anspruch vor dem Senden (genau eine Mail je Faelligkeit, hoechstens 3 Versuche), Systemlesen nur fuer die Kandidatenabfrage - MailService.sendReminderEmail (Berliner Zeit, nur Text), GET /reminders/email-status, Haken im Formular mit Erklaerung - Zugriffsklassifikation und Erlaubnisliste fuer forSystem nachgezogen, Aenderungsliste und Anwenderanleitung Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -248,3 +248,69 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MailService.sendReminderEmail (quick-260929-if2, E-04/E-07, T-IF2-05)', () => {
|
||||
const dueAt = new Date('2026-10-05T12:30:00.000Z'); // 14:30 in Europe/Berlin (Sommerzeit)
|
||||
|
||||
function make() {
|
||||
return new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any);
|
||||
}
|
||||
|
||||
it('sendet Betreff "Erinnerung: <Titel>" mit Berliner Zeit, Titel, Beschreibung und App-Adresse; true bei Erfolg', async () => {
|
||||
const ok = await make().sendReminderEmail('t1', 'alice@a.example.invalid', {
|
||||
title: 'Zahnarzt',
|
||||
description: 'Kartenlesegeraet mitnehmen',
|
||||
dueAt,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
const sent = mockSendMail.mock.calls[0][0] as any;
|
||||
expect(sent.to).toBe('alice@a.example.invalid');
|
||||
expect(sent.from).toBe('noreply@a.example.invalid');
|
||||
expect(sent.subject).toBe('Erinnerung: Zahnarzt');
|
||||
expect(sent.html).toBeUndefined();
|
||||
expect(sent.text).toContain('14:30 Uhr');
|
||||
expect(sent.text).toContain('Zahnarzt');
|
||||
expect(sent.text).toContain('Kartenlesegeraet mitnehmen');
|
||||
expect(sent.text).toContain('http://localhost:3000');
|
||||
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('entfernt CR/LF aus dem Betreff und kuerzt auf 150 Zeichen', async () => {
|
||||
await make().sendReminderEmail('t1', 'a@a.example.invalid', {
|
||||
title: `Zeile1\r\nBcc: boese@example.invalid ${'x'.repeat(300)}`,
|
||||
description: '',
|
||||
dueAt,
|
||||
});
|
||||
const sent = mockSendMail.mock.calls[0][0] as any;
|
||||
expect(sent.subject).not.toMatch(/[\r\n]/);
|
||||
expect(sent.subject.length).toBe(150);
|
||||
expect(sent.subject.startsWith('Erinnerung: Zeile1 Bcc:')).toBe(true);
|
||||
});
|
||||
|
||||
it('laesst die Beschreibung weg, wenn sie leer ist', async () => {
|
||||
await make().sendReminderEmail('t1', 'a@a.example.invalid', { title: 'T', description: ' ', dueAt });
|
||||
const sent = mockSendMail.mock.calls[0][0] as any;
|
||||
expect(sent.text.split('\n')).toEqual([
|
||||
'Guten Tag,',
|
||||
'',
|
||||
expect.stringContaining('eine Erinnerung für'),
|
||||
'',
|
||||
'T',
|
||||
'',
|
||||
'http://localhost:3000',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gibt false zurueck (wirft nicht), wenn der Transport scheitert', async () => {
|
||||
mockSendMail = vi.fn(async () => {
|
||||
throw new Error('SMTP down');
|
||||
});
|
||||
const ok = await make().sendReminderEmail('t1', 'a@a.example.invalid', {
|
||||
title: 'T',
|
||||
description: '',
|
||||
dueAt,
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,4 +316,51 @@ export class MailService {
|
||||
|
||||
await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Welcome');
|
||||
}
|
||||
|
||||
/**
|
||||
* Erinnerungs-E-Mail (quick-260929-if2): eine Mail je faelliger Erinnerung an
|
||||
* die eigene Adresse des Besitzers, ueber den SMTP-Transport seines Mandanten.
|
||||
* Gibt `true` zurueck, wenn der Versand gelang, `false` bei einem
|
||||
* Transportfehler — der Planer (`ReminderMailScheduler`) entscheidet daran,
|
||||
* ob er den Anspruch wieder freigibt (E-04). Wirft nie.
|
||||
*
|
||||
* Nur Text, kein HTML (T-IF2-05, kein HTML-Einschleusen). Der Betreff hat
|
||||
* Zeilenumbrueche durch Leerzeichen ersetzt (Header-Einschleusung) und ist
|
||||
* auf 150 Zeichen gekuerzt. Die Zeit steht in `Europe/Berlin` (E-07): im
|
||||
* Benutzer ist keine Zeitzone gespeichert, das Haus arbeitet in deutscher Zeit.
|
||||
*/
|
||||
async sendReminderEmail(
|
||||
tenantId: string,
|
||||
to: string,
|
||||
reminder: { title: string; description: string; dueAt: Date },
|
||||
): Promise<boolean> {
|
||||
const when = `${new Intl.DateTimeFormat('de-DE', {
|
||||
timeZone: 'Europe/Berlin',
|
||||
dateStyle: 'full',
|
||||
timeStyle: 'short',
|
||||
}).format(reminder.dueAt)} Uhr`;
|
||||
const subject = `Erinnerung: ${reminder.title}`.replace(/[\r\n]+/g, ' ').slice(0, 150);
|
||||
const lines = [
|
||||
'Guten Tag,',
|
||||
'',
|
||||
`Sie haben in Tessera eine Erinnerung für ${when} gesetzt:`,
|
||||
'',
|
||||
reminder.title,
|
||||
];
|
||||
if (reminder.description.trim() !== '') {
|
||||
lines.push('', reminder.description);
|
||||
}
|
||||
lines.push('', this.appUrl);
|
||||
|
||||
try {
|
||||
await this.deliver(tenantId, { to, subject, text: lines.join('\n') }, 'Reminder');
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send Reminder email to ${to}`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,11 +181,20 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
|
||||
* seinem `forSystem()`-Aufruf aus dem Dienst entfernt. Dieselbe Migration
|
||||
* nimmt die `system_read_policy` auf "DashboardImage" zurueck. Summe neu:
|
||||
* 5 Dateien, 6 Aufrufe.
|
||||
*
|
||||
* quick-260929-if2 (Aufgabe 3): eine sechste Datei kommt hinzu —
|
||||
* `reminders/reminder-mail.scheduler.ts`, EIN Aufruf: die Kandidatenabfrage
|
||||
* des E-Mail-Planers fuer Erinnerungen (`reminder.findMany`, nur skalarer
|
||||
* Select, alle Mandanten). Alle Schreib- und Folgezugriffe laufen je Zeile
|
||||
* gebunden ueber `forTenant(prisma, c.tenantId)`. Die passende Regel ist
|
||||
* `system_read_policy ... FOR SELECT` auf "Reminder" (Migration
|
||||
* 20260929140000). Summe neu: 6 Dateien, 7 Aufrufe.
|
||||
*/
|
||||
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
|
||||
['apps/api/src/dkv/dkv.service.ts', 1],
|
||||
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
||||
['apps/api/src/proxmox/proxmox.service.ts', 1],
|
||||
['apps/api/src/reminders/reminder-mail.scheduler.ts', 1],
|
||||
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
|
||||
['apps/api/src/tenders/tender-matching.service.ts', 1],
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsISO8601, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsISO8601,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
const trimString = ({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' ? value.trim() : value;
|
||||
@@ -26,6 +33,15 @@ export class CreateReminderDto {
|
||||
/** Faelligkeit als ISO-8601-Zeitpunkt; der Client rechnet die Ortszeit um. */
|
||||
@IsISO8601({ strict: true })
|
||||
dueAt!: string;
|
||||
|
||||
/**
|
||||
* Zusaetzlich per E-Mail erinnern (Aufgabe 3). `true` ist nur erlaubt, wenn
|
||||
* der Mandant einen E-Mail-Versand eingerichtet UND der Benutzer eine
|
||||
* Adresse hat (sonst 400 im Dienst).
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
emailEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* ReminderMailScheduler.spec (quick-260929-if2, Aufgabe 3). Ein handgebauter,
|
||||
* Prisma-foermiger Speicher (Konvention dieses Repos, vgl.
|
||||
* tender-digest.scheduler.spec.ts). Wichtig: `updateMany` prueft und setzt
|
||||
* OHNE `await` dazwischen — wie die eine SQL-Anweisung in der Datenbank ist der
|
||||
* Anspruch damit atomar, und zwei Durchlaeufe ueber DENSELBEN Speicher koennen
|
||||
* ihn nur einmal gewinnen (T-IF2-06).
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__tenantClient(tenantId)),
|
||||
forSystem: vi.fn((prisma: any) => prisma.__systemClient()),
|
||||
}));
|
||||
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { ReminderMailScheduler } from './reminder-mail.scheduler';
|
||||
|
||||
const NOW = new Date('2026-09-29T12:00:00.000Z');
|
||||
const minutesAgo = (m: number) => new Date(NOW.getTime() - m * 60_000);
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
dueAt: Date;
|
||||
emailEnabled: boolean;
|
||||
emailSentAt: Date | null;
|
||||
emailAttempts: number;
|
||||
}
|
||||
|
||||
function row(over: Partial<Row> & { id: string }): Row {
|
||||
return {
|
||||
tenantId: 't1',
|
||||
userId: 'u1',
|
||||
title: `Titel ${over.id}`,
|
||||
description: '',
|
||||
dueAt: minutesAgo(1),
|
||||
emailEnabled: true,
|
||||
emailSentAt: null,
|
||||
emailAttempts: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const sameTime = (a: Date | null, b: Date | null) =>
|
||||
a === null || b === null ? a === b : a.getTime() === b.getTime();
|
||||
|
||||
function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u1@example.invalid' }) {
|
||||
const systemFindMany = vi.fn(async ({ where, take, orderBy }: any) => {
|
||||
let list = rows.filter(
|
||||
(r) =>
|
||||
r.emailEnabled === where.emailEnabled &&
|
||||
r.emailSentAt === where.emailSentAt &&
|
||||
r.emailAttempts < where.emailAttempts.lt &&
|
||||
r.dueAt.getTime() <= where.dueAt.lte.getTime() &&
|
||||
r.dueAt.getTime() >= where.dueAt.gte.getTime(),
|
||||
);
|
||||
if (orderBy?.dueAt === 'asc') list = [...list].sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime());
|
||||
return list.slice(0, take).map(({ id, tenantId, userId, dueAt }) => ({ id, tenantId, userId, dueAt }));
|
||||
});
|
||||
|
||||
const boundLog: string[] = [];
|
||||
const tenantClient = (tenantId: string) => ({
|
||||
reminder: {
|
||||
updateMany: vi.fn(async ({ where, data }: any) => {
|
||||
boundLog.push(`updateMany:${tenantId}`);
|
||||
let count = 0;
|
||||
for (const r of rows) {
|
||||
if (r.id !== where.id || r.tenantId !== where.tenantId || r.tenantId !== tenantId) continue;
|
||||
if ('dueAt' in where && !sameTime(r.dueAt, where.dueAt)) continue;
|
||||
if ('emailEnabled' in where && r.emailEnabled !== where.emailEnabled) continue;
|
||||
if ('emailSentAt' in where && !sameTime(r.emailSentAt, where.emailSentAt)) continue;
|
||||
if (where.emailAttempts?.lt !== undefined && !(r.emailAttempts < where.emailAttempts.lt)) continue;
|
||||
if (data.emailSentAt !== undefined) r.emailSentAt = data.emailSentAt;
|
||||
if (data.emailAttempts?.increment) r.emailAttempts += data.emailAttempts.increment;
|
||||
count++;
|
||||
}
|
||||
return { count };
|
||||
}),
|
||||
findFirst: vi.fn(async ({ where }: any) => {
|
||||
boundLog.push(`findFirst:${tenantId}`);
|
||||
const r = rows.find(
|
||||
(x) => x.id === where.id && x.tenantId === tenantId && sameTime(x.dueAt, where.dueAt),
|
||||
);
|
||||
return r ? { title: r.title, description: r.description, dueAt: r.dueAt } : null;
|
||||
}),
|
||||
},
|
||||
user: {
|
||||
findFirst: vi.fn(async ({ where }: any) => ({ email: emails[where.id] ?? null })),
|
||||
},
|
||||
});
|
||||
|
||||
const prisma: any = {
|
||||
__systemClient: () => ({ reminder: { findMany: systemFindMany } }),
|
||||
__tenantClient: tenantClient,
|
||||
};
|
||||
return { prisma, rows, systemFindMany, boundLog };
|
||||
}
|
||||
|
||||
function makeScheduler(
|
||||
prisma: any,
|
||||
opts: { smtp?: unknown; sendResult?: boolean | Error } = {},
|
||||
) {
|
||||
const registry = { addInterval: vi.fn(), deleteInterval: vi.fn() };
|
||||
const settings = { getSmtpConfig: vi.fn(async () => (opts.smtp === undefined ? {} : opts.smtp)) };
|
||||
const mail = {
|
||||
sendReminderEmail: vi.fn(async () => {
|
||||
if (opts.sendResult instanceof Error) throw opts.sendResult;
|
||||
return opts.sendResult ?? true;
|
||||
}),
|
||||
};
|
||||
const scheduler = new ReminderMailScheduler(registry as any, prisma, settings as any, mail as any);
|
||||
return { scheduler, registry, settings, mail };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ReminderMailScheduler — Anspruch (T-IF2-06)', () => {
|
||||
it('zwei Instanzen ueber denselben Speicher senden genau eine Mail', async () => {
|
||||
const store = makeStore([row({ id: 'a' })]);
|
||||
const one = makeScheduler(store.prisma);
|
||||
const two = makeScheduler(store.prisma);
|
||||
await Promise.all([one.scheduler.runTick(NOW), two.scheduler.runTick(NOW)]);
|
||||
expect(one.mail.sendReminderEmail.mock.calls.length + two.mail.sendReminderEmail.mock.calls.length).toBe(1);
|
||||
expect(store.rows[0].emailSentAt).toEqual(NOW);
|
||||
expect(store.rows[0].emailAttempts).toBe(1);
|
||||
});
|
||||
|
||||
it('ein zweiter Durchlauf danach sendet nicht erneut', async () => {
|
||||
const store = makeStore([row({ id: 'a' })]);
|
||||
const { scheduler, mail } = makeScheduler(store.prisma);
|
||||
await scheduler.runTick(NOW);
|
||||
await scheduler.runTick(new Date(NOW.getTime() + 30_000));
|
||||
expect(mail.sendReminderEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ein ueberlappender Durchlauf im selben Prozess wird uebersprungen (running)', async () => {
|
||||
const store = makeStore([row({ id: 'a' })]);
|
||||
const { scheduler } = makeScheduler(store.prisma);
|
||||
const first = scheduler.runTick(NOW);
|
||||
await scheduler.runTick(NOW); // laeuft, waehrend der erste noch nicht fertig ist
|
||||
await first;
|
||||
expect(store.systemFindMany).toHaveBeenCalledTimes(1);
|
||||
// danach ist der Riegel wieder offen
|
||||
await scheduler.runTick(NOW);
|
||||
expect(store.systemFindMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('sendet mit Titel, Beschreibung und Faelligkeit an die Adresse des Besitzers, gebunden an dessen Mandanten', async () => {
|
||||
const store = makeStore([row({ id: 'a', tenantId: 't7', userId: 'u1', description: 'Text' })], {
|
||||
u1: 'anna@example.invalid',
|
||||
});
|
||||
const { scheduler, mail, settings } = makeScheduler(store.prisma);
|
||||
await scheduler.runTick(NOW);
|
||||
expect(mail.sendReminderEmail).toHaveBeenCalledWith('t7', 'anna@example.invalid', {
|
||||
title: 'Titel a',
|
||||
description: 'Text',
|
||||
dueAt: store.rows[0].dueAt,
|
||||
});
|
||||
expect(settings.getSmtpConfig).toHaveBeenCalledWith('t7');
|
||||
expect(forTenant).toHaveBeenCalledWith(store.prisma, 't7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReminderMailScheduler — Fehlschlag und Wiederholung (E-04)', () => {
|
||||
it('ein Transportfehler gibt den Anspruch frei; der naechste Durchlauf versucht es erneut', async () => {
|
||||
const store = makeStore([row({ id: 'a' })]);
|
||||
const { scheduler, mail } = makeScheduler(store.prisma, { sendResult: false });
|
||||
await scheduler.runTick(NOW);
|
||||
expect(store.rows[0].emailSentAt).toBeNull();
|
||||
expect(store.rows[0].emailAttempts).toBe(1);
|
||||
await scheduler.runTick(new Date(NOW.getTime() + 30_000));
|
||||
expect(mail.sendReminderEmail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('nach drei Versuchen ist die Erinnerung kein Kandidat mehr', async () => {
|
||||
const store = makeStore([row({ id: 'a' })]);
|
||||
const { scheduler, mail } = makeScheduler(store.prisma, { sendResult: false });
|
||||
for (let i = 0; i < 5; i++) await scheduler.runTick(new Date(NOW.getTime() + i * 30_000));
|
||||
expect(mail.sendReminderEmail).toHaveBeenCalledTimes(3);
|
||||
expect(store.rows[0].emailAttempts).toBe(3);
|
||||
});
|
||||
|
||||
it('wirft der Versand, wird der Anspruch ebenfalls freigegeben und der Durchlauf bricht nicht ab', async () => {
|
||||
const store = makeStore([row({ id: 'a' }), row({ id: 'b', dueAt: minutesAgo(2) })]);
|
||||
const { scheduler, mail } = makeScheduler(store.prisma, { sendResult: new Error('kaputt') });
|
||||
await expect(scheduler.runTick(NOW)).resolves.toBeUndefined();
|
||||
expect(mail.sendReminderEmail).toHaveBeenCalledTimes(2);
|
||||
expect(store.rows.every((r) => r.emailSentAt === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('die Freigabe trifft nur den eigenen Anspruch (gleicher Zeitstempel)', async () => {
|
||||
const store = makeStore([row({ id: 'a' })]);
|
||||
const { scheduler } = makeScheduler(store.prisma, { sendResult: false });
|
||||
const spy = vi.spyOn(store.prisma, '__tenantClient');
|
||||
await scheduler.runTick(NOW);
|
||||
const client = spy.mock.results[0].value;
|
||||
const releaseCall = client.reminder.updateMany.mock.calls[1][0];
|
||||
expect(releaseCall.where).toEqual({ id: 'a', tenantId: 't1', emailSentAt: NOW });
|
||||
expect(releaseCall.data).toEqual({ emailSentAt: null });
|
||||
});
|
||||
|
||||
it('ohne SmtpConfig: kein Versand, Anspruch bleibt, keine Wiederholung', async () => {
|
||||
const store = makeStore([row({ id: 'a' })]);
|
||||
const { scheduler, mail } = makeScheduler(store.prisma, { smtp: null });
|
||||
await scheduler.runTick(NOW);
|
||||
await scheduler.runTick(new Date(NOW.getTime() + 30_000));
|
||||
expect(mail.sendReminderEmail).not.toHaveBeenCalled();
|
||||
expect(store.rows[0].emailSentAt).toEqual(NOW);
|
||||
expect(store.rows[0].emailAttempts).toBe(1);
|
||||
});
|
||||
|
||||
it('ohne E-Mail-Adresse des Benutzers: kein Versand, Anspruch bleibt', async () => {
|
||||
const store = makeStore([row({ id: 'a' })], { u1: null });
|
||||
const { scheduler, mail } = makeScheduler(store.prisma);
|
||||
await scheduler.runTick(NOW);
|
||||
await scheduler.runTick(new Date(NOW.getTime() + 30_000));
|
||||
expect(mail.sendReminderEmail).not.toHaveBeenCalled();
|
||||
expect(store.rows[0].emailSentAt).toEqual(NOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReminderMailScheduler — Kandidaten (E-03, T-IF2-07)', () => {
|
||||
it('die Abfrage waehlt nur emailEnabled, ohne Anspruch, unter 3 Versuchen, faellig und nicht aelter als 24 h', async () => {
|
||||
const store = makeStore([
|
||||
row({ id: 'ok' }),
|
||||
row({ id: 'aus', emailEnabled: false }),
|
||||
row({ id: 'schon', emailSentAt: minutesAgo(1) }),
|
||||
row({ id: 'drei', emailAttempts: 3 }),
|
||||
row({ id: 'zukunft', dueAt: new Date(NOW.getTime() + 60_000) }),
|
||||
row({ id: 'alt', dueAt: new Date(NOW.getTime() - 25 * 3600_000) }),
|
||||
]);
|
||||
const { scheduler, mail } = makeScheduler(store.prisma);
|
||||
await scheduler.runTick(NOW);
|
||||
expect(mail.sendReminderEmail).toHaveBeenCalledTimes(1);
|
||||
const args = store.systemFindMany.mock.calls[0][0];
|
||||
expect(args.where).toEqual({
|
||||
emailEnabled: true,
|
||||
emailSentAt: null,
|
||||
emailAttempts: { lt: 3 },
|
||||
dueAt: { lte: NOW, gte: new Date(NOW.getTime() - 24 * 3600_000) },
|
||||
});
|
||||
expect(args.take).toBe(200);
|
||||
expect(args.orderBy).toEqual({ dueAt: 'asc' });
|
||||
});
|
||||
|
||||
it('der Systemklient liest nur skalar, ohne Relation', async () => {
|
||||
const store = makeStore([row({ id: 'a' })]);
|
||||
const { scheduler } = makeScheduler(store.prisma);
|
||||
await scheduler.runTick(NOW);
|
||||
expect(store.systemFindMany.mock.calls[0][0].select).toEqual({
|
||||
id: true,
|
||||
tenantId: true,
|
||||
userId: true,
|
||||
dueAt: true,
|
||||
});
|
||||
expect(forSystem).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('eine fehlerhafte Zeile haelt die uebrigen nicht an', async () => {
|
||||
const store = makeStore([row({ id: 'a' }), row({ id: 'b', dueAt: minutesAgo(2) })]);
|
||||
const { scheduler, mail } = makeScheduler(store.prisma);
|
||||
// erste Zeile (b ist aelter, kommt zuerst): der Benutzer-Zugriff wirft
|
||||
let calls = 0;
|
||||
const original = store.prisma.__tenantClient;
|
||||
store.prisma.__tenantClient = (tenantId: string) => {
|
||||
const client = original(tenantId);
|
||||
client.user.findFirst = vi.fn(async () => {
|
||||
if (++calls === 1) throw new Error('DB weg');
|
||||
return { email: 'u1@example.invalid' };
|
||||
});
|
||||
return client;
|
||||
};
|
||||
await scheduler.runTick(NOW);
|
||||
expect(mail.sendReminderEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('nach einem Verschieben (Spuren zurueckgesetzt, neue Faelligkeit) gibt es genau eine weitere Mail (D-03)', async () => {
|
||||
const store = makeStore([row({ id: 'a' })]);
|
||||
const { scheduler, mail } = makeScheduler(store.prisma);
|
||||
await scheduler.runTick(NOW);
|
||||
expect(mail.sendReminderEmail).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Wie RemindersService.snooze: neue Faelligkeit, Spuren zurueck
|
||||
const later = new Date(NOW.getTime() + 10 * 60_000);
|
||||
Object.assign(store.rows[0], { dueAt: later, emailSentAt: null, emailAttempts: 0 });
|
||||
await scheduler.runTick(new Date(later.getTime() + 5_000));
|
||||
await scheduler.runTick(new Date(later.getTime() + 35_000));
|
||||
expect(mail.sendReminderEmail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReminderMailScheduler — Start', () => {
|
||||
it('registriert genau ein Intervall "reminder-email" und ersetzt ein vorhandenes', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const store = makeStore([]);
|
||||
const { scheduler, registry } = makeScheduler(store.prisma);
|
||||
scheduler.onApplicationBootstrap();
|
||||
expect(registry.deleteInterval).toHaveBeenCalledWith('reminder-email');
|
||||
expect(registry.addInterval).toHaveBeenCalledTimes(1);
|
||||
expect(registry.addInterval.mock.calls[0][0]).toBe('reminder-email');
|
||||
clearInterval(registry.addInterval.mock.calls[0][1]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('ein Fehler beim Registrieren wird nur protokolliert, nie geworfen', () => {
|
||||
const store = makeStore([]);
|
||||
const { scheduler, registry } = makeScheduler(store.prisma);
|
||||
registry.addInterval.mockImplementation(() => {
|
||||
throw new Error('doppelt');
|
||||
});
|
||||
expect(() => scheduler.onApplicationBootstrap()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
|
||||
/** E-08: Abstand der Pruefung. */
|
||||
const TICK_MS = 30_000;
|
||||
/** E-03: so lange nach der Faelligkeit wird noch gemailt (Neustart, Ausfall). */
|
||||
const CATCH_UP_MS = 24 * 60 * 60 * 1000;
|
||||
/** E-04: hoechstens so viele Versuche je Faelligkeit. */
|
||||
const MAX_ATTEMPTS = 3;
|
||||
/** Kandidaten je Durchlauf. */
|
||||
const BATCH = 200;
|
||||
|
||||
/**
|
||||
* E-Mail-Planer der Erinnerungen (quick-260929-if2): verschickt hoechstens EINE
|
||||
* Mail je faelliger Erinnerung, auch wenn Tessera nirgends geoeffnet ist.
|
||||
*
|
||||
* WARUM DER ANSPRUCH VOR DEM SENDEN STEHT (E-04, T-IF2-06): ein `updateMany`
|
||||
* setzt `emailSentAt` und zaehlt `emailAttempts` hoch, und zwar NUR, wo
|
||||
* `emailSentAt` noch leer ist und `dueAt` unveraendert. Nur wer die Zeile mit
|
||||
* `count === 1` bekommt, sendet. Mehrere API-Instanzen, ein Neustart mitten im
|
||||
* Durchlauf oder zwei ueberlappende Durchlaeufe verschicken so nie doppelt.
|
||||
* Scheitert der Transport, gibt der Planer den Anspruch wieder frei
|
||||
* (`emailSentAt = null`), sodass der naechste Durchlauf es erneut versucht —
|
||||
* hoechstens dreimal. Fehlt beim Senden die SMTP-Einrichtung oder die Adresse
|
||||
* des Benutzers, bleibt der Anspruch: die Faelligkeit gilt als erledigt und
|
||||
* wird nur protokolliert, es gibt keine Wiederholschleife. Ein Verschieben
|
||||
* („Spaeter erinnern“) setzt beide Felder zurueck (siehe `RemindersService`).
|
||||
*
|
||||
* WARUM 24 STUNDEN (E-03): bei Neustart oder Ausfall werden Erinnerungen der
|
||||
* letzten 24 Stunden nachgeholt; wer spaeter SMTP einrichtet, bekommt keine
|
||||
* Mails ueber alte Erinnerungen. WARUM ALLE 30 SEKUNDEN (E-08): die Mail soll
|
||||
* zur Faelligkeit ankommen, die Abfrage ist ein einziger Indexzugriff auf
|
||||
* `dueAt`.
|
||||
*
|
||||
* SYSTEMKONTEXT: die Kandidatenabfrage liest ueber ALLE Mandanten
|
||||
* (`forSystem()`, `system_read_policy ... FOR SELECT`, Migration 20260929140000);
|
||||
* der Select ist bewusst nur skalar — eine Relation im Systemklienten machte
|
||||
* `User` zum Systemlese-Modell (WINDOWS #27). Alles Weitere, jede Schreib- und
|
||||
* Leseoperation je Zeile, laeuft an den Mandanten der Zeile gebunden.
|
||||
* Lebenszyklus `onApplicationBootstrap` wie `TenderSchedulerService`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReminderMailScheduler implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(ReminderMailScheduler.name);
|
||||
private readonly JOB_NAME = 'reminder-email';
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
private readonly schedulerRegistry: SchedulerRegistry,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly settingsService: SettingsService,
|
||||
private readonly mail: MailService,
|
||||
) {}
|
||||
|
||||
/** Registriert das eine globale Intervall. Fehler werden nur protokolliert. */
|
||||
onApplicationBootstrap(): void {
|
||||
try {
|
||||
try {
|
||||
this.schedulerRegistry.deleteInterval(this.JOB_NAME);
|
||||
} catch {
|
||||
/* noch nicht registriert — beim ersten Start erwartet */
|
||||
}
|
||||
this.schedulerRegistry.addInterval(
|
||||
this.JOB_NAME,
|
||||
setInterval(() => {
|
||||
this.runTick().catch((err) =>
|
||||
this.logger.error(`Reminder email tick failed: ${(err as Error).message}`),
|
||||
);
|
||||
}, TICK_MS),
|
||||
);
|
||||
this.logger.log(
|
||||
`Reminder email scheduler registered: every ${TICK_MS / 1000} s (single global job — all tenants)`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(`Reminder email scheduler init failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Durchlauf. Ueberlappende Aufrufe im selben Prozess werden uebersprungen. */
|
||||
async runTick(now: Date = new Date()): Promise<void> {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
try {
|
||||
const systemPrisma = forSystem(this.prisma);
|
||||
const candidates: { id: string; tenantId: string; userId: string; dueAt: Date }[] =
|
||||
await systemPrisma.reminder.findMany({
|
||||
where: {
|
||||
emailEnabled: true,
|
||||
emailSentAt: null,
|
||||
emailAttempts: { lt: MAX_ATTEMPTS },
|
||||
dueAt: { lte: now, gte: new Date(now.getTime() - CATCH_UP_MS) },
|
||||
},
|
||||
select: { id: true, tenantId: true, userId: true, dueAt: true },
|
||||
orderBy: { dueAt: 'asc' },
|
||||
take: BATCH,
|
||||
});
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await this.processCandidate(candidate, now);
|
||||
} catch (err) {
|
||||
// Eine kaputte Zeile darf die uebrigen nicht anhalten.
|
||||
this.logger.error(
|
||||
`Reminder email for ${candidate.id} failed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async processCandidate(
|
||||
c: { id: string; tenantId: string; userId: string; dueAt: Date },
|
||||
now: Date,
|
||||
): Promise<void> {
|
||||
const tenantPrisma = forTenant(this.prisma, c.tenantId);
|
||||
|
||||
// Anspruch: nur wer count === 1 bekommt, sendet.
|
||||
const claim = await tenantPrisma.reminder.updateMany({
|
||||
where: {
|
||||
id: c.id,
|
||||
tenantId: c.tenantId,
|
||||
dueAt: c.dueAt,
|
||||
emailEnabled: true,
|
||||
emailSentAt: null,
|
||||
emailAttempts: { lt: MAX_ATTEMPTS },
|
||||
},
|
||||
data: { emailSentAt: now, emailAttempts: { increment: 1 } },
|
||||
});
|
||||
if (claim.count !== 1) return;
|
||||
|
||||
// Zeile nur mit UNVERAENDERTER Faelligkeit laden: wurde sie zwischen Anspruch
|
||||
// und Laden verschoben, ist die Faelligkeit nicht mehr dieselbe.
|
||||
const row = await tenantPrisma.reminder.findFirst({
|
||||
where: { id: c.id, tenantId: c.tenantId, dueAt: c.dueAt },
|
||||
select: { title: true, description: true, dueAt: true },
|
||||
});
|
||||
const user = await tenantPrisma.user.findFirst({
|
||||
where: { id: c.userId, tenantId: c.tenantId },
|
||||
select: { email: true },
|
||||
});
|
||||
const smtp = await this.settingsService.getSmtpConfig(c.tenantId);
|
||||
if (!row || !user?.email || smtp === null) {
|
||||
this.logger.log(
|
||||
`Reminder email for ${c.id} übersprungen (${!row ? 'Zeile geändert' : !user?.email ? 'keine E-Mail-Adresse' : 'kein E-Mail-Versand eingerichtet'})`,
|
||||
);
|
||||
return; // Anspruch bleibt: gilt als erledigt, keine Wiederholschleife (E-04)
|
||||
}
|
||||
|
||||
let sent = false;
|
||||
try {
|
||||
sent = await this.mail.sendReminderEmail(c.tenantId, user.email, row);
|
||||
} catch (err) {
|
||||
this.logger.error(`Reminder email for ${c.id} threw: ${(err as Error).message}`);
|
||||
}
|
||||
if (!sent) {
|
||||
// Nur der eigene Anspruch (gleicher Zeitstempel) wird freigegeben.
|
||||
await tenantPrisma.reminder.updateMany({
|
||||
where: { id: c.id, tenantId: c.tenantId, emailSentAt: now },
|
||||
data: { emailSentAt: null },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import { RemindersController } from './reminders.controller';
|
||||
function makeService() {
|
||||
return {
|
||||
list: vi.fn(async (..._args: unknown[]) => []),
|
||||
getEmailAvailability: vi.fn(async (..._args: unknown[]) => ({
|
||||
smtpConfigured: true,
|
||||
hasEmail: true,
|
||||
})),
|
||||
create: vi.fn(async (..._args: unknown[]) => ({})),
|
||||
update: vi.fn(async (..._args: unknown[]) => ({})),
|
||||
snooze: vi.fn(async (..._args: unknown[]) => ({})),
|
||||
@@ -20,7 +24,7 @@ const user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any;
|
||||
const proto = RemindersController.prototype as any;
|
||||
|
||||
describe('RemindersController — Rollen', () => {
|
||||
it.each(['list', 'create', 'update', 'snooze', 'remove'])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
|
||||
it.each(['list', 'emailStatus', 'create', 'update', 'snooze', 'remove'])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -34,6 +38,8 @@ describe('RemindersController — Mandant', () => {
|
||||
const service = makeService();
|
||||
const controller = new RemindersController(service as any);
|
||||
await controller.list(req('t1'), user);
|
||||
await controller.emailStatus(req('t1'), user);
|
||||
expect(service.getEmailAvailability).toHaveBeenCalledWith('t1', 'u1');
|
||||
await controller.create(req('t1'), user, { title: 'a', dueAt: '2099-01-01T10:00:00.000Z' });
|
||||
expect(service.list).toHaveBeenCalledWith('t1', 'u1');
|
||||
expect(service.create.mock.calls[0].slice(0, 2)).toEqual(['t1', 'u1']);
|
||||
@@ -48,6 +54,7 @@ describe('RemindersController — Mandant', () => {
|
||||
it('wirft ForbiddenException ohne req.tenantId', async () => {
|
||||
const controller = new RemindersController(makeService() as any);
|
||||
await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.emailStatus(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.update(req(), user, 'x', {})).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(
|
||||
controller.snooze(req(), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }),
|
||||
@@ -102,6 +109,28 @@ describe('RemindersController — Pipe fuer Aendern und Verschieben', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemindersController — emailEnabled', () => {
|
||||
it('die Pipe laesst emailEnabled beim Anlegen und Aendern durch und verlangt einen Wahrheitswert', async () => {
|
||||
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||
const created: any = await pipe.transform(
|
||||
{ title: 'a', dueAt: '2099-01-01T10:00:00.000Z', emailEnabled: true },
|
||||
{ type: 'body', metatype: CreateReminderDto },
|
||||
);
|
||||
expect(created.emailEnabled).toBe(true);
|
||||
const updated: any = await pipe.transform(
|
||||
{ emailEnabled: false },
|
||||
{ type: 'body', metatype: UpdateReminderDto },
|
||||
);
|
||||
expect(updated.emailEnabled).toBe(false);
|
||||
await expect(
|
||||
pipe.transform(
|
||||
{ title: 'a', dueAt: '2099-01-01T10:00:00.000Z', emailEnabled: 'ja' },
|
||||
{ type: 'body', metatype: CreateReminderDto },
|
||||
),
|
||||
).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemindersController — Routen-Reihenfolge (statisch vor :id)', () => {
|
||||
it('deklariert list vor jeder :id-Route', () => {
|
||||
const methods = Object.getOwnPropertyNames(RemindersController.prototype);
|
||||
@@ -111,7 +140,11 @@ describe('RemindersController — Routen-Reihenfolge (statisch vor :id)', () =>
|
||||
const path = Reflect.getMetadata('path', proto[name]);
|
||||
if (typeof path === 'string' && path.startsWith(':id')) {
|
||||
expect(listIdx).toBeLessThan(methods.indexOf(name));
|
||||
// auch die statische Route email-status steht vor jeder :id-Route
|
||||
expect(methods.indexOf('emailStatus')).toBeLessThan(methods.indexOf(name));
|
||||
}
|
||||
}
|
||||
expect(methods.indexOf('emailStatus')).toBeGreaterThanOrEqual(0);
|
||||
expect(Reflect.getMetadata('path', proto.emailStatus)).toBe('email-status');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ import { RemindersService } from './reminders.service';
|
||||
* `TenantGuard`), der Benutzer aus dem Token.
|
||||
*
|
||||
* ROUTEN-REIHENFOLGE: NestJS bildet Routen in Deklarationsreihenfolge ab.
|
||||
* Jede statische GET-Route (Aufgabe 3 ergaenzt `email-status`) MUSS ueber jeder
|
||||
* Jede statische GET-Route (`email-status`) MUSS ueber jeder
|
||||
* `:id`-Route stehen, sonst faengt `:id` sie ab (404-Shadowing); der
|
||||
* Controller-Test haelt die Reihenfolge fest.
|
||||
*/
|
||||
@@ -43,6 +43,12 @@ export class RemindersController {
|
||||
return this.service.list(this.requireTenantId(req), user.id);
|
||||
}
|
||||
|
||||
// Statische Route: MUSS ueber jeder :id-Route stehen (siehe Kopfkommentar).
|
||||
@Get('email-status')
|
||||
async emailStatus(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) {
|
||||
return this.service.getEmailAvailability(this.requireTenantId(req), user.id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MailModule } from '../mail/mail.module';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
import { ReminderMailScheduler } from './reminder-mail.scheduler';
|
||||
import { RemindersController } from './reminders.controller';
|
||||
import { RemindersService } from './reminders.service';
|
||||
|
||||
/** Erinnerungen (quick-260929-if2). `PrismaModule` ist global. */
|
||||
/**
|
||||
* Erinnerungen (quick-260929-if2). `PrismaModule` ist global. `SettingsModule`
|
||||
* liefert die Regel „E-Mail-Versand eingerichtet“ (E-09), `MailModule` den
|
||||
* Versand fuer den Planer.
|
||||
*/
|
||||
@Module({
|
||||
imports: [SettingsModule, MailModule],
|
||||
controllers: [RemindersController],
|
||||
providers: [RemindersService],
|
||||
providers: [RemindersService, ReminderMailScheduler],
|
||||
})
|
||||
export class RemindersModule {}
|
||||
|
||||
@@ -46,12 +46,18 @@ function makeFakePrisma() {
|
||||
).length;
|
||||
}),
|
||||
};
|
||||
return { reminder, rows };
|
||||
const user = {
|
||||
findFirst: vi.fn(async ({ where }: { where: any }) => ({
|
||||
email: where.id === 'u-ohne-mail' ? null : `${where.id}@example.invalid`,
|
||||
})),
|
||||
};
|
||||
return { reminder, user, rows };
|
||||
}
|
||||
|
||||
function setup() {
|
||||
function setup(smtp: unknown = { host: 'smtp.example.invalid' }) {
|
||||
const prisma = makeFakePrisma();
|
||||
return { prisma, service: new RemindersService(prisma as any) };
|
||||
const settings = { getSmtpConfig: vi.fn(async (_tenantId: string) => smtp) };
|
||||
return { prisma, settings, service: new RemindersService(prisma as any, settings as any) };
|
||||
}
|
||||
|
||||
const inHours = (h: number) => new Date(Date.now() + h * 3600_000).toISOString();
|
||||
@@ -244,3 +250,67 @@ describe('RemindersService — loeschen (E-02)', () => {
|
||||
expect(prisma.rows.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
|
||||
it('getEmailAvailability: SMTP eingerichtet und Adresse vorhanden', async () => {
|
||||
const { service, settings } = setup();
|
||||
await expect(service.getEmailAvailability('t1', 'u1')).resolves.toEqual({
|
||||
smtpConfigured: true,
|
||||
hasEmail: true,
|
||||
});
|
||||
expect(settings.getSmtpConfig).toHaveBeenCalledWith('t1');
|
||||
});
|
||||
|
||||
it('getEmailAvailability: ohne SmtpConfig-Zeile ist der Versand nicht eingerichtet', async () => {
|
||||
const { service } = setup(null);
|
||||
await expect(service.getEmailAvailability('t1', 'u1')).resolves.toEqual({
|
||||
smtpConfigured: false,
|
||||
hasEmail: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('getEmailAvailability: ohne Adresse im Konto hasEmail false, gelesen ueber den gebundenen Klienten', async () => {
|
||||
const { prisma, service } = setup();
|
||||
await expect(service.getEmailAvailability('t1', 'u-ohne-mail')).resolves.toEqual({
|
||||
smtpConfigured: true,
|
||||
hasEmail: false,
|
||||
});
|
||||
expect(forTenant).toHaveBeenLastCalledWith(expect.anything(), 't1', 'u-ohne-mail');
|
||||
expect(prisma.user.findFirst.mock.calls[0]?.[0]?.where).toEqual({ id: 'u-ohne-mail', tenantId: 't1' });
|
||||
});
|
||||
|
||||
it('anlegen mit emailEnabled speichert das Feld, wenn E-Mail moeglich ist', async () => {
|
||||
const { prisma, service } = setup();
|
||||
await service.create('t1', 'u1', { title: 'a', dueAt: inHours(1), emailEnabled: true });
|
||||
expect(prisma.reminder.create.mock.calls[0]?.[0]?.data.emailEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('anlegen ohne emailEnabled speichert false', async () => {
|
||||
const { prisma, service } = setup();
|
||||
await service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) });
|
||||
expect(prisma.reminder.create.mock.calls[0]?.[0]?.data.emailEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('anlegen mit emailEnabled ergibt 400, wenn SMTP fehlt oder die Adresse fehlt', async () => {
|
||||
const noSmtp = setup(null);
|
||||
await expect(
|
||||
noSmtp.service.create('t1', 'u1', { title: 'a', dueAt: inHours(1), emailEnabled: true }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
const noMail = setup();
|
||||
await expect(
|
||||
noMail.service.create('t1', 'u-ohne-mail', { title: 'a', dueAt: inHours(1), emailEnabled: true }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(noSmtp.prisma.rows.size).toBe(0);
|
||||
expect(noMail.prisma.rows.size).toBe(0);
|
||||
});
|
||||
|
||||
it('aendern mit emailEnabled true ergibt 400 ohne SMTP; emailEnabled false geht immer', async () => {
|
||||
const { prisma, service } = setup(null);
|
||||
seed(prisma, 'x', future());
|
||||
await expect(service.update('t1', 'u1', 'x', { emailEnabled: true })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
await service.update('t1', 'u1', 'x', { emailEnabled: false });
|
||||
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
|
||||
|
||||
@@ -40,7 +41,35 @@ const MAX_AHEAD_MS = 5 * 365 * 24 * 60 * 60 * 1000;
|
||||
*/
|
||||
@Injectable()
|
||||
export class RemindersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly settingsService: SettingsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Ist die E-Mail-Erinnerung fuer diesen Benutzer moeglich? (E-09: „E-Mail-
|
||||
* Versand eingerichtet“ heisst, der Mandant hat eine `SmtpConfig`-Zeile —
|
||||
* dieselbe Regel wie `TenderMailService`; der Umgebungs-Rueckfall des
|
||||
* `MailService` zaehlt nicht.) Der Benutzer wird ueber den gebundenen Klienten
|
||||
* gelesen.
|
||||
*/
|
||||
async getEmailAvailability(tenantId: string, userId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const user = await tenantPrisma.user.findFirst({
|
||||
where: { id: userId, tenantId },
|
||||
select: { email: true },
|
||||
});
|
||||
const smtp = await this.settingsService.getSmtpConfig(tenantId);
|
||||
return { smtpConfigured: smtp !== null, hasEmail: Boolean(user?.email) };
|
||||
}
|
||||
|
||||
/** 400, wenn `emailEnabled: true` verlangt wird, obwohl E-Mail nicht moeglich ist. */
|
||||
private async assertEmailAvailable(tenantId: string, userId: string) {
|
||||
const { smtpConfigured, hasEmail } = await this.getEmailAvailability(tenantId, userId);
|
||||
if (!smtpConfigured || !hasEmail) {
|
||||
throw new BadRequestException('E-Mail-Erinnerungen sind nicht möglich');
|
||||
}
|
||||
}
|
||||
|
||||
/** Die eigenen Erinnerungen, die naechste Faelligkeit zuerst. */
|
||||
async list(tenantId: string, userId: string) {
|
||||
@@ -54,6 +83,7 @@ export class RemindersService {
|
||||
|
||||
async create(tenantId: string, userId: string, dto: CreateReminderDto) {
|
||||
const dueAt = this.assertValidDueAt(dto.dueAt);
|
||||
if (dto.emailEnabled === true) await this.assertEmailAvailable(tenantId, userId);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const count = await tenantPrisma.reminder.count({ where: { tenantId, userId } });
|
||||
if (count >= MAX_REMINDERS_PER_USER) {
|
||||
@@ -68,6 +98,7 @@ export class RemindersService {
|
||||
title: dto.title,
|
||||
description: dto.description ?? '',
|
||||
dueAt,
|
||||
emailEnabled: dto.emailEnabled === true,
|
||||
},
|
||||
select: REMINDER_SELECT,
|
||||
});
|
||||
@@ -83,10 +114,14 @@ export class RemindersService {
|
||||
if (row.dueAt.getTime() <= Date.now()) {
|
||||
throw new ConflictException('Die Erinnerung ist bereits fällig');
|
||||
}
|
||||
const data: { title?: string; description?: string; dueAt?: Date } = {};
|
||||
const data: { title?: string; description?: string; dueAt?: Date; emailEnabled?: boolean } = {};
|
||||
if (dto.title !== undefined) data.title = dto.title;
|
||||
if (dto.description !== undefined) data.description = dto.description;
|
||||
if (dto.dueAt !== undefined) data.dueAt = this.assertValidDueAt(dto.dueAt);
|
||||
if (dto.emailEnabled !== undefined) {
|
||||
if (dto.emailEnabled) await this.assertEmailAvailable(tenantId, userId);
|
||||
data.emailEnabled = dto.emailEnabled;
|
||||
}
|
||||
return tenantPrisma.reminder.update({
|
||||
where: { id, tenantId, userId },
|
||||
data,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { requestBrowserPermissionOnce } from '@/lib/reminder-notify';
|
||||
import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time';
|
||||
import {
|
||||
type Reminder,
|
||||
type ReminderEmailStatus,
|
||||
ReminderRequestError,
|
||||
createReminder,
|
||||
updateReminder,
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
interface ReminderFormModalProps {
|
||||
/** `null` = neue Erinnerung anlegen, sonst diese (noch nicht faellige) bearbeiten. */
|
||||
reminder: Reminder | null;
|
||||
/** Ergebnis von `GET /reminders/email-status`; `null` = unbekannt oder fehlgeschlagen (gilt als nicht moeglich). */
|
||||
emailStatus: ReminderEmailStatus | null;
|
||||
onClose: () => void;
|
||||
/** Nach erfolgreichem Speichern (die Kachel laedt neu und meldet die Aenderung). */
|
||||
onSaved: () => void;
|
||||
@@ -42,7 +45,13 @@ const DESCRIPTION_MAX = 2000;
|
||||
* eine Nutzerhandlung, die der Browser fuer die Frage akzeptiert. Nie beim
|
||||
* Laden der Seite.
|
||||
*/
|
||||
export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: ReminderFormModalProps) {
|
||||
export function ReminderFormModal({
|
||||
reminder,
|
||||
emailStatus,
|
||||
onClose,
|
||||
onSaved,
|
||||
onStale,
|
||||
}: ReminderFormModalProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const initial = useRef(
|
||||
reminder ? isoToLocalInputs(reminder.dueAt) : defaultNewReminderInputs(new Date()),
|
||||
@@ -51,6 +60,7 @@ export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: Remin
|
||||
const [time, setTime] = useState(initial.time);
|
||||
const [title, setTitle] = useState(reminder?.title ?? '');
|
||||
const [description, setDescription] = useState(reminder?.description ?? '');
|
||||
const [emailEnabled, setEmailEnabled] = useState(reminder?.emailEnabled ?? false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
@@ -84,7 +94,16 @@ export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: Remin
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const input = { title: trimmedTitle, description: description.trim(), dueAt };
|
||||
// `emailEnabled` nur senden, wenn es sich aendert oder beim Anlegen `true`
|
||||
// ist: ein Server, der E-Mail nicht mehr anbietet, lehnt `true` mit 400
|
||||
// ab — eine unveraenderte Alt-Einstellung darf das Speichern nicht kippen.
|
||||
const emailChanged = reminder ? emailEnabled !== reminder.emailEnabled : emailEnabled;
|
||||
const input = {
|
||||
title: trimmedTitle,
|
||||
description: description.trim(),
|
||||
dueAt,
|
||||
...(emailChanged ? { emailEnabled } : {}),
|
||||
};
|
||||
if (reminder) {
|
||||
await updateReminder(reminder.id, input);
|
||||
} else {
|
||||
@@ -105,6 +124,17 @@ export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: Remin
|
||||
}
|
||||
};
|
||||
|
||||
// E-Mail moeglich: Versand eingerichtet UND Adresse im Konto. Unbekannt gilt
|
||||
// als nicht moeglich. Ein bereits angehaktes Feld bleibt bedienbar, damit man
|
||||
// es wieder abwaehlen kann.
|
||||
const emailAvailable = emailStatus?.smtpConfigured === true && emailStatus.hasEmail;
|
||||
const emailHint =
|
||||
emailStatus === null || emailAvailable
|
||||
? null
|
||||
: !emailStatus.smtpConfigured
|
||||
? t('reminder.emailNoSmtp')
|
||||
: t('reminder.emailNoAddress');
|
||||
|
||||
if (typeof document === 'undefined') return null;
|
||||
|
||||
return createPortal(
|
||||
@@ -175,6 +205,24 @@ export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: Remin
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={emailEnabled}
|
||||
disabled={!emailAvailable && !emailEnabled}
|
||||
onChange={(e) => setEmailEnabled(e.target.checked)}
|
||||
aria-describedby={emailHint ? 'reminder-email-hint' : undefined}
|
||||
/>
|
||||
{t('reminder.emailLabel')}
|
||||
</label>
|
||||
{emailHint && (
|
||||
<p id="reminder-email-hint" className="text-xs text-muted-foreground">
|
||||
{emailHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
|
||||
@@ -16,6 +16,7 @@ vi.mock('@/lib/reminders-api', async () => {
|
||||
updateReminder: vi.fn(),
|
||||
snoozeReminder: vi.fn(),
|
||||
deleteReminder: vi.fn(),
|
||||
getReminderEmailStatus: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
ReminderRequestError,
|
||||
createReminder,
|
||||
deleteReminder,
|
||||
getReminderEmailStatus,
|
||||
listReminders,
|
||||
snoozeReminder,
|
||||
updateReminder,
|
||||
@@ -34,6 +36,7 @@ const mockCreate = createReminder as ReturnType<typeof vi.fn>;
|
||||
const mockUpdate = updateReminder as ReturnType<typeof vi.fn>;
|
||||
const mockSnooze = snoozeReminder as ReturnType<typeof vi.fn>;
|
||||
const mockDelete = deleteReminder as ReturnType<typeof vi.fn>;
|
||||
const mockEmailStatus = getReminderEmailStatus as ReturnType<typeof vi.fn>;
|
||||
|
||||
function reminder(id: string, title: string, dueAt: string, description = '') {
|
||||
return {
|
||||
@@ -58,6 +61,8 @@ beforeEach(() => {
|
||||
mockUpdate.mockReset();
|
||||
mockSnooze.mockReset();
|
||||
mockDelete.mockReset();
|
||||
mockEmailStatus.mockReset();
|
||||
mockEmailStatus.mockResolvedValue({ smtpConfigured: true, hasEmail: true });
|
||||
requestPermission = vi.fn(async () => 'granted');
|
||||
const ctor = vi.fn();
|
||||
Object.assign(ctor, { permission: 'default', requestPermission });
|
||||
@@ -307,3 +312,87 @@ describe('ReminderWidget — Faelligkeit ohne Neuladen', () => {
|
||||
expect(screen.getByTestId('reminder-row').getAttribute('data-due')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
|
||||
async function openFormAndWaitForStatus() {
|
||||
await openForm();
|
||||
// Der Status wird beim Einhaengen der Kachel geladen
|
||||
await waitFor(() => expect(mockEmailStatus).toHaveBeenCalledTimes(1));
|
||||
return screen.getByLabelText('reminder.emailLabel') as HTMLInputElement;
|
||||
}
|
||||
|
||||
it('ist bedienbar und wird als emailEnabled gesendet, wenn Versand und Adresse da sind', async () => {
|
||||
mockList.mockResolvedValue([]);
|
||||
mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z'));
|
||||
render(<ReminderWidget {...props} />);
|
||||
const box = await openFormAndWaitForStatus();
|
||||
await waitFor(() => expect(box.disabled).toBe(false));
|
||||
fireEvent.click(box);
|
||||
fillAndSubmit('Mit Mail');
|
||||
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||
expect(mockCreate.mock.calls[0][0].emailEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('ohne E-Mail-Haken wird emailEnabled gar nicht gesendet', async () => {
|
||||
mockList.mockResolvedValue([]);
|
||||
mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z'));
|
||||
render(<ReminderWidget {...props} />);
|
||||
await openFormAndWaitForStatus();
|
||||
fillAndSubmit('Ohne Mail');
|
||||
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('emailEnabled');
|
||||
});
|
||||
|
||||
it('ist ausgegraut mit Erklaerung, wenn kein E-Mail-Versand eingerichtet ist', async () => {
|
||||
mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true });
|
||||
mockList.mockResolvedValue([]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
const box = await openFormAndWaitForStatus();
|
||||
expect(await screen.findByText('reminder.emailNoSmtp')).toBeTruthy();
|
||||
expect(box.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('ist ausgegraut mit eigener Erklaerung, wenn das Konto keine Adresse hat', async () => {
|
||||
mockEmailStatus.mockResolvedValue({ smtpConfigured: true, hasEmail: false });
|
||||
mockList.mockResolvedValue([]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
const box = await openFormAndWaitForStatus();
|
||||
expect(await screen.findByText('reminder.emailNoAddress')).toBeTruthy();
|
||||
expect(box.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('gilt als nicht moeglich, solange der Status fehlt oder fehlschlaegt', async () => {
|
||||
mockEmailStatus.mockRejectedValue(new Error('offline'));
|
||||
mockList.mockResolvedValue([]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
const box = await openFormAndWaitForStatus();
|
||||
expect(box.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('zeigt bei Zeilen mit emailEnabled ein Mail-Symbol, sonst nicht', async () => {
|
||||
mockList.mockResolvedValue([
|
||||
{ ...reminder('a', 'Mit', '2099-01-01T10:00:00.000Z'), emailEnabled: true },
|
||||
reminder('b', 'Ohne', '2099-01-02T10:00:00.000Z'),
|
||||
]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
const rows = await screen.findAllByTestId('reminder-row');
|
||||
expect(within(rows[0]).getByTestId('reminder-email-icon')).toBeTruthy();
|
||||
expect(within(rows[1]).queryByTestId('reminder-email-icon')).toBeNull();
|
||||
});
|
||||
|
||||
it('Bearbeiten einer Erinnerung mit angehakter Mail sendet emailEnabled nur bei Aenderung', async () => {
|
||||
mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true });
|
||||
mockList.mockResolvedValue([
|
||||
{ ...reminder('a', 'Alt', new Date(Date.now() + 3600_000).toISOString()), emailEnabled: true },
|
||||
]);
|
||||
mockUpdate.mockResolvedValue({});
|
||||
render(<ReminderWidget {...props} />);
|
||||
fireEvent.click(await screen.findByLabelText('reminder.edit'));
|
||||
const box = screen.getByLabelText('reminder.emailLabel') as HTMLInputElement;
|
||||
expect(box.checked).toBe(true);
|
||||
expect(box.disabled).toBe(false); // angehakt bleibt bedienbar, zum Abwaehlen
|
||||
fireEvent.click(screen.getByText('reminder.save'));
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
expect(mockUpdate.mock.calls[0][1]).not.toHaveProperty('emailEnabled');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,10 @@ import { type SnoozePreset, snoozeTarget } from '@/lib/reminder-time';
|
||||
import {
|
||||
REMINDERS_CHANGED_EVENT,
|
||||
type Reminder,
|
||||
type ReminderEmailStatus,
|
||||
ReminderRequestError,
|
||||
deleteReminder,
|
||||
getReminderEmailStatus,
|
||||
listReminders,
|
||||
snoozeReminder,
|
||||
} from '@/lib/reminders-api';
|
||||
@@ -45,6 +47,8 @@ export function ReminderWidget(_props: WidgetProps) {
|
||||
const [snoozeOpenId, setSnoozeOpenId] = useState<string | null>(null);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
// Einmal je Einhaengen der Kachel geladen; unbekannt/fehlgeschlagen = E-Mail nicht moeglich.
|
||||
const [emailStatus, setEmailStatus] = useState<ReminderEmailStatus | null>(null);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
try {
|
||||
@@ -55,6 +59,18 @@ export function ReminderWidget(_props: WidgetProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getReminderEmailStatus()
|
||||
.then((status) => {
|
||||
if (!cancelled) setEmailStatus(status);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refetch();
|
||||
const refresh = setInterval(() => void refetch(), REFRESH_MS);
|
||||
@@ -160,8 +176,33 @@ export function ReminderWidget(_props: WidgetProps) {
|
||||
: 'rounded-md border border-border bg-background/60 px-3 py-2'
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="min-w-0 truncate text-sm font-medium text-foreground">{r.title}</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{r.title}</p>
|
||||
{r.emailEnabled && (
|
||||
<span
|
||||
className="shrink-0 text-muted-foreground"
|
||||
role="img"
|
||||
aria-label={t('reminder.emailOn')}
|
||||
title={t('reminder.emailOn')}
|
||||
data-testid="reminder-email-icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="m3 7 9 6 9-6" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{due && (
|
||||
<span className="shrink-0 rounded-full bg-status-warn/20 px-2 py-0.5 text-xs font-semibold text-status-warn-fg">
|
||||
{t('reminder.due')}
|
||||
@@ -308,6 +349,7 @@ export function ReminderWidget(_props: WidgetProps) {
|
||||
{modal && (
|
||||
<ReminderFormModal
|
||||
reminder={modal.reminder}
|
||||
emailStatus={emailStatus}
|
||||
onClose={() => setModal(null)}
|
||||
onSaved={afterMutation}
|
||||
onStale={() => void refetch()}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ReminderRequestError,
|
||||
createReminder,
|
||||
deleteReminder,
|
||||
getReminderEmailStatus,
|
||||
listReminders,
|
||||
snoozeReminder,
|
||||
updateReminder,
|
||||
@@ -68,6 +69,14 @@ describe('reminders-api', () => {
|
||||
expect(init.credentials).toBe('include');
|
||||
});
|
||||
|
||||
it('getReminderEmailStatus ruft GET /reminders/email-status', async () => {
|
||||
fetchMock.mockResolvedValue(ok({ smtpConfigured: true, hasEmail: false }));
|
||||
await expect(getReminderEmailStatus()).resolves.toEqual({ smtpConfigured: true, hasEmail: false });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toMatch(/\/reminders\/email-status$/);
|
||||
expect(init.credentials).toBe('include');
|
||||
});
|
||||
|
||||
it('bei 404 und 409 tragen die Fehler den Status', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) });
|
||||
await expect(deleteReminder('x')).rejects.toMatchObject({ status: 404 });
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface ReminderInput {
|
||||
description?: string;
|
||||
/** ISO-8601-Zeitpunkt. */
|
||||
dueAt: string;
|
||||
/** Zusaetzlich per E-Mail erinnern; nur senden, wenn es sich aendert oder `true` ist. */
|
||||
emailEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function createReminder(input: ReminderInput): Promise<Reminder> {
|
||||
@@ -79,3 +81,13 @@ export function snoozeReminder(id: string, dueAt: string): Promise<Reminder> {
|
||||
export function deleteReminder(id: string): Promise<{ deleted: true }> {
|
||||
return request<{ deleted: true }>(`/reminders/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/** Ob die E-Mail-Erinnerung fuer diesen Benutzer moeglich ist (Versand eingerichtet, Adresse vorhanden). */
|
||||
export interface ReminderEmailStatus {
|
||||
smtpConfigured: boolean;
|
||||
hasEmail: boolean;
|
||||
}
|
||||
|
||||
export function getReminderEmailStatus(): Promise<ReminderEmailStatus> {
|
||||
return request<ReminderEmailStatus>('/reminders/email-status');
|
||||
}
|
||||
|
||||
@@ -508,6 +508,10 @@
|
||||
"alreadyDue": "Die Erinnerung ist inzwischen fällig und lässt sich nicht mehr bearbeiten.",
|
||||
"notDue": "Die Erinnerung ist noch nicht fällig.",
|
||||
"permissionDenied": "Ihr Browser blockiert Benachrichtigungen. Fällige Erinnerungen erscheinen dann nur hier in der Kachel.",
|
||||
"emailLabel": "Zusätzlich per E-Mail erinnern",
|
||||
"emailNoSmtp": "E-Mail-Erinnerungen sind nicht möglich, weil kein E-Mail-Versand eingerichtet ist. Bitte wenden Sie sich an Ihren Administrator.",
|
||||
"emailNoAddress": "In Ihrem Konto ist keine E-Mail-Adresse hinterlegt.",
|
||||
"emailOn": "Zusätzlich per E-Mail",
|
||||
"notificationTitle": "Erinnerung: {title}"
|
||||
},
|
||||
"editShort": "Bearbeiten",
|
||||
|
||||
@@ -508,6 +508,10 @@
|
||||
"alreadyDue": "The reminder is now due and can no longer be edited.",
|
||||
"notDue": "The reminder is not due yet.",
|
||||
"permissionDenied": "Your browser blocks notifications. Due reminders will then only appear here in the tile.",
|
||||
"emailLabel": "Also remind me by e-mail",
|
||||
"emailNoSmtp": "E-mail reminders are not possible because no e-mail delivery is set up. Please contact your administrator.",
|
||||
"emailNoAddress": "Your account has no e-mail address on file.",
|
||||
"emailOn": "Also by e-mail",
|
||||
"notificationTitle": "Reminder: {title}"
|
||||
},
|
||||
"editShort": "Edit",
|
||||
|
||||
Reference in New Issue
Block a user