From 160daab7c91428e4f91839c3ebdf846dbc1e71d0 Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Fri, 31 Jul 2026 10:44:51 +0000 Subject: [PATCH] feat(goals): add goals.service --- src/goals/goals.service.ts | 111 +++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 src/goals/goals.service.ts diff --git a/src/goals/goals.service.ts b/src/goals/goals.service.ts new file mode 100644 index 0000000..2704864 --- /dev/null +++ b/src/goals/goals.service.ts @@ -0,0 +1,111 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { and, desc, eq, isNull, sql } from 'drizzle-orm'; +import { db } from '../db/client'; +import { goals } from '../db/schema'; +import { AuditService } from '../audit/audit.service'; +import { OutboxService } from '../events/outbox.service'; +import type { AuthenticatedSession } from '../auth/tenant.guard'; +import type { CreateGoalDto, GoalStatusValue, UpdateGoalDto } from './dto'; + +@Injectable() +export class GoalsService { + constructor( + private readonly outbox: OutboxService, + private readonly audit: AuditService, + ) {} + + async list(session: AuthenticatedSession, status?: GoalStatusValue) { + return db.query.goals.findMany({ + where: and( + eq(goals.tenantId, session.tenantId), + isNull(goals.deletedAt), + ...(status ? [eq(goals.status, status)] : []), + ), + orderBy: desc(goals.createdAt), + }); + } + + async getById(session: AuthenticatedSession, id: string) { + const row = await db.query.goals.findFirst({ + where: and(eq(goals.id, id), eq(goals.tenantId, session.tenantId), isNull(goals.deletedAt)), + }); + if (!row) throw new NotFoundException('Goal not found'); + return row; + } + + async create(session: AuthenticatedSession, dto: CreateGoalDto) { + return this.outbox.withTransaction(async (tx) => { + const [row] = await tx + .insert(goals) + .values({ + tenantId: session.tenantId, + ownerUserId: session.userId, + horizon: dto.horizon, + metric: dto.metric, + target: dto.target, + milestones: (dto.milestones ?? []) as unknown[], + }) + .returning(); + await this.audit.record(tx, { + tenantId: session.tenantId, + actorId: session.userId, + action: 'goal.created', + resource: `goal:${row.id}`, + }); + await this.outbox.record(tx, { + tenantId: session.tenantId, + workspaceId: session.workspaceId, + actorId: session.userId, + eventType: 'goal.created', + aggregateType: 'goal', + subjectId: row.id, + correlationId: session.correlationId, + payload: { horizon: row.horizon, metric: row.metric }, + }); + return row; + }); + } + + async update(session: AuthenticatedSession, id: string, dto: UpdateGoalDto) { + await this.getById(session, id); + return this.outbox.withTransaction(async (tx) => { + const [row] = await tx + .update(goals) + .set({ + ...(dto.horizon !== undefined ? { horizon: dto.horizon } : {}), + ...(dto.metric !== undefined ? { metric: dto.metric } : {}), + ...(dto.target !== undefined ? { target: dto.target } : {}), + ...(dto.milestones !== undefined ? { milestones: dto.milestones as unknown[] } : {}), + ...(dto.status !== undefined ? { status: dto.status } : {}), + updatedAt: new Date(), + version: sql`${goals.version} + 1`, + }) + .where(and(eq(goals.id, id), eq(goals.tenantId, session.tenantId))) + .returning(); + await this.audit.record(tx, { + tenantId: session.tenantId, + actorId: session.userId, + action: dto.status !== undefined ? `goal.status_changed:${dto.status}` : 'goal.updated', + resource: `goal:${id}`, + }); + return row; + }); + } + + async softDelete(session: AuthenticatedSession, id: string) { + await this.getById(session, id); + await this.outbox.withTransaction(async (tx) => { + await tx + .update(goals) + .set({ deletedAt: new Date() }) + .where(and(eq(goals.id, id), eq(goals.tenantId, session.tenantId))); + await this.audit.record(tx, { + tenantId: session.tenantId, + actorId: session.userId, + action: 'goal.deleted', + resource: `goal:${id}`, + }); + }); + return { deleted: true }; + } +}