fix(reminders): keine Dauermeldung ohne localStorage, Mail nach Zeitaenderung, null-Pruefung

- Merkliste zusaetzlich im Arbeitsspeicher (sonst alle 10 s dieselbe Meldung)
- Aendern der Faelligkeit atomar gegen gleichzeitiges Faelligwerden, setzt Mail-Spur zurueck
- UpdateReminderDto lehnt null ab (400 statt 500)
- keine Mails an deaktivierte Benutzer
- Spaeter erinnern beschriftet heute/morgen nach dem berechneten Zeitpunkt
- Bearbeiten schickt dueAt nur bei geaenderter Zeit

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 03:20:16 +02:00
parent be1e0035e0
commit c2e4467dd8
14 changed files with 442 additions and 78 deletions
+30 -2
View File
@@ -1,4 +1,3 @@
import { PartialType } from '@nestjs/mapped-types';
import { Transform } from 'class-transformer';
import {
IsBoolean,
@@ -7,6 +6,7 @@ import {
IsOptional,
IsString,
MaxLength,
ValidateIf,
} from 'class-validator';
const trimString = ({ value }: { value: unknown }) =>
@@ -44,12 +44,40 @@ export class CreateReminderDto {
emailEnabled?: boolean;
}
/** Nur ein FEHLENDES Feld wird uebersprungen — `null` wird geprueft und damit abgelehnt. */
const whenPresent = ValidateIf((_obj: object, value: unknown) => value !== undefined);
/**
* Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen.
* Eine faellige Erinnerung laesst sich nicht aendern (409 im Dienst) — dafuer
* gibt es „Erledigt“ (loeschen) und „Spaeter erinnern“ (`SnoozeReminderDto`).
*
* WARUM KEIN `PartialType`: das setzt `@IsOptional()`, und das laesst auch
* `null` ungeprueft durch — `{"title": null}` kaeme dann als 500 aus der
* Datenbank statt als 400 aus der Pruefung. `whenPresent` ueberspringt nur
* fehlende Felder.
*/
export class UpdateReminderDto extends PartialType(CreateReminderDto) {}
export class UpdateReminderDto {
@whenPresent
@Transform(trimString)
@IsString()
@IsNotEmpty()
@MaxLength(200)
title?: string;
@whenPresent
@IsString()
@MaxLength(2000)
description?: string;
@whenPresent
@IsISO8601({ strict: true })
dueAt?: string;
@whenPresent
@IsBoolean()
emailEnabled?: boolean;
}
/** Neuer Zeitpunkt beim Spaeter-Erinnern (D-03); der Client rechnet ihn aus (E-05). */
export class SnoozeReminderDto {
@@ -48,7 +48,11 @@ function row(over: Partial<Row> & { id: string }): Row {
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' }) {
function makeStore(
rows: Row[],
emails: Record<string, string | null> = { u1: 'u1@example.invalid' },
inactive: string[] = [],
) {
const systemFindMany = vi.fn(async ({ where, take, orderBy }: any) => {
let list = rows.filter(
(r) =>
@@ -58,8 +62,11 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
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 }));
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[] = [];
@@ -69,11 +76,13 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
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 (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 (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++;
@@ -89,7 +98,11 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
}),
},
user: {
findFirst: vi.fn(async ({ where }: any) => ({ email: emails[where.id] ?? null })),
findFirst: vi.fn(async ({ where }: any) => {
const isActive = !inactive.includes(where.id);
if ('isActive' in where && where.isActive !== isActive) return null;
return { email: emails[where.id] ?? null };
}),
},
});
@@ -100,10 +113,7 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
return { prisma, rows, systemFindMany, boundLog };
}
function makeScheduler(
prisma: any,
opts: { smtp?: unknown; sendResult?: boolean | Error } = {},
) {
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 = {
@@ -112,7 +122,12 @@ function makeScheduler(
return opts.sendResult ?? true;
}),
};
const scheduler = new ReminderMailScheduler(registry as any, prisma, settings as any, mail as any);
const scheduler = new ReminderMailScheduler(
registry as any,
prisma,
settings as any,
mail as any,
);
return { scheduler, registry, settings, mail };
}
@@ -126,7 +141,9 @@ describe('ReminderMailScheduler — Anspruch (T-IF2-06)', () => {
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(
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);
});
@@ -223,6 +240,23 @@ describe('ReminderMailScheduler — Fehlschlag und Wiederholung (E-04)', () => {
expect(mail.sendReminderEmail).not.toHaveBeenCalled();
expect(store.rows[0].emailSentAt).toEqual(NOW);
});
it('deaktivierter Benutzer: kein Versand, Anspruch bleibt wie ohne Adresse', async () => {
const store = makeStore([row({ id: 'a' })], { u1: 'u1@example.invalid' }, ['u1']);
const { scheduler, mail } = makeScheduler(store.prisma);
const spy = vi.spyOn(store.prisma, '__tenantClient');
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);
const client = spy.mock.results[0].value;
expect(client.user.findFirst.mock.calls[0][0].where).toEqual({
id: 'u1',
tenantId: 't1',
isActive: true,
});
});
});
describe('ReminderMailScheduler — Kandidaten (E-03, T-IF2-07)', () => {
@@ -26,7 +26,7 @@ const BATCH = 200;
* 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
* des Benutzers oder ist sein Konto deaktiviert (`isActive = false`), 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`).
*
@@ -104,9 +104,7 @@ export class ReminderMailScheduler implements OnApplicationBootstrap {
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}`,
);
this.logger.error(`Reminder email for ${candidate.id} failed: ${(err as Error).message}`);
}
}
} finally {
@@ -140,14 +138,15 @@ export class ReminderMailScheduler implements OnApplicationBootstrap {
where: { id: c.id, tenantId: c.tenantId, dueAt: c.dueAt },
select: { title: true, description: true, dueAt: true },
});
// Nur aktive Konten: ein deaktivierter Benutzer gilt wie einer ohne Adresse.
const user = await tenantPrisma.user.findFirst({
where: { id: c.userId, tenantId: c.tenantId },
where: { id: c.userId, tenantId: c.tenantId, isActive: true },
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'})`,
`Reminder email for ${c.id} übersprungen (${!row ? 'Zeile geändert' : !user ? 'Benutzer deaktiviert' : !user.email ? 'keine E-Mail-Adresse' : 'kein E-Mail-Versand eingerichtet'})`,
);
return; // Anspruch bleibt: gilt als erledigt, keine Wiederholschleife (E-04)
}
@@ -1,5 +1,5 @@
import 'reflect-metadata';
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
@@ -24,7 +24,14 @@ const user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any;
const proto = RemindersController.prototype as any;
describe('RemindersController — Rollen', () => {
it.each(['list', 'emailStatus', '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();
});
@@ -55,7 +62,9 @@ describe('RemindersController — Mandant', () => {
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.update(req(), user, 'x', {})).rejects.toBeInstanceOf(
ForbiddenException,
);
await expect(
controller.snooze(req(), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }),
).rejects.toBeInstanceOf(ForbiddenException);
@@ -96,6 +105,28 @@ describe('RemindersController — Pipe fuer Aendern und Verschieben', () => {
expect(out).toEqual({ title: 'b' });
});
it.each([
'title',
'description',
'dueAt',
'emailEnabled',
])('Aendern mit %s: null ergibt 400 statt eines Datenbankfehlers', async (field) => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
await expect(
pipe.transform({ [field]: null }, { type: 'body', metatype: UpdateReminderDto }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('Aendern: ein leerer Titel wird abgelehnt, ein leeres Objekt ist erlaubt', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
await expect(
pipe.transform({ title: ' ' }, { type: 'body', metatype: UpdateReminderDto }),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
pipe.transform({}, { type: 'body', metatype: UpdateReminderDto }),
).resolves.toEqual({});
});
it('Verschieben verlangt einen ISO-Zeitpunkt und verwirft Fremdfelder', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const out: any = await pipe.transform(
@@ -16,7 +16,13 @@ function makeFakePrisma() {
const reminder = {
create: vi.fn(async ({ data }: { data: any }) => {
const id = `r-${++seq}`;
const row = { id, createdAt: new Date(), updatedAt: new Date(), emailEnabled: false, ...data };
const row = {
id,
createdAt: new Date(),
updatedAt: new Date(),
emailEnabled: false,
...data,
};
rows.set(id, row);
return row;
}),
@@ -37,6 +43,14 @@ function makeFakePrisma() {
rows.set(where.id, row);
return row;
}),
// Wie die eine SQL-Anweisung: Bedingung pruefen und schreiben ohne `await` dazwischen.
updateMany: vi.fn(async ({ where, data }: { where: any; data: any }) => {
const r = rows.get(where.id);
if (!r || r.tenantId !== where.tenantId || r.userId !== where.userId) return { count: 0 };
if (where.dueAt?.gt && !(r.dueAt.getTime() > where.dueAt.gt.getTime())) return { count: 0 };
rows.set(where.id, { ...r, ...data });
return { count: 1 };
}),
delete: vi.fn(async ({ where }: { where: any }) => {
rows.delete(where.id);
}),
@@ -106,7 +120,9 @@ describe('RemindersService — anlegen', () => {
service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) }),
).rejects.toBeInstanceOf(ConflictException);
// ein anderer Benutzer ist davon nicht betroffen
await expect(service.create('t1', 'u2', { title: 'a', dueAt: inHours(1) })).resolves.toBeTruthy();
await expect(
service.create('t1', 'u2', { title: 'a', dueAt: inHours(1) }),
).resolves.toBeTruthy();
});
});
@@ -153,15 +169,21 @@ describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)'
])('%s: aendern, verschieben und loeschen ergeben 404', async (_n, owner) => {
const { prisma, service } = setup();
seed(prisma, 'x', past(), owner);
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(NotFoundException);
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(1) })).rejects.toBeInstanceOf(NotFoundException);
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(1) })).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(service.remove('t1', 'u1', 'x')).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.rows.has('x')).toBe(true);
});
it('eine unbekannte Kennung ergibt 404', async () => {
const { service } = setup();
await expect(service.remove('t1', 'u1', 'gibt-es-nicht')).rejects.toBeInstanceOf(NotFoundException);
await expect(service.remove('t1', 'u1', 'gibt-es-nicht')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('das where jeder Abfrage traegt Mandant und Benutzer', async () => {
@@ -173,10 +195,11 @@ describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)'
tenantId: 't1',
userId: 'u1',
});
expect(prisma.reminder.update.mock.calls[0]?.[0]?.where).toEqual({
expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.where).toEqual({
id: 'x',
tenantId: 't1',
userId: 'u1',
dueAt: { gt: expect.any(Date) },
});
});
});
@@ -185,7 +208,9 @@ describe('RemindersService — aendern', () => {
it('eine faellige Erinnerung laesst sich nicht aendern (409)', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', past());
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(ConflictException);
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(
ConflictException,
);
});
it('eine vergangene neue Faelligkeit ergibt 400', async () => {
@@ -200,14 +225,61 @@ describe('RemindersService — aendern', () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
await service.update('t1', 'u1', 'x', { title: 'neu' });
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ title: 'neu' });
expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.data).toEqual({ title: 'neu' });
const newDue = inHours(9);
await service.update('t1', 'u1', 'x', { dueAt: newDue, description: '' });
expect(prisma.reminder.update.mock.calls[1]?.[0]?.data).toEqual({
expect(prisma.reminder.updateMany.mock.calls[1]?.[0]?.data).toEqual({
description: '',
dueAt: new Date(newDue),
emailSentAt: null,
emailAttempts: 0,
});
});
it('eine neue Faelligkeit setzt emailSentAt und emailAttempts zurueck, sonst bleiben sie', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
await service.update('t1', 'u1', 'x', { title: 'neu' });
expect(prisma.rows.get('x').emailAttempts).toBe(2);
expect(prisma.rows.get('x').emailSentAt).not.toBeNull();
const newDue = inHours(5);
const out: any = await service.update('t1', 'u1', 'x', { dueAt: newDue });
expect(prisma.rows.get('x')).toMatchObject({
dueAt: new Date(newDue),
emailSentAt: null,
emailAttempts: 0,
});
expect(out.dueAt).toEqual(new Date(newDue));
});
it('wird die Erinnerung zwischen Pruefung und Schreiben faellig, gibt es 409 und keine Aenderung', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
// Nach dem Laden (Pruefung bestanden) ist die Zeile inzwischen faellig.
prisma.reminder.findFirst.mockImplementationOnce(async () => {
const r = prisma.rows.get('x');
const loaded = { ...r };
prisma.rows.set('x', { ...r, dueAt: past() });
return loaded;
});
await expect(service.update('t1', 'u1', 'x', { title: 'neu' })).rejects.toBeInstanceOf(
ConflictException,
);
expect(prisma.rows.get('x').title).toBe('alt');
});
it('wird die Erinnerung zwischen Pruefung und Schreiben geloescht, gibt es 404', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
prisma.reminder.findFirst.mockImplementationOnce(async () => {
const loaded = { ...prisma.rows.get('x') };
prisma.rows.delete('x');
return loaded;
});
await expect(service.update('t1', 'u1', 'x', { title: 'neu' })).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('RemindersService — spaeter erinnern (D-03)', () => {
@@ -276,7 +348,10 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
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' });
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 () => {
@@ -298,7 +373,11 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
).rejects.toBeInstanceOf(BadRequestException);
const noMail = setup();
await expect(
noMail.service.create('t1', 'u-ohne-mail', { title: 'a', dueAt: inHours(1), emailEnabled: true }),
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);
@@ -311,6 +390,6 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
BadRequestException,
);
await service.update('t1', 'u1', 'x', { emailEnabled: false });
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false });
expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false });
});
});
+38 -6
View File
@@ -5,8 +5,8 @@ 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 { SettingsService } from '../settings/settings.service';
import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
/** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */
@@ -107,26 +107,58 @@ export class RemindersService {
/**
* Bearbeiten einer noch NICHT faelligen Erinnerung. Eine faellige ist tabu
* (409): dafuer gibt es „Erledigt“ und „Spaeter erinnern“ (D-03).
*
* WARUM `updateMany` MIT `dueAt > jetzt`: Pruefung und Schreiben sind sonst
* getrennt — wird die Erinnerung dazwischen faellig (und der E-Mail-Planer
* hat sie womoeglich schon beansprucht), wuerde trotzdem geschrieben. Die
* Bedingung im selben Schreibzugriff schliesst das aus; `count === 0` heisst
* dann 409 (oder 404, falls die Zeile inzwischen geloescht ist).
*
* Eine neue Faelligkeit setzt wie beim Verschieben die Spuren des
* E-Mail-Planers zurueck, damit zur neuen Faelligkeit eine Mail geht.
*/
async update(tenantId: string, userId: string, id: string, dto: UpdateReminderDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const row = await this.loadOwn(tenantPrisma, tenantId, userId, id);
if (row.dueAt.getTime() <= Date.now()) {
const now = Date.now();
if (row.dueAt.getTime() <= now) {
throw new ConflictException('Die Erinnerung ist bereits fällig');
}
const data: { title?: string; description?: string; dueAt?: Date; emailEnabled?: boolean } = {};
const data: {
title?: string;
description?: string;
dueAt?: Date;
emailEnabled?: boolean;
emailSentAt?: null;
emailAttempts?: number;
} = {};
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.dueAt !== undefined) {
data.dueAt = this.assertValidDueAt(dto.dueAt);
data.emailSentAt = null;
data.emailAttempts = 0;
}
if (dto.emailEnabled !== undefined) {
if (dto.emailEnabled) await this.assertEmailAvailable(tenantId, userId);
data.emailEnabled = dto.emailEnabled;
}
return tenantPrisma.reminder.update({
where: { id, tenantId, userId },
const result = await tenantPrisma.reminder.updateMany({
where: { id, tenantId, userId, dueAt: { gt: new Date() } },
data,
});
if (result.count !== 1) {
await this.loadOwn(tenantPrisma, tenantId, userId, id); // inzwischen geloescht: 404
throw new ConflictException('Die Erinnerung ist bereits fällig');
}
const updated = await tenantPrisma.reminder.findFirst({
where: { id, tenantId, userId },
select: REMINDER_SELECT,
});
if (!updated) {
throw new NotFoundException('Erinnerung nicht gefunden');
}
return updated;
}
/**
@@ -6,10 +6,10 @@ import { createPortal } from 'react-dom';
import { requestBrowserPermissionOnce } from '@/lib/reminder-notify';
import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time';
import {
createReminder,
type Reminder,
type ReminderEmailStatus,
ReminderRequestError,
createReminder,
updateReminder,
} from '@/lib/reminders-api';
@@ -85,9 +85,15 @@ export function ReminderFormModal({
setError(t('reminder.titleRequired'));
return;
}
const dueAt = localInputsToIso(date, time);
// Beim Bearbeiten nur senden, wenn Datum oder Uhrzeit wirklich geaendert
// wurden: das Formular kennt keine Sekunden, ein unveraendertes Feld wuerde
// die Faelligkeit sonst um bis zu 59 s verschieben — und kurz vor der
// Faelligkeit faelschlich „in der Vergangenheit“ melden. Ist sie inzwischen
// faellig, antwortet der Server ohnehin mit 409 (`alreadyDue`).
const dueChanged = !reminder || date !== initial.date || time !== initial.time;
const dueAt = dueChanged ? localInputsToIso(date, time) : undefined;
// Spiegel der Serverregel: die Faelligkeit muss in der Zukunft liegen.
if (dueAt === null || new Date(dueAt).getTime() <= Date.now()) {
if (dueChanged && (!dueAt || new Date(dueAt).getTime() <= Date.now())) {
setError(t('reminder.pastError'));
return;
}
@@ -98,16 +104,15 @@ export function ReminderFormModal({
// 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 = {
const common = {
title: trimmedTitle,
description: description.trim(),
dueAt,
...(emailChanged ? { emailEnabled } : {}),
};
if (reminder) {
await updateReminder(reminder.id, input);
} else {
await createReminder(input);
await updateReminder(reminder.id, { ...common, ...(dueAt ? { dueAt } : {}) });
} else if (dueAt) {
await createReminder({ ...common, dueAt });
}
onSaved();
onClose();
@@ -21,11 +21,11 @@ vi.mock('@/lib/reminders-api', async () => {
});
import {
ReminderRequestError,
createReminder,
deleteReminder,
getReminderEmailStatus,
listReminders,
ReminderRequestError,
snoozeReminder,
updateReminder,
} from '@/lib/reminders-api';
@@ -79,7 +79,9 @@ async function openForm() {
function fillAndSubmit(title: string) {
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: title } });
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2099-03-04' } });
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), {
target: { value: '2099-03-04' },
});
fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '14:30' } });
fireEvent.click(screen.getByText('reminder.save'));
}
@@ -146,7 +148,9 @@ describe('ReminderWidget', () => {
render(<ReminderWidget {...props} />);
await openForm();
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'x' } });
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2001-01-01' } });
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), {
target: { value: '2001-01-01' },
});
fireEvent.click(screen.getByText('reminder.save'));
expect(await screen.findByText('reminder.pastError')).toBeTruthy();
expect(mockCreate).not.toHaveBeenCalled();
@@ -260,6 +264,38 @@ describe('ReminderWidget — faellig und Aktionen (D-03)', () => {
expect(requestPermission).not.toHaveBeenCalled();
});
it('Bearbeiten ohne Aenderung von Datum/Uhrzeit sendet kein dueAt (keine Sekunden-Verschiebung)', async () => {
// Faellig in 30 s, mit Sekunden: auf Minuten gekuerzt laege sie in der Vergangenheit.
const due = new Date(Date.now() + 30_000);
due.setSeconds(due.getSeconds(), 500);
mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString())]);
mockUpdate.mockResolvedValue({});
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByLabelText('reminder.edit'));
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'Neu' } });
fireEvent.click(screen.getByText('reminder.save'));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
expect(screen.queryByText('reminder.pastError')).toBeNull();
expect(mockUpdate.mock.calls[0][1]).not.toHaveProperty('dueAt');
expect(mockUpdate.mock.calls[0][1].title).toBe('Neu');
});
it('Bearbeiten mit geaenderter Uhrzeit sendet das neue dueAt', async () => {
const due = new Date(Date.now() + 2 * 3600_000);
mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString())]);
mockUpdate.mockResolvedValue({});
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByLabelText('reminder.edit'));
const next = new Date(Date.now() + 26 * 3600_000);
const pad = (n: number) => String(n).padStart(2, '0');
const nextDate = `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}`;
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: nextDate } });
fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '09:15' } });
fireEvent.click(screen.getByText('reminder.save'));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
expect(mockUpdate.mock.calls[0][1].dueAt).toBe(new Date(`${nextDate}T09:15`).toISOString());
});
it('409 beim Verschieben zeigt den Hinweis und laedt neu', async () => {
mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]);
mockSnooze.mockRejectedValue(new ReminderRequestError(409));
@@ -313,6 +349,27 @@ describe('ReminderWidget — Faelligkeit ohne Neuladen', () => {
});
});
describe('ReminderWidget — Spaeter erinnern kurz nach Mitternacht', () => {
afterEach(() => {
vi.useRealTimers();
});
it('faellig gestern 23:50, jetzt 00:10: die Option heisst „Heute“ und verschiebt auf heute 23:50', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date(2026, 8, 30, 0, 10));
mockList.mockResolvedValue([
reminder('a', 'Spaet', new Date(2026, 8, 29, 23, 50).toISOString()),
]);
mockSnooze.mockResolvedValue({});
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByText('reminder.snooze'));
expect(screen.queryByText('reminder.snoozeTomorrow')).toBeNull();
fireEvent.click(await screen.findByText('reminder.snoozeToday'));
await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1));
expect(mockSnooze.mock.calls[0][1]).toBe(new Date(2026, 8, 30, 23, 50).toISOString());
});
});
describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
async function openFormAndWaitForStatus() {
await openForm();
@@ -383,7 +440,10 @@ describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
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 },
{
...reminder('a', 'Alt', new Date(Date.now() + 3600_000).toISOString()),
emailEnabled: true,
},
]);
mockUpdate.mockResolvedValue({});
render(<ReminderWidget {...props} />);
@@ -3,15 +3,15 @@
import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { browserPermissionState } from '@/lib/reminder-notify';
import { type SnoozePreset, snoozeTarget } from '@/lib/reminder-time';
import { isSameLocalDay, type SnoozePreset, snoozeTarget } from '@/lib/reminder-time';
import {
deleteReminder,
getReminderEmailStatus,
listReminders,
REMINDERS_CHANGED_EVENT,
type Reminder,
type ReminderEmailStatus,
ReminderRequestError,
deleteReminder,
getReminderEmailStatus,
listReminders,
snoozeReminder,
} from '@/lib/reminders-api';
import type { WidgetProps } from '../widget-registry';
@@ -107,7 +107,9 @@ export function ReminderWidget(_props: WidgetProps) {
/** Fehler einer Zeilenaktion: 409 heisst, der Stand war veraltet — Text zeigen und neu laden. */
const handleFailure = (err: unknown, conflictKey: string) => {
setNotice(
err instanceof ReminderRequestError && err.status === 409 ? t(conflictKey) : t('reminder.saveError'),
err instanceof ReminderRequestError && err.status === 409
? t(conflictKey)
: t('reminder.saveError'),
);
void refetch();
};
@@ -134,6 +136,16 @@ export function ReminderWidget(_props: WidgetProps) {
}
};
// „Heute“ oder „Morgen“ aus dem tatsaechlich berechneten Zeitpunkt: kurz nach
// Mitternacht liegt das naechste Vorkommen der Uhrzeit noch heute.
const tomorrowLabel = (r: Reminder) => {
const target = snoozeTarget('tomorrow', new Date(r.dueAt), new Date(now));
const time = timeOnly.format(target);
return isSameLocalDay(target, new Date(now))
? t('reminder.snoozeToday', { time })
: t('reminder.snoozeTomorrow', { time });
};
const remove = async (id: string) => {
setNotice(null);
setConfirmDeleteId(null);
@@ -177,7 +189,9 @@ export function ReminderWidget(_props: WidgetProps) {
}
>
<div className="flex items-start gap-2">
<p className="min-w-0 flex-1 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"
@@ -244,8 +258,7 @@ export function ReminderWidget(_props: WidgetProps) {
>
{preset === '10m' && t('reminder.snooze10m')}
{preset === '1h' && t('reminder.snooze1h')}
{preset === 'tomorrow' &&
t('reminder.snoozeTomorrow', { time: timeOnly.format(new Date(r.dueAt)) })}
{preset === 'tomorrow' && tomorrowLabel(r)}
</button>
))}
</div>
@@ -17,12 +17,13 @@ vi.mock('@/lib/reminders-api', async () => {
});
vi.mock('@/lib/reminder-notify', async () => {
const actual = await vi.importActual<typeof import('@/lib/reminder-notify')>('@/lib/reminder-notify');
const actual =
await vi.importActual<typeof import('@/lib/reminder-notify')>('@/lib/reminder-notify');
return { ...actual, showReminderNotification: vi.fn(async () => undefined) };
});
import { showReminderNotification } from '@/lib/reminder-notify';
import { ReminderRequestError, listReminders } from '@/lib/reminders-api';
import { resetNotifiedMemoryForTests, showReminderNotification } from '@/lib/reminder-notify';
import { listReminders, ReminderRequestError } from '@/lib/reminders-api';
import { ReminderNotifier } from './reminder-notifier';
const mockList = listReminders as ReturnType<typeof vi.fn>;
@@ -53,6 +54,7 @@ beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
window.localStorage.clear();
resetNotifiedMemoryForTests();
mockList.mockReset();
mockShow.mockClear();
});
@@ -77,7 +79,9 @@ describe('ReminderNotifier', () => {
});
it('meldet nichts, solange nichts faellig ist', async () => {
mockList.mockResolvedValue([reminder({ dueAt: new Date(NOW.getTime() + 3600_000).toISOString() })]);
mockList.mockResolvedValue([
reminder({ dueAt: new Date(NOW.getTime() + 3600_000).toISOString() }),
]);
render(<ReminderNotifier />);
await advance(30_000);
expect(mockShow).not.toHaveBeenCalled();
@@ -85,7 +89,10 @@ describe('ReminderNotifier', () => {
it('nimmt die Beschreibung als Text, gekuerzt auf 200 Zeichen', async () => {
mockList.mockResolvedValue([
reminder({ dueAt: new Date(NOW.getTime() - 1000).toISOString(), description: 'x'.repeat(300) }),
reminder({
dueAt: new Date(NOW.getTime() - 1000).toISOString(),
description: 'x'.repeat(300),
}),
]);
render(<ReminderNotifier />);
await advance(0);
+27 -1
View File
@@ -5,6 +5,7 @@ import {
isTauriWebview,
remindersToNotify,
requestBrowserPermissionOnce,
resetNotifiedMemoryForTests,
showReminderNotification,
withNotifyLock,
} from './reminder-notify';
@@ -34,6 +35,7 @@ function installNotification(permission: NotificationPermission) {
beforeEach(() => {
window.localStorage.clear();
resetNotifiedMemoryForTests();
});
afterEach(() => {
@@ -65,6 +67,28 @@ describe('claimNotification', () => {
const stored = JSON.parse(window.localStorage.getItem('tessera.reminders.notified') ?? '{}');
expect(Object.keys(stored)).toEqual(['neu']);
});
it('ohne nutzbaren localStorage (wirft beim Lesen und Schreiben) meldet es trotzdem nur einmal', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('SecurityError');
});
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError');
});
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW)).toBe(true);
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 10_000)).toBe(false);
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 3600_000)).toBe(false);
// ein neuer Schluessel (verschoben) wird weiterhin gemeldet
expect(claimNotification('r1|2026-09-29T12:10:00.000Z', NOW)).toBe(true);
});
it('ohne window.localStorage (Zugriff wirft) meldet es trotzdem nur einmal', () => {
vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
throw new Error('SecurityError');
});
expect(claimNotification('k', NOW)).toBe(true);
expect(claimNotification('k', NOW + 10_000)).toBe(false);
});
});
describe('remindersToNotify', () => {
@@ -93,7 +117,9 @@ describe('showReminderNotification', () => {
const invoke = installTauri();
invoke.mockRejectedValue(new Error('nope'));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
await expect(showReminderNotification({ title: 'T', body: 'B', tag: 'x' })).resolves.toBeUndefined();
await expect(
showReminderNotification({ title: 'T', body: 'B', tag: 'x' }),
).resolves.toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1);
});
+23 -9
View File
@@ -30,12 +30,7 @@ export const CATCH_UP_WINDOW_MS = 24 * 60 * 60 * 1000;
/** Eintraege der lokalen Merkliste, die aelter sind, werden entfernt. */
const NOTIFIED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export type BrowserPermissionState =
| 'desktop'
| 'granted'
| 'default'
| 'denied'
| 'unsupported';
export type BrowserPermissionState = 'desktop' | 'granted' | 'default' | 'denied' | 'unsupported';
type TauriInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>;
@@ -135,6 +130,19 @@ function readNotified(): Record<string, number> {
}
}
/**
* Merkliste im Arbeitsspeicher dieses Tabs. Sie greift IMMER, auch wenn der
* localStorage gesperrt, voll oder im privaten Modus nicht nutzbar ist — sonst
* meldete der Melder dieselbe faellige Erinnerung 24 Stunden lang bei jeder
* Pruefung erneut. Der localStorage bleibt fuer die Abstimmung zwischen Tabs.
*/
const notifiedInMemory = new Map<string, number>();
/** Nur fuer Tests: leert die Merkliste im Arbeitsspeicher. */
export function resetNotifiedMemoryForTests(): void {
notifiedInMemory.clear();
}
/**
* Merkt sich `key` (`${id}|${dueAt}`) lokal. `true` nur beim ERSTEN Mal — so
* meldet jeder Client jede Faelligkeit hoechstens einmal, auch ueber mehrere
@@ -143,16 +151,22 @@ function readNotified(): Record<string, number> {
* aelter als 7 Tage werden dabei entfernt.
*/
export function claimNotification(key: string, nowMs: number): boolean {
for (const [k, at] of notifiedInMemory) {
if (nowMs - at > NOTIFIED_TTL_MS) notifiedInMemory.delete(k);
}
const record = readNotified();
for (const [k, at] of Object.entries(record)) {
if (nowMs - at > NOTIFIED_TTL_MS) delete record[k];
}
const first = !(key in record);
if (first) record[key] = nowMs;
const first = !(key in record) && !notifiedInMemory.has(key);
if (first) {
record[key] = nowMs;
notifiedInMemory.set(key, nowMs);
}
try {
window.localStorage.setItem(NOTIFIED_KEY, JSON.stringify(record));
} catch {
/* Speicher voll oder gesperrt: dann eben ohne Merkliste */
/* Speicher voll oder gesperrt: dann greift nur die Merkliste im Arbeitsspeicher */
}
return first;
}
+25 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
defaultNewReminderInputs,
isoToLocalInputs,
isSameLocalDay,
localInputsToIso,
snoozeTarget,
} from './reminder-time';
@@ -50,7 +51,9 @@ describe('snoozeTarget (E-05)', () => {
});
it('morgen = urspruengliche Ortszeit am naechsten Tag', () => {
expect(snoozeTarget('tomorrow', original, now).getTime()).toBe(local(2026, 9, 30, 14, 0).getTime());
expect(snoozeTarget('tomorrow', original, now).getTime()).toBe(
local(2026, 9, 30, 14, 0).getTime(),
);
});
it('morgen ueber das Monatsende', () => {
@@ -67,3 +70,24 @@ describe('snoozeTarget (E-05)', () => {
expect(target.getTime()).toBe(local(2026, 9, 30, 8, 15).getTime());
});
});
describe('snoozeTarget kurz nach Mitternacht / isSameLocalDay', () => {
it('faellig gestern 23:50, verschoben heute 00:10 -> heute 23:50 (heute, nicht morgen)', () => {
const n = local(2026, 9, 30, 0, 10);
const target = snoozeTarget('tomorrow', local(2026, 9, 29, 23, 50), n);
expect(target.getTime()).toBe(local(2026, 9, 30, 23, 50).getTime());
expect(isSameLocalDay(target, n)).toBe(true);
});
it('der Normalfall liegt am naechsten Tag', () => {
const n = local(2026, 9, 29, 14, 5);
const target = snoozeTarget('tomorrow', local(2026, 9, 29, 14, 0), n);
expect(isSameLocalDay(target, n)).toBe(false);
});
it('isSameLocalDay vergleicht den Kalendertag in Ortszeit', () => {
expect(isSameLocalDay(local(2026, 9, 30, 0, 0), local(2026, 9, 30, 23, 59))).toBe(true);
expect(isSameLocalDay(local(2026, 9, 30, 23, 59), local(2026, 10, 1, 0, 0))).toBe(false);
expect(isSameLocalDay(local(2026, 9, 30), local(2025, 9, 30))).toBe(false);
});
});
+13 -1
View File
@@ -45,7 +45,10 @@ export type SnoozePreset = '10m' | '1h' | 'tomorrow';
* Ortszeit und geht einen Kalendertag weiter (`setDate(+1)` ist sommerzeit-
* sicher), so oft, bis das Ergebnis in der Zukunft liegt: faellig heute 14:00,
* verschoben um 14:05 -> morgen 14:00; war die Erinnerung schon Tage ueberfaellig,
* landet sie beim naechsten kuenftigen Vorkommen dieser Uhrzeit.
* landet sie beim naechsten kuenftigen Vorkommen dieser Uhrzeit. Das kann auch
* HEUTE sein (faellig gestern 23:50, verschoben heute 00:10 -> heute 23:50) —
* die Beschriftung leitet „heute“/„morgen“ deshalb aus dem Ergebnis ab
* (`isSameLocalDay`), nicht aus dem Namen der Option.
*/
export function snoozeTarget(preset: SnoozePreset, originalDueAt: Date, now: Date): Date {
if (preset === '10m') return new Date(now.getTime() + 10 * 60_000);
@@ -56,3 +59,12 @@ export function snoozeTarget(preset: SnoozePreset, originalDueAt: Date, now: Dat
} while (d.getTime() <= now.getTime());
return d;
}
/** Liegen beide Zeitpunkte am selben Kalendertag (Ortszeit)? */
export function isSameLocalDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}