feat(260929-if2): faellige Erinnerungen erledigen, spaeter erinnern, bearbeiten und loeschen
- API: Aendern (409 wenn faellig), Spaeter erinnern (409 wenn nicht faellig, setzt E-Mail-Spur zurueck), Loeschen; fremde Kennungen 404 - Kachel: faellige Zeilen hervorgehoben mit Erledigt und drei Spaeter-Optionen, kuenftige mit Bearbeiten und Loeschen - Zeit-Hilfen (morgen zur gleichen Uhrzeit), Zugriffsklassifikation nachgemessen Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsISO8601, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
@@ -26,3 +27,16 @@ export class CreateReminderDto {
|
||||
@IsISO8601({ strict: true })
|
||||
dueAt!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`).
|
||||
*/
|
||||
export class UpdateReminderDto extends PartialType(CreateReminderDto) {}
|
||||
|
||||
/** Neuer Zeitpunkt beim Spaeter-Erinnern (D-03); der Client rechnet ihn aus (E-05). */
|
||||
export class SnoozeReminderDto {
|
||||
@IsISO8601({ strict: true })
|
||||
dueAt!: string;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,16 @@ import 'reflect-metadata';
|
||||
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||
import { CreateReminderDto } from './dto/reminder.dto';
|
||||
import { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
|
||||
import { RemindersController } from './reminders.controller';
|
||||
|
||||
function makeService() {
|
||||
return {
|
||||
list: vi.fn(async (..._args: unknown[]) => []),
|
||||
create: vi.fn(async (..._args: unknown[]) => ({})),
|
||||
update: vi.fn(async (..._args: unknown[]) => ({})),
|
||||
snooze: vi.fn(async (..._args: unknown[]) => ({})),
|
||||
remove: vi.fn(async (..._args: unknown[]) => ({ deleted: true })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +20,7 @@ const user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any;
|
||||
const proto = RemindersController.prototype as any;
|
||||
|
||||
describe('RemindersController — Rollen', () => {
|
||||
it.each(['list', 'create'])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
|
||||
it.each(['list', 'create', 'update', 'snooze', 'remove'])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -34,11 +37,22 @@ describe('RemindersController — Mandant', () => {
|
||||
await controller.create(req('t1'), user, { title: 'a', dueAt: '2099-01-01T10:00:00.000Z' });
|
||||
expect(service.list).toHaveBeenCalledWith('t1', 'u1');
|
||||
expect(service.create.mock.calls[0].slice(0, 2)).toEqual(['t1', 'u1']);
|
||||
await controller.update(req('t1'), user, 'x', { title: 'b' });
|
||||
await controller.snooze(req('t1'), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' });
|
||||
await controller.remove(req('t1'), user, 'x');
|
||||
expect(service.update.mock.calls[0].slice(0, 3)).toEqual(['t1', 'u1', 'x']);
|
||||
expect(service.snooze.mock.calls[0].slice(0, 3)).toEqual(['t1', 'u1', 'x']);
|
||||
expect(service.remove).toHaveBeenCalledWith('t1', 'u1', 'x');
|
||||
});
|
||||
|
||||
it('wirft ForbiddenException ohne req.tenantId', async () => {
|
||||
const controller = new RemindersController(makeService() as any);
|
||||
await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.update(req(), user, 'x', {})).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(
|
||||
controller.snooze(req(), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.remove(req(), user, 'x')).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(
|
||||
controller.create(req(), user, { title: 'a', dueAt: '2099-01-01T10:00:00.000Z' }),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
@@ -65,6 +79,29 @@ describe('RemindersController — Mandant', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemindersController — Pipe fuer Aendern und Verschieben', () => {
|
||||
it('Aendern verwirft untergeschobene Felder, ein Teil-Update ist erlaubt', async () => {
|
||||
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||
const out: any = await pipe.transform(
|
||||
{ title: 'b', tenantId: 'evil', userId: 'evil', emailAttempts: 0 },
|
||||
{ type: 'body', metatype: UpdateReminderDto },
|
||||
);
|
||||
expect(out).toEqual({ title: 'b' });
|
||||
});
|
||||
|
||||
it('Verschieben verlangt einen ISO-Zeitpunkt und verwirft Fremdfelder', async () => {
|
||||
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||
const out: any = await pipe.transform(
|
||||
{ dueAt: '2099-01-01T10:00:00.000Z', userId: 'evil' },
|
||||
{ type: 'body', metatype: SnoozeReminderDto },
|
||||
);
|
||||
expect(out).toEqual({ dueAt: '2099-01-01T10:00:00.000Z' });
|
||||
await expect(
|
||||
pipe.transform({ dueAt: 'morgen' }, { type: 'body', metatype: SnoozeReminderDto }),
|
||||
).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemindersController — Routen-Reihenfolge (statisch vor :id)', () => {
|
||||
it('deklariert list vor jeder :id-Route', () => {
|
||||
const methods = Object.getOwnPropertyNames(RemindersController.prototype);
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { Body, Controller, ForbiddenException, Get, Post, Req } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user';
|
||||
import { CreateReminderDto } from './dto/reminder.dto';
|
||||
import { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
|
||||
import { RemindersService } from './reminders.service';
|
||||
|
||||
/**
|
||||
@@ -41,4 +51,33 @@ export class RemindersController {
|
||||
) {
|
||||
return this.service.create(this.requireTenantId(req), user.id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateReminderDto,
|
||||
) {
|
||||
return this.service.update(this.requireTenantId(req), user.id, id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/snooze')
|
||||
async snooze(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: SnoozeReminderDto,
|
||||
) {
|
||||
return this.service.snooze(this.requireTenantId(req), user.id, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.service.remove(this.requireTenantId(req), user.id, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// `forTenant` reicht den Klienten durch — die Bindung selbst prueft
|
||||
@@ -27,6 +27,19 @@ function makeFakePrisma() {
|
||||
if (orderBy?.dueAt === 'asc') list.sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime());
|
||||
return list;
|
||||
}),
|
||||
findFirst: vi.fn(async ({ where }: { where: any }) => {
|
||||
const r = rows.get(where.id);
|
||||
if (!r || r.tenantId !== where.tenantId || r.userId !== where.userId) return null;
|
||||
return r;
|
||||
}),
|
||||
update: vi.fn(async ({ where, data }: { where: any; data: any }) => {
|
||||
const row = { ...rows.get(where.id), ...data };
|
||||
rows.set(where.id, row);
|
||||
return row;
|
||||
}),
|
||||
delete: vi.fn(async ({ where }: { where: any }) => {
|
||||
rows.delete(where.id);
|
||||
}),
|
||||
count: vi.fn(async ({ where }: { where?: any } = {}) => {
|
||||
return [...rows.values()].filter(
|
||||
(r) => r.tenantId === where?.tenantId && r.userId === where?.userId,
|
||||
@@ -104,3 +117,130 @@ describe('RemindersService — auflisten', () => {
|
||||
expect(where).toEqual({ tenantId: 't1', userId: 'u1' });
|
||||
});
|
||||
});
|
||||
|
||||
/** Zeile direkt in den Speicher legen (auch faellige, die `create` ablehnen wuerde). */
|
||||
function seed(
|
||||
prisma: ReturnType<typeof makeFakePrisma>,
|
||||
id: string,
|
||||
dueAt: Date,
|
||||
owner: { tenantId: string; userId: string } = { tenantId: 't1', userId: 'u1' },
|
||||
) {
|
||||
prisma.rows.set(id, {
|
||||
id,
|
||||
...owner,
|
||||
title: 'alt',
|
||||
description: 'beschr',
|
||||
dueAt,
|
||||
emailEnabled: false,
|
||||
emailSentAt: new Date(),
|
||||
emailAttempts: 2,
|
||||
});
|
||||
}
|
||||
|
||||
const past = () => new Date(Date.now() - 3600_000);
|
||||
const future = () => new Date(Date.now() + 3600_000);
|
||||
|
||||
describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)', () => {
|
||||
it.each([
|
||||
['fremder Benutzer', { tenantId: 't1', userId: 'u2' }],
|
||||
['fremder Mandant', { tenantId: 't2', userId: 'u1' }],
|
||||
])('%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.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);
|
||||
});
|
||||
|
||||
it('das where jeder Abfrage traegt Mandant und Benutzer', async () => {
|
||||
const { prisma, service } = setup();
|
||||
seed(prisma, 'x', future());
|
||||
await service.update('t1', 'u1', 'x', { title: 'n' });
|
||||
expect(prisma.reminder.findFirst.mock.calls[0]?.[0]?.where).toEqual({
|
||||
id: 'x',
|
||||
tenantId: 't1',
|
||||
userId: 'u1',
|
||||
});
|
||||
expect(prisma.reminder.update.mock.calls[0]?.[0]?.where).toEqual({
|
||||
id: 'x',
|
||||
tenantId: 't1',
|
||||
userId: 'u1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('eine vergangene neue Faelligkeit ergibt 400', async () => {
|
||||
const { prisma, service } = setup();
|
||||
seed(prisma, 'x', future());
|
||||
await expect(service.update('t1', 'u1', 'x', { dueAt: inHours(-2) })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('aendert nur die gesetzten Felder', async () => {
|
||||
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' });
|
||||
const newDue = inHours(9);
|
||||
await service.update('t1', 'u1', 'x', { dueAt: newDue, description: '' });
|
||||
expect(prisma.reminder.update.mock.calls[1]?.[0]?.data).toEqual({
|
||||
description: '',
|
||||
dueAt: new Date(newDue),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemindersService — spaeter erinnern (D-03)', () => {
|
||||
it('eine noch nicht faellige Erinnerung laesst sich nicht verschieben (409)', async () => {
|
||||
const { prisma, service } = setup();
|
||||
seed(prisma, 'x', future());
|
||||
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(3) })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('eine vergangene neue Faelligkeit ergibt 400', async () => {
|
||||
const { prisma, service } = setup();
|
||||
seed(prisma, 'x', past());
|
||||
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(-1) })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('schreibt dueAt UND setzt emailSentAt und emailAttempts zurueck', async () => {
|
||||
const { prisma, service } = setup();
|
||||
seed(prisma, 'x', past());
|
||||
const newDue = inHours(1);
|
||||
await service.snooze('t1', 'u1', 'x', { dueAt: newDue });
|
||||
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({
|
||||
dueAt: new Date(newDue),
|
||||
emailSentAt: null,
|
||||
emailAttempts: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemindersService — loeschen (E-02)', () => {
|
||||
it('loescht die eigene Zeile, vor und nach der Faelligkeit', async () => {
|
||||
const { prisma, service } = setup();
|
||||
seed(prisma, 'a', future());
|
||||
seed(prisma, 'b', past());
|
||||
await expect(service.remove('t1', 'u1', 'a')).resolves.toEqual({ deleted: true });
|
||||
await expect(service.remove('t1', 'u1', 'b')).resolves.toEqual({ deleted: true });
|
||||
expect(prisma.rows.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import type { CreateReminderDto } from './dto/reminder.dto';
|
||||
import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
|
||||
|
||||
/** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */
|
||||
export const REMINDER_SELECT = {
|
||||
@@ -68,6 +73,76 @@ export class RemindersService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearbeiten einer noch NICHT faelligen Erinnerung. Eine faellige ist tabu
|
||||
* (409): dafuer gibt es „Erledigt“ und „Spaeter erinnern“ (D-03).
|
||||
*/
|
||||
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()) {
|
||||
throw new ConflictException('Die Erinnerung ist bereits fällig');
|
||||
}
|
||||
const data: { title?: string; description?: string; dueAt?: Date } = {};
|
||||
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);
|
||||
return tenantPrisma.reminder.update({
|
||||
where: { id, tenantId, userId },
|
||||
data,
|
||||
select: REMINDER_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* „Spaeter erinnern“ (D-03): nur bei einer faelligen Erinnerung (409 sonst).
|
||||
* Setzt die neue Faelligkeit UND die Spuren des E-Mail-Planers zurueck, damit
|
||||
* die Benachrichtigungen wieder ausloesen und die E-Mail erneut verschickt
|
||||
* wird.
|
||||
*/
|
||||
async snooze(tenantId: string, userId: string, id: string, dto: SnoozeReminderDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const row = await this.loadOwn(tenantPrisma, tenantId, userId, id);
|
||||
if (row.dueAt.getTime() > Date.now()) {
|
||||
throw new ConflictException('Die Erinnerung ist noch nicht fällig');
|
||||
}
|
||||
const dueAt = this.assertValidDueAt(dto.dueAt);
|
||||
return tenantPrisma.reminder.update({
|
||||
where: { id, tenantId, userId },
|
||||
data: { dueAt, emailSentAt: null, emailAttempts: 0 },
|
||||
select: REMINDER_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
/** Loescht die Zeile (E-02) — bedient „Loeschen“ vor und „Erledigt“ nach der Faelligkeit. */
|
||||
async remove(tenantId: string, userId: string, id: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
await this.loadOwn(tenantPrisma, tenantId, userId, id);
|
||||
await tenantPrisma.reminder.delete({ where: { id, tenantId, userId } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Eigene Zeile laden. Unbekannt, fremder Mandant und fremder Benutzer sind
|
||||
* ununterscheidbar 404 (D-05, T-IF2-01) — nie ein Hinweis, dass es die
|
||||
* Kennung gibt.
|
||||
*/
|
||||
private async loadOwn(
|
||||
tenantPrisma: ReturnType<typeof forTenant>,
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
id: string,
|
||||
) {
|
||||
const row = await tenantPrisma.reminder.findFirst({
|
||||
where: { id, tenantId, userId },
|
||||
select: { id: true, dueAt: true },
|
||||
});
|
||||
if (!row) {
|
||||
throw new NotFoundException('Erinnerung nicht gefunden');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/** E-06: die Faelligkeit muss in der Zukunft und hoechstens 5 Jahre voraus liegen. */
|
||||
private assertValidDueAt(iso: string, now: number = Date.now()): Date {
|
||||
const dueAt = new Date(iso);
|
||||
|
||||
@@ -4,13 +4,22 @@ import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { requestBrowserPermissionOnce } from '@/lib/reminder-notify';
|
||||
import { defaultNewReminderInputs, localInputsToIso } from '@/lib/reminder-time';
|
||||
import { ReminderRequestError, createReminder } from '@/lib/reminders-api';
|
||||
import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time';
|
||||
import {
|
||||
type Reminder,
|
||||
ReminderRequestError,
|
||||
createReminder,
|
||||
updateReminder,
|
||||
} from '@/lib/reminders-api';
|
||||
|
||||
interface ReminderFormModalProps {
|
||||
/** `null` = neue Erinnerung anlegen, sonst diese (noch nicht faellige) bearbeiten. */
|
||||
reminder: Reminder | null;
|
||||
onClose: () => void;
|
||||
/** Nach erfolgreichem Speichern (die Kachel laedt neu und meldet die Aenderung). */
|
||||
onSaved: () => void;
|
||||
/** Der Server meldet, dass die Erinnerung inzwischen faellig ist (409) — die Kachel laedt neu. */
|
||||
onStale?: () => void;
|
||||
}
|
||||
|
||||
const TITLE_MAX = 200;
|
||||
@@ -25,18 +34,23 @@ const DESCRIPTION_MAX = 2000;
|
||||
* macht sich fuer `position: fixed` zum Bezugsrahmen (Vorbild
|
||||
* `picture-frame-lightbox.tsx`). Dialog-Aufbau wie `custom-module-form-modal`.
|
||||
*
|
||||
* ERLAUBNIS (D-04): `requestBrowserPermissionOnce()` laeuft als ERSTES im
|
||||
* Absende-Handler, synchron und noch vor jedem `await` — so bleibt der Aufruf
|
||||
* BEARBEITEN (`reminder` gesetzt): nur fuer eine noch nicht faellige Erinnerung;
|
||||
* eine inzwischen faellige lehnt der Server mit 409 ab (`alreadyDue`).
|
||||
*
|
||||
* ERLAUBNIS (D-04): `requestBrowserPermissionOnce()` laeuft beim ANLEGEN als
|
||||
* ERSTES im Absende-Handler, synchron und noch vor jedem `await` — so bleibt der Aufruf
|
||||
* eine Nutzerhandlung, die der Browser fuer die Frage akzeptiert. Nie beim
|
||||
* Laden der Seite.
|
||||
*/
|
||||
export function ReminderFormModal({ onClose, onSaved }: ReminderFormModalProps) {
|
||||
export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: ReminderFormModalProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const initial = useRef(defaultNewReminderInputs(new Date())).current;
|
||||
const initial = useRef(
|
||||
reminder ? isoToLocalInputs(reminder.dueAt) : defaultNewReminderInputs(new Date()),
|
||||
).current;
|
||||
const [date, setDate] = useState(initial.date);
|
||||
const [time, setTime] = useState(initial.time);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [title, setTitle] = useState(reminder?.title ?? '');
|
||||
const [description, setDescription] = useState(reminder?.description ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
@@ -53,7 +67,7 @@ export function ReminderFormModal({ onClose, onSaved }: ReminderFormModalProps)
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Als Erstes und synchron: Nutzerhandlung fuer die Browser-Erlaubnis (D-04).
|
||||
requestBrowserPermissionOnce();
|
||||
if (!reminder) requestBrowserPermissionOnce();
|
||||
setError(null);
|
||||
|
||||
const trimmedTitle = title.trim();
|
||||
@@ -70,15 +84,22 @@ export function ReminderFormModal({ onClose, onSaved }: ReminderFormModalProps)
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await createReminder({ title: trimmedTitle, description: description.trim(), dueAt });
|
||||
const input = { title: trimmedTitle, description: description.trim(), dueAt };
|
||||
if (reminder) {
|
||||
await updateReminder(reminder.id, input);
|
||||
} else {
|
||||
await createReminder(input);
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ReminderRequestError && err.status === 409
|
||||
? t('reminder.limitReached')
|
||||
: t('reminder.saveError'),
|
||||
);
|
||||
if (err instanceof ReminderRequestError && err.status === 409) {
|
||||
// Anlegen: Grenze erreicht; Bearbeiten: inzwischen faellig.
|
||||
setError(reminder ? t('reminder.alreadyDue') : t('reminder.limitReached'));
|
||||
if (reminder) onStale?.();
|
||||
} else {
|
||||
setError(t('reminder.saveError'));
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -95,7 +116,7 @@ export function ReminderFormModal({ onClose, onSaved }: ReminderFormModalProps)
|
||||
className="w-full max-w-md rounded-lg bg-card p-6 shadow-lg dark:border dark:border-border"
|
||||
>
|
||||
<h2 id="reminder-form-title" className="mb-4 text-lg font-semibold text-foreground">
|
||||
{t('reminder.formTitle')}
|
||||
{reminder ? t('reminder.editTitle') : t('reminder.formTitle')}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
@@ -13,14 +13,27 @@ vi.mock('@/lib/reminders-api', async () => {
|
||||
ReminderRequestError: actual.ReminderRequestError,
|
||||
listReminders: vi.fn(),
|
||||
createReminder: vi.fn(),
|
||||
updateReminder: vi.fn(),
|
||||
snoozeReminder: vi.fn(),
|
||||
deleteReminder: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { createReminder, listReminders } from '@/lib/reminders-api';
|
||||
import {
|
||||
ReminderRequestError,
|
||||
createReminder,
|
||||
deleteReminder,
|
||||
listReminders,
|
||||
snoozeReminder,
|
||||
updateReminder,
|
||||
} from '@/lib/reminders-api';
|
||||
import { ReminderWidget } from './reminder-widget';
|
||||
|
||||
const mockList = listReminders as ReturnType<typeof vi.fn>;
|
||||
const mockCreate = createReminder as ReturnType<typeof vi.fn>;
|
||||
const mockUpdate = updateReminder as ReturnType<typeof vi.fn>;
|
||||
const mockSnooze = snoozeReminder as ReturnType<typeof vi.fn>;
|
||||
const mockDelete = deleteReminder as ReturnType<typeof vi.fn>;
|
||||
|
||||
function reminder(id: string, title: string, dueAt: string, description = '') {
|
||||
return {
|
||||
@@ -42,6 +55,9 @@ beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
mockList.mockReset();
|
||||
mockCreate.mockReset();
|
||||
mockUpdate.mockReset();
|
||||
mockSnooze.mockReset();
|
||||
mockDelete.mockReset();
|
||||
requestPermission = vi.fn(async () => 'granted');
|
||||
const ctor = vi.fn();
|
||||
Object.assign(ctor, { permission: 'default', requestPermission });
|
||||
@@ -145,3 +161,149 @@ describe('ReminderWidget', () => {
|
||||
window.removeEventListener('tessera:reminders-changed', handler);
|
||||
});
|
||||
});
|
||||
|
||||
const iso = (offsetMs: number) => new Date(Date.now() + offsetMs).toISOString();
|
||||
|
||||
describe('ReminderWidget — faellig und Aktionen (D-03)', () => {
|
||||
it('eine faellige Zeile ist hervorgehoben mit Faellig, Erledigt und Spaeter erinnern, ohne Bearbeiten/Loeschen', async () => {
|
||||
mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
const row = await screen.findByTestId('reminder-row');
|
||||
expect(row.getAttribute('data-due')).toBe('true');
|
||||
expect(row.className).toContain('border-status-warn');
|
||||
expect(within(row).getByText('reminder.due')).toBeTruthy();
|
||||
expect(within(row).getByText('reminder.done')).toBeTruthy();
|
||||
expect(within(row).getByText('reminder.snooze')).toBeTruthy();
|
||||
expect(within(row).queryByLabelText('reminder.edit')).toBeNull();
|
||||
expect(within(row).queryByLabelText('reminder.delete')).toBeNull();
|
||||
});
|
||||
|
||||
it('eine kuenftige Zeile bietet Bearbeiten und Loeschen, aber nicht Erledigt/Spaeter', async () => {
|
||||
mockList.mockResolvedValue([reminder('a', 'Kommt', iso(3600_000))]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
const row = await screen.findByTestId('reminder-row');
|
||||
expect(row.getAttribute('data-due')).toBe('false');
|
||||
expect(within(row).getByLabelText('reminder.edit')).toBeTruthy();
|
||||
expect(within(row).getByLabelText('reminder.delete')).toBeTruthy();
|
||||
expect(within(row).queryByText('reminder.done')).toBeNull();
|
||||
expect(within(row).queryByText('reminder.snooze')).toBeNull();
|
||||
});
|
||||
|
||||
it('Erledigt ruft deleteReminder und die Zeile verschwindet nach dem Neuladen', async () => {
|
||||
mockList.mockResolvedValueOnce([reminder('a', 'Faellig', iso(-60_000))]).mockResolvedValue([]);
|
||||
mockDelete.mockResolvedValue({ deleted: true });
|
||||
render(<ReminderWidget {...props} />);
|
||||
fireEvent.click(await screen.findByText('reminder.done'));
|
||||
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('a'));
|
||||
await waitFor(() => expect(screen.queryByTestId('reminder-row')).toBeNull());
|
||||
});
|
||||
|
||||
it('jede Spaeter-Option ruft snoozeReminder mit dem Zeitpunkt aus snoozeTarget', async () => {
|
||||
const original = new Date(Date.now() - 5 * 60_000);
|
||||
mockList.mockResolvedValue([reminder('a', 'Faellig', original.toISOString())]);
|
||||
mockSnooze.mockResolvedValue({});
|
||||
render(<ReminderWidget {...props} />);
|
||||
|
||||
for (const [label, expected] of [
|
||||
['reminder.snooze10m', () => Date.now() + 10 * 60_000],
|
||||
['reminder.snooze1h', () => Date.now() + 60 * 60_000],
|
||||
] as const) {
|
||||
mockSnooze.mockClear();
|
||||
fireEvent.click(await screen.findByText('reminder.snooze'));
|
||||
fireEvent.click(await screen.findByText(label));
|
||||
await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1));
|
||||
const [id, dueAt] = mockSnooze.mock.calls[0];
|
||||
expect(id).toBe('a');
|
||||
expect(Math.abs(new Date(dueAt).getTime() - expected())).toBeLessThan(5_000);
|
||||
}
|
||||
|
||||
mockSnooze.mockClear();
|
||||
fireEvent.click(await screen.findByText('reminder.snooze'));
|
||||
fireEvent.click(await screen.findByText('reminder.snoozeTomorrow'));
|
||||
await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1));
|
||||
const tomorrow = new Date(original);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
expect(mockSnooze.mock.calls[0][1]).toBe(tomorrow.toISOString());
|
||||
});
|
||||
|
||||
it('Loeschen fragt zweistufig nach und ruft dann deleteReminder', async () => {
|
||||
mockList.mockResolvedValue([reminder('a', 'Kommt', iso(3600_000))]);
|
||||
mockDelete.mockResolvedValue({ deleted: true });
|
||||
render(<ReminderWidget {...props} />);
|
||||
fireEvent.click(await screen.findByLabelText('reminder.delete'));
|
||||
expect(mockDelete).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByText('reminder.yes'));
|
||||
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('a'));
|
||||
});
|
||||
|
||||
it('Bearbeiten oeffnet das Formular mit den Werten und speichert per updateReminder', async () => {
|
||||
const due = new Date(Date.now() + 3600_000);
|
||||
mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString(), 'Text')]);
|
||||
mockUpdate.mockResolvedValue({});
|
||||
render(<ReminderWidget {...props} />);
|
||||
fireEvent.click(await screen.findByLabelText('reminder.edit'));
|
||||
const titleInput = screen.getByLabelText('reminder.titleLabel') as HTMLInputElement;
|
||||
expect(titleInput.value).toBe('Alt');
|
||||
fireEvent.change(titleInput, { target: { value: 'Neu' } });
|
||||
fireEvent.click(screen.getByText('reminder.save'));
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
const [id, patch] = mockUpdate.mock.calls[0];
|
||||
expect(id).toBe('a');
|
||||
expect(patch.title).toBe('Neu');
|
||||
expect(patch.description).toBe('Text');
|
||||
// Bearbeiten fragt nie die Benachrichtigungs-Erlaubnis
|
||||
expect(requestPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('409 beim Verschieben zeigt den Hinweis und laedt neu', async () => {
|
||||
mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]);
|
||||
mockSnooze.mockRejectedValue(new ReminderRequestError(409));
|
||||
render(<ReminderWidget {...props} />);
|
||||
fireEvent.click(await screen.findByText('reminder.snooze'));
|
||||
fireEvent.click(await screen.findByText('reminder.snooze10m'));
|
||||
expect(await screen.findByText('reminder.notDue')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('zeigt bei blockierten Browser-Benachrichtigungen einen Hinweis, sonst nicht', async () => {
|
||||
mockList.mockResolvedValue([]);
|
||||
(Notification as unknown as { permission: string }).permission = 'denied';
|
||||
const { unmount } = render(<ReminderWidget {...props} />);
|
||||
expect(await screen.findByTestId('reminder-permission-hint')).toBeTruthy();
|
||||
unmount();
|
||||
(Notification as unknown as { permission: string }).permission = 'default';
|
||||
render(<ReminderWidget {...props} />);
|
||||
await screen.findByText('reminder.empty');
|
||||
expect(screen.queryByTestId('reminder-permission-hint')).toBeNull();
|
||||
});
|
||||
|
||||
it('zeigt in der Desktop-App keinen Hinweis', async () => {
|
||||
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = { invoke: vi.fn() };
|
||||
(Notification as unknown as { permission: string }).permission = 'denied';
|
||||
mockList.mockResolvedValue([]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
await screen.findByText('reminder.empty');
|
||||
expect(screen.queryByTestId('reminder-permission-hint')).toBeNull();
|
||||
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReminderWidget — Faelligkeit ohne Neuladen', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('eine Zeile wird durch den lokalen 10-s-Takt faellig', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-09-29T12:00:00.000Z'));
|
||||
mockList.mockResolvedValue([reminder('a', 'Bald', '2026-09-29T12:00:30.000Z')]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(screen.getByTestId('reminder-row').getAttribute('data-due')).toBe('false');
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(40_000);
|
||||
});
|
||||
expect(screen.getByTestId('reminder-row').getAttribute('data-due')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,31 +2,49 @@
|
||||
|
||||
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 {
|
||||
REMINDERS_CHANGED_EVENT,
|
||||
type Reminder,
|
||||
ReminderRequestError,
|
||||
deleteReminder,
|
||||
listReminders,
|
||||
snoozeReminder,
|
||||
} from '@/lib/reminders-api';
|
||||
import type { WidgetProps } from '../widget-registry';
|
||||
import { ReminderFormModal } from './reminder-form-modal';
|
||||
|
||||
/** Abstand der Auffrischung beim Server. */
|
||||
const REFRESH_MS = 60_000;
|
||||
/** Abstand, in dem die Kachel neu entscheidet, welche Erinnerung faellig ist. */
|
||||
const NOW_TICK_MS = 10_000;
|
||||
|
||||
const SNOOZE_PRESETS: SnoozePreset[] = ['10m', '1h', 'tomorrow'];
|
||||
|
||||
/**
|
||||
* Kachel „Erinnerungen“ (quick-260929-if2): die persoenlichen Erinnerungen des
|
||||
* Benutzers, die naechste Faelligkeit zuerst, dazu „Neue Erinnerung“. Die
|
||||
* Benachrichtigung selbst macht der globale `ReminderNotifier` im Portal-
|
||||
* Rahmen — die Kachel muss dafuer nicht sichtbar sein. Nach jeder Aenderung
|
||||
* laedt sie neu und meldet `REMINDERS_CHANGED_EVENT`, damit der Melder eine
|
||||
* neue Faelligkeit sofort kennt.
|
||||
* Benutzers, die naechste Faelligkeit zuerst. Die Benachrichtigung selbst macht
|
||||
* der globale `ReminderNotifier` im Portal-Rahmen — die Kachel muss dafuer
|
||||
* nicht sichtbar sein. Nach jeder Aenderung laedt sie neu und meldet
|
||||
* `REMINDERS_CHANGED_EVENT`, damit der Melder eine neue Faelligkeit sofort kennt.
|
||||
*
|
||||
* Faellige Zeilen (D-03) bleiben hervorgehoben stehen und bieten „Erledigt“
|
||||
* (loescht die Zeile, E-02) und „Spaeter erinnern“ (+10 Minuten, +1 Stunde,
|
||||
* morgen zur gleichen Uhrzeit). Noch nicht faellige lassen sich bearbeiten und
|
||||
* loeschen. Ob eine Zeile faellig ist, entscheidet ein 10-s-Takt lokal, ohne
|
||||
* Neuladen.
|
||||
*/
|
||||
export function ReminderWidget(_props: WidgetProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const locale = useLocale();
|
||||
const [reminders, setReminders] = useState<Reminder[] | null>(null);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [modal, setModal] = useState<{ reminder: Reminder | null } | null>(null);
|
||||
const [snoozeOpenId, setSnoozeOpenId] = useState<string | null>(null);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
try {
|
||||
@@ -39,11 +57,13 @@ export function ReminderWidget(_props: WidgetProps) {
|
||||
|
||||
useEffect(() => {
|
||||
void refetch();
|
||||
const timer = setInterval(() => void refetch(), REFRESH_MS);
|
||||
const refresh = setInterval(() => void refetch(), REFRESH_MS);
|
||||
const tick = setInterval(() => setNow(Date.now()), NOW_TICK_MS);
|
||||
const onChanged = () => void refetch();
|
||||
window.addEventListener(REMINDERS_CHANGED_EVENT, onChanged);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
clearInterval(refresh);
|
||||
clearInterval(tick);
|
||||
window.removeEventListener(REMINDERS_CHANGED_EVENT, onChanged);
|
||||
};
|
||||
}, [refetch]);
|
||||
@@ -56,16 +76,61 @@ export function ReminderWidget(_props: WidgetProps) {
|
||||
[reminders],
|
||||
);
|
||||
|
||||
const formatter = useMemo(
|
||||
const dateTime = useMemo(
|
||||
() => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }),
|
||||
[locale],
|
||||
);
|
||||
const timeOnly = useMemo(() => new Intl.DateTimeFormat(locale, { timeStyle: 'short' }), [locale]);
|
||||
|
||||
const handleSaved = () => {
|
||||
/** Nach jeder erfolgreichen Aenderung: neu laden und den Melder informieren. */
|
||||
const afterMutation = () => {
|
||||
void refetch();
|
||||
window.dispatchEvent(new Event(REMINDERS_CHANGED_EVENT));
|
||||
};
|
||||
|
||||
/** 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'),
|
||||
);
|
||||
void refetch();
|
||||
};
|
||||
|
||||
const markDone = async (id: string) => {
|
||||
setNotice(null);
|
||||
try {
|
||||
await deleteReminder(id);
|
||||
afterMutation();
|
||||
} catch (err) {
|
||||
handleFailure(err, 'reminder.saveError');
|
||||
}
|
||||
};
|
||||
|
||||
const snooze = async (r: Reminder, preset: SnoozePreset) => {
|
||||
setNotice(null);
|
||||
setSnoozeOpenId(null);
|
||||
try {
|
||||
const target = snoozeTarget(preset, new Date(r.dueAt), new Date());
|
||||
await snoozeReminder(r.id, target.toISOString());
|
||||
afterMutation();
|
||||
} catch (err) {
|
||||
handleFailure(err, 'reminder.notDue');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
setNotice(null);
|
||||
setConfirmDeleteId(null);
|
||||
try {
|
||||
await deleteReminder(id);
|
||||
afterMutation();
|
||||
} catch (err) {
|
||||
handleFailure(err, 'reminder.saveError');
|
||||
}
|
||||
};
|
||||
|
||||
const blocked = browserPermissionState() === 'denied';
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 p-3" data-testid="reminder-widget">
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto">
|
||||
@@ -74,34 +139,179 @@ export function ReminderWidget(_props: WidgetProps) {
|
||||
{t('reminder.loadError')}
|
||||
</p>
|
||||
)}
|
||||
{notice && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{!loadError && reminders !== null && sorted.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('reminder.empty')}</p>
|
||||
)}
|
||||
{sorted.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
data-testid="reminder-row"
|
||||
className="rounded-md border border-border bg-background/60 px-3 py-2"
|
||||
>
|
||||
<p className="truncate text-sm font-medium text-foreground">{r.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatter.format(new Date(r.dueAt))}</p>
|
||||
{r.description !== '' && (
|
||||
<p className="mt-1 line-clamp-2 whitespace-pre-line text-xs text-muted-foreground">
|
||||
{r.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sorted.map((r) => {
|
||||
const due = new Date(r.dueAt).getTime() <= now;
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
data-testid="reminder-row"
|
||||
data-due={due ? 'true' : 'false'}
|
||||
className={
|
||||
due
|
||||
? 'rounded-md border border-status-warn bg-status-warn/10 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">
|
||||
<p className="min-w-0 truncate text-sm font-medium text-foreground">{r.title}</p>
|
||||
{due && (
|
||||
<span className="shrink-0 rounded-full bg-status-warn/20 px-2 py-0.5 text-xs font-semibold text-status-warn-fg">
|
||||
{t('reminder.due')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{dateTime.format(new Date(r.dueAt))}</p>
|
||||
{r.description !== '' && (
|
||||
<p className="mt-1 line-clamp-2 whitespace-pre-line text-xs text-muted-foreground">
|
||||
{r.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{due ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void markDone(r.id)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('reminder.done')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSnoozeOpenId(snoozeOpenId === r.id ? null : r.id)}
|
||||
aria-expanded={snoozeOpenId === r.id}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('reminder.snooze')}
|
||||
</button>
|
||||
{snoozeOpenId === r.id && (
|
||||
<div className="flex w-full flex-wrap gap-2">
|
||||
{SNOOZE_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => void snooze(r, preset)}
|
||||
className="btn btn-subtle border-border"
|
||||
>
|
||||
{preset === '10m' && t('reminder.snooze10m')}
|
||||
{preset === '1h' && t('reminder.snooze1h')}
|
||||
{preset === 'tomorrow' &&
|
||||
t('reminder.snoozeTomorrow', { time: timeOnly.format(new Date(r.dueAt)) })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{confirmDeleteId === r.id ? (
|
||||
<>
|
||||
<span className="text-xs text-foreground">{t('reminder.deleteConfirm')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void remove(r.id)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('reminder.yes')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDeleteId(null)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('reminder.no')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNotice(null);
|
||||
setModal({ reminder: r });
|
||||
}}
|
||||
className="btn btn-subtle btn-icon"
|
||||
aria-label={t('reminder.edit')}
|
||||
title={t('reminder.edit')}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDeleteId(r.id)}
|
||||
className="btn btn-subtle btn-icon"
|
||||
aria-label={t('reminder.delete')}
|
||||
title={t('reminder.delete')}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4h8v2" />
|
||||
<path d="M19 6l-1 14H6L5 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{blocked && (
|
||||
<p className="text-xs text-muted-foreground" data-testid="reminder-permission-hint">
|
||||
{t('reminder.permissionDenied')}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalOpen(true)}
|
||||
onClick={() => {
|
||||
setNotice(null);
|
||||
setModal({ reminder: null });
|
||||
}}
|
||||
className="btn btn-secondary shrink-0 self-start"
|
||||
>
|
||||
{t('reminder.add')}
|
||||
</button>
|
||||
{modalOpen && (
|
||||
<ReminderFormModal onClose={() => setModalOpen(false)} onSaved={handleSaved} />
|
||||
{modal && (
|
||||
<ReminderFormModal
|
||||
reminder={modal.reminder}
|
||||
onClose={() => setModal(null)}
|
||||
onSaved={afterMutation}
|
||||
onStale={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
defaultNewReminderInputs,
|
||||
isoToLocalInputs,
|
||||
localInputsToIso,
|
||||
snoozeTarget,
|
||||
} from './reminder-time';
|
||||
|
||||
// Alle Zeitpunkte in Ortszeit gebildet (`new Date(y, m, d, h, min)`), damit die
|
||||
// Tests in jeder Zeitzone dasselbe pruefen.
|
||||
const local = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m - 1, d, h, min);
|
||||
|
||||
describe('localInputsToIso / isoToLocalInputs', () => {
|
||||
it('laufen im Kreis', () => {
|
||||
const iso = localInputsToIso('2026-10-05', '14:30');
|
||||
expect(iso).toBe(local(2026, 10, 5, 14, 30).toISOString());
|
||||
expect(isoToLocalInputs(iso as string)).toEqual({ date: '2026-10-05', time: '14:30' });
|
||||
});
|
||||
|
||||
it('liefert null bei unvollstaendiger oder ungueltiger Eingabe', () => {
|
||||
expect(localInputsToIso('', '14:30')).toBeNull();
|
||||
expect(localInputsToIso('2026-10-05', '')).toBeNull();
|
||||
expect(localInputsToIso('2026-13-45', '14:30')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultNewReminderInputs', () => {
|
||||
it('ist die naechste volle Stunde', () => {
|
||||
expect(defaultNewReminderInputs(local(2026, 9, 29, 13, 20))).toEqual({
|
||||
date: '2026-09-29',
|
||||
time: '14:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('rollt ueber Mitternacht und Monatsende', () => {
|
||||
expect(defaultNewReminderInputs(local(2026, 9, 30, 23, 30))).toEqual({
|
||||
date: '2026-10-01',
|
||||
time: '00:00',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('snoozeTarget (E-05)', () => {
|
||||
const now = local(2026, 9, 29, 14, 5);
|
||||
const original = local(2026, 9, 29, 14, 0);
|
||||
|
||||
it('10m und 1h zaehlen ab jetzt, nicht ab der alten Faelligkeit', () => {
|
||||
expect(snoozeTarget('10m', original, now).getTime()).toBe(now.getTime() + 10 * 60_000);
|
||||
expect(snoozeTarget('1h', original, now).getTime()).toBe(now.getTime() + 3600_000);
|
||||
});
|
||||
|
||||
it('morgen = urspruengliche Ortszeit am naechsten Tag', () => {
|
||||
expect(snoozeTarget('tomorrow', original, now).getTime()).toBe(local(2026, 9, 30, 14, 0).getTime());
|
||||
});
|
||||
|
||||
it('morgen ueber das Monatsende', () => {
|
||||
const n = local(2026, 9, 30, 15, 0);
|
||||
expect(snoozeTarget('tomorrow', local(2026, 9, 30, 14, 0), n).getTime()).toBe(
|
||||
local(2026, 10, 1, 14, 0).getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it('eine tagelang ueberfaellige Erinnerung landet beim naechsten kuenftigen Vorkommen', () => {
|
||||
const old = local(2026, 9, 25, 8, 15);
|
||||
const target = snoozeTarget('tomorrow', old, now);
|
||||
expect(target.getTime()).toBeGreaterThan(now.getTime());
|
||||
expect(target.getTime()).toBe(local(2026, 9, 30, 8, 15).getTime());
|
||||
});
|
||||
});
|
||||
@@ -27,3 +27,32 @@ export function defaultNewReminderInputs(now: Date): { date: string; time: strin
|
||||
time: `${pad(next.getHours())}:${pad(next.getMinutes())}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Zeitpunkt aus ISO-Zeichenkette als Datums- und Zeitfeld (Ortszeit) fuer das Formular. */
|
||||
export function isoToLocalInputs(iso: string): { date: string; time: string } {
|
||||
const d = new Date(iso);
|
||||
return {
|
||||
date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`,
|
||||
time: `${pad(d.getHours())}:${pad(d.getMinutes())}`,
|
||||
};
|
||||
}
|
||||
|
||||
export type SnoozePreset = '10m' | '1h' | 'tomorrow';
|
||||
|
||||
/**
|
||||
* Neuer Zeitpunkt fuer „Spaeter erinnern“ (D-03, E-05). `10m` und `1h` zaehlen
|
||||
* ab JETZT, nicht ab der alten Faelligkeit. `tomorrow` nimmt die urspruengliche
|
||||
* 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.
|
||||
*/
|
||||
export function snoozeTarget(preset: SnoozePreset, originalDueAt: Date, now: Date): Date {
|
||||
if (preset === '10m') return new Date(now.getTime() + 10 * 60_000);
|
||||
if (preset === '1h') return new Date(now.getTime() + 60 * 60_000);
|
||||
const d = new Date(originalDueAt);
|
||||
do {
|
||||
d.setDate(d.getDate() + 1);
|
||||
} while (d.getTime() <= now.getTime());
|
||||
return d;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ReminderRequestError, createReminder, listReminders } from './reminders-api';
|
||||
import {
|
||||
ReminderRequestError,
|
||||
createReminder,
|
||||
deleteReminder,
|
||||
listReminders,
|
||||
snoozeReminder,
|
||||
updateReminder,
|
||||
} from './reminders-api';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
@@ -33,6 +40,41 @@ describe('reminders-api', () => {
|
||||
expect(JSON.parse(init.body)).toEqual({ title: 'a', dueAt: '2099-01-01T10:00:00.000Z' });
|
||||
});
|
||||
|
||||
it('updateReminder schickt PATCH an /reminders/:id', async () => {
|
||||
fetchMock.mockResolvedValue(ok({ id: 'r 1' }));
|
||||
await updateReminder('r 1', { title: 'neu' });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toMatch(/\/reminders\/r%201$/);
|
||||
expect(init.method).toBe('PATCH');
|
||||
expect(init.credentials).toBe('include');
|
||||
expect(JSON.parse(init.body)).toEqual({ title: 'neu' });
|
||||
});
|
||||
|
||||
it('snoozeReminder schickt POST /reminders/:id/snooze mit dueAt', async () => {
|
||||
fetchMock.mockResolvedValue(ok({ id: 'r1' }));
|
||||
await snoozeReminder('r1', '2099-01-01T10:00:00.000Z');
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toMatch(/\/reminders\/r1\/snooze$/);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body)).toEqual({ dueAt: '2099-01-01T10:00:00.000Z' });
|
||||
});
|
||||
|
||||
it('deleteReminder schickt DELETE an /reminders/:id', async () => {
|
||||
fetchMock.mockResolvedValue(ok({ deleted: true }));
|
||||
await expect(deleteReminder('r1')).resolves.toEqual({ deleted: true });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toMatch(/\/reminders\/r1$/);
|
||||
expect(init.method).toBe('DELETE');
|
||||
expect(init.credentials).toBe('include');
|
||||
});
|
||||
|
||||
it('bei 404 und 409 tragen die Fehler den Status', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) });
|
||||
await expect(deleteReminder('x')).rejects.toMatchObject({ status: 404 });
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 409, json: async () => ({}) });
|
||||
await expect(snoozeReminder('x', 'y')).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
|
||||
it('ein Status ausserhalb 2xx wirft ReminderRequestError mit diesem Status', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 401, json: async () => ({}) });
|
||||
await expect(listReminders()).rejects.toMatchObject({ status: 401 });
|
||||
|
||||
@@ -60,3 +60,22 @@ export interface ReminderInput {
|
||||
export function createReminder(input: ReminderInput): Promise<Reminder> {
|
||||
return request<Reminder>('/reminders', jsonInit('POST', input));
|
||||
}
|
||||
|
||||
export type ReminderPatch = Partial<ReminderInput>;
|
||||
|
||||
export function updateReminder(id: string, patch: ReminderPatch): Promise<Reminder> {
|
||||
return request<Reminder>(`/reminders/${encodeURIComponent(id)}`, jsonInit('PATCH', patch));
|
||||
}
|
||||
|
||||
/** „Spaeter erinnern“: neue Faelligkeit (ISO), nur bei einer faelligen Erinnerung. */
|
||||
export function snoozeReminder(id: string, dueAt: string): Promise<Reminder> {
|
||||
return request<Reminder>(
|
||||
`/reminders/${encodeURIComponent(id)}/snooze`,
|
||||
jsonInit('POST', { dueAt }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Loescht die Erinnerung — bedient „Loeschen“ (vorher) und „Erledigt“ (nachher). */
|
||||
export function deleteReminder(id: string): Promise<{ deleted: true }> {
|
||||
return request<{ deleted: true }>(`/reminders/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
@@ -493,6 +493,21 @@
|
||||
"saveError": "Die Erinnerung konnte nicht gespeichert werden.",
|
||||
"loadError": "Erinnerungen konnten nicht geladen werden.",
|
||||
"limitReached": "Es sind höchstens 100 Erinnerungen möglich.",
|
||||
"due": "Fällig",
|
||||
"done": "Erledigt",
|
||||
"snooze": "Später erinnern",
|
||||
"snooze10m": "In 10 Minuten",
|
||||
"snooze1h": "In 1 Stunde",
|
||||
"snoozeTomorrow": "Morgen um {time}",
|
||||
"edit": "Erinnerung bearbeiten",
|
||||
"editTitle": "Erinnerung bearbeiten",
|
||||
"delete": "Erinnerung löschen",
|
||||
"deleteConfirm": "Löschen?",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"alreadyDue": "Die Erinnerung ist inzwischen fällig und lässt sich nicht mehr bearbeiten.",
|
||||
"notDue": "Die Erinnerung ist noch nicht fällig.",
|
||||
"permissionDenied": "Ihr Browser blockiert Benachrichtigungen. Fällige Erinnerungen erscheinen dann nur hier in der Kachel.",
|
||||
"notificationTitle": "Erinnerung: {title}"
|
||||
},
|
||||
"editShort": "Bearbeiten",
|
||||
|
||||
@@ -493,6 +493,21 @@
|
||||
"saveError": "The reminder could not be saved.",
|
||||
"loadError": "Reminders could not be loaded.",
|
||||
"limitReached": "You can have at most 100 reminders.",
|
||||
"due": "Due",
|
||||
"done": "Done",
|
||||
"snooze": "Remind me later",
|
||||
"snooze10m": "In 10 minutes",
|
||||
"snooze1h": "In 1 hour",
|
||||
"snoozeTomorrow": "Tomorrow at {time}",
|
||||
"edit": "Edit reminder",
|
||||
"editTitle": "Edit reminder",
|
||||
"delete": "Delete reminder",
|
||||
"deleteConfirm": "Delete?",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"alreadyDue": "The reminder is now due and can no longer be edited.",
|
||||
"notDue": "The reminder is not due yet.",
|
||||
"permissionDenied": "Your browser blocks notifications. Due reminders will then only appear here in the tile.",
|
||||
"notificationTitle": "Reminder: {title}"
|
||||
},
|
||||
"editShort": "Edit",
|
||||
|
||||
@@ -103,6 +103,8 @@ export const UMLAUT_REPLACEMENTS: Record<string, string> = {
|
||||
* and must never be touched by the replacement or flagged by the guard.
|
||||
*/
|
||||
export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
// quick-260929-if2: „lässt“ (Erinnerung „lässt sich nicht mehr bearbeiten“)
|
||||
'lässt',
|
||||
'manuell',
|
||||
'Manuell',
|
||||
'Quelle',
|
||||
|
||||
@@ -177,8 +177,8 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
|
||||
| settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
|
||||
| proxmox | 0 | 11 | 1 | **quick-260923-dhh (Aufgabe 5, Endstand):** 7→11 gebunden — `updateServer` (`proxmoxServer.findUnique` UND `.update`) und `deleteServer` (`proxmoxServer.findUnique` UND `.delete`) bringen vier weitere gebundene Rohtreffer, je ein Klient je Methode. Nachgemessen mit der Gate-Schleife (`grep -c` ueber `tenantPrisma\.\(proxmoxServer\|proxmoxServerStatus\)\.` in `proxmox.service.ts`: 10 fuer `proxmoxServer`, 1 fuer `proxmoxServerStatus`). Vorher: **quick-260923-dhh (Aufgabe 4):** 4→7 gebunden, 0→1 System — `proxmox.service.ts` bringt drei weitere gebundene Rohtreffer (`pollServer` mit `include: { status: true }` bleibt EIN Klient, `testConnection`, `listActiveServerIdsForTenant`, `loadActiveServersForTenantScheduling` — vier neue Methoden, aber `pollServer`s zweiter Zugriff war schon gezaehlt, macht drei zusaetzliche) und einen System-Rohtreffer (`loadActiveServersForScheduler()`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Vorher: **quick-260923-dhh (Aufgabe 1):** neu, vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode) |
|
||||
| custom-modules | 0 | 6 | 0 | **Nachgemessen quick-260929-dzu:** 0/6/0 — persönliche Einträge je Benutzer: `create` trägt jetzt zwei Klienten in getrennten Zweigen (gemeinsam ohne Benutzer, persönlich mit Benutzer, je ein `tenantPrisma.customModule.create`), die gemeinsame Ladefunktion `loadVisible` trägt das einzige `findUnique` für `getOne`/`update`/`remove` (vorher je Methode eines): `list` 1, `create` 2, `loadVisible` 1, `update` 1, `remove` 1. Das Ergebnis ist ein Treffer weniger als bei quick-260929-9wc, obwohl der Zugriff strenger geworden ist. Vorher: **quick-260929-9wc:** neu, sieben gebundene Rohtreffer in `custom-modules.service.ts` (`list` 1, `getOne` 1, `create` 1, `update` 2, `remove` 2), nachgemessen mit der Gate-Schleife: 0/7/0. Kein ungebundener Zugriff, kein Systemkontext. |
|
||||
| reminders | 0 | 3 | 0 | **quick-260929-if2 (Aufgabe 1, Tracer):** neu, drei gebundene Rohtreffer in `reminders.service.ts`, nachgemessen mit der Gate-Schleife: 0/3/0 — `list` (`findMany`), `create` (`count` fuer die Grenze von 100 und `create`). Persönliche Erinnerungen je Benutzer, jede Methode bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`). Kein ungebundener Zugriff, kein Systemkontext in diesem Bereich (der E-Mail-Planer folgt in Aufgabe 3). |
|
||||
| **Summe** | **61** | **226** | **6** | **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||
| reminders | 0 | 7 | 0 | **quick-260929-if2 (Aufgabe 2):** nachgemessen mit der Gate-Schleife: 0/7/0 — +4 gebunden: `update` (`update`), `snooze` (`update`), `remove` (`delete`) und die gemeinsame Besitzprüfung `loadOwn` (`findFirst`, ein Treffer für alle drei; fremde und unbekannte Kennungen sind dort ununterscheidbar 404, D-05). Vorher: **quick-260929-if2 (Aufgabe 1, Tracer):** neu, drei gebundene Rohtreffer in `reminders.service.ts`, nachgemessen mit der Gate-Schleife: 0/3/0 — `list` (`findMany`), `create` (`count` fuer die Grenze von 100 und `create`). Persönliche Erinnerungen je Benutzer, jede Methode bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`). Kein ungebundener Zugriff, kein Systemkontext in diesem Bereich (der E-Mail-Planer folgt in Aufgabe 3). |
|
||||
| **Summe** | **61** | **230** | **6** | **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||
|
||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 80 Paare)
|
||||
|
||||
@@ -784,7 +784,7 @@ werden.
|
||||
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | system-gebunden | **quick-260923-dhh, Aufgabe 4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadActiveServersForScheduler()` liest beim Start des Planers `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht ueber `system_read_policy … FOR SELECT` auf "ProxmoxServer", Migration 20260923140000) — der Planer muss die aktiven Server ALLER Mandanten sehen, um je Mandant einen Cron-Auftrag zu registrieren (Muster `DkvSchedulerService`). GESCHRIEBEN wird auch dort nur je Zeile gebunden. Sechs mandantengebundene Zugriffe blieben nach Aufgabe 4 bestehen: `createServer` (`proxmoxServer.create`), `listWithStatus` (`findMany`), `pollServer` (`findUnique`, mit `include: { status: true }` fuer die Zehn-Sekunden-Sperre), `testConnection` (`findUnique`), `listActiveServerIdsForTenant` (`findMany`), `loadActiveServersForTenantScheduling` (`findMany` auf `proxmoxServer`, `select: { pollIntervalMin: true }`). **Aufgabe 5** ergaenzt vier weitere: `updateServer` (`findUnique` UND `update`) und `deleteServer` (`findUnique` UND `delete`), je ein Klient je Methode — macht zehn mandantengebundene `proxmoxServer`-Rohtreffer insgesamt, plus der eine System-Rohtreffer aus Aufgabe 4. Vorher (Aufgabe 1): vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). |
|
||||
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServerStatus | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1/4 — Zwischenlager je Server (D-05), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, dieselbe Form wie `proxmoxServer`). `pollServer` schreibt ueber `tenantPrisma.proxmoxServerStatus.upsert()`, DENSELBEN Klienten wie das Lesen des Servers in derselben Methode; dieselbe Methode liest zusaetzlich `include: { status: true }` fuer die Zehn-Sekunden-Sperre (Aufgabe 4, T-DHH-06) — ebenfalls ueber den gebundenen Klienten. Bewusst KEINE `system_read_policy` auf dieser Tabelle (anders als `proxmoxServer`) — der Planer-Startpfad liest nur die Serverzeilen, das Zwischenlager wird ausschliesslich je Mandant gebunden geschrieben, ein Systemlesezugriff hat keinen Aufrufer. |
|
||||
| apps/api/src/custom-modules/custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden | **quick-260929-9wc:** neu — vom Administrator angelegte Seitenleisten-Eintraege („Eigene Module“, Name, https-Adresse, Kategorie), fuer alle Benutzer des Mandanten sichtbar. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260929120000, Form aus `ProxmoxServer`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der eigene Module ueber alle Mandanten liest. Sieben mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `list` (`findMany` mit `where: { tenantId }`), `getOne` (`findUnique`), `create`, `update` (`findUnique` UND `update`), `remove` (`findUnique` UND `delete`). `getOne`/`update`/`remove` pruefen zusaetzlich `row.tenantId !== tenantId` und antworten mit 404 — zweites Netz, solange der RLS-Schalter aus ist (Muster `dashboardImage`). **quick-260929-dzu — persönliche Einträge:** neue Spalte `ownerUserId` (NULL = gemeinsam, gesetzt = persönlich, nur für den Besitzer sichtbar). Klasse und Stand unverändert (`muss-mandantengebunden`, `gebunden`); der Zeilenschutz bekommt die Benutzerdimension nach dem Muster `SearchProvider` (Migration 20260929130000): vier nach Befehl getrennte Regeln — Lesen: Mandant UND (kein Benutzer gesetzt ODER `ownerUserId` NULL ODER eigene Zeile), Schreiben (INSERT/UPDATE/DELETE): Mandant UND (kein Benutzer gesetzt ODER eigene Zeile). Persönliche Zugriffe binden mit Benutzer (`forTenant(prisma, tenantId, user.id)`); das Schreiben GEMEINSAMER Einträge bindet bewusst OHNE Benutzer, weil die Regel einem Benutzerkontext das Schreiben gemeinsamer Zeilen verwehrt — davor prüft der Dienst die Rolle (nur Administrator, sonst 403). Fremde persönliche Einträge sind für jeden anderen Benutzer, auch Administratoren, ununterscheidbar 404. Sechs mandantengebundene Rohtreffer (siehe Bereichszeile). |
|
||||
| apps/api/src/reminders/reminders.service.ts | reminder | muss-mandantengebunden | gebunden | **quick-260929-if2:** neu — persönliche, einmalige Erinnerungen des Dashboard-Widgets „Erinnerungen“ (Titel, Beschreibung, Fälligkeit). `tenantId`- und `userId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension (Migration 20260929140000, Form aus `DashboardImage`): Mandant UND (kein Benutzer gesetzt ODER eigene Zeile). Jede Methode bindet mit Mandant UND Benutzer (`const tenantPrisma = forTenant(this.prisma, tenantId, userId)`), jedes `where` trägt zusätzlich `tenantId` und `userId` (Anwendungspruefung, solange der RLS-Schalter aus ist). Fremde oder unbekannte Kennungen sind ununterscheidbar 404, nie 403 (D-05). Drei mandantengebundene Rohtreffer: `list` (`findMany`), `create` (`count` und `create`). |
|
||||
| apps/api/src/reminders/reminders.service.ts | reminder | muss-mandantengebunden | gebunden | **quick-260929-if2:** neu — persönliche, einmalige Erinnerungen des Dashboard-Widgets „Erinnerungen“ (Titel, Beschreibung, Fälligkeit). `tenantId`- und `userId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension (Migration 20260929140000, Form aus `DashboardImage`): Mandant UND (kein Benutzer gesetzt ODER eigene Zeile). Jede Methode bindet mit Mandant UND Benutzer (`const tenantPrisma = forTenant(this.prisma, tenantId, userId)`), jedes `where` trägt zusätzlich `tenantId` und `userId` (Anwendungspruefung, solange der RLS-Schalter aus ist). Fremde oder unbekannte Kennungen sind ununterscheidbar 404, nie 403 (D-05). Sieben mandantengebundene Rohtreffer: `list` (`findMany`), `create` (`count` und `create`), `update` (`update`), `snooze` (`update`), `remove` (`delete`) und die gemeinsame Besitzprüfung `loadOwn` (`findFirst` mit `where: { id, tenantId, userId }`, für `update`/`snooze`/`remove`). Das Verschieben setzt `emailSentAt` und `emailAttempts` zurück (D-03). |
|
||||
|
||||
## Was diese Etappe NICHT entscheidet
|
||||
|
||||
|
||||
Reference in New Issue
Block a user