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[]
|
||||
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])
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user