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:
@@ -4,6 +4,10 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
|||||||
|
|
||||||
## Unveröffentlicht
|
## Unveröffentlicht
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Dashboard: Neues Widget „Erinnerungen“. Sie legen eine Erinnerung mit Datum, Uhrzeit, Titel und Beschreibung an, und Tessera meldet sich genau zur gewählten Zeit: im Browser mit einer Benachrichtigung (der Browser fragt dafür einmal um Erlaubnis, und zwar beim ersten Anlegen), in der Desktop-App mit einer Windows-Benachrichtigung – auch wenn das Fenster im Infobereich liegt. Wenn Sie möchten, schickt Tessera zusätzlich eine E-Mail an Ihre Adresse, auch dann, wenn Tessera gerade nirgends geöffnet ist. Eine fällige Erinnerung bleibt im Widget hervorgehoben stehen, bis Sie „Erledigt“ wählen oder mit „Später erinnern“ verschieben – auf in 10 Minuten, in 1 Stunde oder morgen zur gleichen Uhrzeit; dann meldet sich Tessera (und bei Bedarf die E-Mail) noch einmal. Erinnerungen sind persönlich: nur Sie sehen und ändern Ihre. Für die Desktop-Benachrichtigungen braucht die Desktop-App ihre neue Version, die Sie über „Auf Version … aktualisieren“ im Menü des Tessera-Symbols erhalten; Widget und E-Mail funktionieren auch mit der bisherigen Version.
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
|
|
||||||
- Eigene Module: Die Seite füllt jetzt den ganzen Inhaltsbereich. Name und Hinweiszeile darüber sind weggefallen – der Name steht ohnehin oben in der Leiste, und „In neuem Tab öffnen“ sitzt jetzt dort rechts.
|
- Eigene Module: Die Seite füllt jetzt den ganzen Inhaltsbereich. Name und Hinweiszeile darüber sind weggefallen – der Name steht ohnehin oben in der Leiste, und „In neuem Tab öffnen“ sitzt jetzt dort rechts.
|
||||||
|
|||||||
@@ -248,3 +248,69 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260
|
|||||||
expect(errorSpy).toHaveBeenCalled();
|
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');
|
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
|
* seinem `forSystem()`-Aufruf aus dem Dienst entfernt. Dieselbe Migration
|
||||||
* nimmt die `system_read_policy` auf "DashboardImage" zurueck. Summe neu:
|
* nimmt die `system_read_policy` auf "DashboardImage" zurueck. Summe neu:
|
||||||
* 5 Dateien, 6 Aufrufe.
|
* 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>([
|
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
|
||||||
['apps/api/src/dkv/dkv.service.ts', 1],
|
['apps/api/src/dkv/dkv.service.ts', 1],
|
||||||
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
||||||
['apps/api/src/proxmox/proxmox.service.ts', 1],
|
['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-digest.scheduler.ts', 1],
|
||||||
['apps/api/src/tenders/tender-matching.service.ts', 1],
|
['apps/api/src/tenders/tender-matching.service.ts', 1],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { PartialType } from '@nestjs/mapped-types';
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
import { Transform } from 'class-transformer';
|
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 }) =>
|
const trimString = ({ value }: { value: unknown }) =>
|
||||||
typeof value === 'string' ? value.trim() : value;
|
typeof value === 'string' ? value.trim() : value;
|
||||||
@@ -26,6 +33,15 @@ export class CreateReminderDto {
|
|||||||
/** Faelligkeit als ISO-8601-Zeitpunkt; der Client rechnet die Ortszeit um. */
|
/** Faelligkeit als ISO-8601-Zeitpunkt; der Client rechnet die Ortszeit um. */
|
||||||
@IsISO8601({ strict: true })
|
@IsISO8601({ strict: true })
|
||||||
dueAt!: string;
|
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() {
|
function makeService() {
|
||||||
return {
|
return {
|
||||||
list: vi.fn(async (..._args: unknown[]) => []),
|
list: vi.fn(async (..._args: unknown[]) => []),
|
||||||
|
getEmailAvailability: vi.fn(async (..._args: unknown[]) => ({
|
||||||
|
smtpConfigured: true,
|
||||||
|
hasEmail: true,
|
||||||
|
})),
|
||||||
create: vi.fn(async (..._args: unknown[]) => ({})),
|
create: vi.fn(async (..._args: unknown[]) => ({})),
|
||||||
update: vi.fn(async (..._args: unknown[]) => ({})),
|
update: vi.fn(async (..._args: unknown[]) => ({})),
|
||||||
snooze: 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;
|
const proto = RemindersController.prototype as any;
|
||||||
|
|
||||||
describe('RemindersController — Rollen', () => {
|
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();
|
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,6 +38,8 @@ describe('RemindersController — Mandant', () => {
|
|||||||
const service = makeService();
|
const service = makeService();
|
||||||
const controller = new RemindersController(service as any);
|
const controller = new RemindersController(service as any);
|
||||||
await controller.list(req('t1'), user);
|
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' });
|
await controller.create(req('t1'), user, { title: 'a', dueAt: '2099-01-01T10:00:00.000Z' });
|
||||||
expect(service.list).toHaveBeenCalledWith('t1', 'u1');
|
expect(service.list).toHaveBeenCalledWith('t1', 'u1');
|
||||||
expect(service.create.mock.calls[0].slice(0, 2)).toEqual(['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 () => {
|
it('wirft ForbiddenException ohne req.tenantId', async () => {
|
||||||
const controller = new RemindersController(makeService() as any);
|
const controller = new RemindersController(makeService() as any);
|
||||||
await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
|
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.update(req(), user, 'x', {})).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
await expect(
|
await expect(
|
||||||
controller.snooze(req(), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }),
|
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)', () => {
|
describe('RemindersController — Routen-Reihenfolge (statisch vor :id)', () => {
|
||||||
it('deklariert list vor jeder :id-Route', () => {
|
it('deklariert list vor jeder :id-Route', () => {
|
||||||
const methods = Object.getOwnPropertyNames(RemindersController.prototype);
|
const methods = Object.getOwnPropertyNames(RemindersController.prototype);
|
||||||
@@ -111,7 +140,11 @@ describe('RemindersController — Routen-Reihenfolge (statisch vor :id)', () =>
|
|||||||
const path = Reflect.getMetadata('path', proto[name]);
|
const path = Reflect.getMetadata('path', proto[name]);
|
||||||
if (typeof path === 'string' && path.startsWith(':id')) {
|
if (typeof path === 'string' && path.startsWith(':id')) {
|
||||||
expect(listIdx).toBeLessThan(methods.indexOf(name));
|
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.
|
* `TenantGuard`), der Benutzer aus dem Token.
|
||||||
*
|
*
|
||||||
* ROUTEN-REIHENFOLGE: NestJS bildet Routen in Deklarationsreihenfolge ab.
|
* 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
|
* `:id`-Route stehen, sonst faengt `:id` sie ab (404-Shadowing); der
|
||||||
* Controller-Test haelt die Reihenfolge fest.
|
* Controller-Test haelt die Reihenfolge fest.
|
||||||
*/
|
*/
|
||||||
@@ -43,6 +43,12 @@ export class RemindersController {
|
|||||||
return this.service.list(this.requireTenantId(req), user.id);
|
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()
|
@Post()
|
||||||
async create(
|
async create(
|
||||||
@Req() req: AuthenticatedRequest,
|
@Req() req: AuthenticatedRequest,
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { Module } from '@nestjs/common';
|
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 { RemindersController } from './reminders.controller';
|
||||||
import { RemindersService } from './reminders.service';
|
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({
|
@Module({
|
||||||
|
imports: [SettingsModule, MailModule],
|
||||||
controllers: [RemindersController],
|
controllers: [RemindersController],
|
||||||
providers: [RemindersService],
|
providers: [RemindersService, ReminderMailScheduler],
|
||||||
})
|
})
|
||||||
export class RemindersModule {}
|
export class RemindersModule {}
|
||||||
|
|||||||
@@ -46,12 +46,18 @@ function makeFakePrisma() {
|
|||||||
).length;
|
).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();
|
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();
|
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);
|
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,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SettingsService } from '../settings/settings.service';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
|
import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
|
||||||
|
|
||||||
@@ -40,7 +41,35 @@ const MAX_AHEAD_MS = 5 * 365 * 24 * 60 * 60 * 1000;
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RemindersService {
|
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. */
|
/** Die eigenen Erinnerungen, die naechste Faelligkeit zuerst. */
|
||||||
async list(tenantId: string, userId: string) {
|
async list(tenantId: string, userId: string) {
|
||||||
@@ -54,6 +83,7 @@ export class RemindersService {
|
|||||||
|
|
||||||
async create(tenantId: string, userId: string, dto: CreateReminderDto) {
|
async create(tenantId: string, userId: string, dto: CreateReminderDto) {
|
||||||
const dueAt = this.assertValidDueAt(dto.dueAt);
|
const dueAt = this.assertValidDueAt(dto.dueAt);
|
||||||
|
if (dto.emailEnabled === true) await this.assertEmailAvailable(tenantId, userId);
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
const count = await tenantPrisma.reminder.count({ where: { tenantId, userId } });
|
const count = await tenantPrisma.reminder.count({ where: { tenantId, userId } });
|
||||||
if (count >= MAX_REMINDERS_PER_USER) {
|
if (count >= MAX_REMINDERS_PER_USER) {
|
||||||
@@ -68,6 +98,7 @@ export class RemindersService {
|
|||||||
title: dto.title,
|
title: dto.title,
|
||||||
description: dto.description ?? '',
|
description: dto.description ?? '',
|
||||||
dueAt,
|
dueAt,
|
||||||
|
emailEnabled: dto.emailEnabled === true,
|
||||||
},
|
},
|
||||||
select: REMINDER_SELECT,
|
select: REMINDER_SELECT,
|
||||||
});
|
});
|
||||||
@@ -83,10 +114,14 @@ export class RemindersService {
|
|||||||
if (row.dueAt.getTime() <= Date.now()) {
|
if (row.dueAt.getTime() <= Date.now()) {
|
||||||
throw new ConflictException('Die Erinnerung ist bereits fällig');
|
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.title !== undefined) data.title = dto.title;
|
||||||
if (dto.description !== undefined) data.description = dto.description;
|
if (dto.description !== undefined) data.description = dto.description;
|
||||||
if (dto.dueAt !== undefined) data.dueAt = this.assertValidDueAt(dto.dueAt);
|
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({
|
return tenantPrisma.reminder.update({
|
||||||
where: { id, tenantId, userId },
|
where: { id, tenantId, userId },
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { requestBrowserPermissionOnce } from '@/lib/reminder-notify';
|
|||||||
import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time';
|
import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time';
|
||||||
import {
|
import {
|
||||||
type Reminder,
|
type Reminder,
|
||||||
|
type ReminderEmailStatus,
|
||||||
ReminderRequestError,
|
ReminderRequestError,
|
||||||
createReminder,
|
createReminder,
|
||||||
updateReminder,
|
updateReminder,
|
||||||
@@ -15,6 +16,8 @@ import {
|
|||||||
interface ReminderFormModalProps {
|
interface ReminderFormModalProps {
|
||||||
/** `null` = neue Erinnerung anlegen, sonst diese (noch nicht faellige) bearbeiten. */
|
/** `null` = neue Erinnerung anlegen, sonst diese (noch nicht faellige) bearbeiten. */
|
||||||
reminder: Reminder | null;
|
reminder: Reminder | null;
|
||||||
|
/** Ergebnis von `GET /reminders/email-status`; `null` = unbekannt oder fehlgeschlagen (gilt als nicht moeglich). */
|
||||||
|
emailStatus: ReminderEmailStatus | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Nach erfolgreichem Speichern (die Kachel laedt neu und meldet die Aenderung). */
|
/** Nach erfolgreichem Speichern (die Kachel laedt neu und meldet die Aenderung). */
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
@@ -42,7 +45,13 @@ const DESCRIPTION_MAX = 2000;
|
|||||||
* eine Nutzerhandlung, die der Browser fuer die Frage akzeptiert. Nie beim
|
* eine Nutzerhandlung, die der Browser fuer die Frage akzeptiert. Nie beim
|
||||||
* Laden der Seite.
|
* 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 t = useTranslations('widgets');
|
||||||
const initial = useRef(
|
const initial = useRef(
|
||||||
reminder ? isoToLocalInputs(reminder.dueAt) : defaultNewReminderInputs(new Date()),
|
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 [time, setTime] = useState(initial.time);
|
||||||
const [title, setTitle] = useState(reminder?.title ?? '');
|
const [title, setTitle] = useState(reminder?.title ?? '');
|
||||||
const [description, setDescription] = useState(reminder?.description ?? '');
|
const [description, setDescription] = useState(reminder?.description ?? '');
|
||||||
|
const [emailEnabled, setEmailEnabled] = useState(reminder?.emailEnabled ?? false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const titleRef = useRef<HTMLInputElement>(null);
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -84,7 +94,16 @@ export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: Remin
|
|||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
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) {
|
if (reminder) {
|
||||||
await updateReminder(reminder.id, input);
|
await updateReminder(reminder.id, input);
|
||||||
} else {
|
} 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;
|
if (typeof document === 'undefined') return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -175,6 +205,24 @@ export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: Remin
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ vi.mock('@/lib/reminders-api', async () => {
|
|||||||
updateReminder: vi.fn(),
|
updateReminder: vi.fn(),
|
||||||
snoozeReminder: vi.fn(),
|
snoozeReminder: vi.fn(),
|
||||||
deleteReminder: vi.fn(),
|
deleteReminder: vi.fn(),
|
||||||
|
getReminderEmailStatus: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
ReminderRequestError,
|
ReminderRequestError,
|
||||||
createReminder,
|
createReminder,
|
||||||
deleteReminder,
|
deleteReminder,
|
||||||
|
getReminderEmailStatus,
|
||||||
listReminders,
|
listReminders,
|
||||||
snoozeReminder,
|
snoozeReminder,
|
||||||
updateReminder,
|
updateReminder,
|
||||||
@@ -34,6 +36,7 @@ const mockCreate = createReminder as ReturnType<typeof vi.fn>;
|
|||||||
const mockUpdate = updateReminder as ReturnType<typeof vi.fn>;
|
const mockUpdate = updateReminder as ReturnType<typeof vi.fn>;
|
||||||
const mockSnooze = snoozeReminder as ReturnType<typeof vi.fn>;
|
const mockSnooze = snoozeReminder as ReturnType<typeof vi.fn>;
|
||||||
const mockDelete = deleteReminder 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 = '') {
|
function reminder(id: string, title: string, dueAt: string, description = '') {
|
||||||
return {
|
return {
|
||||||
@@ -58,6 +61,8 @@ beforeEach(() => {
|
|||||||
mockUpdate.mockReset();
|
mockUpdate.mockReset();
|
||||||
mockSnooze.mockReset();
|
mockSnooze.mockReset();
|
||||||
mockDelete.mockReset();
|
mockDelete.mockReset();
|
||||||
|
mockEmailStatus.mockReset();
|
||||||
|
mockEmailStatus.mockResolvedValue({ smtpConfigured: true, hasEmail: true });
|
||||||
requestPermission = vi.fn(async () => 'granted');
|
requestPermission = vi.fn(async () => 'granted');
|
||||||
const ctor = vi.fn();
|
const ctor = vi.fn();
|
||||||
Object.assign(ctor, { permission: 'default', requestPermission });
|
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');
|
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 {
|
import {
|
||||||
REMINDERS_CHANGED_EVENT,
|
REMINDERS_CHANGED_EVENT,
|
||||||
type Reminder,
|
type Reminder,
|
||||||
|
type ReminderEmailStatus,
|
||||||
ReminderRequestError,
|
ReminderRequestError,
|
||||||
deleteReminder,
|
deleteReminder,
|
||||||
|
getReminderEmailStatus,
|
||||||
listReminders,
|
listReminders,
|
||||||
snoozeReminder,
|
snoozeReminder,
|
||||||
} from '@/lib/reminders-api';
|
} from '@/lib/reminders-api';
|
||||||
@@ -45,6 +47,8 @@ export function ReminderWidget(_props: WidgetProps) {
|
|||||||
const [snoozeOpenId, setSnoozeOpenId] = useState<string | null>(null);
|
const [snoozeOpenId, setSnoozeOpenId] = useState<string | null>(null);
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
const [notice, setNotice] = 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 () => {
|
const refetch = useCallback(async () => {
|
||||||
try {
|
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(() => {
|
useEffect(() => {
|
||||||
void refetch();
|
void refetch();
|
||||||
const refresh = setInterval(() => void refetch(), REFRESH_MS);
|
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'
|
: 'rounded-md border border-border bg-background/60 px-3 py-2'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<p className="min-w-0 truncate text-sm font-medium text-foreground">{r.title}</p>
|
<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 && (
|
{due && (
|
||||||
<span className="shrink-0 rounded-full bg-status-warn/20 px-2 py-0.5 text-xs font-semibold text-status-warn-fg">
|
<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')}
|
{t('reminder.due')}
|
||||||
@@ -308,6 +349,7 @@ export function ReminderWidget(_props: WidgetProps) {
|
|||||||
{modal && (
|
{modal && (
|
||||||
<ReminderFormModal
|
<ReminderFormModal
|
||||||
reminder={modal.reminder}
|
reminder={modal.reminder}
|
||||||
|
emailStatus={emailStatus}
|
||||||
onClose={() => setModal(null)}
|
onClose={() => setModal(null)}
|
||||||
onSaved={afterMutation}
|
onSaved={afterMutation}
|
||||||
onStale={() => void refetch()}
|
onStale={() => void refetch()}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ReminderRequestError,
|
ReminderRequestError,
|
||||||
createReminder,
|
createReminder,
|
||||||
deleteReminder,
|
deleteReminder,
|
||||||
|
getReminderEmailStatus,
|
||||||
listReminders,
|
listReminders,
|
||||||
snoozeReminder,
|
snoozeReminder,
|
||||||
updateReminder,
|
updateReminder,
|
||||||
@@ -68,6 +69,14 @@ describe('reminders-api', () => {
|
|||||||
expect(init.credentials).toBe('include');
|
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 () => {
|
it('bei 404 und 409 tragen die Fehler den Status', async () => {
|
||||||
fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) });
|
fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) });
|
||||||
await expect(deleteReminder('x')).rejects.toMatchObject({ status: 404 });
|
await expect(deleteReminder('x')).rejects.toMatchObject({ status: 404 });
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export interface ReminderInput {
|
|||||||
description?: string;
|
description?: string;
|
||||||
/** ISO-8601-Zeitpunkt. */
|
/** ISO-8601-Zeitpunkt. */
|
||||||
dueAt: string;
|
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> {
|
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 }> {
|
export function deleteReminder(id: string): Promise<{ deleted: true }> {
|
||||||
return request<{ deleted: true }>(`/reminders/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
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.",
|
"alreadyDue": "Die Erinnerung ist inzwischen fällig und lässt sich nicht mehr bearbeiten.",
|
||||||
"notDue": "Die Erinnerung ist noch nicht fällig.",
|
"notDue": "Die Erinnerung ist noch nicht fällig.",
|
||||||
"permissionDenied": "Ihr Browser blockiert Benachrichtigungen. Fällige Erinnerungen erscheinen dann nur hier in der Kachel.",
|
"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}"
|
"notificationTitle": "Erinnerung: {title}"
|
||||||
},
|
},
|
||||||
"editShort": "Bearbeiten",
|
"editShort": "Bearbeiten",
|
||||||
|
|||||||
@@ -508,6 +508,10 @@
|
|||||||
"alreadyDue": "The reminder is now due and can no longer be edited.",
|
"alreadyDue": "The reminder is now due and can no longer be edited.",
|
||||||
"notDue": "The reminder is not due yet.",
|
"notDue": "The reminder is not due yet.",
|
||||||
"permissionDenied": "Your browser blocks notifications. Due reminders will then only appear here in the tile.",
|
"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}"
|
"notificationTitle": "Reminder: {title}"
|
||||||
},
|
},
|
||||||
"editShort": "Edit",
|
"editShort": "Edit",
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ Ihre Änderungen werden über **„Fertig"** übernommen. Verlassen Sie den Bear
|
|||||||
| Bilderrahmen | Diashow aus eigenen Bildern: Sie laden Bilder hoch (PNG, JPEG, GIF oder WebP, höchstens 5 MB je Bild, bis zu 30 Bilder) oder binden Bilder über eine https-Adresse ein. Ob das Bild ganz sichtbar oder formatfüllend erscheint, wie oft gewechselt wird (oder gar nicht), ob in Reihenfolge oder zufällig, und welche Bildunterschrift jedes Bild trägt, stellen Sie unter Einstellungen > Dashboard ein. Ein Klick auf das Bild zeigt es groß; Escape oder ein Klick daneben schließt die Großansicht wieder |
|
| Bilderrahmen | Diashow aus eigenen Bildern: Sie laden Bilder hoch (PNG, JPEG, GIF oder WebP, höchstens 5 MB je Bild, bis zu 30 Bilder) oder binden Bilder über eine https-Adresse ein. Ob das Bild ganz sichtbar oder formatfüllend erscheint, wie oft gewechselt wird (oder gar nicht), ob in Reihenfolge oder zufällig, und welche Bildunterschrift jedes Bild trägt, stellen Sie unter Einstellungen > Dashboard ein. Ein Klick auf das Bild zeigt es groß; Escape oder ein Klick daneben schließt die Großansicht wieder |
|
||||||
| XFrame | Zeigt eine Webseite als Rahmen in der Kachel. Die https-Adresse, einen optionalen Titel und ob die Seite automatisch neu geladen wird (nie, 1 Minute bis 1 Stunde), stellen Sie unter Einstellungen > Dashboard ein. Die eingebettete Seite kann Tessera nicht verlassen; über „In neuem Tab öffnen“ erreichen Sie die Seite jederzeit direkt. Manche Webseiten erlauben das Einbetten nicht — der Rahmen bleibt dann leer, der Knopf funktioniert trotzdem. Wahlweise zeigen Sie nur einen Ausschnitt der Seite: den Rahmen in der Vorschau verschieben oder an den Ecken ziehen (oder Links, Oben, Breite und Höhe eintippen) – die Kachel zeigt dann genau diesen Ausschnitt, passend zu ihrer Größe; für die ganze Seite gibt es eine Vergrößerung (50 bis 150 %), und „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen. |
|
| XFrame | Zeigt eine Webseite als Rahmen in der Kachel. Die https-Adresse, einen optionalen Titel und ob die Seite automatisch neu geladen wird (nie, 1 Minute bis 1 Stunde), stellen Sie unter Einstellungen > Dashboard ein. Die eingebettete Seite kann Tessera nicht verlassen; über „In neuem Tab öffnen“ erreichen Sie die Seite jederzeit direkt. Manche Webseiten erlauben das Einbetten nicht — der Rahmen bleibt dann leer, der Knopf funktioniert trotzdem. Wahlweise zeigen Sie nur einen Ausschnitt der Seite: den Rahmen in der Vorschau verschieben oder an den Ecken ziehen (oder Links, Oben, Breite und Höhe eintippen) – die Kachel zeigt dann genau diesen Ausschnitt, passend zu ihrer Größe; für die ganze Seite gibt es eine Vergrößerung (50 bis 150 %), und „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen. |
|
||||||
| Proxmox | Zeigt den Zustand Ihrer Proxmox-Server: oben ein farbiger Balken mit „Alles in Ordnung“ oder zum Beispiel „1 nicht erreichbar, 1 mit Warnung“, darunter die Server, auffällige zuerst, jeweils mit einer Kennzahl (laufende Gäste, Auslastung bei einer Warnung, letzte Sicherung oder eingehende E-Mails). Ein Klick auf einen Server öffnet die Proxmox-Seite. Die Kachel steht nur Benutzern zur Verfügung, die das Proxmox-Modul nutzen dürfen. Sie aktualisiert sich jede Minute aus dem zuletzt gespeicherten Stand und fragt die Server dabei nicht neu ab. Einen Titel und die Auswahl der angezeigten Server (ohne Auswahl: alle) legen Sie im Bearbeitungsmodus direkt an der Kachel über das Titelfeld und „Server auswählen“ fest oder unter Einstellungen > Dashboard. In einer schmalen Kachel stehen nur Punkte und Namen, in einer sehr kleinen nur Balken und Zusammenfassung |
|
| Proxmox | Zeigt den Zustand Ihrer Proxmox-Server: oben ein farbiger Balken mit „Alles in Ordnung“ oder zum Beispiel „1 nicht erreichbar, 1 mit Warnung“, darunter die Server, auffällige zuerst, jeweils mit einer Kennzahl (laufende Gäste, Auslastung bei einer Warnung, letzte Sicherung oder eingehende E-Mails). Ein Klick auf einen Server öffnet die Proxmox-Seite. Die Kachel steht nur Benutzern zur Verfügung, die das Proxmox-Modul nutzen dürfen. Sie aktualisiert sich jede Minute aus dem zuletzt gespeicherten Stand und fragt die Server dabei nicht neu ab. Einen Titel und die Auswahl der angezeigten Server (ohne Auswahl: alle) legen Sie im Bearbeitungsmodus direkt an der Kachel über das Titelfeld und „Server auswählen“ fest oder unter Einstellungen > Dashboard. In einer schmalen Kachel stehen nur Punkte und Namen, in einer sehr kleinen nur Balken und Zusammenfassung |
|
||||||
|
| Erinnerungen | Persönliche Erinnerungen mit Datum, Uhrzeit, Titel und Beschreibung. Über „Neue Erinnerung“ legen Sie eine an; die Liste zeigt Ihre Erinnerungen nach Zeit sortiert. Zur gewählten Zeit meldet sich Tessera mit einer Benachrichtigung – im Browser nach einer einmaligen Erlaubnis (der Browser fragt beim ersten Anlegen), in der Desktop-App als Windows-Benachrichtigung, auch wenn das Fenster im Infobereich liegt. Blockiert Ihr Browser Benachrichtigungen, sehen Sie die fällige Erinnerung nur hier in der Kachel; ein Hinweis in der Kachel sagt das. Wer möchte, setzt beim Anlegen den Haken „Zusätzlich per E-Mail erinnern“: Tessera schickt dann zur gewählten Zeit eine E-Mail an Ihre Adresse, auch wenn Tessera nirgends geöffnet ist. Der Haken ist ausgegraut, wenn Ihr Administrator noch keinen E-Mail-Versand eingerichtet hat oder in Ihrem Konto keine E-Mail-Adresse hinterlegt ist; die Kachel nennt den Grund. Eine fällige Erinnerung bleibt hervorgehoben mit „Fällig“ stehen. „Erledigt“ entfernt sie; „Später erinnern“ verschiebt sie auf in 10 Minuten, in 1 Stunde oder morgen zur gleichen Uhrzeit — dann melden sich Benachrichtigung und (falls gewählt) E-Mail noch einmal. Bearbeiten und Löschen sind nur möglich, solange die Erinnerung noch nicht fällig ist. Erinnerungen sind persönlich: nur Sie sehen und ändern Ihre |
|
||||||
|
|
||||||
Für Uhr, Suchleiste, Kalender, Notizen, Favoriten, Bilderrahmen, XFrame und Proxmox gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, Überschrift der Notiz- und Favoriten-Kachel, Bilder und Wechselintervall des Bilderrahmens, Adresse, Titel und Neuladen des XFrame, Titel und angezeigte Server der Proxmox-Kachel) — diese finden Sie unter **Einstellungen > Dashboard**, siehe [Persönliche Einstellungen](#persönliche-einstellungen).
|
Für Uhr, Suchleiste, Kalender, Notizen, Favoriten, Bilderrahmen, XFrame und Proxmox gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, Überschrift der Notiz- und Favoriten-Kachel, Bilder und Wechselintervall des Bilderrahmens, Adresse, Titel und Neuladen des XFrame, Titel und angezeigte Server der Proxmox-Kachel) — diese finden Sie unter **Einstellungen > Dashboard**, siehe [Persönliche Einstellungen](#persönliche-einstellungen).
|
||||||
|
|
||||||
@@ -224,7 +225,7 @@ Schließen Sie das Fenster über das X, legt sich Tessera lediglich in den Infob
|
|||||||
- **Mit Windows starten** (unter Linux: **Beim Anmelden starten**) — mit Häkchen
|
- **Mit Windows starten** (unter Linux: **Beim Anmelden starten**) — mit Häkchen
|
||||||
- **Beenden**
|
- **Beenden**
|
||||||
|
|
||||||
Nur „Beenden" beendet die App tatsächlich, auch im Infobereich. Fenstergröße und -position merkt sich die App bis zum nächsten Start. Fahren Sie mit der Maus über das Symbol, nennt der Hinweistext ebenfalls den verbundenen Server (unter Windows).
|
Nur „Beenden" beendet die App tatsächlich, auch im Infobereich. Solange die App läuft, melden sich auch die Erinnerungen aus dem Widget „Erinnerungen“ als Windows-Benachrichtigung, wenn das Fenster im Infobereich liegt (bei ausgeblendetem Fenster kann die Meldung bis zu einer Minute nach der gewählten Zeit erscheinen). Fenstergröße und -position merkt sich die App bis zum nächsten Start. Fahren Sie mit der Maus über das Symbol, nennt der Hinweistext ebenfalls den verbundenen Server (unter Windows).
|
||||||
|
|
||||||
### Server-Adresse ändern
|
### Server-Adresse ändern
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user