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
@@ -33,6 +33,7 @@ import { DocumentsModule } from './documents/documents.module';
|
||||
import { AutomationModule } from './automation/automation.module';
|
||||
import { ImportModule } from './import/import.module';
|
||||
import { PublicModule } from './public/public.module';
|
||||
import { TaskTemplatesModule } from './task-templates/task-templates.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './auth/guards/roles.guard';
|
||||
|
||||
@@ -71,6 +72,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
|
||||
AutomationModule,
|
||||
ImportModule,
|
||||
PublicModule,
|
||||
TaskTemplatesModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import {
|
||||
TaskPriority,
|
||||
TaskTemplateRecurrenceType,
|
||||
} from '../../../generated/prisma';
|
||||
|
||||
export class CreateTaskTemplateDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
title!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TaskPriority })
|
||||
@IsOptional()
|
||||
@IsEnum(TaskPriority)
|
||||
priority?: TaskPriority;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assignedUserId?: string;
|
||||
|
||||
@ApiProperty({ enum: TaskTemplateRecurrenceType })
|
||||
@IsEnum(TaskTemplateRecurrenceType)
|
||||
recurrenceType!: TaskTemplateRecurrenceType;
|
||||
|
||||
@ApiPropertyOptional({ description: '1-12, erforderlich bei YEARLY' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(12)
|
||||
recurrenceMonth?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '1-31, erforderlich bei YEARLY und MONTHLY',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(31)
|
||||
recurrenceDayOfMonth?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'erforderlich bei INTERVAL_DAYS' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
intervalDays?: number;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
import { CreateTaskTemplateDto } from './create-task-template.dto';
|
||||
|
||||
export class UpdateTaskTemplateDto extends PartialType(CreateTaskTemplateDto) {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { computeInitialDueDate, computeNextDueDate } from './recurrence';
|
||||
|
||||
function utc(year: number, month: number, day: number): Date {
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
describe('recurrence', () => {
|
||||
describe('computeInitialDueDate', () => {
|
||||
it('INTERVAL_DAYS: adds intervalDays to today', () => {
|
||||
const result = computeInitialDueDate(
|
||||
{
|
||||
recurrenceType: 'INTERVAL_DAYS',
|
||||
recurrenceMonth: null,
|
||||
recurrenceDayOfMonth: null,
|
||||
intervalDays: 14,
|
||||
},
|
||||
utc(2026, 3, 1),
|
||||
);
|
||||
expect(result).toEqual(utc(2026, 3, 15));
|
||||
});
|
||||
|
||||
it('YEARLY: uses this year when the date has not passed yet', () => {
|
||||
const result = computeInitialDueDate(
|
||||
{
|
||||
recurrenceType: 'YEARLY',
|
||||
recurrenceMonth: 6,
|
||||
recurrenceDayOfMonth: 15,
|
||||
intervalDays: null,
|
||||
},
|
||||
utc(2026, 3, 1),
|
||||
);
|
||||
expect(result).toEqual(utc(2026, 6, 15));
|
||||
});
|
||||
|
||||
it('YEARLY: rolls over to next year when the date already passed', () => {
|
||||
const result = computeInitialDueDate(
|
||||
{
|
||||
recurrenceType: 'YEARLY',
|
||||
recurrenceMonth: 1,
|
||||
recurrenceDayOfMonth: 15,
|
||||
intervalDays: null,
|
||||
},
|
||||
utc(2026, 3, 1),
|
||||
);
|
||||
expect(result).toEqual(utc(2027, 1, 15));
|
||||
});
|
||||
|
||||
it('YEARLY: clamps day-of-month to the last real day (Feb 30 -> Feb 28)', () => {
|
||||
const result = computeInitialDueDate(
|
||||
{
|
||||
recurrenceType: 'YEARLY',
|
||||
recurrenceMonth: 2,
|
||||
recurrenceDayOfMonth: 30,
|
||||
intervalDays: null,
|
||||
},
|
||||
utc(2026, 1, 1),
|
||||
);
|
||||
expect(result).toEqual(utc(2026, 2, 28));
|
||||
});
|
||||
|
||||
it('MONTHLY: uses this month when the day has not passed yet', () => {
|
||||
const result = computeInitialDueDate(
|
||||
{
|
||||
recurrenceType: 'MONTHLY',
|
||||
recurrenceMonth: null,
|
||||
recurrenceDayOfMonth: 20,
|
||||
intervalDays: null,
|
||||
},
|
||||
utc(2026, 3, 1),
|
||||
);
|
||||
expect(result).toEqual(utc(2026, 3, 20));
|
||||
});
|
||||
|
||||
it('MONTHLY: rolls over to next month when the day already passed', () => {
|
||||
const result = computeInitialDueDate(
|
||||
{
|
||||
recurrenceType: 'MONTHLY',
|
||||
recurrenceMonth: null,
|
||||
recurrenceDayOfMonth: 5,
|
||||
intervalDays: null,
|
||||
},
|
||||
utc(2026, 3, 10),
|
||||
);
|
||||
expect(result).toEqual(utc(2026, 4, 5));
|
||||
});
|
||||
|
||||
it('MONTHLY: rolls over across a year boundary (December -> January)', () => {
|
||||
const result = computeInitialDueDate(
|
||||
{
|
||||
recurrenceType: 'MONTHLY',
|
||||
recurrenceMonth: null,
|
||||
recurrenceDayOfMonth: 5,
|
||||
intervalDays: null,
|
||||
},
|
||||
utc(2026, 12, 10),
|
||||
);
|
||||
expect(result).toEqual(utc(2027, 1, 5));
|
||||
});
|
||||
|
||||
it('MONTHLY: clamps day 31 in a 30-day month', () => {
|
||||
const result = computeInitialDueDate(
|
||||
{
|
||||
recurrenceType: 'MONTHLY',
|
||||
recurrenceMonth: null,
|
||||
recurrenceDayOfMonth: 31,
|
||||
intervalDays: null,
|
||||
},
|
||||
utc(2026, 4, 1),
|
||||
);
|
||||
expect(result).toEqual(utc(2026, 4, 30));
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeNextDueDate', () => {
|
||||
it('INTERVAL_DAYS: adds intervalDays to the fired date', () => {
|
||||
const result = computeNextDueDate(
|
||||
{
|
||||
recurrenceType: 'INTERVAL_DAYS',
|
||||
recurrenceMonth: null,
|
||||
recurrenceDayOfMonth: null,
|
||||
intervalDays: 7,
|
||||
},
|
||||
utc(2026, 3, 15),
|
||||
);
|
||||
expect(result).toEqual(utc(2026, 3, 22));
|
||||
});
|
||||
|
||||
it('YEARLY: advances to next year even if fired exactly on the anniversary', () => {
|
||||
const result = computeNextDueDate(
|
||||
{
|
||||
recurrenceType: 'YEARLY',
|
||||
recurrenceMonth: 6,
|
||||
recurrenceDayOfMonth: 15,
|
||||
intervalDays: null,
|
||||
},
|
||||
utc(2026, 6, 15),
|
||||
);
|
||||
expect(result).toEqual(utc(2027, 6, 15));
|
||||
});
|
||||
|
||||
it('MONTHLY: advances to next month even if fired exactly on the day', () => {
|
||||
const result = computeNextDueDate(
|
||||
{
|
||||
recurrenceType: 'MONTHLY',
|
||||
recurrenceMonth: null,
|
||||
recurrenceDayOfMonth: 20,
|
||||
intervalDays: null,
|
||||
},
|
||||
utc(2026, 3, 20),
|
||||
);
|
||||
expect(result).toEqual(utc(2026, 4, 20));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { TaskTemplateRecurrenceType } from '../../generated/prisma';
|
||||
|
||||
export interface RecurrenceInput {
|
||||
recurrenceType: TaskTemplateRecurrenceType;
|
||||
recurrenceMonth: number | null;
|
||||
recurrenceDayOfMonth: number | null;
|
||||
intervalDays: number | null;
|
||||
}
|
||||
|
||||
function startOfUtcDay(date: Date): Date {
|
||||
return new Date(
|
||||
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
|
||||
);
|
||||
}
|
||||
|
||||
function addUtcDays(date: Date, days: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setUTCDate(result.getUTCDate() + days);
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildUtcDate(year: number, month: number, dayOfMonth: number): Date {
|
||||
const lastDayOfMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
const day = Math.min(dayOfMonth, lastDayOfMonth);
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
function nextYearlyOccurrence(
|
||||
onOrAfter: Date,
|
||||
month: number,
|
||||
dayOfMonth: number,
|
||||
): Date {
|
||||
const from = startOfUtcDay(onOrAfter);
|
||||
const year = from.getUTCFullYear();
|
||||
const candidate = buildUtcDate(year, month, dayOfMonth);
|
||||
return candidate.getTime() >= from.getTime()
|
||||
? candidate
|
||||
: buildUtcDate(year + 1, month, dayOfMonth);
|
||||
}
|
||||
|
||||
function nextMonthlyOccurrence(onOrAfter: Date, dayOfMonth: number): Date {
|
||||
const from = startOfUtcDay(onOrAfter);
|
||||
const candidate = buildUtcDate(
|
||||
from.getUTCFullYear(),
|
||||
from.getUTCMonth() + 1,
|
||||
dayOfMonth,
|
||||
);
|
||||
if (candidate.getTime() >= from.getTime()) {
|
||||
return candidate;
|
||||
}
|
||||
const nextMonth = from.getUTCMonth() + 2;
|
||||
return nextMonth > 12
|
||||
? buildUtcDate(from.getUTCFullYear() + 1, 1, dayOfMonth)
|
||||
: buildUtcDate(from.getUTCFullYear(), nextMonth, dayOfMonth);
|
||||
}
|
||||
|
||||
function occurrenceOnOrAfter(input: RecurrenceInput, onOrAfter: Date): Date {
|
||||
switch (input.recurrenceType) {
|
||||
case 'YEARLY':
|
||||
return nextYearlyOccurrence(
|
||||
onOrAfter,
|
||||
input.recurrenceMonth as number,
|
||||
input.recurrenceDayOfMonth as number,
|
||||
);
|
||||
case 'MONTHLY':
|
||||
return nextMonthlyOccurrence(
|
||||
onOrAfter,
|
||||
input.recurrenceDayOfMonth as number,
|
||||
);
|
||||
case 'INTERVAL_DAYS':
|
||||
return startOfUtcDay(onOrAfter);
|
||||
}
|
||||
}
|
||||
|
||||
/** The due date for a brand-new template, anchored to "today". */
|
||||
export function computeInitialDueDate(
|
||||
input: RecurrenceInput,
|
||||
today: Date,
|
||||
): Date {
|
||||
if (input.recurrenceType === 'INTERVAL_DAYS') {
|
||||
return addUtcDays(startOfUtcDay(today), input.intervalDays as number);
|
||||
}
|
||||
return occurrenceOnOrAfter(input, today);
|
||||
}
|
||||
|
||||
/** The next due date after a template has just fired on `firedDate`. */
|
||||
export function computeNextDueDate(
|
||||
input: RecurrenceInput,
|
||||
firedDate: Date,
|
||||
): Date {
|
||||
if (input.recurrenceType === 'INTERVAL_DAYS') {
|
||||
return addUtcDays(startOfUtcDay(firedDate), input.intervalDays as number);
|
||||
}
|
||||
return occurrenceOnOrAfter(input, addUtcDays(firedDate, 1));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { Role } from '../../generated/prisma';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateTaskTemplateDto } from './dto/create-task-template.dto';
|
||||
import { UpdateTaskTemplateDto } from './dto/update-task-template.dto';
|
||||
import { TaskTemplatesService } from './task-templates.service';
|
||||
|
||||
@ApiTags('task-templates')
|
||||
@ApiBearerAuth()
|
||||
@Roles(Role.ADMIN, Role.BEEKEEPER)
|
||||
@Controller('task-templates')
|
||||
export class TaskTemplatesController {
|
||||
constructor(private readonly taskTemplatesService: TaskTemplatesService) {}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.taskTemplatesService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.taskTemplatesService.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@Body() dto: CreateTaskTemplateDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.taskTemplatesService.create(dto, actor.id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateTaskTemplateDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.taskTemplatesService.update(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@Post(':id/deactivate')
|
||||
deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||
return this.taskTemplatesService.deactivate(id, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TaskTemplatesController } from './task-templates.controller';
|
||||
import { TaskTemplatesService } from './task-templates.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TaskTemplatesController],
|
||||
providers: [TaskTemplatesService],
|
||||
exports: [TaskTemplatesService],
|
||||
})
|
||||
export class TaskTemplatesModule {}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { TaskTemplatesService } from './task-templates.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
|
||||
describe('TaskTemplatesService', () => {
|
||||
let service: TaskTemplatesService;
|
||||
let prisma: {
|
||||
taskTemplate: {
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
updateMany: jest.Mock;
|
||||
};
|
||||
task: { create: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let webhooks: { dispatch: jest.Mock };
|
||||
|
||||
const template = {
|
||||
id: 'template-1',
|
||||
title: 'Varroa-Kontrolle',
|
||||
description: null,
|
||||
priority: 'NORMAL',
|
||||
assignedUserId: null,
|
||||
recurrenceType: 'MONTHLY',
|
||||
recurrenceMonth: null,
|
||||
recurrenceDayOfMonth: 15,
|
||||
intervalDays: null,
|
||||
nextDueDate: new Date('2026-03-15T00:00:00.000Z'),
|
||||
active: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
taskTemplate: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
task: { create: jest.fn() },
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new TaskTemplatesService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
webhooks as unknown as WebhooksService,
|
||||
);
|
||||
});
|
||||
|
||||
it('findById throws NotFoundException when missing', async () => {
|
||||
prisma.taskTemplate.findUnique.mockResolvedValue(null);
|
||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('create rejects YEARLY without recurrenceMonth/recurrenceDayOfMonth', async () => {
|
||||
await expect(
|
||||
service.create(
|
||||
{ title: 'Jahresinventur', recurrenceType: 'YEARLY' },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.taskTemplate.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('create rejects MONTHLY without recurrenceDayOfMonth', async () => {
|
||||
await expect(
|
||||
service.create(
|
||||
{ title: 'Monatscheck', recurrenceType: 'MONTHLY' },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('create rejects INTERVAL_DAYS without intervalDays', async () => {
|
||||
await expect(
|
||||
service.create(
|
||||
{ title: 'Fütterung', recurrenceType: 'INTERVAL_DAYS' },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('create computes nextDueDate and stores the acting user as creator', async () => {
|
||||
prisma.taskTemplate.create.mockResolvedValue(template);
|
||||
|
||||
await service.create(
|
||||
{
|
||||
title: 'Varroa-Kontrolle',
|
||||
recurrenceType: 'MONTHLY',
|
||||
recurrenceDayOfMonth: 15,
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[createArg]] = prisma.taskTemplate.create.mock.calls as [
|
||||
[{ data: { nextDueDate: Date; createdByUserId: string } }],
|
||||
];
|
||||
expect(createArg.data.nextDueDate).toBeInstanceOf(Date);
|
||||
expect(createArg.data.createdByUserId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('update recomputes nextDueDate only when a recurrence field changes', async () => {
|
||||
prisma.taskTemplate.findUnique.mockResolvedValue(template);
|
||||
prisma.taskTemplate.update.mockResolvedValue(template);
|
||||
|
||||
await service.update('template-1', { title: 'Neuer Titel' }, 'user-1');
|
||||
|
||||
const [[updateArg]] = prisma.taskTemplate.update.mock.calls as [
|
||||
[{ data: { nextDueDate: Date | undefined } }],
|
||||
];
|
||||
expect(updateArg.data.nextDueDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('update recomputes nextDueDate when recurrenceDayOfMonth changes', async () => {
|
||||
prisma.taskTemplate.findUnique.mockResolvedValue(template);
|
||||
prisma.taskTemplate.update.mockResolvedValue(template);
|
||||
|
||||
await service.update('template-1', { recurrenceDayOfMonth: 20 }, 'user-1');
|
||||
|
||||
const [[updateArg]] = prisma.taskTemplate.update.mock.calls as [
|
||||
[{ data: { nextDueDate: Date | undefined } }],
|
||||
];
|
||||
expect(updateArg.data.nextDueDate).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('deactivate sets active to false', async () => {
|
||||
prisma.taskTemplate.findUnique.mockResolvedValue(template);
|
||||
prisma.taskTemplate.update.mockResolvedValue({
|
||||
...template,
|
||||
active: false,
|
||||
});
|
||||
|
||||
await service.deactivate('template-1', 'user-1');
|
||||
|
||||
const [[updateArg]] = prisma.taskTemplate.update.mock.calls as [
|
||||
[{ data: { active: boolean } }],
|
||||
];
|
||||
expect(updateArg.data.active).toBe(false);
|
||||
});
|
||||
|
||||
describe('generateDueTasks', () => {
|
||||
it('creates a Task and advances nextDueDate for a due template', async () => {
|
||||
prisma.taskTemplate.findMany.mockResolvedValue([template]);
|
||||
prisma.taskTemplate.updateMany.mockResolvedValue({ count: 1 });
|
||||
prisma.task.create.mockResolvedValue({ id: 'task-1' });
|
||||
|
||||
await service.generateDueTasks();
|
||||
|
||||
const [[createArg]] = prisma.task.create.mock.calls as [
|
||||
[{ data: { title: string; sourceTemplateId: string } }],
|
||||
];
|
||||
expect(createArg.data.title).toBe(template.title);
|
||||
expect(createArg.data.sourceTemplateId).toBe(template.id);
|
||||
expect(webhooks.dispatch).toHaveBeenCalledWith(
|
||||
'task.created',
|
||||
expect.objectContaining({ id: 'task-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips Task creation when the compare-and-swap claim loses the race', async () => {
|
||||
prisma.taskTemplate.findMany.mockResolvedValue([template]);
|
||||
prisma.taskTemplate.updateMany.mockResolvedValue({ count: 0 });
|
||||
|
||||
await service.generateDueTasks();
|
||||
|
||||
expect(prisma.task.create).not.toHaveBeenCalled();
|
||||
expect(webhooks.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when no template is due', async () => {
|
||||
prisma.taskTemplate.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.generateDueTasks();
|
||||
|
||||
expect(prisma.taskTemplate.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.task.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { Task, TaskTemplate } from '../../generated/prisma';
|
||||
import { CreateTaskTemplateDto } from './dto/create-task-template.dto';
|
||||
import { UpdateTaskTemplateDto } from './dto/update-task-template.dto';
|
||||
import {
|
||||
computeInitialDueDate,
|
||||
computeNextDueDate,
|
||||
RecurrenceInput,
|
||||
} from './recurrence';
|
||||
|
||||
function toRecurrenceInput(input: {
|
||||
recurrenceType: TaskTemplate['recurrenceType'];
|
||||
recurrenceMonth?: number | null;
|
||||
recurrenceDayOfMonth?: number | null;
|
||||
intervalDays?: number | null;
|
||||
}): RecurrenceInput {
|
||||
return {
|
||||
recurrenceType: input.recurrenceType,
|
||||
recurrenceMonth: input.recurrenceMonth ?? null,
|
||||
recurrenceDayOfMonth: input.recurrenceDayOfMonth ?? null,
|
||||
intervalDays: input.intervalDays ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TaskTemplatesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly webhooks: WebhooksService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<TaskTemplate[]> {
|
||||
return this.prisma.taskTemplate.findMany({ orderBy: { title: 'asc' } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<TaskTemplate> {
|
||||
const template = await this.prisma.taskTemplate.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!template) {
|
||||
throw new NotFoundException('Task template not found');
|
||||
}
|
||||
return template;
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateTaskTemplateDto,
|
||||
actingUserId: string,
|
||||
): Promise<TaskTemplate> {
|
||||
const recurrence = toRecurrenceInput(dto);
|
||||
this.assertRecurrenceFieldsPresent(recurrence);
|
||||
const nextDueDate = computeInitialDueDate(recurrence, new Date());
|
||||
|
||||
const template = await this.prisma.taskTemplate.create({
|
||||
data: { ...dto, nextDueDate, createdByUserId: actingUserId },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'TaskTemplate',
|
||||
entityId: template.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: template,
|
||||
});
|
||||
return template;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateTaskTemplateDto,
|
||||
actingUserId: string,
|
||||
): Promise<TaskTemplate> {
|
||||
const before = await this.findById(id);
|
||||
const recurrenceChanged =
|
||||
dto.recurrenceType !== undefined ||
|
||||
dto.recurrenceMonth !== undefined ||
|
||||
dto.recurrenceDayOfMonth !== undefined ||
|
||||
dto.intervalDays !== undefined;
|
||||
|
||||
let nextDueDate: Date | undefined;
|
||||
if (recurrenceChanged) {
|
||||
const recurrence = toRecurrenceInput({
|
||||
recurrenceType: dto.recurrenceType ?? before.recurrenceType,
|
||||
recurrenceMonth: dto.recurrenceMonth ?? before.recurrenceMonth,
|
||||
recurrenceDayOfMonth:
|
||||
dto.recurrenceDayOfMonth ?? before.recurrenceDayOfMonth,
|
||||
intervalDays: dto.intervalDays ?? before.intervalDays,
|
||||
});
|
||||
this.assertRecurrenceFieldsPresent(recurrence);
|
||||
nextDueDate = computeInitialDueDate(recurrence, new Date());
|
||||
}
|
||||
|
||||
const template = await this.prisma.taskTemplate.update({
|
||||
where: { id },
|
||||
data: { ...dto, nextDueDate },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'TaskTemplate',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: template,
|
||||
});
|
||||
return template;
|
||||
}
|
||||
|
||||
async deactivate(id: string, actingUserId: string): Promise<TaskTemplate> {
|
||||
const before = await this.findById(id);
|
||||
const template = await this.prisma.taskTemplate.update({
|
||||
where: { id },
|
||||
data: { active: false },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'TaskTemplate',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: template,
|
||||
});
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily catches up any template whose nextDueDate has passed: creates the
|
||||
* Task and atomically advances nextDueDate. The update's WHERE clause
|
||||
* matches the previous nextDueDate (compare-and-swap), so concurrent calls
|
||||
* from multiple API replicas cannot double-fire the same occurrence.
|
||||
*/
|
||||
async generateDueTasks(): Promise<void> {
|
||||
const now = new Date();
|
||||
const dueTemplates = await this.prisma.taskTemplate.findMany({
|
||||
where: { active: true, nextDueDate: { lte: now } },
|
||||
});
|
||||
|
||||
for (const template of dueTemplates) {
|
||||
const nextDueDate = computeNextDueDate(template, template.nextDueDate);
|
||||
const claim = await this.prisma.taskTemplate.updateMany({
|
||||
where: { id: template.id, nextDueDate: template.nextDueDate },
|
||||
data: { nextDueDate },
|
||||
});
|
||||
if (claim.count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const task: Task = await this.prisma.task.create({
|
||||
data: {
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
priority: template.priority,
|
||||
assignedUserId: template.assignedUserId,
|
||||
relatedType: 'GENERAL',
|
||||
sourceTemplateId: template.id,
|
||||
},
|
||||
});
|
||||
await this.webhooks.dispatch('task.created', task);
|
||||
}
|
||||
}
|
||||
|
||||
private assertRecurrenceFieldsPresent(input: RecurrenceInput): void {
|
||||
if (
|
||||
input.recurrenceType === 'YEARLY' &&
|
||||
(!input.recurrenceMonth || !input.recurrenceDayOfMonth)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'recurrenceMonth und recurrenceDayOfMonth sind für YEARLY erforderlich',
|
||||
);
|
||||
}
|
||||
if (input.recurrenceType === 'MONTHLY' && !input.recurrenceDayOfMonth) {
|
||||
throw new BadRequestException(
|
||||
'recurrenceDayOfMonth ist für MONTHLY erforderlich',
|
||||
);
|
||||
}
|
||||
if (input.recurrenceType === 'INTERVAL_DAYS' && !input.intervalDays) {
|
||||
throw new BadRequestException(
|
||||
'intervalDays ist für INTERVAL_DAYS erforderlich',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TaskTemplatesModule } from '../task-templates/task-templates.module';
|
||||
import { TasksController } from './tasks.controller';
|
||||
import { TasksService } from './tasks.service';
|
||||
|
||||
@Module({
|
||||
imports: [TaskTemplatesModule],
|
||||
controllers: [TasksController],
|
||||
providers: [TasksService],
|
||||
exports: [TasksService],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TasksService } from './tasks.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { TaskTemplatesService } from '../task-templates/task-templates.service';
|
||||
|
||||
describe('TasksService', () => {
|
||||
let service: TasksService;
|
||||
@@ -15,6 +16,7 @@ describe('TasksService', () => {
|
||||
};
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let taskTemplates: { generateDueTasks: jest.Mock };
|
||||
|
||||
const task = { id: 'task-1', title: 'Beute prüfen', status: 'OPEN' };
|
||||
|
||||
@@ -29,13 +31,26 @@ describe('TasksService', () => {
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||
taskTemplates = {
|
||||
generateDueTasks: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
service = new TasksService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
webhooks as unknown as WebhooksService,
|
||||
taskTemplates as unknown as TaskTemplatesService,
|
||||
);
|
||||
});
|
||||
|
||||
it('findAll generates due tasks from templates before listing', async () => {
|
||||
prisma.task.findMany.mockResolvedValue([task]);
|
||||
|
||||
const result = await service.findAll({});
|
||||
|
||||
expect(taskTemplates.generateDueTasks).toHaveBeenCalled();
|
||||
expect(result).toEqual([task]);
|
||||
});
|
||||
|
||||
it('findById throws NotFoundException when missing', async () => {
|
||||
prisma.task.findUnique.mockResolvedValue(null);
|
||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { TaskTemplatesService } from '../task-templates/task-templates.service';
|
||||
import { Task, TaskPriority, TaskStatus } from '../../generated/prisma';
|
||||
import { CreateTaskDto } from './dto/create-task.dto';
|
||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||
@@ -17,9 +18,11 @@ export class TasksService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly webhooks: WebhooksService,
|
||||
private readonly taskTemplates: TaskTemplatesService,
|
||||
) {}
|
||||
|
||||
async findAll(filter: TaskFilter): Promise<Task[]> {
|
||||
await this.taskTemplates.generateDueTasks();
|
||||
return this.prisma.task.findMany({
|
||||
where: filter,
|
||||
orderBy: [{ dueDate: 'asc' }, { createdAt: 'desc' }],
|
||||
|
||||
Reference in new issue
Block a user