diff --git a/ApiCore-Backend/prisma/migrations/20260825031814_add_task_templates/migration.sql b/ApiCore-Backend/prisma/migrations/20260825031814_add_task_templates/migration.sql new file mode 100644 index 0000000..3ebf945 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260825031814_add_task_templates/migration.sql @@ -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; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 1426041..6d64135 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -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) // --------------------------------------------------------------------------- diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 566ebf5..92ceac5 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -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: [ diff --git a/ApiCore-Backend/src/task-templates/dto/create-task-template.dto.ts b/ApiCore-Backend/src/task-templates/dto/create-task-template.dto.ts new file mode 100644 index 0000000..4711301 --- /dev/null +++ b/ApiCore-Backend/src/task-templates/dto/create-task-template.dto.ts @@ -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; +} diff --git a/ApiCore-Backend/src/task-templates/dto/update-task-template.dto.ts b/ApiCore-Backend/src/task-templates/dto/update-task-template.dto.ts new file mode 100644 index 0000000..3bd6dc2 --- /dev/null +++ b/ApiCore-Backend/src/task-templates/dto/update-task-template.dto.ts @@ -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; +} diff --git a/ApiCore-Backend/src/task-templates/recurrence.spec.ts b/ApiCore-Backend/src/task-templates/recurrence.spec.ts new file mode 100644 index 0000000..12d9dfd --- /dev/null +++ b/ApiCore-Backend/src/task-templates/recurrence.spec.ts @@ -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)); + }); + }); +}); diff --git a/ApiCore-Backend/src/task-templates/recurrence.ts b/ApiCore-Backend/src/task-templates/recurrence.ts new file mode 100644 index 0000000..54197f9 --- /dev/null +++ b/ApiCore-Backend/src/task-templates/recurrence.ts @@ -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)); +} diff --git a/ApiCore-Backend/src/task-templates/task-templates.controller.ts b/ApiCore-Backend/src/task-templates/task-templates.controller.ts new file mode 100644 index 0000000..159d6a4 --- /dev/null +++ b/ApiCore-Backend/src/task-templates/task-templates.controller.ts @@ -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); + } +} diff --git a/ApiCore-Backend/src/task-templates/task-templates.module.ts b/ApiCore-Backend/src/task-templates/task-templates.module.ts new file mode 100644 index 0000000..3081615 --- /dev/null +++ b/ApiCore-Backend/src/task-templates/task-templates.module.ts @@ -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 {} diff --git a/ApiCore-Backend/src/task-templates/task-templates.service.spec.ts b/ApiCore-Backend/src/task-templates/task-templates.service.spec.ts new file mode 100644 index 0000000..c6ce214 --- /dev/null +++ b/ApiCore-Backend/src/task-templates/task-templates.service.spec.ts @@ -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(); + }); + }); +}); diff --git a/ApiCore-Backend/src/task-templates/task-templates.service.ts b/ApiCore-Backend/src/task-templates/task-templates.service.ts new file mode 100644 index 0000000..52f35c5 --- /dev/null +++ b/ApiCore-Backend/src/task-templates/task-templates.service.ts @@ -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 { + return this.prisma.taskTemplate.findMany({ orderBy: { title: 'asc' } }); + } + + async findById(id: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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', + ); + } + } +} diff --git a/ApiCore-Backend/src/tasks/tasks.module.ts b/ApiCore-Backend/src/tasks/tasks.module.ts index e3adeb3..6d38465 100644 --- a/ApiCore-Backend/src/tasks/tasks.module.ts +++ b/ApiCore-Backend/src/tasks/tasks.module.ts @@ -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], diff --git a/ApiCore-Backend/src/tasks/tasks.service.spec.ts b/ApiCore-Backend/src/tasks/tasks.service.spec.ts index 89d25c3..b3a2432 100644 --- a/ApiCore-Backend/src/tasks/tasks.service.spec.ts +++ b/ApiCore-Backend/src/tasks/tasks.service.spec.ts @@ -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( diff --git a/ApiCore-Backend/src/tasks/tasks.service.ts b/ApiCore-Backend/src/tasks/tasks.service.ts index c7e89af..c125fdc 100644 --- a/ApiCore-Backend/src/tasks/tasks.service.ts +++ b/ApiCore-Backend/src/tasks/tasks.service.ts @@ -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 { + await this.taskTemplates.generateDueTasks(); return this.prisma.task.findMany({ where: filter, orderBy: [{ dueDate: 'asc' }, { createdAt: 'desc' }], diff --git a/ApiCore-Backend/test/task-templates.e2e-spec.ts b/ApiCore-Backend/test/task-templates.e2e-spec.ts new file mode 100644 index 0000000..8b22df8 --- /dev/null +++ b/ApiCore-Backend/test/task-templates.e2e-spec.ts @@ -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; + 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); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/settings/task-templates/create-task-template-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/task-templates/create-task-template-dialog.tsx new file mode 100644 index 0000000..1775306 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/task-templates/create-task-template-dialog.tsx @@ -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 = { + 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("MONTHLY"); + const [recurrenceMonth, setRecurrenceMonth] = useState("1"); + const [recurrenceDayOfMonth, setRecurrenceDayOfMonth] = useState("1"); + const [intervalDays, setIntervalDays] = useState("14"); + const [error, setError] = useState(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) { + 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 ( + { + setOpen(next); + if (!next) reset(); + }} + > + + + + Neue wiederkehrende Aufgabe + +
+
+ + setTitle(event.target.value)} + /> +
+
+ +