Files
ApiCore/ApiCore-Backend/src/task-templates/task-templates.service.ts
T
Nick Szaunig 4609a0f9ec feat: Wiederkehrende Aufgaben-Vorlagen (recurring task templates)
Neue TaskTemplate-Entität mit YEARLY/MONTHLY/INTERVAL_DAYS-Wiederholung.
Fällige Aufgaben werden lazy bei GET /tasks erzeugt (Compare-and-Swap auf
nextDueDate), damit mehrere API-Replicas im Kubernetes-Cluster dieselbe
Vorlage nicht doppelt feuern - ohne zusätzliche Cron-Abhängigkeit.

Frontend: Verwaltungsseite unter /settings/task-templates zum Anlegen,
Auflisten und Deaktivieren von Vorlagen.
2026-08-25 05:36:55 +02:00

189 lines
5.8 KiB
TypeScript

import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { Task, TaskTemplate } from '../../generated/prisma';
import { CreateTaskTemplateDto } from './dto/create-task-template.dto';
import { UpdateTaskTemplateDto } from './dto/update-task-template.dto';
import {
computeInitialDueDate,
computeNextDueDate,
RecurrenceInput,
} from './recurrence';
function toRecurrenceInput(input: {
recurrenceType: TaskTemplate['recurrenceType'];
recurrenceMonth?: number | null;
recurrenceDayOfMonth?: number | null;
intervalDays?: number | null;
}): RecurrenceInput {
return {
recurrenceType: input.recurrenceType,
recurrenceMonth: input.recurrenceMonth ?? null,
recurrenceDayOfMonth: input.recurrenceDayOfMonth ?? null,
intervalDays: input.intervalDays ?? null,
};
}
@Injectable()
export class TaskTemplatesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
) {}
async findAll(): Promise<TaskTemplate[]> {
return this.prisma.taskTemplate.findMany({ orderBy: { title: 'asc' } });
}
async findById(id: string): Promise<TaskTemplate> {
const template = await this.prisma.taskTemplate.findUnique({
where: { id },
});
if (!template) {
throw new NotFoundException('Task template not found');
}
return template;
}
async create(
dto: CreateTaskTemplateDto,
actingUserId: string,
): Promise<TaskTemplate> {
const recurrence = toRecurrenceInput(dto);
this.assertRecurrenceFieldsPresent(recurrence);
const nextDueDate = computeInitialDueDate(recurrence, new Date());
const template = await this.prisma.taskTemplate.create({
data: { ...dto, nextDueDate, createdByUserId: actingUserId },
});
await this.auditLog.record({
entityType: 'TaskTemplate',
entityId: template.id,
action: 'CREATE',
userId: actingUserId,
afterData: template,
});
return template;
}
async update(
id: string,
dto: UpdateTaskTemplateDto,
actingUserId: string,
): Promise<TaskTemplate> {
const before = await this.findById(id);
const recurrenceChanged =
dto.recurrenceType !== undefined ||
dto.recurrenceMonth !== undefined ||
dto.recurrenceDayOfMonth !== undefined ||
dto.intervalDays !== undefined;
let nextDueDate: Date | undefined;
if (recurrenceChanged) {
const recurrence = toRecurrenceInput({
recurrenceType: dto.recurrenceType ?? before.recurrenceType,
recurrenceMonth: dto.recurrenceMonth ?? before.recurrenceMonth,
recurrenceDayOfMonth:
dto.recurrenceDayOfMonth ?? before.recurrenceDayOfMonth,
intervalDays: dto.intervalDays ?? before.intervalDays,
});
this.assertRecurrenceFieldsPresent(recurrence);
nextDueDate = computeInitialDueDate(recurrence, new Date());
}
const template = await this.prisma.taskTemplate.update({
where: { id },
data: { ...dto, nextDueDate },
});
await this.auditLog.record({
entityType: 'TaskTemplate',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: template,
});
return template;
}
async deactivate(id: string, actingUserId: string): Promise<TaskTemplate> {
const before = await this.findById(id);
const template = await this.prisma.taskTemplate.update({
where: { id },
data: { active: false },
});
await this.auditLog.record({
entityType: 'TaskTemplate',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: template,
});
return template;
}
/**
* Lazily catches up any template whose nextDueDate has passed: creates the
* Task and atomically advances nextDueDate. The update's WHERE clause
* matches the previous nextDueDate (compare-and-swap), so concurrent calls
* from multiple API replicas cannot double-fire the same occurrence.
*/
async generateDueTasks(): Promise<void> {
const now = new Date();
const dueTemplates = await this.prisma.taskTemplate.findMany({
where: { active: true, nextDueDate: { lte: now } },
});
for (const template of dueTemplates) {
const nextDueDate = computeNextDueDate(template, template.nextDueDate);
const claim = await this.prisma.taskTemplate.updateMany({
where: { id: template.id, nextDueDate: template.nextDueDate },
data: { nextDueDate },
});
if (claim.count === 0) {
continue;
}
const task: Task = await this.prisma.task.create({
data: {
title: template.title,
description: template.description,
priority: template.priority,
assignedUserId: template.assignedUserId,
relatedType: 'GENERAL',
sourceTemplateId: template.id,
},
});
await this.webhooks.dispatch('task.created', task);
}
}
private assertRecurrenceFieldsPresent(input: RecurrenceInput): void {
if (
input.recurrenceType === 'YEARLY' &&
(!input.recurrenceMonth || !input.recurrenceDayOfMonth)
) {
throw new BadRequestException(
'recurrenceMonth und recurrenceDayOfMonth sind für YEARLY erforderlich',
);
}
if (input.recurrenceType === 'MONTHLY' && !input.recurrenceDayOfMonth) {
throw new BadRequestException(
'recurrenceDayOfMonth ist für MONTHLY erforderlich',
);
}
if (input.recurrenceType === 'INTERVAL_DAYS' && !input.intervalDays) {
throw new BadRequestException(
'intervalDays ist für INTERVAL_DAYS erforderlich',
);
}
}
}