diff --git a/src/opportunities/opportunities.service.ts b/src/opportunities/opportunities.service.ts new file mode 100644 index 0000000..ccf7b08 --- /dev/null +++ b/src/opportunities/opportunities.service.ts @@ -0,0 +1,92 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { and, desc, eq } from 'drizzle-orm'; +import { db } from '../db/client'; +import { opportunities } from '../db/schema'; +import { AuditService } from '../audit/audit.service'; +import { OutboxService } from '../events/outbox.service'; +import type { AuthenticatedSession } from '../auth/tenant.guard'; +import type { CreateOpportunityDto, UpdateOpportunityDto } from './dto'; + +@Injectable() +export class OpportunitiesService { + constructor( + private readonly outbox: OutboxService, + private readonly audit: AuditService, + ) {} + + async list(session: AuthenticatedSession) { + return db.query.opportunities.findMany({ + where: eq(opportunities.tenantId, session.tenantId), + orderBy: desc(opportunities.createdAt), + }); + } + + async getById(session: AuthenticatedSession, id: string) { + const row = await db.query.opportunities.findFirst({ + where: and(eq(opportunities.id, id), eq(opportunities.tenantId, session.tenantId)), + }); + if (!row) throw new NotFoundException('Opportunity not found'); + return row; + } + + async create(session: AuthenticatedSession, dto: CreateOpportunityDto) { + return this.outbox.withTransaction(async (tx) => { + const [row] = await tx + .insert(opportunities) + .values({ + tenantId: session.tenantId, + source: dto.source, + entityType: dto.entityType, + entityId: dto.entityId, + valueRangeMin: dto.valueRangeMin !== undefined ? String(Math.round(dto.valueRangeMin)) : undefined, + valueRangeMax: dto.valueRangeMax !== undefined ? String(Math.round(dto.valueRangeMax)) : undefined, + probability: dto.probability !== undefined ? String(dto.probability) : undefined, + nextAction: dto.nextAction, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined, + }) + .returning(); + await this.audit.record(tx, { + tenantId: session.tenantId, + actorId: session.userId, + action: 'opportunity.created', + resource: `opportunity:${row.id}`, + }); + await this.outbox.record(tx, { + tenantId: session.tenantId, + workspaceId: session.workspaceId, + actorId: session.userId, + eventType: 'opportunity.created', + aggregateType: 'opportunity', + subjectId: row.id, + correlationId: session.correlationId, + payload: { source: row.source, entityType: row.entityType }, + }); + return row; + }); + } + + async update(session: AuthenticatedSession, id: string, dto: UpdateOpportunityDto) { + await this.getById(session, id); + return this.outbox.withTransaction(async (tx) => { + const [row] = await tx + .update(opportunities) + .set({ + ...(dto.probability !== undefined ? { probability: String(dto.probability) } : {}), + ...(dto.nextAction !== undefined ? { nextAction: dto.nextAction } : {}), + ...(dto.expiresAt !== undefined ? { expiresAt: new Date(dto.expiresAt) } : {}), + ...(dto.valueRangeMin !== undefined ? { valueRangeMin: String(Math.round(dto.valueRangeMin)) } : {}), + ...(dto.valueRangeMax !== undefined ? { valueRangeMax: String(Math.round(dto.valueRangeMax)) } : {}), + updatedAt: new Date(), + }) + .where(and(eq(opportunities.id, id), eq(opportunities.tenantId, session.tenantId))) + .returning(); + await this.audit.record(tx, { + tenantId: session.tenantId, + actorId: session.userId, + action: 'opportunity.updated', + resource: `opportunity:${id}`, + }); + return row; + }); + } +}