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:
Nick Szaunig committed 2026-08-25 05:36:55 +02:00
1 parent e458a7cf07
commit 4609a0f9ec
20 files changed
+1341

No files matched your search

@@ -0,0 +1,53 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import {
TaskPriority,
TaskTemplateRecurrenceType,
} from '../../../generated/prisma';
export class CreateTaskTemplateDto {
@ApiProperty()
@IsString()
title!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ enum: TaskPriority })
@IsOptional()
@IsEnum(TaskPriority)
priority?: TaskPriority;
@ApiPropertyOptional()
@IsOptional()
@IsString()
assignedUserId?: string;
@ApiProperty({ enum: TaskTemplateRecurrenceType })
@IsEnum(TaskTemplateRecurrenceType)
recurrenceType!: TaskTemplateRecurrenceType;
@ApiPropertyOptional({ description: '1-12, erforderlich bei YEARLY' })
@IsOptional()
@IsInt()
@Min(1)
@Max(12)
recurrenceMonth?: number;
@ApiPropertyOptional({
description: '1-31, erforderlich bei YEARLY und MONTHLY',
})
@IsOptional()
@IsInt()
@Min(1)
@Max(31)
recurrenceDayOfMonth?: number;
@ApiPropertyOptional({ description: 'erforderlich bei INTERVAL_DAYS' })
@IsOptional()
@IsInt()
@Min(1)
intervalDays?: number;
}
@@ -0,0 +1,10 @@
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator';
import { CreateTaskTemplateDto } from './create-task-template.dto';
export class UpdateTaskTemplateDto extends PartialType(CreateTaskTemplateDto) {
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
active?: boolean;
}
@@ -0,0 +1,154 @@
import { computeInitialDueDate, computeNextDueDate } from './recurrence';
function utc(year: number, month: number, day: number): Date {
return new Date(Date.UTC(year, month - 1, day));
}
describe('recurrence', () => {
describe('computeInitialDueDate', () => {
it('INTERVAL_DAYS: adds intervalDays to today', () => {
const result = computeInitialDueDate(
{
recurrenceType: 'INTERVAL_DAYS',
recurrenceMonth: null,
recurrenceDayOfMonth: null,
intervalDays: 14,
},
utc(2026, 3, 1),
);
expect(result).toEqual(utc(2026, 3, 15));
});
it('YEARLY: uses this year when the date has not passed yet', () => {
const result = computeInitialDueDate(
{
recurrenceType: 'YEARLY',
recurrenceMonth: 6,
recurrenceDayOfMonth: 15,
intervalDays: null,
},
utc(2026, 3, 1),
);
expect(result).toEqual(utc(2026, 6, 15));
});
it('YEARLY: rolls over to next year when the date already passed', () => {
const result = computeInitialDueDate(
{
recurrenceType: 'YEARLY',
recurrenceMonth: 1,
recurrenceDayOfMonth: 15,
intervalDays: null,
},
utc(2026, 3, 1),
);
expect(result).toEqual(utc(2027, 1, 15));
});
it('YEARLY: clamps day-of-month to the last real day (Feb 30 -> Feb 28)', () => {
const result = computeInitialDueDate(
{
recurrenceType: 'YEARLY',
recurrenceMonth: 2,
recurrenceDayOfMonth: 30,
intervalDays: null,
},
utc(2026, 1, 1),
);
expect(result).toEqual(utc(2026, 2, 28));
});
it('MONTHLY: uses this month when the day has not passed yet', () => {
const result = computeInitialDueDate(
{
recurrenceType: 'MONTHLY',
recurrenceMonth: null,
recurrenceDayOfMonth: 20,
intervalDays: null,
},
utc(2026, 3, 1),
);
expect(result).toEqual(utc(2026, 3, 20));
});
it('MONTHLY: rolls over to next month when the day already passed', () => {
const result = computeInitialDueDate(
{
recurrenceType: 'MONTHLY',
recurrenceMonth: null,
recurrenceDayOfMonth: 5,
intervalDays: null,
},
utc(2026, 3, 10),
);
expect(result).toEqual(utc(2026, 4, 5));
});
it('MONTHLY: rolls over across a year boundary (December -> January)', () => {
const result = computeInitialDueDate(
{
recurrenceType: 'MONTHLY',
recurrenceMonth: null,
recurrenceDayOfMonth: 5,
intervalDays: null,
},
utc(2026, 12, 10),
);
expect(result).toEqual(utc(2027, 1, 5));
});
it('MONTHLY: clamps day 31 in a 30-day month', () => {
const result = computeInitialDueDate(
{
recurrenceType: 'MONTHLY',
recurrenceMonth: null,
recurrenceDayOfMonth: 31,
intervalDays: null,
},
utc(2026, 4, 1),
);
expect(result).toEqual(utc(2026, 4, 30));
});
});
describe('computeNextDueDate', () => {
it('INTERVAL_DAYS: adds intervalDays to the fired date', () => {
const result = computeNextDueDate(
{
recurrenceType: 'INTERVAL_DAYS',
recurrenceMonth: null,
recurrenceDayOfMonth: null,
intervalDays: 7,
},
utc(2026, 3, 15),
);
expect(result).toEqual(utc(2026, 3, 22));
});
it('YEARLY: advances to next year even if fired exactly on the anniversary', () => {
const result = computeNextDueDate(
{
recurrenceType: 'YEARLY',
recurrenceMonth: 6,
recurrenceDayOfMonth: 15,
intervalDays: null,
},
utc(2026, 6, 15),
);
expect(result).toEqual(utc(2027, 6, 15));
});
it('MONTHLY: advances to next month even if fired exactly on the day', () => {
const result = computeNextDueDate(
{
recurrenceType: 'MONTHLY',
recurrenceMonth: null,
recurrenceDayOfMonth: 20,
intervalDays: null,
},
utc(2026, 3, 20),
);
expect(result).toEqual(utc(2026, 4, 20));
});
});
});
@@ -0,0 +1,95 @@
import { TaskTemplateRecurrenceType } from '../../generated/prisma';
export interface RecurrenceInput {
recurrenceType: TaskTemplateRecurrenceType;
recurrenceMonth: number | null;
recurrenceDayOfMonth: number | null;
intervalDays: number | null;
}
function startOfUtcDay(date: Date): Date {
return new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
);
}
function addUtcDays(date: Date, days: number): Date {
const result = new Date(date);
result.setUTCDate(result.getUTCDate() + days);
return result;
}
function buildUtcDate(year: number, month: number, dayOfMonth: number): Date {
const lastDayOfMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
const day = Math.min(dayOfMonth, lastDayOfMonth);
return new Date(Date.UTC(year, month - 1, day));
}
function nextYearlyOccurrence(
onOrAfter: Date,
month: number,
dayOfMonth: number,
): Date {
const from = startOfUtcDay(onOrAfter);
const year = from.getUTCFullYear();
const candidate = buildUtcDate(year, month, dayOfMonth);
return candidate.getTime() >= from.getTime()
? candidate
: buildUtcDate(year + 1, month, dayOfMonth);
}
function nextMonthlyOccurrence(onOrAfter: Date, dayOfMonth: number): Date {
const from = startOfUtcDay(onOrAfter);
const candidate = buildUtcDate(
from.getUTCFullYear(),
from.getUTCMonth() + 1,
dayOfMonth,
);
if (candidate.getTime() >= from.getTime()) {
return candidate;
}
const nextMonth = from.getUTCMonth() + 2;
return nextMonth > 12
? buildUtcDate(from.getUTCFullYear() + 1, 1, dayOfMonth)
: buildUtcDate(from.getUTCFullYear(), nextMonth, dayOfMonth);
}
function occurrenceOnOrAfter(input: RecurrenceInput, onOrAfter: Date): Date {
switch (input.recurrenceType) {
case 'YEARLY':
return nextYearlyOccurrence(
onOrAfter,
input.recurrenceMonth as number,
input.recurrenceDayOfMonth as number,
);
case 'MONTHLY':
return nextMonthlyOccurrence(
onOrAfter,
input.recurrenceDayOfMonth as number,
);
case 'INTERVAL_DAYS':
return startOfUtcDay(onOrAfter);
}
}
/** The due date for a brand-new template, anchored to "today". */
export function computeInitialDueDate(
input: RecurrenceInput,
today: Date,
): Date {
if (input.recurrenceType === 'INTERVAL_DAYS') {
return addUtcDays(startOfUtcDay(today), input.intervalDays as number);
}
return occurrenceOnOrAfter(input, today);
}
/** The next due date after a template has just fired on `firedDate`. */
export function computeNextDueDate(
input: RecurrenceInput,
firedDate: Date,
): Date {
if (input.recurrenceType === 'INTERVAL_DAYS') {
return addUtcDays(startOfUtcDay(firedDate), input.intervalDays as number);
}
return occurrenceOnOrAfter(input, addUtcDays(firedDate, 1));
}
@@ -0,0 +1,49 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateTaskTemplateDto } from './dto/create-task-template.dto';
import { UpdateTaskTemplateDto } from './dto/update-task-template.dto';
import { TaskTemplatesService } from './task-templates.service';
@ApiTags('task-templates')
@ApiBearerAuth()
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Controller('task-templates')
export class TaskTemplatesController {
constructor(private readonly taskTemplatesService: TaskTemplatesService) {}
@Get()
findAll() {
return this.taskTemplatesService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.taskTemplatesService.findById(id);
}
@Post()
create(
@Body() dto: CreateTaskTemplateDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.taskTemplatesService.create(dto, actor.id);
}
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateTaskTemplateDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.taskTemplatesService.update(id, dto, actor.id);
}
@Post(':id/deactivate')
deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.taskTemplatesService.deactivate(id, actor.id);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TaskTemplatesController } from './task-templates.controller';
import { TaskTemplatesService } from './task-templates.service';
@Module({
controllers: [TaskTemplatesController],
providers: [TaskTemplatesService],
exports: [TaskTemplatesService],
})
export class TaskTemplatesModule {}
@@ -0,0 +1,187 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { TaskTemplatesService } from './task-templates.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
describe('TaskTemplatesService', () => {
let service: TaskTemplatesService;
let prisma: {
taskTemplate: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
updateMany: jest.Mock;
};
task: { create: jest.Mock };
};
let auditLog: { record: jest.Mock };
let webhooks: { dispatch: jest.Mock };
const template = {
id: 'template-1',
title: 'Varroa-Kontrolle',
description: null,
priority: 'NORMAL',
assignedUserId: null,
recurrenceType: 'MONTHLY',
recurrenceMonth: null,
recurrenceDayOfMonth: 15,
intervalDays: null,
nextDueDate: new Date('2026-03-15T00:00:00.000Z'),
active: true,
};
beforeEach(() => {
prisma = {
taskTemplate: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
},
task: { create: jest.fn() },
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
service = new TaskTemplatesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
);
});
it('findById throws NotFoundException when missing', async () => {
prisma.taskTemplate.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('create rejects YEARLY without recurrenceMonth/recurrenceDayOfMonth', async () => {
await expect(
service.create(
{ title: 'Jahresinventur', recurrenceType: 'YEARLY' },
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.taskTemplate.create).not.toHaveBeenCalled();
});
it('create rejects MONTHLY without recurrenceDayOfMonth', async () => {
await expect(
service.create(
{ title: 'Monatscheck', recurrenceType: 'MONTHLY' },
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('create rejects INTERVAL_DAYS without intervalDays', async () => {
await expect(
service.create(
{ title: 'Fütterung', recurrenceType: 'INTERVAL_DAYS' },
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('create computes nextDueDate and stores the acting user as creator', async () => {
prisma.taskTemplate.create.mockResolvedValue(template);
await service.create(
{
title: 'Varroa-Kontrolle',
recurrenceType: 'MONTHLY',
recurrenceDayOfMonth: 15,
},
'user-1',
);
const [[createArg]] = prisma.taskTemplate.create.mock.calls as [
[{ data: { nextDueDate: Date; createdByUserId: string } }],
];
expect(createArg.data.nextDueDate).toBeInstanceOf(Date);
expect(createArg.data.createdByUserId).toBe('user-1');
});
it('update recomputes nextDueDate only when a recurrence field changes', async () => {
prisma.taskTemplate.findUnique.mockResolvedValue(template);
prisma.taskTemplate.update.mockResolvedValue(template);
await service.update('template-1', { title: 'Neuer Titel' }, 'user-1');
const [[updateArg]] = prisma.taskTemplate.update.mock.calls as [
[{ data: { nextDueDate: Date | undefined } }],
];
expect(updateArg.data.nextDueDate).toBeUndefined();
});
it('update recomputes nextDueDate when recurrenceDayOfMonth changes', async () => {
prisma.taskTemplate.findUnique.mockResolvedValue(template);
prisma.taskTemplate.update.mockResolvedValue(template);
await service.update('template-1', { recurrenceDayOfMonth: 20 }, 'user-1');
const [[updateArg]] = prisma.taskTemplate.update.mock.calls as [
[{ data: { nextDueDate: Date | undefined } }],
];
expect(updateArg.data.nextDueDate).toBeInstanceOf(Date);
});
it('deactivate sets active to false', async () => {
prisma.taskTemplate.findUnique.mockResolvedValue(template);
prisma.taskTemplate.update.mockResolvedValue({
...template,
active: false,
});
await service.deactivate('template-1', 'user-1');
const [[updateArg]] = prisma.taskTemplate.update.mock.calls as [
[{ data: { active: boolean } }],
];
expect(updateArg.data.active).toBe(false);
});
describe('generateDueTasks', () => {
it('creates a Task and advances nextDueDate for a due template', async () => {
prisma.taskTemplate.findMany.mockResolvedValue([template]);
prisma.taskTemplate.updateMany.mockResolvedValue({ count: 1 });
prisma.task.create.mockResolvedValue({ id: 'task-1' });
await service.generateDueTasks();
const [[createArg]] = prisma.task.create.mock.calls as [
[{ data: { title: string; sourceTemplateId: string } }],
];
expect(createArg.data.title).toBe(template.title);
expect(createArg.data.sourceTemplateId).toBe(template.id);
expect(webhooks.dispatch).toHaveBeenCalledWith(
'task.created',
expect.objectContaining({ id: 'task-1' }),
);
});
it('skips Task creation when the compare-and-swap claim loses the race', async () => {
prisma.taskTemplate.findMany.mockResolvedValue([template]);
prisma.taskTemplate.updateMany.mockResolvedValue({ count: 0 });
await service.generateDueTasks();
expect(prisma.task.create).not.toHaveBeenCalled();
expect(webhooks.dispatch).not.toHaveBeenCalled();
});
it('does nothing when no template is due', async () => {
prisma.taskTemplate.findMany.mockResolvedValue([]);
await service.generateDueTasks();
expect(prisma.taskTemplate.updateMany).not.toHaveBeenCalled();
expect(prisma.task.create).not.toHaveBeenCalled();
});
});
});
@@ -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',
);
}
}
}