feat: Wiederkehrende Aufgaben-Vorlagen (recurring task templates)
Neue TaskTemplate-Entität mit YEARLY/MONTHLY/INTERVAL_DAYS-Wiederholung. Fällige Aufgaben werden lazy bei GET /tasks erzeugt (Compare-and-Swap auf nextDueDate), damit mehrere API-Replicas im Kubernetes-Cluster dieselbe Vorlage nicht doppelt feuern - ohne zusätzliche Cron-Abhängigkeit. Frontend: Verwaltungsseite unter /settings/task-templates zum Anlegen, Auflisten und Deaktivieren von Vorlagen.
This commit is contained in:
1 parent
e458a7cf07
commit
4609a0f9ec
20 files changed
+1341
No files matched your search
@@ -0,0 +1,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;
|
||||||
@@ -47,6 +47,8 @@ model User {
|
|||||||
stockMovements StockMovement[]
|
stockMovements StockMovement[]
|
||||||
assignedTasks Task[] @relation("TaskAssignee")
|
assignedTasks Task[] @relation("TaskAssignee")
|
||||||
createdTasks Task[] @relation("TaskCreator")
|
createdTasks Task[] @relation("TaskCreator")
|
||||||
|
assignedTaskTemplates TaskTemplate[] @relation("TaskTemplateAssignee")
|
||||||
|
createdTaskTemplates TaskTemplate[] @relation("TaskTemplateCreator")
|
||||||
colonyMoves ColonyApiaryHistory[]
|
colonyMoves ColonyApiaryHistory[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
bottlingBatches BottlingBatch[]
|
bottlingBatches BottlingBatch[]
|
||||||
@@ -386,6 +388,8 @@ model Task {
|
|||||||
relatedId String?
|
relatedId String?
|
||||||
sourceInspectionId String?
|
sourceInspectionId String?
|
||||||
sourceInspection Inspection? @relation(fields: [sourceInspectionId], references: [id])
|
sourceInspection Inspection? @relation(fields: [sourceInspectionId], references: [id])
|
||||||
|
sourceTemplateId String?
|
||||||
|
sourceTemplate TaskTemplate? @relation(fields: [sourceTemplateId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
|
|
||||||
@@ -393,6 +397,37 @@ model Task {
|
|||||||
@@index([relatedType, relatedId])
|
@@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)
|
// Varroamanagement (Abschnitt 4.10)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { DocumentsModule } from './documents/documents.module';
|
|||||||
import { AutomationModule } from './automation/automation.module';
|
import { AutomationModule } from './automation/automation.module';
|
||||||
import { ImportModule } from './import/import.module';
|
import { ImportModule } from './import/import.module';
|
||||||
import { PublicModule } from './public/public.module';
|
import { PublicModule } from './public/public.module';
|
||||||
|
import { TaskTemplatesModule } from './task-templates/task-templates.module';
|
||||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from './auth/guards/roles.guard';
|
import { RolesGuard } from './auth/guards/roles.guard';
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
|
|||||||
AutomationModule,
|
AutomationModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
PublicModule,
|
PublicModule,
|
||||||
|
TaskTemplatesModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
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 { Module } from '@nestjs/common';
|
||||||
|
import { TaskTemplatesModule } from '../task-templates/task-templates.module';
|
||||||
import { TasksController } from './tasks.controller';
|
import { TasksController } from './tasks.controller';
|
||||||
import { TasksService } from './tasks.service';
|
import { TasksService } from './tasks.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [TaskTemplatesModule],
|
||||||
controllers: [TasksController],
|
controllers: [TasksController],
|
||||||
providers: [TasksService],
|
providers: [TasksService],
|
||||||
exports: [TasksService],
|
exports: [TasksService],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { TasksService } from './tasks.service';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
import { TaskTemplatesService } from '../task-templates/task-templates.service';
|
||||||
|
|
||||||
describe('TasksService', () => {
|
describe('TasksService', () => {
|
||||||
let service: TasksService;
|
let service: TasksService;
|
||||||
@@ -15,6 +16,7 @@ describe('TasksService', () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
|
let taskTemplates: { generateDueTasks: jest.Mock };
|
||||||
|
|
||||||
const task = { id: 'task-1', title: 'Beute prüfen', status: 'OPEN' };
|
const task = { id: 'task-1', title: 'Beute prüfen', status: 'OPEN' };
|
||||||
|
|
||||||
@@ -29,13 +31,26 @@ describe('TasksService', () => {
|
|||||||
};
|
};
|
||||||
auditLog = { record: jest.fn() };
|
auditLog = { record: jest.fn() };
|
||||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
taskTemplates = {
|
||||||
|
generateDueTasks: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
service = new TasksService(
|
service = new TasksService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
webhooks as unknown as WebhooksService,
|
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 () => {
|
it('findById throws NotFoundException when missing', async () => {
|
||||||
prisma.task.findUnique.mockResolvedValue(null);
|
prisma.task.findUnique.mockResolvedValue(null);
|
||||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
import { TaskTemplatesService } from '../task-templates/task-templates.service';
|
||||||
import { Task, TaskPriority, TaskStatus } from '../../generated/prisma';
|
import { Task, TaskPriority, TaskStatus } from '../../generated/prisma';
|
||||||
import { CreateTaskDto } from './dto/create-task.dto';
|
import { CreateTaskDto } from './dto/create-task.dto';
|
||||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||||
@@ -17,9 +18,11 @@ export class TasksService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly webhooks: WebhooksService,
|
private readonly webhooks: WebhooksService,
|
||||||
|
private readonly taskTemplates: TaskTemplatesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(filter: TaskFilter): Promise<Task[]> {
|
async findAll(filter: TaskFilter): Promise<Task[]> {
|
||||||
|
await this.taskTemplates.generateDueTasks();
|
||||||
return this.prisma.task.findMany({
|
return this.prisma.task.findMany({
|
||||||
where: filter,
|
where: filter,
|
||||||
orderBy: [{ dueDate: 'asc' }, { createdAt: 'desc' }],
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+206
@@ -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,
|
type LucideIcon,
|
||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
|
Repeat,
|
||||||
Search,
|
Search,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -85,6 +86,7 @@ const ADMIN_GROUP: NavGroup = {
|
|||||||
{ label: "Audit-Log", href: "/settings/audit-log", icon: History },
|
{ label: "Audit-Log", href: "/settings/audit-log", icon: History },
|
||||||
{ label: "Webhooks", href: "/settings/webhooks", icon: Webhook },
|
{ label: "Webhooks", href: "/settings/webhooks", icon: Webhook },
|
||||||
{ label: "Automatisierungen", href: "/settings/automation-rules", icon: Zap },
|
{ label: "Automatisierungen", href: "/settings/automation-rules", icon: Zap },
|
||||||
|
{ label: "Wiederkehrende Aufgaben", href: "/settings/task-templates", icon: Repeat },
|
||||||
{ label: "Import", href: "/settings/import", icon: Upload },
|
{ 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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user