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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user