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.
189 lines
5.8 KiB
TypeScript
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',
|
|
);
|
|
}
|
|
}
|
|
}
|