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,37 @@
-- CreateEnum
CREATE TYPE "TaskTemplateRecurrenceType" AS ENUM ('YEARLY', 'MONTHLY', 'INTERVAL_DAYS');
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "sourceTemplateId" TEXT;
-- CreateTable
CREATE TABLE "TaskTemplate" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"priority" "TaskPriority" NOT NULL DEFAULT 'NORMAL',
"assignedUserId" TEXT,
"createdByUserId" TEXT,
"recurrenceType" "TaskTemplateRecurrenceType" NOT NULL,
"recurrenceMonth" INTEGER,
"recurrenceDayOfMonth" INTEGER,
"intervalDays" INTEGER,
"nextDueDate" TIMESTAMP(3) NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TaskTemplate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TaskTemplate_active_nextDueDate_idx" ON "TaskTemplate"("active", "nextDueDate");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_sourceTemplateId_fkey" FOREIGN KEY ("sourceTemplateId") REFERENCES "TaskTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TaskTemplate" ADD CONSTRAINT "TaskTemplate_assignedUserId_fkey" FOREIGN KEY ("assignedUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TaskTemplate" ADD CONSTRAINT "TaskTemplate_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+35
View File
@@ -47,6 +47,8 @@ model User {
stockMovements StockMovement[]
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
assignedTaskTemplates TaskTemplate[] @relation("TaskTemplateAssignee")
createdTaskTemplates TaskTemplate[] @relation("TaskTemplateCreator")
colonyMoves ColonyApiaryHistory[]
auditLogs AuditLog[]
bottlingBatches BottlingBatch[]
@@ -386,6 +388,8 @@ model Task {
relatedId String?
sourceInspectionId String?
sourceInspection Inspection? @relation(fields: [sourceInspectionId], references: [id])
sourceTemplateId String?
sourceTemplate TaskTemplate? @relation(fields: [sourceTemplateId], references: [id])
createdAt DateTime @default(now())
completedAt DateTime?
@@ -393,6 +397,37 @@ model Task {
@@index([relatedType, relatedId])
}
enum TaskTemplateRecurrenceType {
YEARLY
MONTHLY
INTERVAL_DAYS
}
// Wiederkehrende Aufgaben-Vorlagen – erzeugen automatisch eine neue Task,
// sobald nextDueDate erreicht ist (siehe TasksService.generateDueTasks).
model TaskTemplate {
id String @id @default(cuid())
title String
description String?
priority TaskPriority @default(NORMAL)
assignedUserId String?
assignedUser User? @relation("TaskTemplateAssignee", fields: [assignedUserId], references: [id])
createdByUserId String?
createdByUser User? @relation("TaskTemplateCreator", fields: [createdByUserId], references: [id])
recurrenceType TaskTemplateRecurrenceType
recurrenceMonth Int?
recurrenceDayOfMonth Int?
intervalDays Int?
nextDueDate DateTime
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
generatedTasks Task[]
@@index([active, nextDueDate])
}
// ---------------------------------------------------------------------------
// Varroamanagement (Abschnitt 4.10)
// ---------------------------------------------------------------------------
+2
View File
@@ -33,6 +33,7 @@ import { DocumentsModule } from './documents/documents.module';
import { AutomationModule } from './automation/automation.module';
import { ImportModule } from './import/import.module';
import { PublicModule } from './public/public.module';
import { TaskTemplatesModule } from './task-templates/task-templates.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard';
@@ -71,6 +72,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
AutomationModule,
ImportModule,
PublicModule,
TaskTemplatesModule,
],
controllers: [],
providers: [
@@ -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',
);
}
}
}
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { TaskTemplatesModule } from '../task-templates/task-templates.module';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
imports: [TaskTemplatesModule],
controllers: [TasksController],
providers: [TasksService],
exports: [TasksService],
@@ -3,6 +3,7 @@ import { TasksService } from './tasks.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { TaskTemplatesService } from '../task-templates/task-templates.service';
describe('TasksService', () => {
let service: TasksService;
@@ -15,6 +16,7 @@ describe('TasksService', () => {
};
};
let auditLog: { record: jest.Mock };
let taskTemplates: { generateDueTasks: jest.Mock };
const task = { id: 'task-1', title: 'Beute prüfen', status: 'OPEN' };
@@ -29,13 +31,26 @@ describe('TasksService', () => {
};
auditLog = { record: jest.fn() };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
taskTemplates = {
generateDueTasks: jest.fn().mockResolvedValue(undefined),
};
service = new TasksService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
taskTemplates as unknown as TaskTemplatesService,
);
});
it('findAll generates due tasks from templates before listing', async () => {
prisma.task.findMany.mockResolvedValue([task]);
const result = await service.findAll({});
expect(taskTemplates.generateDueTasks).toHaveBeenCalled();
expect(result).toEqual([task]);
});
it('findById throws NotFoundException when missing', async () => {
prisma.task.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
@@ -2,6 +2,7 @@ import { 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 { TaskTemplatesService } from '../task-templates/task-templates.service';
import { Task, TaskPriority, TaskStatus } from '../../generated/prisma';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
@@ -17,9 +18,11 @@ export class TasksService {
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly taskTemplates: TaskTemplatesService,
) {}
async findAll(filter: TaskFilter): Promise<Task[]> {
await this.taskTemplates.generateDueTasks();
return this.prisma.task.findMany({
where: filter,
orderBy: [{ dueDate: 'asc' }, { createdAt: 'desc' }],
@@ -0,0 +1,106 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface TaskTemplateBody {
id: string;
nextDueDate: string;
active: boolean;
}
describe('TaskTemplates (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const beekeeperEmail = `task-templates-e2e-beekeeper-${Date.now()}@example.com`;
const password = 'SuperSecret123!';
let beekeeperId: string;
let beekeeperToken: string;
let templateId: string;
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const beekeeper = await prisma.user.create({
data: {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: 'BEEKEEPER',
},
});
beekeeperId = beekeeper.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
});
afterAll(async () => {
if (templateId) {
await prisma.task.deleteMany({ where: { sourceTemplateId: templateId } });
await prisma.taskTemplate.delete({ where: { id: templateId } });
}
await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } });
await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } });
await prisma.user.deleteMany({ where: { id: beekeeperId } });
await app.close();
});
it('rejects a YEARLY template missing recurrenceMonth/recurrenceDayOfMonth', () => {
return request(app.getHttpServer())
.post('/api/v1/task-templates')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ title: 'Jahresinventur', recurrenceType: 'YEARLY' })
.expect(400);
});
it('creates an INTERVAL_DAYS template and computes nextDueDate', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/task-templates')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
title: `Fütterung ${Date.now()}`,
recurrenceType: 'INTERVAL_DAYS',
intervalDays: 1,
})
.expect(201);
const body = response.body as TaskTemplateBody;
templateId = body.id;
expect(body.nextDueDate).toEqual(expect.any(String));
});
it('generates a due Task lazily on GET /tasks and advances nextDueDate', async () => {
const yesterday = new Date();
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
await prisma.taskTemplate.update({
where: { id: templateId },
data: { nextDueDate: yesterday },
});
await request(app.getHttpServer())
.get('/api/v1/tasks')
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
const generatedTask = await prisma.task.findFirst({
where: { sourceTemplateId: templateId },
});
expect(generatedTask).not.toBeNull();
const template = await prisma.taskTemplate.findUniqueOrThrow({
where: { id: templateId },
});
expect(template.nextDueDate.getTime()).toBeGreaterThan(yesterday.getTime());
});
it('deactivates a template', async () => {
const response = await request(app.getHttpServer())
.post(`/api/v1/task-templates/${templateId}/deactivate`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(201);
expect((response.body as TaskTemplateBody).active).toBe(false);
});
});
@@ -0,0 +1,206 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createTaskTemplate } from "@/lib/task-templates/api";
import { TaskTemplateRecurrenceType } from "@/lib/task-templates/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
const RECURRENCE_LABELS: Record<TaskTemplateRecurrenceType, string> = {
YEARLY: "Jährlich",
MONTHLY: "Monatlich",
INTERVAL_DAYS: "Alle X Tage",
};
const MONTH_LABELS = [
"Januar",
"Februar",
"März",
"April",
"Mai",
"Juni",
"Juli",
"August",
"September",
"Oktober",
"November",
"Dezember",
];
export function CreateTaskTemplateDialog() {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [recurrenceType, setRecurrenceType] = useState<TaskTemplateRecurrenceType>("MONTHLY");
const [recurrenceMonth, setRecurrenceMonth] = useState("1");
const [recurrenceDayOfMonth, setRecurrenceDayOfMonth] = useState("1");
const [intervalDays, setIntervalDays] = useState("14");
const [error, setError] = useState<string | null>(null);
function reset() {
setTitle("");
setDescription("");
setRecurrenceType("MONTHLY");
setRecurrenceMonth("1");
setRecurrenceDayOfMonth("1");
setIntervalDays("14");
setError(null);
}
const mutation = useMutation({
mutationFn: createTaskTemplate,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["task-templates"] });
setOpen(false);
reset();
},
onError: () => setError("Vorlage konnte nicht angelegt werden."),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate({
title,
description: description || undefined,
recurrenceType,
recurrenceMonth: recurrenceType === "YEARLY" ? Number(recurrenceMonth) : undefined,
recurrenceDayOfMonth:
recurrenceType === "YEARLY" || recurrenceType === "MONTHLY"
? Number(recurrenceDayOfMonth)
: undefined,
intervalDays: recurrenceType === "INTERVAL_DAYS" ? Number(intervalDays) : undefined,
});
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) reset();
}}
>
<Button onClick={() => setOpen(true)}>+ Neue Vorlage</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>Neue wiederkehrende Aufgabe</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="template-title">Titel</Label>
<Input
id="template-title"
required
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="template-description">Beschreibung</Label>
<Textarea
id="template-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="template-recurrence">Wiederholung</Label>
<Select
value={recurrenceType}
onValueChange={(value) =>
setRecurrenceType((value ?? recurrenceType) as TaskTemplateRecurrenceType)
}
>
<SelectTrigger id="template-recurrence" className="w-full">
<SelectValue>{RECURRENCE_LABELS[recurrenceType]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(RECURRENCE_LABELS) as TaskTemplateRecurrenceType[]).map((option) => (
<SelectItem key={option} value={option}>
{RECURRENCE_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{recurrenceType === "YEARLY" ? (
<div className="flex flex-col gap-2">
<Label htmlFor="template-month">Monat</Label>
<Select
value={recurrenceMonth}
onValueChange={(value) => setRecurrenceMonth(value ?? recurrenceMonth)}
>
<SelectTrigger id="template-month" className="w-full">
<SelectValue>{MONTH_LABELS[Number(recurrenceMonth) - 1]}</SelectValue>
</SelectTrigger>
<SelectContent>
{MONTH_LABELS.map((label, index) => (
<SelectItem key={label} value={String(index + 1)}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
{recurrenceType === "YEARLY" || recurrenceType === "MONTHLY" ? (
<div className="flex flex-col gap-2">
<Label htmlFor="template-day">Tag im Monat</Label>
<Input
id="template-day"
type="number"
min={1}
max={31}
required
value={recurrenceDayOfMonth}
onChange={(event) => setRecurrenceDayOfMonth(event.target.value)}
/>
</div>
) : null}
{recurrenceType === "INTERVAL_DAYS" ? (
<div className="flex flex-col gap-2">
<Label htmlFor="template-interval">Alle wie viele Tage</Label>
<Input
id="template-interval"
type="number"
min={1}
required
value={intervalDays}
onChange={(event) => setIntervalDays(event.target.value)}
/>
</div>
) : null}
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,120 @@
"use client";
import { Repeat } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { deactivateTaskTemplate, fetchTaskTemplates } from "@/lib/task-templates/api";
import { TaskTemplate, TaskTemplateRecurrenceType } from "@/lib/task-templates/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CreateTaskTemplateDialog } from "./create-task-template-dialog";
const RECURRENCE_LABELS: Record<TaskTemplateRecurrenceType, string> = {
YEARLY: "Jährlich",
MONTHLY: "Monatlich",
INTERVAL_DAYS: "Alle X Tage",
};
function describeRecurrence(template: TaskTemplate): string {
if (template.recurrenceType === "YEARLY") {
return `${RECURRENCE_LABELS.YEARLY} (Tag ${template.recurrenceDayOfMonth ?? "?"}.${template.recurrenceMonth ?? "?"}.)`;
}
if (template.recurrenceType === "MONTHLY") {
return `${RECURRENCE_LABELS.MONTHLY} (Tag ${template.recurrenceDayOfMonth ?? "?"})`;
}
return `Alle ${template.intervalDays ?? "?"} Tage`;
}
function formatDate(value: string): string {
return new Date(value).toLocaleDateString("de-DE");
}
export default function TaskTemplatesPage() {
const queryClient = useQueryClient();
const { data: templates, isLoading, isError } = useQuery({
queryKey: ["task-templates"],
queryFn: fetchTaskTemplates,
});
const deactivateMutation = useMutation({
mutationFn: deactivateTaskTemplate,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["task-templates"] });
toast.success("Vorlage deaktiviert");
},
onError: () => toast.error("Vorlage konnte nicht deaktiviert werden"),
});
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Repeat}
title="Wiederkehrende Aufgaben"
description="Vorlagen für regelmäßig automatisch angelegte Aufgaben verwalten"
gradient="var(--gradient-sky)"
actions={<CreateTaskTemplateDialog />}
/>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
Vorlagen konnten nicht geladen werden.
</p>
) : null}
{templates && templates.length === 0 ? (
<p className="text-sm text-muted-foreground">Noch keine Vorlagen angelegt.</p>
) : null}
{templates && templates.length > 0 ? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Titel</TableHead>
<TableHead>Wiederholung</TableHead>
<TableHead>Nächste Fälligkeit</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.map((template) => (
<TableRow key={template.id}>
<TableCell className="font-medium">{template.title}</TableCell>
<TableCell>{describeRecurrence(template)}</TableCell>
<TableCell>{formatDate(template.nextDueDate)}</TableCell>
<TableCell>
<Badge variant={template.active ? "default" : "outline"}>
{template.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
type="button"
variant="ghost"
size="sm"
disabled={!template.active || deactivateMutation.isPending}
onClick={() => deactivateMutation.mutate(template.id)}
>
Deaktivieren
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : null}
</main>
);
}
@@ -16,6 +16,7 @@ import {
type LucideIcon,
MapPin,
Package,
Repeat,
Search,
ShoppingBag,
Upload,
@@ -85,6 +86,7 @@ const ADMIN_GROUP: NavGroup = {
{ label: "Audit-Log", href: "/settings/audit-log", icon: History },
{ label: "Webhooks", href: "/settings/webhooks", icon: Webhook },
{ label: "Automatisierungen", href: "/settings/automation-rules", icon: Zap },
{ label: "Wiederkehrende Aufgaben", href: "/settings/task-templates", icon: Repeat },
{ label: "Import", href: "/settings/import", icon: Upload },
],
};
@@ -0,0 +1,24 @@
import { apiFetch } from "@/lib/auth/api-client";
import { CreateTaskTemplateInput, TaskTemplate, UpdateTaskTemplateInput } from "./types";
export function fetchTaskTemplates(): Promise<TaskTemplate[]> {
return apiFetch<TaskTemplate[]>("/task-templates");
}
export function createTaskTemplate(input: CreateTaskTemplateInput): Promise<TaskTemplate> {
return apiFetch<TaskTemplate>("/task-templates", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateTaskTemplate(id: string, input: UpdateTaskTemplateInput): Promise<TaskTemplate> {
return apiFetch<TaskTemplate>(`/task-templates/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function deactivateTaskTemplate(id: string): Promise<TaskTemplate> {
return apiFetch<TaskTemplate>(`/task-templates/${id}/deactivate`, { method: "POST" });
}
@@ -0,0 +1,43 @@
import { TaskPriority } from "@/lib/tasks/types";
export type TaskTemplateRecurrenceType = "YEARLY" | "MONTHLY" | "INTERVAL_DAYS";
export interface TaskTemplate {
id: string;
title: string;
description: string | null;
priority: TaskPriority;
assignedUserId: string | null;
createdByUserId: string | null;
recurrenceType: TaskTemplateRecurrenceType;
recurrenceMonth: number | null;
recurrenceDayOfMonth: number | null;
intervalDays: number | null;
nextDueDate: string;
active: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateTaskTemplateInput {
title: string;
description?: string;
priority?: TaskPriority;
assignedUserId?: string;
recurrenceType: TaskTemplateRecurrenceType;
recurrenceMonth?: number;
recurrenceDayOfMonth?: number;
intervalDays?: number;
}
export interface UpdateTaskTemplateInput {
title?: string;
description?: string;
priority?: TaskPriority;
assignedUserId?: string;
recurrenceType?: TaskTemplateRecurrenceType;
recurrenceMonth?: number;
recurrenceDayOfMonth?: number;
intervalDays?: number;
active?: boolean;
}