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.
This commit is contained in:
1 parent
e458a7cf07
commit
4609a0f9ec
20 files changed
+1341
No files matched your search
@@ -0,0 +1,188 @@
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user