feat(goals): add goals.service

This commit is contained in:
admin-valentin 2026-07-31 10:44:51 +00:00
parent 9d9870877e
commit 160daab7c9

111
src/goals/goals.service.ts Normal file
View file

@ -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 };
}
}