import { Injectable, NotFoundException } from '@nestjs/common'; import { and, asc, eq, isNull, sql } from 'drizzle-orm'; import { db } from '../db/client'; import { tasks } from '../db/schema'; import { AuditService } from '../audit/audit.service'; import { OutboxService } from '../events/outbox.service'; import type { AuthenticatedSession } from '../auth/tenant.guard'; import type { CreateTaskDto, TaskStatusValue, UpdateTaskDto } from './dto'; @Injectable() export class TasksService { constructor( private readonly outbox: OutboxService, private readonly audit: AuditService, ) {} async list(session: AuthenticatedSession, status?: TaskStatusValue) { return db.query.tasks.findMany({ where: and( eq(tasks.tenantId, session.tenantId), isNull(tasks.deletedAt), ...(status ? [eq(tasks.status, status)] : []), ), orderBy: [asc(tasks.priority), asc(tasks.dueAt)], }); } async getById(session: AuthenticatedSession, id: string) { const row = await db.query.tasks.findFirst({ where: and(eq(tasks.id, id), eq(tasks.tenantId, session.tenantId), isNull(tasks.deletedAt)), }); if (!row) { throw new NotFoundException('Task not found'); } return row; } async create(session: AuthenticatedSession, dto: CreateTaskDto) { return this.outbox.withTransaction(async (tx) => { const [row] = await tx .insert(tasks) .values({ tenantId: session.tenantId, ownerUserId: session.userId, title: dto.title, priority: dto.priority ?? 3, dueAt: dto.dueAt ? new Date(dto.dueAt) : undefined, sourceEntityType: dto.sourceEntityType, sourceEntityId: dto.sourceEntityId, }) .returning(); await this.audit.record(tx, { tenantId: session.tenantId, actorId: session.userId, action: 'task.created', resource: `task:${row.id}`, }); return row; }); } async update(session: AuthenticatedSession, id: string, dto: UpdateTaskDto) { await this.getById(session, id); return this.outbox.withTransaction(async (tx) => { const [row] = await tx .update(tasks) .set({ ...(dto.title !== undefined ? { title: dto.title } : {}), ...(dto.priority !== undefined ? { priority: dto.priority } : {}), ...(dto.dueAt !== undefined ? { dueAt: new Date(dto.dueAt) } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), updatedAt: new Date(), version: sql`${tasks.version} + 1`, }) .where(and(eq(tasks.id, id), eq(tasks.tenantId, session.tenantId))) .returning(); await this.audit.record(tx, { tenantId: session.tenantId, actorId: session.userId, action: dto.status !== undefined ? `task.status_changed:${dto.status}` : 'task.updated', resource: `task:${id}`, }); return row; }); } async softDelete(session: AuthenticatedSession, id: string) { await this.getById(session, id); await this.outbox.withTransaction(async (tx) => { await tx .update(tasks) .set({ deletedAt: new Date() }) .where(and(eq(tasks.id, id), eq(tasks.tenantId, session.tenantId))); await this.audit.record(tx, { tenantId: session.tenantId, actorId: session.userId, action: 'task.deleted', resource: `task:${id}`, }); }); return { deleted: true }; } }