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:
2026-09-29 13:54:07 +02:00
parent cd1f8f6cda
commit 325c5ddbf2
31 changed files with 1969 additions and 11 deletions
@@ -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());
+25
View File
@@ -54,6 +54,7 @@ model User {
groupMemberships GroupMembership[]
moduleGrants ModuleGrant[]
customModules CustomModule[]
reminders Reminder[]
@@index([tenantId])
@@index([username])
@@ -739,3 +740,27 @@ model CustomModule {
@@index([tenantId])
@@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])
}
+2
View File
@@ -28,6 +28,7 @@ import { TendersModule } from './tenders/tenders.module';
import { UserModule } from './user/user.module';
import { ProxmoxModule } from './proxmox/proxmox.module';
import { CustomModulesModule } from './custom-modules/custom-modules.module';
import { RemindersModule } from './reminders/reminders.module';
@Module({
imports: [
@@ -55,6 +56,7 @@ import { CustomModulesModule } from './custom-modules/custom-modules.module';
BugReportsModule,
ProxmoxModule,
CustomModulesModule,
RemindersModule,
],
providers: [
// 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;
}
}
+246
View File
@@ -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
/// Texte aus der Server-Adresse ableitet. Gedankenstrich U+2013 wie in
/// `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.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));
spawn_version_check(app.clone(), normalized.clone(), false);
@@ -801,6 +930,9 @@ pub fn run() {
let server_url = stored_server_url(app.handle());
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 Ok(parsed) = parse_server_url(url) {
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_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",
]
);
}
}
+2
View File
@@ -20,6 +20,7 @@ import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget
import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget';
import { XframeWidget } from '@/components/dashboard/widgets/xframe-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 type { WidgetType } from '@/components/dashboard/widget-registry';
@@ -39,6 +40,7 @@ registerWidget('favorites', FavoritesWidget);
registerWidget('picture-frame', PictureFrameWidget);
registerWidget('xframe', XframeWidget);
registerWidget('proxmox', ProxmoxWidget);
registerWidget('reminder', ReminderWidget);
/** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */
interface ActiveModule {
@@ -151,11 +151,11 @@ describe('WidgetCatalogModal: Liste kommt aus der Registry (quick-260922-m1h)',
).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']} />);
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]);
});
@@ -31,6 +31,8 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
'xframe',
// Proxmox — erste Modul-Kachel (quick-260924-i8v)
'proxmox',
// Erinnerungen (quick-260929-if2)
'reminder',
];
describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
@@ -66,7 +68,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
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
// Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche
// 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 },
xframe: { minW: 8, minH: 4, defaultW: 24, defaultH: 12 },
proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 },
reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 },
});
let counted = 0;
@@ -91,7 +94,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
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]);
});
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);
});
@@ -19,6 +19,7 @@ import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared';
* picture-frame: Bilderrahmen (quick-260921-pi9).
* xframe: Webseite als Rahmen (quick-260921-qd3).
* proxmox: Zustand der Proxmox-Server, erste Modul-Kachel (quick-260924-i8v).
* reminder: persoenliche Erinnerungen mit Benachrichtigung (quick-260929-if2).
*/
export type { WidgetType };
@@ -89,6 +90,12 @@ export const WIDGET_CONSTRAINTS: Record<
// zeigt etwa sechs Serverzeilen; 6 Spalten = rund 166 px zeigen nur Punkte
// und Namen.
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
* 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,
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" />
</>
),
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({
@@ -8,12 +8,13 @@ import { WidgetIcon } from './widget-icon';
/**
* 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)
* mit; Suche und Bilderrahmen bleiben bewusst randlos — ein Suchfeld bzw.
* 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
@@ -6,6 +6,7 @@ import { installErrorBuffer } from '@/lib/error-buffer';
import { Header } from '@/components/layout/header';
import { Sidebar } from '@/components/layout/sidebar';
import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host';
import { ReminderNotifier } from '@/components/reminders/reminder-notifier';
export function AppShell({ children }: { children: React.ReactNode }) {
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):
nur hier im Portal-Rahmen, nie auf der Anmeldeseite. */}
<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>
);
}
@@ -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;
}
+162
View File
@@ -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));
});
});
+178
View File
@@ -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;
});
}
+29
View File
@@ -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())}`,
};
}
+44
View File
@@ -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);
});
});
+62
View File
@@ -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));
}
+19
View File
@@ -476,6 +476,25 @@
"serversHint": "Ohne Auswahl zeigt die Kachel alle Server.",
"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",
"done": "Fertig",
"toolbarLabel": "Dashboard-Aktionen",
+19
View File
@@ -476,6 +476,25 @@
"serversHint": "With nothing selected, the tile shows all 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",
"done": "Done",
"toolbarLabel": "Dashboard actions",