feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste

- E-Mail-Planer: Anspruch vor dem Senden (genau eine Mail je Faelligkeit, hoechstens 3 Versuche), Systemlesen nur fuer die Kandidatenabfrage
- MailService.sendReminderEmail (Berliner Zeit, nur Text), GET /reminders/email-status, Haken im Formular mit Erklaerung
- Zugriffsklassifikation und Erlaubnisliste fuer forSystem nachgezogen, Aenderungsliste und Anwenderanleitung

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 14:10:06 +02:00
parent 709b41a007
commit 6879c756f2
21 changed files with 1038 additions and 21 deletions
+37 -2
View File
@@ -5,6 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { SettingsService } from '../settings/settings.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
@@ -40,7 +41,35 @@ const MAX_AHEAD_MS = 5 * 365 * 24 * 60 * 60 * 1000;
*/
@Injectable()
export class RemindersService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly settingsService: SettingsService,
) {}
/**
* Ist die E-Mail-Erinnerung fuer diesen Benutzer moeglich? (E-09: „E-Mail-
* Versand eingerichtet“ heisst, der Mandant hat eine `SmtpConfig`-Zeile —
* dieselbe Regel wie `TenderMailService`; der Umgebungs-Rueckfall des
* `MailService` zaehlt nicht.) Der Benutzer wird ueber den gebundenen Klienten
* gelesen.
*/
async getEmailAvailability(tenantId: string, userId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const user = await tenantPrisma.user.findFirst({
where: { id: userId, tenantId },
select: { email: true },
});
const smtp = await this.settingsService.getSmtpConfig(tenantId);
return { smtpConfigured: smtp !== null, hasEmail: Boolean(user?.email) };
}
/** 400, wenn `emailEnabled: true` verlangt wird, obwohl E-Mail nicht moeglich ist. */
private async assertEmailAvailable(tenantId: string, userId: string) {
const { smtpConfigured, hasEmail } = await this.getEmailAvailability(tenantId, userId);
if (!smtpConfigured || !hasEmail) {
throw new BadRequestException('E-Mail-Erinnerungen sind nicht möglich');
}
}
/** Die eigenen Erinnerungen, die naechste Faelligkeit zuerst. */
async list(tenantId: string, userId: string) {
@@ -54,6 +83,7 @@ export class RemindersService {
async create(tenantId: string, userId: string, dto: CreateReminderDto) {
const dueAt = this.assertValidDueAt(dto.dueAt);
if (dto.emailEnabled === true) await this.assertEmailAvailable(tenantId, userId);
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const count = await tenantPrisma.reminder.count({ where: { tenantId, userId } });
if (count >= MAX_REMINDERS_PER_USER) {
@@ -68,6 +98,7 @@ export class RemindersService {
title: dto.title,
description: dto.description ?? '',
dueAt,
emailEnabled: dto.emailEnabled === true,
},
select: REMINDER_SELECT,
});
@@ -83,10 +114,14 @@ export class RemindersService {
if (row.dueAt.getTime() <= Date.now()) {
throw new ConflictException('Die Erinnerung ist bereits fällig');
}
const data: { title?: string; description?: string; dueAt?: Date } = {};
const data: { title?: string; description?: string; dueAt?: Date; emailEnabled?: boolean } = {};
if (dto.title !== undefined) data.title = dto.title;
if (dto.description !== undefined) data.description = dto.description;
if (dto.dueAt !== undefined) data.dueAt = this.assertValidDueAt(dto.dueAt);
if (dto.emailEnabled !== undefined) {
if (dto.emailEnabled) await this.assertEmailAvailable(tenantId, userId);
data.emailEnabled = dto.emailEnabled;
}
return tenantPrisma.reminder.update({
where: { id, tenantId, userId },
data,