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 { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; import { Task, TaskPriority, TaskStatus } from '../../generated/prisma'; import { CreateTaskDto } from './dto/create-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto'; /** * Keeps completedAt in step with status when the status is edited directly: * set when a task becomes DONE, cleared when it is reopened or cancelled - * a reopened task must not keep claiming it was finished. */ function completedAtForStatusChange( before: Task, nextStatus: TaskStatus | undefined, ): Date | null | undefined { if (nextStatus === undefined || nextStatus === before.status) { return undefined; } return nextStatus === 'DONE' ? new Date() : null; } export interface TaskFilter { status?: TaskStatus; priority?: TaskPriority; } @Injectable() export class TasksService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly webhooks: WebhooksService, private readonly taskTemplates: TaskTemplatesService, private readonly calendarSync: CalendarSyncService, ) {} async findAll(filter: TaskFilter): Promise { await this.taskTemplates.generateDueTasks(); return this.prisma.task.findMany({ where: filter, orderBy: [{ dueDate: 'asc' }, { createdAt: 'desc' }], }); } async findById(id: string): Promise { const task = await this.prisma.task.findUnique({ where: { id } }); if (!task) { throw new NotFoundException('Task not found'); } return task; } async create(dto: CreateTaskDto, actingUserId: string): Promise { const task = await this.prisma.task.create({ data: { ...dto, dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined, createdByUserId: actingUserId, }, }); await this.auditLog.record({ entityType: 'Task', entityId: task.id, action: 'CREATE', userId: actingUserId, afterData: task, }); await this.webhooks.dispatch('task.created', task); if (task.dueDate) { await this.calendarSync.enqueue('TASK', task.id); } return task; } async update( id: string, dto: UpdateTaskDto, actingUserId: string, ): Promise { const before = await this.findById(id); const task = await this.prisma.task.update({ where: { id }, data: { ...dto, dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined, completedAt: completedAtForStatusChange(before, dto.status), }, }); await this.auditLog.record({ entityType: 'Task', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: before, afterData: task, }); if (task.dueDate) { await this.calendarSync.enqueue('TASK', task.id); } else { await this.calendarSync.cancel('TASK', task.id); } return task; } async complete(id: string, actingUserId: string): Promise { const before = await this.findById(id); // A second tap on "Erledigt" (or a retried request) must not move the // completion time or announce the completion a second time. if (before.status === 'DONE') { return before; } const task = await this.prisma.task.update({ where: { id }, data: { status: 'DONE', completedAt: new Date() }, }); await this.auditLog.record({ entityType: 'Task', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: before, afterData: task, }); await this.webhooks.dispatch('task.completed', task); await this.calendarSync.cancel('TASK', task.id); return task; } }