feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer)
- Reminder-Tabelle mit Zeilenschutz (Mandant+Benutzer, Systemlesen fuer den E-Mail-Planer), API reminders (Liste, Anlegen) - Kachel "Erinnerungen", globaler Melder im Portalrahmen (Browser und Desktop, je Faelligkeit einmal) - Desktop: Laufzeit-Berechtigung fuer Benachrichtigungen nur fuer die gespeicherte Server-Adresse - Zugriffsklassifikation nachgemessen Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
|||||||
|
-- 260929-if2 — Erinnerungen: persoenliche, einmalige Erinnerungen je Benutzer.
|
||||||
|
--
|
||||||
|
-- Zweck: die Tabelle "Reminder" traegt die Erinnerungen des Dashboard-Widgets
|
||||||
|
-- „Erinnerungen“ (Titel, Beschreibung, Faelligkeit, optional E-Mail). Es gibt
|
||||||
|
-- keine Wiederholung (D-01) und keine Historie: „Erledigt“ loescht die Zeile.
|
||||||
|
--
|
||||||
|
-- Besitz: eine Erinnerung gehoert genau einem Benutzer (gleicher Mandant UND
|
||||||
|
-- gleicher Benutzer, D-05). Faellt der Benutzer weg, fallen seine Erinnerungen
|
||||||
|
-- mit (ON DELETE CASCADE). Die Anwendung antwortet fuer fremde Kennungen mit
|
||||||
|
-- 404 (nie 403).
|
||||||
|
--
|
||||||
|
-- Spuren des E-Mail-Planers: "emailSentAt" ist der ANSPRUCH auf den Versand
|
||||||
|
-- (wird vor dem Senden gesetzt, damit mehrere API-Instanzen nicht doppelt
|
||||||
|
-- senden), "emailAttempts" zaehlt die Versuche (hoechstens 3). Ein Verschieben
|
||||||
|
-- der Faelligkeit setzt beide zurueck.
|
||||||
|
--
|
||||||
|
-- Zeilenschutz, zwei Regeln:
|
||||||
|
-- tenant_isolation_policy — Mandant UND Benutzer (Form aus DashboardImage,
|
||||||
|
-- 20260921120000_dashboard_image): ohne gesetzten Benutzer (Hintergrund-
|
||||||
|
-- dienst, der je Mandant gebunden schreibt) gilt nur der Mandant, mit
|
||||||
|
-- Benutzer zusaetzlich "userId".
|
||||||
|
-- system_read_policy — NUR FOR SELECT, Form aus 20260914120000_rls_system_
|
||||||
|
-- context_read. Sie bedient allein die Kandidatenabfrage des E-Mail-
|
||||||
|
-- Planers (reminder-mail.scheduler.ts), der einmal ueber ALLE Mandanten
|
||||||
|
-- liest und dann je Zeile gebunden anspricht. Schreiben bleibt der
|
||||||
|
-- Mandantenregel vorbehalten.
|
||||||
|
--
|
||||||
|
-- Rechte fuer die Anwendungsrolle tessera_app: kommen ueber ALTER DEFAULT
|
||||||
|
-- PRIVILEGES aus 20260909130000_rls_app_role automatisch — hier nichts zu tun.
|
||||||
|
--
|
||||||
|
-- WICHTIG: wie alle RLS-Regeln dieses Schemas wirken diese erst, wenn die
|
||||||
|
-- Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, siehe
|
||||||
|
-- docs/mandantentrennung-datenbankrolle.md). Bis dahin tragen die
|
||||||
|
-- Anwendungspruefungen im Dienst den Schutz allein.
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Reminder" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL DEFAULT '',
|
||||||
|
"dueAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"emailEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"emailSentAt" TIMESTAMP(3),
|
||||||
|
"emailAttempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Reminder_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Reminder_tenantId_userId_dueAt_idx" ON "Reminder"("tenantId", "userId", "dueAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Reminder_dueAt_idx" ON "Reminder"("dueAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Reminder" ADD CONSTRAINT "Reminder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Zeilenschutz: Mandant UND Benutzer (Muster 20260921120000)
|
||||||
|
ALTER TABLE "Reminder" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "Reminder" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_policy ON "Reminder"
|
||||||
|
USING (
|
||||||
|
"tenantId" = current_tenant_id()
|
||||||
|
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Systemkontext: nur Lesen, fuer die Kandidatenabfrage des E-Mail-Planers
|
||||||
|
CREATE POLICY system_read_policy ON "Reminder"
|
||||||
|
FOR SELECT USING (is_system_context());
|
||||||
@@ -54,6 +54,7 @@ model User {
|
|||||||
groupMemberships GroupMembership[]
|
groupMemberships GroupMembership[]
|
||||||
moduleGrants ModuleGrant[]
|
moduleGrants ModuleGrant[]
|
||||||
customModules CustomModule[]
|
customModules CustomModule[]
|
||||||
|
reminders Reminder[]
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@index([username])
|
@@index([username])
|
||||||
@@ -739,3 +740,27 @@ model CustomModule {
|
|||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@index([tenantId, ownerUserId])
|
@@index([tenantId, ownerUserId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// quick-260929-if2: persoenliche Erinnerungen, einmalig (D-01). Eine Zeile
|
||||||
|
// gehoert genau einem Benutzer (D-05); fuer fremde Kennungen antwortet die API
|
||||||
|
// mit 404. "Erledigt" loescht die Zeile (E-02), es gibt keine Historie.
|
||||||
|
// emailSentAt/emailAttempts sind die Rechenspur des E-Mail-Planers (Anspruch
|
||||||
|
// vor dem Senden, hoechstens 3 Versuche); ein Verschieben (dueAt) setzt beide
|
||||||
|
// zurueck, damit die E-Mail erneut verschickt wird (D-03).
|
||||||
|
model Reminder {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
title String
|
||||||
|
description String @default("")
|
||||||
|
dueAt DateTime
|
||||||
|
emailEnabled Boolean @default(false)
|
||||||
|
emailSentAt DateTime?
|
||||||
|
emailAttempts Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([tenantId, userId, dueAt])
|
||||||
|
@@index([dueAt])
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { TendersModule } from './tenders/tenders.module';
|
|||||||
import { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
import { ProxmoxModule } from './proxmox/proxmox.module';
|
import { ProxmoxModule } from './proxmox/proxmox.module';
|
||||||
import { CustomModulesModule } from './custom-modules/custom-modules.module';
|
import { CustomModulesModule } from './custom-modules/custom-modules.module';
|
||||||
|
import { RemindersModule } from './reminders/reminders.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -55,6 +56,7 @@ import { CustomModulesModule } from './custom-modules/custom-modules.module';
|
|||||||
BugReportsModule,
|
BugReportsModule,
|
||||||
ProxmoxModule,
|
ProxmoxModule,
|
||||||
CustomModulesModule,
|
CustomModulesModule,
|
||||||
|
RemindersModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global JWT guard: all routes require auth unless @Public()
|
// Global JWT guard: all routes require auth unless @Public()
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsISO8601, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
const trimString = ({ value }: { value: unknown }) =>
|
||||||
|
typeof value === 'string' ? value.trim() : value;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO fuer das Anlegen einer Erinnerung (quick-260929-if2). `tenantId`,
|
||||||
|
* `userId` und die Spuren des E-Mail-Planers gibt es hier nicht: die globale
|
||||||
|
* ValidationPipe (`whitelist: true`) verwirft sie, der Dienst setzt Mandant und
|
||||||
|
* Benutzer ausschliesslich aus dem Anmelde-Token (T-IF2-02).
|
||||||
|
*/
|
||||||
|
export class CreateReminderDto {
|
||||||
|
@Transform(trimString)
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(200)
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
/** Faelligkeit als ISO-8601-Zeitpunkt; der Client rechnet die Ortszeit um. */
|
||||||
|
@IsISO8601({ strict: true })
|
||||||
|
dueAt!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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 { RemindersController } from './reminders.controller';
|
||||||
|
|
||||||
|
function makeService() {
|
||||||
|
return {
|
||||||
|
list: vi.fn(async (..._args: unknown[]) => []),
|
||||||
|
create: vi.fn(async (..._args: unknown[]) => ({})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = (tenantId?: string) => ({ tenantId }) as any;
|
||||||
|
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) => {
|
||||||
|
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('haengt an Pfad reminders', () => {
|
||||||
|
expect(Reflect.getMetadata('path', RemindersController)).toBe('reminders');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RemindersController — Mandant', () => {
|
||||||
|
it('reicht req.tenantId und die Benutzerkennung an den Dienst weiter', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
const controller = new RemindersController(service as any);
|
||||||
|
await controller.list(req('t1'), user);
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
|
||||||
|
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.create(req(), user, { title: 'a', dueAt: '2099-01-01T10:00:00.000Z' }),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die globale Pipe verwirft untergeschobene Felder (T-IF2-02)', async () => {
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
const out: any = await pipe.transform(
|
||||||
|
{
|
||||||
|
title: ' a ',
|
||||||
|
dueAt: '2099-01-01T10:00:00.000Z',
|
||||||
|
tenantId: 'evil',
|
||||||
|
userId: 'evil',
|
||||||
|
emailSentAt: '2020-01-01T00:00:00.000Z',
|
||||||
|
emailAttempts: 9,
|
||||||
|
},
|
||||||
|
{ type: 'body', metatype: CreateReminderDto },
|
||||||
|
);
|
||||||
|
expect(out).not.toHaveProperty('tenantId');
|
||||||
|
expect(out).not.toHaveProperty('userId');
|
||||||
|
expect(out).not.toHaveProperty('emailSentAt');
|
||||||
|
expect(out).not.toHaveProperty('emailAttempts');
|
||||||
|
expect(out.title).toBe('a');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RemindersController — Routen-Reihenfolge (statisch vor :id)', () => {
|
||||||
|
it('deklariert list vor jeder :id-Route', () => {
|
||||||
|
const methods = Object.getOwnPropertyNames(RemindersController.prototype);
|
||||||
|
const listIdx = methods.indexOf('list');
|
||||||
|
expect(listIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
for (const name of methods) {
|
||||||
|
const path = Reflect.getMetadata('path', proto[name]);
|
||||||
|
if (typeof path === 'string' && path.startsWith(':id')) {
|
||||||
|
expect(listIdx).toBeLessThan(methods.indexOf(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Body, Controller, ForbiddenException, Get, 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 { RemindersService } from './reminders.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persoenliche Erinnerungen (quick-260929-if2). Jeder angemeldete Benutzer
|
||||||
|
* verwaltet seine eigenen; fremde Kennungen sind 404 (D-05). Kein `@Roles`,
|
||||||
|
* kein `@UseModule`: das Widget haengt an keiner Modul-Aktivierung.
|
||||||
|
* `tenantId` kommt ausschliesslich aus `req.tenantId` (gesetzt vom
|
||||||
|
* `TenantGuard`), der Benutzer aus dem Token.
|
||||||
|
*
|
||||||
|
* ROUTEN-REIHENFOLGE: NestJS bildet Routen in Deklarationsreihenfolge ab.
|
||||||
|
* Jede statische GET-Route (Aufgabe 3 ergaenzt `email-status`) MUSS ueber jeder
|
||||||
|
* `:id`-Route stehen, sonst faengt `:id` sie ab (404-Shadowing); der
|
||||||
|
* Controller-Test haelt die Reihenfolge fest.
|
||||||
|
*/
|
||||||
|
@Controller('reminders')
|
||||||
|
export class RemindersController {
|
||||||
|
constructor(private readonly service: RemindersService) {}
|
||||||
|
|
||||||
|
private requireTenantId(req: AuthenticatedRequest): string {
|
||||||
|
const tenantId = req.tenantId;
|
||||||
|
if (!tenantId) {
|
||||||
|
throw new ForbiddenException('Kein Mandantenkontext');
|
||||||
|
}
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) {
|
||||||
|
return this.service.list(this.requireTenantId(req), user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Body() dto: CreateReminderDto,
|
||||||
|
) {
|
||||||
|
return this.service.create(this.requireTenantId(req), user.id, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { RemindersController } from './reminders.controller';
|
||||||
|
import { RemindersService } from './reminders.service';
|
||||||
|
|
||||||
|
/** Erinnerungen (quick-260929-if2). `PrismaModule` ist global. */
|
||||||
|
@Module({
|
||||||
|
controllers: [RemindersController],
|
||||||
|
providers: [RemindersService],
|
||||||
|
})
|
||||||
|
export class RemindersModule {}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// `forTenant` reicht den Klienten durch — die Bindung selbst prueft
|
||||||
|
// rls-access-inventory.spec.ts; hier zaehlt, mit welchen Argumenten gebunden wird.
|
||||||
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((p: unknown) => p),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import { RemindersService } from './reminders.service';
|
||||||
|
|
||||||
|
function makeFakePrisma() {
|
||||||
|
const rows = new Map<string, any>();
|
||||||
|
let seq = 0;
|
||||||
|
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 };
|
||||||
|
rows.set(id, row);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
findMany: vi.fn(async ({ where, orderBy }: { where?: any; orderBy?: any } = {}) => {
|
||||||
|
let list = [...rows.values()];
|
||||||
|
if (where?.tenantId) list = list.filter((r) => r.tenantId === where.tenantId);
|
||||||
|
if (where?.userId) list = list.filter((r) => r.userId === where.userId);
|
||||||
|
if (orderBy?.dueAt === 'asc') list.sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime());
|
||||||
|
return list;
|
||||||
|
}),
|
||||||
|
count: vi.fn(async ({ where }: { where?: any } = {}) => {
|
||||||
|
return [...rows.values()].filter(
|
||||||
|
(r) => r.tenantId === where?.tenantId && r.userId === where?.userId,
|
||||||
|
).length;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return { reminder, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
return { prisma, service: new RemindersService(prisma as any) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const inHours = (h: number) => new Date(Date.now() + h * 3600_000).toISOString();
|
||||||
|
|
||||||
|
describe('RemindersService — anlegen', () => {
|
||||||
|
it('speichert tenantId und userId aus den Argumenten, nie aus dem DTO', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
await service.create('t1', 'u1', {
|
||||||
|
title: 'Zahnarzt',
|
||||||
|
dueAt: inHours(2),
|
||||||
|
tenantId: 'evil',
|
||||||
|
userId: 'evil',
|
||||||
|
} as any);
|
||||||
|
const data = prisma.reminder.create.mock.calls[0][0].data;
|
||||||
|
expect(data.tenantId).toBe('t1');
|
||||||
|
expect(data.userId).toBe('u1');
|
||||||
|
expect(data.description).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bindet mit Mandant UND Benutzer', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
await service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) });
|
||||||
|
expect(forTenant).toHaveBeenLastCalledWith(expect.anything(), 't1', 'u1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt eine vergangene Faelligkeit mit 400 ab', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
await expect(
|
||||||
|
service.create('t1', 'u1', { title: 'a', dueAt: inHours(-1) }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt eine Faelligkeit ueber 5 Jahre voraus mit 400 ab', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
await expect(
|
||||||
|
service.create('t1', 'u1', { title: 'a', dueAt: inHours(24 * 365 * 5 + 48) }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die 101. Erinnerung eines Benutzers ergibt 409', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
prisma.rows.set(`x${i}`, { id: `x${i}`, tenantId: 't1', userId: 'u1', dueAt: new Date() });
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RemindersService — auflisten', () => {
|
||||||
|
it('liefert nur Zeilen von Mandant und Benutzer, nach Faelligkeit aufsteigend', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
await service.create('t1', 'u1', { title: 'spaet', dueAt: inHours(5) });
|
||||||
|
await service.create('t1', 'u1', { title: 'frueh', dueAt: inHours(1) });
|
||||||
|
await service.create('t1', 'u2', { title: 'fremd', dueAt: inHours(2) });
|
||||||
|
await service.create('t2', 'u1', { title: 'anderer Mandant', dueAt: inHours(2) });
|
||||||
|
const list = await service.list('t1', 'u1');
|
||||||
|
expect(list.map((r: any) => r.title)).toEqual(['frueh', 'spaet']);
|
||||||
|
const where = prisma.reminder.findMany.mock.calls[0]?.[0]?.where;
|
||||||
|
expect(where).toEqual({ tenantId: 't1', userId: 'u1' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import type { CreateReminderDto } from './dto/reminder.dto';
|
||||||
|
|
||||||
|
/** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */
|
||||||
|
export const REMINDER_SELECT = {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
dueAt: true,
|
||||||
|
emailEnabled: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** E-06: hoechstens so viele Erinnerungen je Benutzer. */
|
||||||
|
export const MAX_REMINDERS_PER_USER = 100;
|
||||||
|
/** E-06: die Faelligkeit darf hoechstens so weit in der Zukunft liegen. */
|
||||||
|
const MAX_AHEAD_MS = 5 * 365 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persoenliche Erinnerungen (quick-260929-if2). Eine Erinnerung gehoert genau
|
||||||
|
* einem Benutzer (D-05): fuer fremde oder unbekannte Kennungen antwortet der
|
||||||
|
* Dienst immer mit 404, nie mit 403 — kein Hinweis darauf, dass es sie gibt.
|
||||||
|
*
|
||||||
|
* `tenantId` und `userId` kommen ausschliesslich als Argumente (aus
|
||||||
|
* `req.tenantId` und dem Anmelde-Token), nie aus dem DTO.
|
||||||
|
*
|
||||||
|
* RLS-BINDUNG (Muster DashboardImage, Migration 20260929140000): jede Methode
|
||||||
|
* bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`), die
|
||||||
|
* Regel laesst dann nur eigene Zeilen zu. Zusaetzlich tragen alle `where`
|
||||||
|
* `tenantId` und `userId` als Anwendungspruefung, solange der RLS-Schalter aus
|
||||||
|
* ist.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RemindersService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** Die eigenen Erinnerungen, die naechste Faelligkeit zuerst. */
|
||||||
|
async list(tenantId: string, userId: string) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
|
return tenantPrisma.reminder.findMany({
|
||||||
|
where: { tenantId, userId },
|
||||||
|
orderBy: { dueAt: 'asc' },
|
||||||
|
select: REMINDER_SELECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(tenantId: string, userId: string, dto: CreateReminderDto) {
|
||||||
|
const dueAt = this.assertValidDueAt(dto.dueAt);
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
|
const count = await tenantPrisma.reminder.count({ where: { tenantId, userId } });
|
||||||
|
if (count >= MAX_REMINDERS_PER_USER) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Es sind höchstens ${MAX_REMINDERS_PER_USER} Erinnerungen möglich`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return tenantPrisma.reminder.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
title: dto.title,
|
||||||
|
description: dto.description ?? '',
|
||||||
|
dueAt,
|
||||||
|
},
|
||||||
|
select: REMINDER_SELECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
if (Number.isNaN(dueAt.getTime())) {
|
||||||
|
throw new BadRequestException('Ungültige Fälligkeit');
|
||||||
|
}
|
||||||
|
if (dueAt.getTime() <= now) {
|
||||||
|
throw new BadRequestException('Die Fälligkeit muss in der Zukunft liegen');
|
||||||
|
}
|
||||||
|
if (dueAt.getTime() > now + MAX_AHEAD_MS) {
|
||||||
|
throw new BadRequestException('Die Fälligkeit liegt zu weit in der Zukunft');
|
||||||
|
}
|
||||||
|
return dueAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -324,6 +324,133 @@ fn server_host(url: Option<&str>) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die drei Benachrichtigungs-Berechtigungen, die die Server-Seite in der
|
||||||
|
/// Webview bekommt -- genau diese und keine weitere (T-IF2-03). Die
|
||||||
|
/// Bezeichner stammen aus tauri-plugin-notification 2.3.3
|
||||||
|
/// (`permissions/autogenerated/commands/notify.toml`,
|
||||||
|
/// `is_permission_granted.toml`, `request_permission.toml`). Eine unbekannte
|
||||||
|
/// Kennung wuerde in `add_capability` ebenfalls in einen Panic laufen; darum
|
||||||
|
/// pinnt ein Test die exakte Menge samt Reihenfolge.
|
||||||
|
const SERVER_NOTIFICATION_PERMISSIONS: [&str; 3] = [
|
||||||
|
"notification:allow-notify",
|
||||||
|
"notification:allow-is-permission-granted",
|
||||||
|
"notification:allow-request-permission",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Bereits berechtigte Herkunfts-Muster. `add_capability` haengt bei jedem
|
||||||
|
/// Aufruf an die bestehenden Erlaubnisse an; ein zweites Speichern derselben
|
||||||
|
/// Adresse soll nichts verdoppeln.
|
||||||
|
static GRANTED_SERVER_ORIGINS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
/// Baut aus der gespeicherten Server-Adresse das URL-Muster fuer die
|
||||||
|
/// Laufzeit-Berechtigung: genau `Schema://Host[:Port]`, ohne Pfad, Query und
|
||||||
|
/// Fragment (quick-260929-if2, E-01).
|
||||||
|
///
|
||||||
|
/// Jedes Zeichen des Hosts ausserhalb von ASCII `A-Z a-z 0-9 . -` bekommt ein
|
||||||
|
/// Backslash (URLPattern-Maskierung). Grund: der `url`-Crate nimmt
|
||||||
|
/// `http://*.example.com/` an und liefert den Host `*.example.com`, der
|
||||||
|
/// ungeschuetzt zum Platzhalter fuer JEDE Subdomain wuerde. IPv6-Adressen
|
||||||
|
/// kommen in Klammern (`[::1]`); der URLPattern-Zerleger (urlpattern 0.3.0)
|
||||||
|
/// weist `http://[::1]:8080` mit `Tokenizer(InvalidName, 1)` ab, die
|
||||||
|
/// maskierte Form `http://\[\:\:1\]:8080` dagegen parst und trifft nur
|
||||||
|
/// `[::1]:8080` (beides gemessen).
|
||||||
|
///
|
||||||
|
/// Der Port steht nur, wenn er ausdruecklich und nicht der Standardport ist
|
||||||
|
/// (der `url`-Crate laesst Standardports weg).
|
||||||
|
///
|
||||||
|
/// SELBSTPRUEFUNG, Pflicht: `add_capability` fuehrt intern
|
||||||
|
/// `Resolved::resolve(..).unwrap()` aus, waehrend es die Sperre der
|
||||||
|
/// Laufzeit-Berechtigungen haelt, und tauri-utils 2.9.3 gerät bei einem
|
||||||
|
/// nicht zerlegbaren Muster in einen Panic ("invalid URL pattern for remote
|
||||||
|
/// URL"). Ein Fehler kommt NICHT als `Err` zurueck, er legt `setup()` lahm --
|
||||||
|
/// und ein `catch_unwind` haette die Sperre vergiftet, sodass jeder spaetere
|
||||||
|
/// IPC-Aufruf scheitert. Deshalb wird das Muster VOR dem Aufruf mit
|
||||||
|
/// demselben Zerleger geprueft, den Tauri fuer `remote.urls` benutzt
|
||||||
|
/// (`RemoteUrlPattern`), und muss die gespeicherte Adresse tatsaechlich
|
||||||
|
/// treffen. Sonst `None`: kein Muster, keine Berechtigung.
|
||||||
|
fn server_origin_pattern(url: &str) -> Option<String> {
|
||||||
|
use std::str::FromStr;
|
||||||
|
let parsed = parse_server_url(url).ok()?;
|
||||||
|
let host = parsed.host_str()?;
|
||||||
|
let escaped: String = host
|
||||||
|
.chars()
|
||||||
|
.flat_map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
|
||||||
|
vec![c]
|
||||||
|
} else {
|
||||||
|
vec!['\\', c]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut pattern = format!("{}://{}", parsed.scheme(), escaped);
|
||||||
|
if let Some(port) = parsed.port() {
|
||||||
|
pattern.push_str(&format!(":{}", port));
|
||||||
|
}
|
||||||
|
let checked = tauri::utils::acl::RemoteUrlPattern::from_str(&pattern).ok()?;
|
||||||
|
if checked.test(&parsed) {
|
||||||
|
Some(pattern)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erteilt der gespeicherten Server-Adresse -- und nur ihr -- zur Laufzeit das
|
||||||
|
/// Recht, das Benachrichtigungs-Plugin aus der Webview aufzurufen
|
||||||
|
/// (quick-260929-if2, E-01). Der globale Erinnerungs-Melder der Web-Oberflaeche
|
||||||
|
/// ruft `plugin:notification|notify`, damit auch bei ausgeblendetem Fenster
|
||||||
|
/// (Infobereich) eine Windows-Meldung erscheint.
|
||||||
|
///
|
||||||
|
/// WARUM ZUR LAUFZEIT UND NICHT IN `capabilities/default.json`: die statische
|
||||||
|
/// Datei hat absichtlich keinen `remote`-Block (T-JN2-01, siehe
|
||||||
|
/// `get_server_url`), Tauri verweigert deshalb jeden Plugin- und App-Aufruf
|
||||||
|
/// aus der Server-Seite. Die Serveradresse ist erst zur Laufzeit bekannt und
|
||||||
|
/// je Installation verschieden. Die Berechtigung gilt genau fuer den
|
||||||
|
/// gespeicherten Ursprung (maskiert und selbstgeprueft, siehe
|
||||||
|
/// `server_origin_pattern` -- nie ein Platzhalter), nur fuer das Fenster
|
||||||
|
/// `main` und nur fuer die drei Benachrichtigungs-Rechte. App-Befehle wie
|
||||||
|
/// `save_server_url` bleiben lokal-only, T-JN2-01 gilt unveraendert. Nach
|
||||||
|
/// einem Serverwechsel behaelt der alte Ursprung sein Benachrichtigungsrecht
|
||||||
|
/// bis zum App-Neustart (akzeptiert, T-IF2-03).
|
||||||
|
///
|
||||||
|
/// Verworfene Alternativen: (1) ein Abruf der API von Rust aus -- der
|
||||||
|
/// vorgeschaltete Basic-Auth-Schutz von alpha liefert reqwest ein 401 (wie
|
||||||
|
/// beim Updater), und das Sitzungs-Cookie lebt nur in der Webview.
|
||||||
|
/// (2) Die Web-Benachrichtigung in der Webview -- das Plugin ersetzt
|
||||||
|
/// `window.Notification` durch einen Ersatz mit demselben IPC-Aufruf, der
|
||||||
|
/// unter Windows bei jedem Seitenaufruf "denied" meldet.
|
||||||
|
///
|
||||||
|
/// Darf den Start nie verhindern: bei einem Muster, das sich nicht bilden
|
||||||
|
/// laesst, bleibt es bei einer Zeile auf stderr -- die Desktop-Meldung ist
|
||||||
|
/// dann fuer diese Adresse aus, Browser-Meldung und E-Mail laufen weiter.
|
||||||
|
fn grant_server_notifications(app: &AppHandle, url: &str) {
|
||||||
|
let Some(pattern) = server_origin_pattern(url) else {
|
||||||
|
eprintln!(
|
||||||
|
"Benachrichtigungen: keine Berechtigung fuer die Server-Adresse erteilt (Muster nicht bildbar)"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut granted = match GRANTED_SERVER_ORIGINS.lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
if granted.contains(&pattern) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
granted.push(pattern.clone());
|
||||||
|
}
|
||||||
|
let mut capability = tauri::ipc::CapabilityBuilder::new("server-notifications")
|
||||||
|
.remote(pattern)
|
||||||
|
.local(false)
|
||||||
|
.window("main");
|
||||||
|
for permission in SERVER_NOTIFICATION_PERMISSIONS {
|
||||||
|
capability = capability.permission(permission);
|
||||||
|
}
|
||||||
|
if let Err(e) = app.add_capability(capability) {
|
||||||
|
eprintln!("Benachrichtigungen: Berechtigung nicht erteilt: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Liefert (Tooltip, Menüzeile) für den Tray -- einzige Stelle, die beide
|
/// Liefert (Tooltip, Menüzeile) für den Tray -- einzige Stelle, die beide
|
||||||
/// Texte aus der Server-Adresse ableitet. Gedankenstrich U+2013 wie in
|
/// Texte aus der Server-Adresse ableitet. Gedankenstrich U+2013 wie in
|
||||||
/// `update_labels`.
|
/// `update_labels`.
|
||||||
@@ -666,6 +793,8 @@ fn save_server_url(app: AppHandle, url: String) -> Result<(), String> {
|
|||||||
store.set("server_url", serde_json::json!(normalized));
|
store.set("server_url", serde_json::json!(normalized));
|
||||||
store.save().map_err(|e| e.to_string())?;
|
store.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Vor dem Navigieren: die Seite darf danach sofort Desktop-Meldungen ausloesen.
|
||||||
|
grant_server_notifications(&app, &normalized);
|
||||||
apply_server(&app, Some(&normalized));
|
apply_server(&app, Some(&normalized));
|
||||||
spawn_version_check(app.clone(), normalized.clone(), false);
|
spawn_version_check(app.clone(), normalized.clone(), false);
|
||||||
|
|
||||||
@@ -801,6 +930,9 @@ pub fn run() {
|
|||||||
let server_url = stored_server_url(app.handle());
|
let server_url = stored_server_url(app.handle());
|
||||||
|
|
||||||
if let Some(url) = &server_url {
|
if let Some(url) = &server_url {
|
||||||
|
// Vor dem ersten `navigate`: Benachrichtigungsrecht fuer die
|
||||||
|
// gespeicherte Adresse (quick-260929-if2, E-01).
|
||||||
|
grant_server_notifications(app.handle(), url);
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
if let Ok(parsed) = parse_server_url(url) {
|
if let Ok(parsed) = parse_server_url(url) {
|
||||||
let _ = window.navigate(with_desktop_marker(&parsed));
|
let _ = window.navigate(with_desktop_marker(&parsed));
|
||||||
@@ -1361,4 +1493,118 @@ mod tests {
|
|||||||
assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich");
|
assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich");
|
||||||
assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600));
|
assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600));
|
||||||
}
|
}
|
||||||
|
// --- server_origin_* (quick-260929-if2, E-01) ---
|
||||||
|
|
||||||
|
fn muster(p: &str) -> tauri::utils::acl::RemoteUrlPattern {
|
||||||
|
p.parse().expect("Muster muss zerlegbar sein")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adresse(s: &str) -> tauri::Url {
|
||||||
|
tauri::Url::parse(s).expect("Testadresse muss gueltig sein")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_entfernt_pfad_query_und_fragment() {
|
||||||
|
assert_eq!(
|
||||||
|
server_origin_pattern("https://alpha.tessera.ctl.de/dashboard?x=1#h").as_deref(),
|
||||||
|
Some("https://alpha.tessera.ctl.de")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_behaelt_nicht_standard_port() {
|
||||||
|
assert_eq!(
|
||||||
|
server_origin_pattern("http://192.168.13.12:8080/").as_deref(),
|
||||||
|
Some("http://192.168.13.12:8080")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_laesst_standardport_weg() {
|
||||||
|
assert_eq!(
|
||||||
|
server_origin_pattern("https://alpha.tessera.ctl.de:443/").as_deref(),
|
||||||
|
Some("https://alpha.tessera.ctl.de")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_mit_und_ohne_schlussstrich_gleich() {
|
||||||
|
assert_eq!(
|
||||||
|
server_origin_pattern("https://tessera.example.de/"),
|
||||||
|
server_origin_pattern("https://tessera.example.de")
|
||||||
|
);
|
||||||
|
assert!(server_origin_pattern("https://tessera.example.de").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_none_bei_fremdem_schema_und_unparsbarer_eingabe() {
|
||||||
|
assert_eq!(server_origin_pattern("ftp://tessera.example.de/"), None);
|
||||||
|
assert_eq!(server_origin_pattern("das ist keine adresse"), None);
|
||||||
|
assert_eq!(server_origin_pattern(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_ipv6_wird_maskiert() {
|
||||||
|
assert_eq!(
|
||||||
|
server_origin_pattern("http://[::1]:8080/").as_deref(),
|
||||||
|
Some(r"http://\[\:\:1\]:8080")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_platzhalter_host_wird_maskiert() {
|
||||||
|
assert_eq!(
|
||||||
|
server_origin_pattern("http://*.example.com/").as_deref(),
|
||||||
|
Some(r"http://\*.example.com")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_trifft_eigenen_ursprung_mit_anderem_pfad_und_query() {
|
||||||
|
let p = muster(&server_origin_pattern("https://alpha.tessera.ctl.de/x").unwrap());
|
||||||
|
assert!(p.test(&adresse("https://alpha.tessera.ctl.de/")));
|
||||||
|
assert!(p.test(&adresse("https://alpha.tessera.ctl.de/dashboard?a=b#c")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_trifft_kein_anderes_schema_port_subdomain_oder_host() {
|
||||||
|
let p = muster(&server_origin_pattern("https://alpha.tessera.ctl.de/").unwrap());
|
||||||
|
assert!(!p.test(&adresse("http://alpha.tessera.ctl.de/")));
|
||||||
|
assert!(!p.test(&adresse("https://alpha.tessera.ctl.de:8443/")));
|
||||||
|
assert!(!p.test(&adresse("https://x.alpha.tessera.ctl.de/")));
|
||||||
|
assert!(!p.test(&adresse("https://beta.tessera.ctl.de/")));
|
||||||
|
|
||||||
|
let mit_port = muster(&server_origin_pattern("http://192.168.13.12:8080/").unwrap());
|
||||||
|
assert!(mit_port.test(&adresse("http://192.168.13.12:8080/a")));
|
||||||
|
assert!(!mit_port.test(&adresse("http://192.168.13.12:8081/")));
|
||||||
|
assert!(!mit_port.test(&adresse("http://192.168.13.12/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_platzhalter_host_trifft_keine_subdomain() {
|
||||||
|
let p = muster(&server_origin_pattern("http://*.example.com/").unwrap());
|
||||||
|
assert!(!p.test(&adresse("http://a.example.com/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_ipv6_trifft_nur_die_eigene_adresse() {
|
||||||
|
let p = muster(&server_origin_pattern("http://[::1]:8080/").unwrap());
|
||||||
|
assert!(p.test(&adresse("http://[::1]:8080/dashboard")));
|
||||||
|
assert!(!p.test(&adresse("http://[::2]:8080/")));
|
||||||
|
assert!(!p.test(&adresse("http://[::1]/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_berechtigungen_sind_genau_diese_drei() {
|
||||||
|
// Pinnt die Menge (T-IF2-03): ein zusaetzliches `notification:default`
|
||||||
|
// oder eine Platzhalter-Berechtigung laesst den Test rot werden.
|
||||||
|
assert_eq!(
|
||||||
|
SERVER_NOTIFICATION_PERMISSIONS,
|
||||||
|
[
|
||||||
|
"notification:allow-notify",
|
||||||
|
"notification:allow-is-permission-granted",
|
||||||
|
"notification:allow-request-permission",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget
|
|||||||
import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget';
|
import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget';
|
||||||
import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
|
import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
|
||||||
import { ProxmoxWidget } from '@/components/dashboard/widgets/proxmox-widget';
|
import { ProxmoxWidget } from '@/components/dashboard/widgets/proxmox-widget';
|
||||||
|
import { ReminderWidget } from '@/components/dashboard/widgets/reminder-widget';
|
||||||
import { useDashboardStore } from '@/lib/stores/dashboard-store';
|
import { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ registerWidget('favorites', FavoritesWidget);
|
|||||||
registerWidget('picture-frame', PictureFrameWidget);
|
registerWidget('picture-frame', PictureFrameWidget);
|
||||||
registerWidget('xframe', XframeWidget);
|
registerWidget('xframe', XframeWidget);
|
||||||
registerWidget('proxmox', ProxmoxWidget);
|
registerWidget('proxmox', ProxmoxWidget);
|
||||||
|
registerWidget('reminder', ReminderWidget);
|
||||||
|
|
||||||
/** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */
|
/** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */
|
||||||
interface ActiveModule {
|
interface ActiveModule {
|
||||||
|
|||||||
@@ -151,11 +151,11 @@ describe('WidgetCatalogModal: Liste kommt aus der Registry (quick-260922-m1h)',
|
|||||||
).map((c) => c.getAttribute('data-widget-type'));
|
).map((c) => c.getAttribute('data-widget-type'));
|
||||||
}
|
}
|
||||||
|
|
||||||
it("zeigt mit Zugriff auf 'proxmox' alle zehn Kacheln in der Reihenfolge der Registry, Proxmox zuletzt", () => {
|
it("zeigt mit Zugriff auf 'proxmox' alle elf Kacheln in der Reihenfolge der Registry, Erinnerungen zuletzt", () => {
|
||||||
render(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
|
render(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
|
||||||
|
|
||||||
expect(catalogTypes()).toEqual([...WIDGET_TYPES]);
|
expect(catalogTypes()).toEqual([...WIDGET_TYPES]);
|
||||||
expect(catalogTypes().at(-1)).toBe('proxmox');
|
expect(catalogTypes().at(-1)).toBe('reminder');
|
||||||
expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]);
|
expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
|
|||||||
'xframe',
|
'xframe',
|
||||||
// Proxmox — erste Modul-Kachel (quick-260924-i8v)
|
// Proxmox — erste Modul-Kachel (quick-260924-i8v)
|
||||||
'proxmox',
|
'proxmox',
|
||||||
|
// Erinnerungen (quick-260929-if2)
|
||||||
|
'reminder',
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||||
@@ -66,7 +68,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
|||||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch');
|
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu, zehn Typen)', () => {
|
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu; quick-260929-if2: Erinnerungen dazu, elf Typen)', () => {
|
||||||
// Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle
|
// Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle
|
||||||
// Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche
|
// Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche
|
||||||
// Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW 8
|
// Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW 8
|
||||||
@@ -82,6 +84,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
|||||||
'picture-frame': { minW: 8, minH: 4, defaultW: 16, defaultH: 12 },
|
'picture-frame': { minW: 8, minH: 4, defaultW: 16, defaultH: 12 },
|
||||||
xframe: { minW: 8, minH: 4, defaultW: 24, defaultH: 12 },
|
xframe: { minW: 8, minH: 4, defaultW: 24, defaultH: 12 },
|
||||||
proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 },
|
proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 },
|
||||||
|
reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 },
|
||||||
});
|
});
|
||||||
|
|
||||||
let counted = 0;
|
let counted = 0;
|
||||||
@@ -91,7 +94,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
|||||||
counted++;
|
counted++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(counted).toBe(40);
|
expect(counted).toBe(44);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,7 +110,7 @@ describe('Typliste ist an einer Stelle definiert (quick-260922-m1h)', () => {
|
|||||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toEqual([...WIDGET_TYPES]);
|
expect(Object.keys(WIDGET_CONSTRAINTS)).toEqual([...WIDGET_TYPES]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('die zehn erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => {
|
it('die elf erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => {
|
||||||
expect([...WIDGET_TYPES]).toEqual(ALL_WIDGET_TYPES);
|
expect([...WIDGET_TYPES]).toEqual(ALL_WIDGET_TYPES);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared';
|
|||||||
* picture-frame: Bilderrahmen (quick-260921-pi9).
|
* picture-frame: Bilderrahmen (quick-260921-pi9).
|
||||||
* xframe: Webseite als Rahmen (quick-260921-qd3).
|
* xframe: Webseite als Rahmen (quick-260921-qd3).
|
||||||
* proxmox: Zustand der Proxmox-Server, erste Modul-Kachel (quick-260924-i8v).
|
* proxmox: Zustand der Proxmox-Server, erste Modul-Kachel (quick-260924-i8v).
|
||||||
|
* reminder: persoenliche Erinnerungen mit Benachrichtigung (quick-260929-if2).
|
||||||
*/
|
*/
|
||||||
export type { WidgetType };
|
export type { WidgetType };
|
||||||
|
|
||||||
@@ -89,6 +90,12 @@ export const WIDGET_CONSTRAINTS: Record<
|
|||||||
// zeigt etwa sechs Serverzeilen; 6 Spalten = rund 166 px zeigen nur Punkte
|
// zeigt etwa sechs Serverzeilen; 6 Spalten = rund 166 px zeigen nur Punkte
|
||||||
// und Namen.
|
// und Namen.
|
||||||
proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 },
|
proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 },
|
||||||
|
// quick-260929-if2: Liste plus Knopfzeile „Neue Erinnerung“. Im 48er-Raster
|
||||||
|
// sind 8 Spalten = rund 230 px die kleinste Breite, in der Titel, Zeitangabe
|
||||||
|
// und die umbrechenden Knoepfe noch benutzbar sind (wie Notiz und Favoriten);
|
||||||
|
// 4 Zeilen = 104 px zeigen eine Erinnerung plus den Knopf. Vorgabe 12x10 =
|
||||||
|
// rund 340x270 px: drei bis vier Erinnerungen auf einmal.
|
||||||
|
reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -358,6 +365,27 @@ function ProxmoxIcon({ className }: { className?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ReminderIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||||
|
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry of all widget types. Used by the widget catalog modal
|
* Registry of all widget types. Used by the widget catalog modal
|
||||||
* and the grid renderer to look up components and constraints.
|
* and the grid renderer to look up components and constraints.
|
||||||
@@ -455,6 +483,15 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
|||||||
moduleSlug: WIDGET_MODULE_SLUGS.proxmox,
|
moduleSlug: WIDGET_MODULE_SLUGS.proxmox,
|
||||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||||
},
|
},
|
||||||
|
reminder: {
|
||||||
|
type: 'reminder',
|
||||||
|
nameKey: 'reminder.name',
|
||||||
|
descriptionKey: 'reminder.description',
|
||||||
|
icon: ReminderIcon,
|
||||||
|
...WIDGET_CONSTRAINTS.reminder,
|
||||||
|
moduleSlug: WIDGET_MODULE_SLUGS.reminder,
|
||||||
|
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
interface ReminderFormModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
/** Nach erfolgreichem Speichern (die Kachel laedt neu und meldet die Aenderung). */
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TITLE_MAX = 200;
|
||||||
|
const DESCRIPTION_MAX = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formular „Neue Erinnerung“ (quick-260929-if2). Datum und Uhrzeit sind
|
||||||
|
* Ortszeit; erst hier wird daraus ein ISO-Zeitpunkt.
|
||||||
|
*
|
||||||
|
* Gerendert per `createPortal` in `document.body`: die Kachel liegt in einem
|
||||||
|
* `react-grid-item` mit CSS-`transform`, und ein transformierter Vorfahr
|
||||||
|
* 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
|
||||||
|
* eine Nutzerhandlung, die der Browser fuer die Frage akzeptiert. Nie beim
|
||||||
|
* Laden der Seite.
|
||||||
|
*/
|
||||||
|
export function ReminderFormModal({ onClose, onSaved }: ReminderFormModalProps) {
|
||||||
|
const t = useTranslations('widgets');
|
||||||
|
const initial = useRef(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 [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
titleRef.current?.focus();
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// Als Erstes und synchron: Nutzerhandlung fuer die Browser-Erlaubnis (D-04).
|
||||||
|
requestBrowserPermissionOnce();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const trimmedTitle = title.trim();
|
||||||
|
if (trimmedTitle === '') {
|
||||||
|
setError(t('reminder.titleRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dueAt = localInputsToIso(date, time);
|
||||||
|
// Spiegel der Serverregel: die Faelligkeit muss in der Zukunft liegen.
|
||||||
|
if (dueAt === null || new Date(dueAt).getTime() <= Date.now()) {
|
||||||
|
setError(t('reminder.pastError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await createReminder({ title: trimmedTitle, description: description.trim(), dueAt });
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof ReminderRequestError && err.status === 409
|
||||||
|
? t('reminder.limitReached')
|
||||||
|
: t('reminder.saveError'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof document === 'undefined') return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="reminder-form-title"
|
||||||
|
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')}
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<label htmlFor="reminder-date" className="text-sm font-medium text-foreground">
|
||||||
|
{t('reminder.dateLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reminder-date"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<label htmlFor="reminder-time" className="text-sm font-medium text-foreground">
|
||||||
|
{t('reminder.timeLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reminder-time"
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => setTime(e.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="reminder-title" className="text-sm font-medium text-foreground">
|
||||||
|
{t('reminder.titleLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reminder-title"
|
||||||
|
ref={titleRef}
|
||||||
|
type="text"
|
||||||
|
maxLength={TITLE_MAX}
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="reminder-description" className="text-sm font-medium text-foreground">
|
||||||
|
{t('reminder.descriptionLabel')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="reminder-description"
|
||||||
|
rows={3}
|
||||||
|
maxLength={DESCRIPTION_MAX}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onClick={onClose} className="btn btn-secondary">
|
||||||
|
{t('reminder.cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={saving} className="btn btn-primary">
|
||||||
|
{t('reminder.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string) => key,
|
||||||
|
useLocale: () => 'de',
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/reminders-api', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/reminders-api')>('@/lib/reminders-api');
|
||||||
|
return {
|
||||||
|
REMINDERS_CHANGED_EVENT: actual.REMINDERS_CHANGED_EVENT,
|
||||||
|
ReminderRequestError: actual.ReminderRequestError,
|
||||||
|
listReminders: vi.fn(),
|
||||||
|
createReminder: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { createReminder, listReminders } 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>;
|
||||||
|
|
||||||
|
function reminder(id: string, title: string, dueAt: string, description = '') {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
dueAt,
|
||||||
|
emailEnabled: false,
|
||||||
|
createdAt: dueAt,
|
||||||
|
updatedAt: dueAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = { instanceId: 'w1', config: {}, isEditMode: false };
|
||||||
|
|
||||||
|
let requestPermission: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
mockList.mockReset();
|
||||||
|
mockCreate.mockReset();
|
||||||
|
requestPermission = vi.fn(async () => 'granted');
|
||||||
|
const ctor = vi.fn();
|
||||||
|
Object.assign(ctor, { permission: 'default', requestPermission });
|
||||||
|
vi.stubGlobal('Notification', ctor);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openForm() {
|
||||||
|
fireEvent.click(await screen.findByText('reminder.add'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillAndSubmit(title: string) {
|
||||||
|
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: title } });
|
||||||
|
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2099-03-04' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '14:30' } });
|
||||||
|
fireEvent.click(screen.getByText('reminder.save'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ReminderWidget', () => {
|
||||||
|
it('listet die Erinnerungen nach Faelligkeit sortiert', async () => {
|
||||||
|
mockList.mockResolvedValue([
|
||||||
|
reminder('b', 'Spaeter', '2099-01-02T10:00:00.000Z'),
|
||||||
|
reminder('a', 'Frueh', '2099-01-01T10:00:00.000Z', 'Beschreibung'),
|
||||||
|
]);
|
||||||
|
render(<ReminderWidget {...props} />);
|
||||||
|
const rows = await screen.findAllByTestId('reminder-row');
|
||||||
|
expect(rows[0].textContent).toContain('Frueh');
|
||||||
|
expect(rows[0].textContent).toContain('Beschreibung');
|
||||||
|
expect(rows[1].textContent).toContain('Spaeter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt den Leerzustand', async () => {
|
||||||
|
mockList.mockResolvedValue([]);
|
||||||
|
render(<ReminderWidget {...props} />);
|
||||||
|
expect(await screen.findByText('reminder.empty')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('das Rendern fragt keine Benachrichtigungs-Erlaubnis (D-04)', async () => {
|
||||||
|
mockList.mockResolvedValue([]);
|
||||||
|
render(<ReminderWidget {...props} />);
|
||||||
|
await screen.findByText('reminder.empty');
|
||||||
|
expect(requestPermission).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anlegen sendet die aus den Ortszeit-Eingaben gebildete ISO-Zeit', async () => {
|
||||||
|
mockList.mockResolvedValue([]);
|
||||||
|
mockCreate.mockResolvedValue(reminder('n', 'Zahnarzt', '2099-03-04T13:30:00.000Z'));
|
||||||
|
render(<ReminderWidget {...props} />);
|
||||||
|
await openForm();
|
||||||
|
fillAndSubmit('Zahnarzt');
|
||||||
|
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||||
|
const arg = mockCreate.mock.calls[0][0];
|
||||||
|
expect(arg.title).toBe('Zahnarzt');
|
||||||
|
expect(arg.dueAt).toBe(new Date('2099-03-04T14:30').toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die erste Anlage fragt die Erlaubnis einmal, die zweite nicht mehr', async () => {
|
||||||
|
mockList.mockResolvedValue([]);
|
||||||
|
mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z'));
|
||||||
|
render(<ReminderWidget {...props} />);
|
||||||
|
|
||||||
|
await openForm();
|
||||||
|
fillAndSubmit('Erste');
|
||||||
|
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||||
|
expect(requestPermission).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Dialog ist nach dem Speichern zu; neu oeffnen
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||||
|
(Notification as unknown as { permission: string }).permission = 'default';
|
||||||
|
await openForm();
|
||||||
|
fillAndSubmit('Zweite');
|
||||||
|
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(2));
|
||||||
|
expect(requestPermission).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt eine Vergangenheit im Formular ab, ohne zu senden', async () => {
|
||||||
|
mockList.mockResolvedValue([]);
|
||||||
|
render(<ReminderWidget {...props} />);
|
||||||
|
await openForm();
|
||||||
|
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'x' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2001-01-01' } });
|
||||||
|
fireEvent.click(screen.getByText('reminder.save'));
|
||||||
|
expect(await screen.findByText('reminder.pastError')).toBeTruthy();
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet REMINDERS_CHANGED nach dem Anlegen', async () => {
|
||||||
|
mockList.mockResolvedValue([]);
|
||||||
|
mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z'));
|
||||||
|
const handler = vi.fn();
|
||||||
|
window.addEventListener('tessera:reminders-changed', handler);
|
||||||
|
render(<ReminderWidget {...props} />);
|
||||||
|
await openForm();
|
||||||
|
await act(async () => {
|
||||||
|
fillAndSubmit('Meldung');
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(handler).toHaveBeenCalled());
|
||||||
|
window.removeEventListener('tessera:reminders-changed', handler);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
REMINDERS_CHANGED_EVENT,
|
||||||
|
type Reminder,
|
||||||
|
listReminders,
|
||||||
|
} 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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 refetch = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setReminders(await listReminders());
|
||||||
|
setLoadError(false);
|
||||||
|
} catch {
|
||||||
|
setLoadError(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refetch();
|
||||||
|
const timer = setInterval(() => void refetch(), REFRESH_MS);
|
||||||
|
const onChanged = () => void refetch();
|
||||||
|
window.addEventListener(REMINDERS_CHANGED_EVENT, onChanged);
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
window.removeEventListener(REMINDERS_CHANGED_EVENT, onChanged);
|
||||||
|
};
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
const sorted = useMemo(
|
||||||
|
() =>
|
||||||
|
[...(reminders ?? [])].sort(
|
||||||
|
(a, b) => new Date(a.dueAt).getTime() - new Date(b.dueAt).getTime(),
|
||||||
|
),
|
||||||
|
[reminders],
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatter = useMemo(
|
||||||
|
() => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }),
|
||||||
|
[locale],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSaved = () => {
|
||||||
|
void refetch();
|
||||||
|
window.dispatchEvent(new Event(REMINDERS_CHANGED_EVENT));
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
{loadError && (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
{t('reminder.loadError')}
|
||||||
|
</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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setModalOpen(true)}
|
||||||
|
className="btn btn-secondary shrink-0 self-start"
|
||||||
|
>
|
||||||
|
{t('reminder.add')}
|
||||||
|
</button>
|
||||||
|
{modalOpen && (
|
||||||
|
<ReminderFormModal onClose={() => setModalOpen(false)} onSaved={handleSaved} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -67,6 +67,12 @@ const PATHS: Record<string, ReactNode> = {
|
|||||||
<path d="M6 6h.01M6 18h.01" />
|
<path d="M6 6h.01M6 18h.01" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
reminder: (
|
||||||
|
<>
|
||||||
|
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||||
|
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WidgetIcon({
|
export function WidgetIcon({
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import { WidgetIcon } from './widget-icon';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Kacheltypen ohne eigene Kopfzeile, denen der Rahmen eine einheitliche
|
* Kacheltypen ohne eigene Kopfzeile, denen der Rahmen eine einheitliche
|
||||||
* Kopfzeile (Symbol + Name) gibt (Design „Mosaik“, Runde 2). Notiz,
|
* Kopfzeile (Symbol + Name) gibt (Design „Mosaik“, Runde 2; Erinnerungen seit
|
||||||
|
* quick-260929-if2). Notiz,
|
||||||
* Favoriten, Proxmox und XFrame bringen ihre eigene (mit editierbarem Titel)
|
* Favoriten, Proxmox und XFrame bringen ihre eigene (mit editierbarem Titel)
|
||||||
* mit; Suche und Bilderrahmen bleiben bewusst randlos — ein Suchfeld bzw.
|
* mit; Suche und Bilderrahmen bleiben bewusst randlos — ein Suchfeld bzw.
|
||||||
* ein Foto braucht keine Ueberschrift.
|
* ein Foto braucht keine Ueberschrift.
|
||||||
*/
|
*/
|
||||||
const FRAME_HEADER_TYPES = new Set(['calendar', 'clock', 'calculator', 'stopwatch']);
|
const FRAME_HEADER_TYPES = new Set(['calendar', 'clock', 'calculator', 'stopwatch', 'reminder']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kacheltypen mit Titelzeile — nur diese bekommen im Bearbeitungsmodus den
|
* Kacheltypen mit Titelzeile — nur diese bekommen im Bearbeitungsmodus den
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { installErrorBuffer } from '@/lib/error-buffer';
|
|||||||
import { Header } from '@/components/layout/header';
|
import { Header } from '@/components/layout/header';
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { Sidebar } from '@/components/layout/sidebar';
|
||||||
import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host';
|
import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host';
|
||||||
|
import { ReminderNotifier } from '@/components/reminders/reminder-notifier';
|
||||||
|
|
||||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
const isCollapsed = useSidebarStore((s) => s.isCollapsed);
|
const isCollapsed = useSidebarStore((s) => s.isCollapsed);
|
||||||
@@ -41,6 +42,10 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
{/* "Was ist neu"-Fenster nach einem Versionswechsel (quick-260925-bow):
|
{/* "Was ist neu"-Fenster nach einem Versionswechsel (quick-260925-bow):
|
||||||
nur hier im Portal-Rahmen, nie auf der Anmeldeseite. */}
|
nur hier im Portal-Rahmen, nie auf der Anmeldeseite. */}
|
||||||
<ReleaseNoticeHost />
|
<ReleaseNoticeHost />
|
||||||
|
{/* Erinnerungen (quick-260929-if2): bewusst GLOBAL im Rahmen, damit die
|
||||||
|
Benachrichtigung auf jeder Portalseite ausloest — nicht nur, wenn die
|
||||||
|
Kachel auf dem Dashboard sichtbar ist. Rendert nichts. */}
|
||||||
|
<ReminderNotifier />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { act, render } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string, values?: Record<string, string>) =>
|
||||||
|
values?.title ? `${key}:${values.title}` : key,
|
||||||
|
useLocale: () => 'de',
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/reminders-api', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/reminders-api')>('@/lib/reminders-api');
|
||||||
|
return {
|
||||||
|
REMINDERS_CHANGED_EVENT: actual.REMINDERS_CHANGED_EVENT,
|
||||||
|
ReminderRequestError: actual.ReminderRequestError,
|
||||||
|
listReminders: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/lib/reminder-notify', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/reminder-notify')>('@/lib/reminder-notify');
|
||||||
|
return { ...actual, showReminderNotification: vi.fn(async () => undefined) };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { showReminderNotification } from '@/lib/reminder-notify';
|
||||||
|
import { ReminderRequestError, listReminders } from '@/lib/reminders-api';
|
||||||
|
import { ReminderNotifier } from './reminder-notifier';
|
||||||
|
|
||||||
|
const mockList = listReminders as ReturnType<typeof vi.fn>;
|
||||||
|
const mockShow = showReminderNotification as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const NOW = new Date('2026-09-29T12:00:00.000Z');
|
||||||
|
|
||||||
|
function reminder(over: Partial<{ id: string; dueAt: string; description: string }> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'r1',
|
||||||
|
title: 'Zahnarzt',
|
||||||
|
description: '',
|
||||||
|
dueAt: new Date(NOW.getTime() + 30_000).toISOString(),
|
||||||
|
emailEnabled: false,
|
||||||
|
createdAt: NOW.toISOString(),
|
||||||
|
updatedAt: NOW.toISOString(),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function advance(ms: number) {
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(NOW);
|
||||||
|
window.localStorage.clear();
|
||||||
|
mockList.mockReset();
|
||||||
|
mockShow.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ReminderNotifier', () => {
|
||||||
|
it('meldet eine faellige Erinnerung genau einmal ueber mehrere Takte', async () => {
|
||||||
|
mockList.mockResolvedValue([reminder()]);
|
||||||
|
render(<ReminderNotifier />);
|
||||||
|
await advance(0);
|
||||||
|
expect(mockShow).not.toHaveBeenCalled(); // noch nicht faellig (D-02)
|
||||||
|
await advance(40_000); // ueber dueAt (+30 s) hinweg
|
||||||
|
await advance(30_000);
|
||||||
|
expect(mockShow).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockShow.mock.calls[0][0]).toMatchObject({
|
||||||
|
title: 'reminder.notificationTitle:Zahnarzt',
|
||||||
|
tag: `reminder-r1-${reminder().dueAt}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet nichts, solange nichts faellig ist', async () => {
|
||||||
|
mockList.mockResolvedValue([reminder({ dueAt: new Date(NOW.getTime() + 3600_000).toISOString() })]);
|
||||||
|
render(<ReminderNotifier />);
|
||||||
|
await advance(30_000);
|
||||||
|
expect(mockShow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt die Beschreibung als Text, gekuerzt auf 200 Zeichen', async () => {
|
||||||
|
mockList.mockResolvedValue([
|
||||||
|
reminder({ dueAt: new Date(NOW.getTime() - 1000).toISOString(), description: 'x'.repeat(300) }),
|
||||||
|
]);
|
||||||
|
render(<ReminderNotifier />);
|
||||||
|
await advance(0);
|
||||||
|
expect(mockShow.mock.calls[0][0].body).toBe('x'.repeat(200));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet nach geaendertem dueAt (Verschieben) erneut', async () => {
|
||||||
|
const first = reminder({ dueAt: new Date(NOW.getTime() - 1000).toISOString() });
|
||||||
|
mockList.mockResolvedValue([first]);
|
||||||
|
render(<ReminderNotifier />);
|
||||||
|
await advance(0);
|
||||||
|
expect(mockShow).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Verschoben auf +10 s (in der Zukunft), dann faellig
|
||||||
|
mockList.mockResolvedValue([{ ...first, dueAt: new Date(Date.now() + 10_000).toISOString() }]);
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(new Event('tessera:reminders-changed'));
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
});
|
||||||
|
expect(mockShow).toHaveBeenCalledTimes(1);
|
||||||
|
await advance(20_000);
|
||||||
|
expect(mockShow).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('das Aenderungsereignis laedt die Liste neu', async () => {
|
||||||
|
mockList.mockResolvedValue([]);
|
||||||
|
render(<ReminderNotifier />);
|
||||||
|
await advance(0);
|
||||||
|
const before = mockList.mock.calls.length;
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(new Event('tessera:reminders-changed'));
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
});
|
||||||
|
expect(mockList.mock.calls.length).toBe(before + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pausiert bei 401 bis zum naechsten Fokus', async () => {
|
||||||
|
mockList.mockRejectedValue(new ReminderRequestError(401));
|
||||||
|
render(<ReminderNotifier />);
|
||||||
|
await advance(0);
|
||||||
|
const after401 = mockList.mock.calls.length;
|
||||||
|
await advance(180_000);
|
||||||
|
expect(mockList.mock.calls.length).toBe(after401);
|
||||||
|
mockList.mockResolvedValue([]);
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(new Event('focus'));
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
});
|
||||||
|
expect(mockList.mock.calls.length).toBe(after401 + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
claimNotification,
|
||||||
|
remindersToNotify,
|
||||||
|
showReminderNotification,
|
||||||
|
withNotifyLock,
|
||||||
|
} from '@/lib/reminder-notify';
|
||||||
|
import {
|
||||||
|
REMINDERS_CHANGED_EVENT,
|
||||||
|
type Reminder,
|
||||||
|
ReminderRequestError,
|
||||||
|
listReminders,
|
||||||
|
} from '@/lib/reminders-api';
|
||||||
|
|
||||||
|
/** Abstand der Abfrage beim Server. */
|
||||||
|
const POLL_MS = 60_000;
|
||||||
|
/** Abstand der lokalen Faelligkeitspruefung gegen die geladene Liste (ohne Netz). */
|
||||||
|
const TICK_MS = 10_000;
|
||||||
|
const BODY_MAX = 200;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Globaler Melder fuer Erinnerungen (quick-260929-if2). Er haengt im
|
||||||
|
* Portal-Rahmen (`AppShell`), NICHT in der Kachel: die Benachrichtigung muss
|
||||||
|
* auf jeder Portalseite ausloesen, auch wenn das Dashboard gerade nicht
|
||||||
|
* sichtbar ist. Rendert nichts.
|
||||||
|
*
|
||||||
|
* Ablauf: die Liste wird beim Einhaengen, alle 60 s, nach einer Aenderung
|
||||||
|
* (`REMINDERS_CHANGED_EVENT`), bei Rueckkehr in den Vordergrund und bei
|
||||||
|
* Fenster-Fokus geladen. Ein lokaler 10-s-Takt prueft die geladene Liste auf
|
||||||
|
* Faelligkeit und meldet jede (id, dueAt) hoechstens einmal je Client —
|
||||||
|
* mehrere Tabs teilen die lokale Merkliste und eine Web-Sperre (E-01, D-02).
|
||||||
|
* Bei 401 (abgemeldet) ruht die Abfrage bis zum naechsten Fokus.
|
||||||
|
*/
|
||||||
|
export function ReminderNotifier() {
|
||||||
|
const t = useTranslations('widgets');
|
||||||
|
const locale = useLocale();
|
||||||
|
const remindersRef = useRef<Reminder[]>([]);
|
||||||
|
const pausedRef = useRef(false);
|
||||||
|
// Uebersetzung und Sprache ueber Refs, damit die Effekte unten nicht bei
|
||||||
|
// jedem Render neu aufgebaut werden.
|
||||||
|
const textRef = useRef({ t, locale });
|
||||||
|
textRef.current = { t, locale };
|
||||||
|
|
||||||
|
const notifyDue = useCallback(async () => {
|
||||||
|
if (pausedRef.current) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const due = remindersToNotify(remindersRef.current, now);
|
||||||
|
if (due.length === 0) return;
|
||||||
|
await withNotifyLock(async () => {
|
||||||
|
for (const r of due) {
|
||||||
|
if (!claimNotification(`${r.id}|${r.dueAt}`, Date.now())) continue;
|
||||||
|
const { t: tr, locale: loc } = textRef.current;
|
||||||
|
const body =
|
||||||
|
r.description.trim() !== ''
|
||||||
|
? r.description.trim().slice(0, BODY_MAX)
|
||||||
|
: new Intl.DateTimeFormat(loc, { dateStyle: 'medium', timeStyle: 'short' }).format(
|
||||||
|
new Date(r.dueAt),
|
||||||
|
);
|
||||||
|
await showReminderNotification({
|
||||||
|
title: tr('reminder.notificationTitle', { title: r.title }),
|
||||||
|
body,
|
||||||
|
tag: `reminder-${r.id}-${r.dueAt}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (pausedRef.current) return;
|
||||||
|
try {
|
||||||
|
remindersRef.current = await listReminders();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ReminderRequestError && err.status === 401) {
|
||||||
|
pausedRef.current = true;
|
||||||
|
}
|
||||||
|
// Andere Fehler: still ignorieren, der naechste Takt versucht es erneut.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await notifyDue();
|
||||||
|
}, [notifyDue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
const poll = setInterval(() => void load(), POLL_MS);
|
||||||
|
const tick = setInterval(() => void notifyDue(), TICK_MS);
|
||||||
|
|
||||||
|
const resume = () => {
|
||||||
|
pausedRef.current = false;
|
||||||
|
void load();
|
||||||
|
};
|
||||||
|
const onVisible = () => {
|
||||||
|
if (document.visibilityState === 'visible') resume();
|
||||||
|
};
|
||||||
|
const onChanged = () => void load();
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
|
window.addEventListener('focus', resume);
|
||||||
|
window.addEventListener(REMINDERS_CHANGED_EVENT, onChanged);
|
||||||
|
return () => {
|
||||||
|
clearInterval(poll);
|
||||||
|
clearInterval(tick);
|
||||||
|
document.removeEventListener('visibilitychange', onVisible);
|
||||||
|
window.removeEventListener('focus', resume);
|
||||||
|
window.removeEventListener(REMINDERS_CHANGED_EVENT, onChanged);
|
||||||
|
};
|
||||||
|
}, [load, notifyDue]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
browserPermissionState,
|
||||||
|
claimNotification,
|
||||||
|
isTauriWebview,
|
||||||
|
remindersToNotify,
|
||||||
|
requestBrowserPermissionOnce,
|
||||||
|
showReminderNotification,
|
||||||
|
withNotifyLock,
|
||||||
|
} from './reminder-notify';
|
||||||
|
import type { Reminder } from './reminders-api';
|
||||||
|
|
||||||
|
const NOW = Date.parse('2026-09-29T12:00:00.000Z');
|
||||||
|
|
||||||
|
function reminder(id: string, dueAt: string): Reminder {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: id,
|
||||||
|
description: '',
|
||||||
|
dueAt,
|
||||||
|
emailEnabled: false,
|
||||||
|
createdAt: dueAt,
|
||||||
|
updatedAt: dueAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function installNotification(permission: NotificationPermission) {
|
||||||
|
const ctor = vi.fn();
|
||||||
|
const requestPermission = vi.fn(async () => 'granted' as NotificationPermission);
|
||||||
|
Object.assign(ctor, { permission, requestPermission });
|
||||||
|
vi.stubGlobal('Notification', ctor);
|
||||||
|
return { ctor, requestPermission };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function installTauri() {
|
||||||
|
const invoke = vi.fn(async () => undefined);
|
||||||
|
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = { invoke };
|
||||||
|
return invoke;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('claimNotification', () => {
|
||||||
|
it('gibt beim ersten Mal true, danach false (gleicher Schluessel)', () => {
|
||||||
|
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW)).toBe(true);
|
||||||
|
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 1000)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nach einem Verschieben (neues dueAt) ist es ein neuer Schluessel', () => {
|
||||||
|
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW)).toBe(true);
|
||||||
|
expect(claimNotification('r1|2026-09-29T12:10:00.000Z', NOW)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt Eintraege aelter als 7 Tage', () => {
|
||||||
|
claimNotification('alt', NOW - 8 * 24 * 3600_000);
|
||||||
|
claimNotification('neu', NOW);
|
||||||
|
const stored = JSON.parse(window.localStorage.getItem('tessera.reminders.notified') ?? '{}');
|
||||||
|
expect(Object.keys(stored)).toEqual(['neu']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remindersToNotify', () => {
|
||||||
|
it('nimmt nur Faellige der letzten 24 Stunden (nie vorher, D-02; E-03)', () => {
|
||||||
|
const list = [
|
||||||
|
reminder('zukunft', new Date(NOW + 1000).toISOString()),
|
||||||
|
reminder('genau', new Date(NOW).toISOString()),
|
||||||
|
reminder('vor-1h', new Date(NOW - 3600_000).toISOString()),
|
||||||
|
reminder('vor-25h', new Date(NOW - 25 * 3600_000).toISOString()),
|
||||||
|
];
|
||||||
|
expect(remindersToNotify(list, NOW).map((r) => r.id)).toEqual(['genau', 'vor-1h']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('showReminderNotification', () => {
|
||||||
|
it('in Tauri: invoke plugin:notification|notify mit genau diesem Koerper', async () => {
|
||||||
|
const invoke = installTauri();
|
||||||
|
expect(isTauriWebview()).toBe(true);
|
||||||
|
await showReminderNotification({ title: 'T', body: 'B', tag: 'x' });
|
||||||
|
expect(invoke).toHaveBeenCalledWith('plugin:notification|notify', {
|
||||||
|
options: { title: 'T', body: 'B' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('in Tauri: ein Fehler des Plugins wird nur als Warnung ausgegeben', async () => {
|
||||||
|
const invoke = installTauri();
|
||||||
|
invoke.mockRejectedValue(new Error('nope'));
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
|
await expect(showReminderNotification({ title: 'T', body: 'B', tag: 'x' })).resolves.toBeUndefined();
|
||||||
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('im Browser: nur mit Erlaubnis granted', async () => {
|
||||||
|
const { ctor } = installNotification('granted');
|
||||||
|
await showReminderNotification({ title: 'T', body: 'B', tag: 'tag1' });
|
||||||
|
expect(ctor).toHaveBeenCalledWith('T', { body: 'B', tag: 'tag1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['default', 'denied'] as const)('im Browser: bei %s keine Meldung', async (p) => {
|
||||||
|
const { ctor } = installNotification(p);
|
||||||
|
await showReminderNotification({ title: 'T', body: 'B', tag: 't' });
|
||||||
|
expect(ctor).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requestBrowserPermissionOnce', () => {
|
||||||
|
it('fragt hoechstens einmal (D-04)', () => {
|
||||||
|
const { requestPermission } = installNotification('default');
|
||||||
|
requestBrowserPermissionOnce();
|
||||||
|
requestBrowserPermissionOnce();
|
||||||
|
expect(requestPermission).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fragt nie in Tauri', () => {
|
||||||
|
installTauri();
|
||||||
|
const { requestPermission } = installNotification('default');
|
||||||
|
requestBrowserPermissionOnce();
|
||||||
|
expect(requestPermission).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fragt nicht, wenn die Entscheidung schon getroffen ist', () => {
|
||||||
|
const { requestPermission } = installNotification('denied');
|
||||||
|
requestBrowserPermissionOnce();
|
||||||
|
expect(requestPermission).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Web-Benachrichtigungen passiert nichts', () => {
|
||||||
|
vi.stubGlobal('Notification', undefined);
|
||||||
|
expect(() => requestBrowserPermissionOnce()).not.toThrow();
|
||||||
|
expect(browserPermissionState()).toBe('unsupported');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('browserPermissionState', () => {
|
||||||
|
it('meldet desktop in Tauri, sonst die Browser-Erlaubnis', () => {
|
||||||
|
installNotification('denied');
|
||||||
|
expect(browserPermissionState()).toBe('denied');
|
||||||
|
installTauri();
|
||||||
|
expect(browserPermissionState()).toBe('desktop');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('withNotifyLock', () => {
|
||||||
|
it('ruft fn direkt, wenn es keine Web-Sperren gibt', async () => {
|
||||||
|
const fn = vi.fn(() => 42);
|
||||||
|
await expect(withNotifyLock(fn)).resolves.toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nutzt navigator.locks, wenn vorhanden', async () => {
|
||||||
|
const request = vi.fn(async (_name: string, cb: () => unknown) => cb());
|
||||||
|
vi.stubGlobal('navigator', { ...navigator, locks: { request } });
|
||||||
|
await expect(withNotifyLock(() => 'x')).resolves.toBe('x');
|
||||||
|
expect(request).toHaveBeenCalledWith('tessera-reminder-notify', expect.any(Function));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Benachrichtigungen fuer Erinnerungen (quick-260929-if2) — reine Funktionen
|
||||||
|
* ohne React, damit Melder und Kachel sie gemeinsam nutzen und testen koennen.
|
||||||
|
*
|
||||||
|
* Zwei Wege, je nach Umgebung:
|
||||||
|
* - Desktop-App (Tauri-Webview): der Aufruf geht DIREKT an das Benachrichtigungs-
|
||||||
|
* Plugin (`plugin:notification|notify`). Die Rust-Seite erteilt dafuer zur
|
||||||
|
* Laufzeit genau diese eine Berechtigung fuer die gespeicherte Serveradresse
|
||||||
|
* (E-01, `grant_server_notifications` in apps/desktop/src-tauri/src/lib.rs).
|
||||||
|
* - Browser: die Web-Benachrichtigung (`new Notification(...)`), nur mit
|
||||||
|
* erteilter Erlaubnis.
|
||||||
|
*
|
||||||
|
* WARUM DER TAURI-WEG NIE `Notification.permission` LIEST (E-01): das Plugin
|
||||||
|
* ersetzt `window.Notification` in der Webview durch einen Ersatz, der
|
||||||
|
* denselben IPC-Aufruf macht. Dieser Ersatz meldet unter Windows bei JEDEM
|
||||||
|
* Seitenaufruf `permission = "denied"`, bis einmal `requestPermission()`
|
||||||
|
* gelaufen ist. Wer darauf hoeren wuerde, unterdrueckte im Desktop-Client
|
||||||
|
* jede Meldung. Deshalb ruft der Tauri-Zweig `invoke` direkt und fragt keine
|
||||||
|
* Erlaubnis ab.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Reminder } from './reminders-api';
|
||||||
|
|
||||||
|
const PERMISSION_ASKED_KEY = 'tessera.reminders.permissionAsked';
|
||||||
|
const NOTIFIED_KEY = 'tessera.reminders.notified';
|
||||||
|
const LOCK_NAME = 'tessera-reminder-notify';
|
||||||
|
|
||||||
|
/** E-03: so lange nach der Faelligkeit wird noch nachgemeldet (ein Client, der spaet oeffnet). */
|
||||||
|
export const CATCH_UP_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||||
|
/** Eintraege der lokalen Merkliste, die aelter sind, werden entfernt. */
|
||||||
|
const NOTIFIED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export type BrowserPermissionState =
|
||||||
|
| 'desktop'
|
||||||
|
| 'granted'
|
||||||
|
| 'default'
|
||||||
|
| 'denied'
|
||||||
|
| 'unsupported';
|
||||||
|
|
||||||
|
type TauriInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
|
||||||
|
/** Die `invoke`-Funktion der Tauri-Webview, sonst `null` (Browser). */
|
||||||
|
function tauriInvoke(): TauriInvoke | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
const internals: unknown = (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
|
||||||
|
if (typeof internals !== 'object' || internals === null) return null;
|
||||||
|
const invoke: unknown = (internals as Record<string, unknown>).invoke;
|
||||||
|
return typeof invoke === 'function' ? (invoke as TauriInvoke) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `true` in der Desktop-App (Tauri-Webview), `false` im normalen Browser. */
|
||||||
|
export function isTauriWebview(): boolean {
|
||||||
|
return tauriInvoke() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasWebNotifications(): boolean {
|
||||||
|
return typeof Notification !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zustand der Benachrichtigungs-Erlaubnis; in der Desktop-App immer `desktop`. */
|
||||||
|
export function browserPermissionState(): BrowserPermissionState {
|
||||||
|
if (isTauriWebview()) return 'desktop';
|
||||||
|
if (!hasWebNotifications()) return 'unsupported';
|
||||||
|
return Notification.permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFlag(key: string): boolean {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(key) !== null;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragt die Browser-Erlaubnis HOECHSTENS EINMAL (D-04). Aufzurufen aus einer
|
||||||
|
* Nutzerhandlung (erstes Anlegen im Widget), nie beim Laden der Seite. Nichts
|
||||||
|
* passiert in der Desktop-App, ohne Web-Benachrichtigungen, bei bereits
|
||||||
|
* getroffener Entscheidung oder wenn die Frage schon einmal gestellt wurde.
|
||||||
|
*/
|
||||||
|
export function requestBrowserPermissionOnce(): void {
|
||||||
|
if (isTauriWebview() || !hasWebNotifications()) return;
|
||||||
|
if (Notification.permission !== 'default') return;
|
||||||
|
if (readFlag(PERMISSION_ASKED_KEY)) return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(PERMISSION_ASKED_KEY, '1');
|
||||||
|
} catch {
|
||||||
|
/* ohne Speicher lieber fragen als nie */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
void Notification.requestPermission();
|
||||||
|
} catch {
|
||||||
|
/* aeltere Browser: kein Versprechen, keine Folge */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReminderNotification {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
tag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt eine Benachrichtigung — Desktop ueber das Plugin, Browser nur mit Erlaubnis. */
|
||||||
|
export async function showReminderNotification(n: ReminderNotification): Promise<void> {
|
||||||
|
const invoke = tauriInvoke();
|
||||||
|
if (invoke) {
|
||||||
|
try {
|
||||||
|
await invoke('plugin:notification|notify', { options: { title: n.title, body: n.body } });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[reminders] Desktop-Benachrichtigung fehlgeschlagen', err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasWebNotifications() || Notification.permission !== 'granted') return;
|
||||||
|
try {
|
||||||
|
new Notification(n.title, { body: n.body, tag: n.tag });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[reminders] Browser-Benachrichtigung fehlgeschlagen', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNotified(): Record<string, number> {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(NOTIFIED_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (typeof parsed !== 'object' || parsed === null) return {};
|
||||||
|
const out: Record<string, number> = {};
|
||||||
|
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||||
|
if (typeof v === 'number') out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merkt sich `key` (`${id}|${dueAt}`) lokal. `true` nur beim ERSTEN Mal — so
|
||||||
|
* meldet jeder Client jede Faelligkeit hoechstens einmal, auch ueber mehrere
|
||||||
|
* Tabs desselben Browsers hinweg (gemeinsamer localStorage). Nach einem
|
||||||
|
* Verschieben aendert sich `dueAt` und damit der Schluessel. Eintraege
|
||||||
|
* aelter als 7 Tage werden dabei entfernt.
|
||||||
|
*/
|
||||||
|
export function claimNotification(key: string, nowMs: number): boolean {
|
||||||
|
const record = readNotified();
|
||||||
|
for (const [k, at] of Object.entries(record)) {
|
||||||
|
if (nowMs - at > NOTIFIED_TTL_MS) delete record[k];
|
||||||
|
}
|
||||||
|
const first = !(key in record);
|
||||||
|
if (first) record[key] = nowMs;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(NOTIFIED_KEY, JSON.stringify(record));
|
||||||
|
} catch {
|
||||||
|
/* Speicher voll oder gesperrt: dann eben ohne Merkliste */
|
||||||
|
}
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fuehrt `fn` unter einer Web-Sperre aus (mehrere Tabs nacheinander), sonst direkt. */
|
||||||
|
export async function withNotifyLock<T>(fn: () => T | Promise<T>): Promise<T> {
|
||||||
|
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
|
||||||
|
if (locks && typeof locks.request === 'function') {
|
||||||
|
return locks.request(LOCK_NAME, async () => fn());
|
||||||
|
}
|
||||||
|
return fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erinnerungen, die JETZT gemeldet werden sollen: faellig (`dueAt <= now`,
|
||||||
|
* D-02: nie vorher) und nicht aelter als 24 Stunden (E-03).
|
||||||
|
*/
|
||||||
|
export function remindersToNotify(reminders: Reminder[], nowMs: number): Reminder[] {
|
||||||
|
return reminders.filter((r) => {
|
||||||
|
const due = new Date(r.dueAt).getTime();
|
||||||
|
return due <= nowMs && due > nowMs - CATCH_UP_WINDOW_MS;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Zeit-Hilfen der Erinnerungen (quick-260929-if2) — alles in Ortszeit des
|
||||||
|
* Benutzers, reine Funktionen ohne React.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
const TIME_RE = /^\d{2}:\d{2}$/;
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datums- und Zeitfeld (Ortszeit, `type="date"`/`type="time"`) als ISO-Zeitpunkt.
|
||||||
|
* `null` bei unvollstaendiger oder ungueltiger Eingabe.
|
||||||
|
*/
|
||||||
|
export function localInputsToIso(date: string, time: string): string | null {
|
||||||
|
if (!DATE_RE.test(date) || !TIME_RE.test(time)) return null;
|
||||||
|
const d = new Date(`${date}T${time}`);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorbelegung fuer eine neue Erinnerung: heute, die naechste volle Stunde. */
|
||||||
|
export function defaultNewReminderInputs(now: Date): { date: string; time: string } {
|
||||||
|
const next = new Date(now);
|
||||||
|
next.setHours(next.getHours() + 1, 0, 0, 0);
|
||||||
|
return {
|
||||||
|
date: `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}`,
|
||||||
|
time: `${pad(next.getHours())}:${pad(next.getMinutes())}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ReminderRequestError, createReminder, listReminders } from './reminders-api';
|
||||||
|
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fetchMock.mockReset();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body });
|
||||||
|
|
||||||
|
describe('reminders-api', () => {
|
||||||
|
it('listReminders ruft GET /reminders mit Cookie-Anmeldung', async () => {
|
||||||
|
fetchMock.mockResolvedValue(ok([]));
|
||||||
|
await expect(listReminders()).resolves.toEqual([]);
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toMatch(/\/reminders$/);
|
||||||
|
expect(init.credentials).toBe('include');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createReminder schickt POST mit JSON-Koerper', async () => {
|
||||||
|
fetchMock.mockResolvedValue(ok({ id: 'r1' }));
|
||||||
|
await createReminder({ title: 'a', dueAt: '2099-01-01T10:00:00.000Z' });
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toMatch(/\/reminders$/);
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
expect(init.credentials).toBe('include');
|
||||||
|
expect(JSON.parse(init.body)).toEqual({ title: 'a', dueAt: '2099-01-01T10:00:00.000Z' });
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
fetchMock.mockResolvedValue({ ok: false, status: 409, json: async () => ({}) });
|
||||||
|
const err = await createReminder({ title: 'a', dueAt: 'x' }).catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(ReminderRequestError);
|
||||||
|
expect(err.status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Erinnerungen — API-Klient (quick-260929-if2). Spiegelt den
|
||||||
|
* `RemindersController` der API. Alle Aufrufe mit `credentials: 'include'`
|
||||||
|
* (Cookie-Anmeldung), Muster `favorites-api.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
/** Ereignis nach jeder erfolgreichen Aenderung — der globale Melder laedt daraufhin neu. */
|
||||||
|
export const REMINDERS_CHANGED_EVENT = 'tessera:reminders-changed';
|
||||||
|
|
||||||
|
/** Eine Erinnerung; Zeitangaben als ISO-Zeichenketten (so liefert sie die API). */
|
||||||
|
export interface Reminder {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
dueAt: string;
|
||||||
|
emailEnabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein abgewiesener Aufruf; `status` ist der HTTP-Status (401, 404, 409 ...). */
|
||||||
|
export class ReminderRequestError extends Error {
|
||||||
|
public readonly status: number;
|
||||||
|
|
||||||
|
constructor(status: number) {
|
||||||
|
super(`Reminder request failed (${status})`);
|
||||||
|
this.name = 'ReminderRequestError';
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${API_URL}${path}`, { credentials: 'include', ...init });
|
||||||
|
if (!res.ok) throw new ReminderRequestError(res.status);
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonInit(method: string, body: unknown): RequestInit {
|
||||||
|
return {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die eigenen Erinnerungen, die naechste Faelligkeit zuerst. */
|
||||||
|
export function listReminders(): Promise<Reminder[]> {
|
||||||
|
return request<Reminder[]>('/reminders');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReminderInput {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
/** ISO-8601-Zeitpunkt. */
|
||||||
|
dueAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createReminder(input: ReminderInput): Promise<Reminder> {
|
||||||
|
return request<Reminder>('/reminders', jsonInit('POST', input));
|
||||||
|
}
|
||||||
@@ -476,6 +476,25 @@
|
|||||||
"serversHint": "Ohne Auswahl zeigt die Kachel alle Server.",
|
"serversHint": "Ohne Auswahl zeigt die Kachel alle Server.",
|
||||||
"chooseServers": "Server auswählen"
|
"chooseServers": "Server auswählen"
|
||||||
},
|
},
|
||||||
|
"reminder": {
|
||||||
|
"name": "Erinnerungen",
|
||||||
|
"description": "Termine und Aufgaben mit Benachrichtigung zur gewünschten Zeit",
|
||||||
|
"add": "Neue Erinnerung",
|
||||||
|
"empty": "Keine Erinnerungen. Legen Sie eine neue Erinnerung an.",
|
||||||
|
"formTitle": "Neue Erinnerung",
|
||||||
|
"dateLabel": "Datum",
|
||||||
|
"timeLabel": "Uhrzeit",
|
||||||
|
"titleLabel": "Titel",
|
||||||
|
"descriptionLabel": "Beschreibung (optional)",
|
||||||
|
"save": "Speichern",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"titleRequired": "Bitte geben Sie einen Titel ein.",
|
||||||
|
"pastError": "Der gewählte Zeitpunkt liegt in der Vergangenheit.",
|
||||||
|
"saveError": "Die Erinnerung konnte nicht gespeichert werden.",
|
||||||
|
"loadError": "Erinnerungen konnten nicht geladen werden.",
|
||||||
|
"limitReached": "Es sind höchstens 100 Erinnerungen möglich.",
|
||||||
|
"notificationTitle": "Erinnerung: {title}"
|
||||||
|
},
|
||||||
"editShort": "Bearbeiten",
|
"editShort": "Bearbeiten",
|
||||||
"done": "Fertig",
|
"done": "Fertig",
|
||||||
"toolbarLabel": "Dashboard-Aktionen",
|
"toolbarLabel": "Dashboard-Aktionen",
|
||||||
|
|||||||
@@ -476,6 +476,25 @@
|
|||||||
"serversHint": "With nothing selected, the tile shows all servers.",
|
"serversHint": "With nothing selected, the tile shows all servers.",
|
||||||
"chooseServers": "Choose servers"
|
"chooseServers": "Choose servers"
|
||||||
},
|
},
|
||||||
|
"reminder": {
|
||||||
|
"name": "Reminders",
|
||||||
|
"description": "Appointments and tasks with a notification at the time you choose",
|
||||||
|
"add": "New reminder",
|
||||||
|
"empty": "No reminders. Create a new reminder.",
|
||||||
|
"formTitle": "New reminder",
|
||||||
|
"dateLabel": "Date",
|
||||||
|
"timeLabel": "Time",
|
||||||
|
"titleLabel": "Title",
|
||||||
|
"descriptionLabel": "Description (optional)",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"titleRequired": "Please enter a title.",
|
||||||
|
"pastError": "The chosen time is in the past.",
|
||||||
|
"saveError": "The reminder could not be saved.",
|
||||||
|
"loadError": "Reminders could not be loaded.",
|
||||||
|
"limitReached": "You can have at most 100 reminders.",
|
||||||
|
"notificationTitle": "Reminder: {title}"
|
||||||
|
},
|
||||||
"editShort": "Edit",
|
"editShort": "Edit",
|
||||||
"done": "Done",
|
"done": "Done",
|
||||||
"toolbarLabel": "Dashboard actions",
|
"toolbarLabel": "Dashboard actions",
|
||||||
|
|||||||
@@ -177,9 +177,10 @@ 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 |
|
| 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) |
|
| 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. |
|
| 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. |
|
||||||
| **Summe** | **61** | **223** | **6** | **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 | 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 |
|
||||||
|
|
||||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 79 Paare)
|
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 80 Paare)
|
||||||
|
|
||||||
Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf
|
Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf
|
||||||
(260909-ipc) plus ein bisher vollstaendig unsichtbares Paar
|
(260909-ipc) plus ein bisher vollstaendig unsichtbares Paar
|
||||||
@@ -344,11 +345,11 @@ entnommen (30 Zusicherungen, darunter der Wachhund
|
|||||||
|
|
||||||
| Klasse | Anzahl Paare |
|
| Klasse | Anzahl Paare |
|
||||||
|---|---|
|
|---|---|
|
||||||
| muss-mandantengebunden | 42 |
|
| muss-mandantengebunden | 43 |
|
||||||
| keine-mandantengebundene-tabelle | 21 |
|
| keine-mandantengebundene-tabelle | 21 |
|
||||||
| beides | 14 |
|
| beides | 14 |
|
||||||
| bewusst-uebergreifend | 2 |
|
| bewusst-uebergreifend | 2 |
|
||||||
| **Summe** | **79** |
|
| **Summe** | **80** |
|
||||||
|
|
||||||
quick-260923-dhh (Aufgabe 1): +2 `muss-mandantengebunden` (`proxmox.service.ts`/`proxmoxServer`
|
quick-260923-dhh (Aufgabe 1): +2 `muss-mandantengebunden` (`proxmox.service.ts`/`proxmoxServer`
|
||||||
und `/proxmoxServerStatus`, beide `gebunden`) — nachgerechnet mit der Gate-Schleife, nicht
|
und `/proxmoxServerStatus`, beide `gebunden`) — nachgerechnet mit der Gate-Schleife, nicht
|
||||||
@@ -366,6 +367,10 @@ quick-260929-dzu: keine neue (Datei, Modell)-Zeile, Verteilung unverändert (79
|
|||||||
haben sich nur die Zeile `custom-modules.service.ts`/`customModule` (Benutzerdimension) und die
|
haben sich nur die Zeile `custom-modules.service.ts`/`customModule` (Benutzerdimension) und die
|
||||||
Bereichs-/Summenzeile (Gebunden 224→223, nachgemessen mit der Gate-Schleife).
|
Bereichs-/Summenzeile (Gebunden 224→223, nachgemessen mit der Gate-Schleife).
|
||||||
|
|
||||||
|
quick-260929-if2 (Aufgabe 1): +1 `muss-mandantengebunden` (`reminders.service.ts`/`reminder`, `gebunden`):
|
||||||
|
80 Paare, davon 43 `muss-mandantengebunden`, 21 `keine-mandantengebundene-tabelle`, 14 `beides`,
|
||||||
|
2 `bewusst-uebergreifend` — nachgezaehlt mit `grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme.
|
||||||
|
|
||||||
## Der Hintergrunddienst als Falle — sechs Fälle
|
## Der Hintergrunddienst als Falle — sechs Fälle
|
||||||
|
|
||||||
Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend —
|
Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend —
|
||||||
@@ -779,6 +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 | 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/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/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`). |
|
||||||
|
|
||||||
## Was diese Etappe NICHT entscheidet
|
## Was diese Etappe NICHT entscheidet
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export const WIDGET_TYPES = [
|
|||||||
'picture-frame',
|
'picture-frame',
|
||||||
'xframe',
|
'xframe',
|
||||||
'proxmox',
|
'proxmox',
|
||||||
|
'reminder',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WidgetType = (typeof WIDGET_TYPES)[number];
|
export type WidgetType = (typeof WIDGET_TYPES)[number];
|
||||||
|
|||||||
Reference in New Issue
Block a user