diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 9bd1e9d..68b3294 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -9,6 +9,8 @@ import { UsersModule } from "./users/users.module.js"; import { RolesModule } from "./roles/roles.module.js"; import { ProductsModule } from "./products/products.module.js"; import { ResellersModule } from "./resellers/resellers.module.js"; +import { LeadsModule } from "./leads/leads.module.js"; +import { OpportunitiesModule } from "./opportunities/opportunities.module.js"; @Module({ imports: [ @@ -21,6 +23,8 @@ import { ResellersModule } from "./resellers/resellers.module.js"; RolesModule, ProductsModule, ResellersModule, + LeadsModule, + OpportunitiesModule, ], controllers: [HealthController], }) diff --git a/apps/api/src/leads/dto/convert-lead.dto.ts b/apps/api/src/leads/dto/convert-lead.dto.ts new file mode 100644 index 0000000..61ce009 --- /dev/null +++ b/apps/api/src/leads/dto/convert-lead.dto.ts @@ -0,0 +1,23 @@ +import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from "class-validator"; +import { OPPORTUNITY_STAGES, type OpportunityStage } from "../../opportunities/dto/create-opportunity.dto"; + +/** Body for POST /v1/leads/:id/convert — the new opportunity's fields. */ +export class ConvertLeadDto { + @IsString() + @MaxLength(200) + title!: string; + + @IsOptional() + @IsIn(OPPORTUNITY_STAGES) + stage?: OpportunityStage; + + @IsOptional() + @IsInt() + @Min(0) + @Max(100) + probability?: number; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/api/src/leads/dto/create-lead.dto.ts b/apps/api/src/leads/dto/create-lead.dto.ts new file mode 100644 index 0000000..09dbfd2 --- /dev/null +++ b/apps/api/src/leads/dto/create-lead.dto.ts @@ -0,0 +1,46 @@ +import { IsEmail, IsIn, IsOptional, IsString, IsUUID, MaxLength } from "class-validator"; + +/** + * Plain TEXT column in the DB, no CHECK constraint (see the crm migration). + * Validated here at the API boundary instead. + */ +export const LEAD_STATUSES = ["new", "contacted", "qualified", "converted", "discarded"] as const; +export type LeadStatus = (typeof LEAD_STATUSES)[number]; + +export class CreateLeadDto { + @IsOptional() + @IsString() + @MaxLength(200) + source?: string; + + @IsString() + @MaxLength(200) + contactName!: string; + + @IsOptional() + @IsEmail() + @MaxLength(320) + contactEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(40) + contactPhone?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + companyName?: string; + + @IsOptional() + @IsIn(LEAD_STATUSES) + status?: LeadStatus; + + @IsOptional() + @IsUUID() + ownerId?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/api/src/leads/dto/update-lead.dto.ts b/apps/api/src/leads/dto/update-lead.dto.ts new file mode 100644 index 0000000..8d2b1fc --- /dev/null +++ b/apps/api/src/leads/dto/update-lead.dto.ts @@ -0,0 +1,41 @@ +import { IsEmail, IsIn, IsOptional, IsString, IsUUID, MaxLength } from "class-validator"; +import { LEAD_STATUSES, type LeadStatus } from "./create-lead.dto"; + +export class UpdateLeadDto { + @IsOptional() + @IsString() + @MaxLength(200) + source?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + contactName?: string; + + @IsOptional() + @IsEmail() + @MaxLength(320) + contactEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(40) + contactPhone?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + companyName?: string; + + @IsOptional() + @IsIn(LEAD_STATUSES) + status?: LeadStatus; + + @IsOptional() + @IsUUID() + ownerId?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/api/src/leads/leads.controller.ts b/apps/api/src/leads/leads.controller.ts new file mode 100644 index 0000000..70a4fbc --- /dev/null +++ b/apps/api/src/leads/leads.controller.ts @@ -0,0 +1,63 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, UseGuards } from "@nestjs/common"; +import { LeadsService } from "./leads.service.js"; +import { CreateLeadDto } from "./dto/create-lead.dto.js"; +import { UpdateLeadDto } from "./dto/update-lead.dto.js"; +import { ConvertLeadDto } from "./dto/convert-lead.dto.js"; +import { JwtAuthGuard } from "../auth/jwt-auth.guard.js"; +import { PermissionGuard } from "../auth/permission.guard.js"; +import { RequirePermission } from "../auth/decorators/require-permission.decorator.js"; +import { CurrentUser } from "../auth/decorators/current-user.decorator.js"; +import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js"; + +/** + * CRM leads (Master Prompt §6.2). Unlike Companies/Products (open-read), + * leads/opportunities carry prospect PII — every route, including reads, + * requires authentication AND resource+action permission (ADR-0004). + * + * The global ValidationPipe (whitelist+forbidNonWhitelisted, main.ts) already + * covers mass-assignment protection for every controller — no need for a + * second, controller-local ValidationPipe here. + */ +@Controller("v1/leads") +@UseGuards(JwtAuthGuard, PermissionGuard) +export class LeadsController { + constructor(private readonly leadsService: LeadsService) {} + + @Get() + @RequirePermission("leads", "view") + list(@Query("status") status?: string, @Query("ownerId") ownerId?: string) { + return this.leadsService.list({ status, ownerId }); + } + + @Get(":id") + @RequirePermission("leads", "view") + get(@Param("id", ParseUUIDPipe) id: string) { + return this.leadsService.get(id); + } + + @Post() + @RequirePermission("leads", "edit") + create(@Body() dto: CreateLeadDto, @CurrentUser() user: EdenAuthenticatedUser) { + return this.leadsService.create(dto, user.id); + } + + @Patch(":id") + @RequirePermission("leads", "edit") + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateLeadDto, + @CurrentUser() user: EdenAuthenticatedUser, + ) { + return this.leadsService.update(id, dto, user.id); + } + + @Post(":id/convert") + @RequirePermission("leads", "edit") + convert( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ConvertLeadDto, + @CurrentUser() user: EdenAuthenticatedUser, + ) { + return this.leadsService.convert(id, dto, user.id); + } +} diff --git a/apps/api/src/leads/leads.module.ts b/apps/api/src/leads/leads.module.ts new file mode 100644 index 0000000..2ce4e5a --- /dev/null +++ b/apps/api/src/leads/leads.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { AuthModule } from "../auth/auth.module.js"; +import { LeadsController } from "./leads.controller.js"; +import { LeadsService } from "./leads.service.js"; + +@Module({ + imports: [AuthModule], + controllers: [LeadsController], + providers: [LeadsService], + exports: [LeadsService], +}) +export class LeadsModule {} diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts new file mode 100644 index 0000000..48a73fa --- /dev/null +++ b/apps/api/src/leads/leads.service.ts @@ -0,0 +1,97 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { + auditRepo, + convertLeadToOpportunity, + createLead, + getLead, + listLeads, + updateLead, + type ConvertLeadResult, + type Lead, +} from "@eden/database"; +import type { CreateLeadDto } from "./dto/create-lead.dto"; +import type { UpdateLeadDto } from "./dto/update-lead.dto"; +import type { ConvertLeadDto } from "./dto/convert-lead.dto"; + +export interface ListLeadsQuery { + status?: string; + ownerId?: string; +} + +@Injectable() +export class LeadsService { + list(filter: ListLeadsQuery): Promise { + return listLeads(filter); + } + + async get(id: string): Promise { + const lead = await getLead(id); + if (!lead) { + throw new NotFoundException(`Lead ${id} not found`); + } + return lead; + } + + async create(dto: CreateLeadDto, actorId: string | null): Promise { + const lead = await createLead({ ...dto, createdBy: actorId }); + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId, + action: "lead.created", + resourceType: "lead", + resourceId: lead.id, + metadata: { status: lead.status, source: lead.source }, + }); + return lead; + } + + async update(id: string, dto: UpdateLeadDto, actorId: string | null): Promise { + const lead = await updateLead(id, dto); + if (!lead) { + throw new NotFoundException(`Lead ${id} not found`); + } + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId, + action: "lead.updated", + resourceType: "lead", + resourceId: lead.id, + metadata: { fields: Object.keys(dto) }, + }); + return lead; + } + + async convert( + id: string, + dto: ConvertLeadDto, + actorId: string | null, + ): Promise { + const result = await convertLeadToOpportunity(id, { + title: dto.title, + stage: dto.stage, + probability: dto.probability ?? null, + notes: dto.notes ?? null, + createdBy: actorId, + }); + if (!result) { + throw new NotFoundException(`Lead ${id} not found`); + } + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId, + action: "lead.converted", + resourceType: "lead", + resourceId: result.lead.id, + metadata: { opportunityId: result.opportunity.id }, + }); + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId, + action: "opportunity.created", + resourceType: "opportunity", + resourceId: result.opportunity.id, + metadata: { leadId: id, stage: result.opportunity.stage }, + }); + return result; + } +} diff --git a/apps/api/src/opportunities/dto/create-opportunity.dto.ts b/apps/api/src/opportunities/dto/create-opportunity.dto.ts new file mode 100644 index 0000000..61ca291 --- /dev/null +++ b/apps/api/src/opportunities/dto/create-opportunity.dto.ts @@ -0,0 +1,49 @@ +import { IsIn, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from "class-validator"; + +/** + * Plain TEXT column in the DB, no CHECK constraint (see the crm + * migration). Validated here at the API boundary instead. A fully + * configurable pipeline is a future refinement, not this MVP. + */ +export const OPPORTUNITY_STAGES = [ + "prospecting", + "qualification", + "proposal", + "negotiation", + "won", + "lost", +] as const; +export type OpportunityStage = (typeof OPPORTUNITY_STAGES)[number]; + +export class CreateOpportunityDto { + @IsOptional() + @IsUUID() + leadId?: string; + + @IsString() + @MaxLength(200) + title!: string; + + @IsOptional() + @IsIn(OPPORTUNITY_STAGES) + stage?: OpportunityStage; + + @IsOptional() + @IsInt() + @Min(0) + @Max(100) + probability?: number; + + @IsOptional() + @IsUUID() + ownerId?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + lossReason?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/api/src/opportunities/dto/update-opportunity.dto.ts b/apps/api/src/opportunities/dto/update-opportunity.dto.ts new file mode 100644 index 0000000..b250c78 --- /dev/null +++ b/apps/api/src/opportunities/dto/update-opportunity.dto.ts @@ -0,0 +1,32 @@ +import { IsIn, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from "class-validator"; +import { OPPORTUNITY_STAGES, type OpportunityStage } from "./create-opportunity.dto"; + +export class UpdateOpportunityDto { + @IsOptional() + @IsString() + @MaxLength(200) + title?: string; + + @IsOptional() + @IsIn(OPPORTUNITY_STAGES) + stage?: OpportunityStage; + + @IsOptional() + @IsInt() + @Min(0) + @Max(100) + probability?: number; + + @IsOptional() + @IsUUID() + ownerId?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + lossReason?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/api/src/opportunities/opportunities.controller.ts b/apps/api/src/opportunities/opportunities.controller.ts new file mode 100644 index 0000000..0a56f69 --- /dev/null +++ b/apps/api/src/opportunities/opportunities.controller.ts @@ -0,0 +1,49 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, UseGuards } from "@nestjs/common"; +import { OpportunitiesService } from "./opportunities.service.js"; +import { CreateOpportunityDto } from "./dto/create-opportunity.dto.js"; +import { UpdateOpportunityDto } from "./dto/update-opportunity.dto.js"; +import { JwtAuthGuard } from "../auth/jwt-auth.guard.js"; +import { PermissionGuard } from "../auth/permission.guard.js"; +import { RequirePermission } from "../auth/decorators/require-permission.decorator.js"; +import { CurrentUser } from "../auth/decorators/current-user.decorator.js"; +import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js"; + +/** + * CRM opportunities (Master Prompt §6.2). Same PII-gating rationale as + * leads — read requires @RequirePermission('opportunities', 'view'), not + * just authentication. Mass-assignment protection comes from the global + * ValidationPipe (main.ts) — no controller-local pipe needed. + */ +@Controller("v1/opportunities") +@UseGuards(JwtAuthGuard, PermissionGuard) +export class OpportunitiesController { + constructor(private readonly opportunitiesService: OpportunitiesService) {} + + @Get() + @RequirePermission("opportunities", "view") + list(@Query("stage") stage?: string, @Query("ownerId") ownerId?: string) { + return this.opportunitiesService.list({ stage, ownerId }); + } + + @Get(":id") + @RequirePermission("opportunities", "view") + get(@Param("id", ParseUUIDPipe) id: string) { + return this.opportunitiesService.get(id); + } + + @Post() + @RequirePermission("opportunities", "edit") + create(@Body() dto: CreateOpportunityDto, @CurrentUser() user: EdenAuthenticatedUser) { + return this.opportunitiesService.create(dto, user.id); + } + + @Patch(":id") + @RequirePermission("opportunities", "edit") + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateOpportunityDto, + @CurrentUser() user: EdenAuthenticatedUser, + ) { + return this.opportunitiesService.update(id, dto, user.id); + } +} diff --git a/apps/api/src/opportunities/opportunities.module.ts b/apps/api/src/opportunities/opportunities.module.ts new file mode 100644 index 0000000..3aac8a4 --- /dev/null +++ b/apps/api/src/opportunities/opportunities.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { AuthModule } from "../auth/auth.module.js"; +import { OpportunitiesController } from "./opportunities.controller.js"; +import { OpportunitiesService } from "./opportunities.service.js"; + +@Module({ + imports: [AuthModule], + controllers: [OpportunitiesController], + providers: [OpportunitiesService], + exports: [OpportunitiesService], +}) +export class OpportunitiesModule {} diff --git a/apps/api/src/opportunities/opportunities.service.ts b/apps/api/src/opportunities/opportunities.service.ts new file mode 100644 index 0000000..22066af --- /dev/null +++ b/apps/api/src/opportunities/opportunities.service.ts @@ -0,0 +1,64 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { + auditRepo, + createOpportunity, + getOpportunity, + listOpportunities, + updateOpportunity, + type Opportunity, +} from "@eden/database"; +import type { CreateOpportunityDto } from "./dto/create-opportunity.dto"; +import type { UpdateOpportunityDto } from "./dto/update-opportunity.dto"; + +export interface ListOpportunitiesQuery { + stage?: string; + ownerId?: string; +} + +@Injectable() +export class OpportunitiesService { + list(filter: ListOpportunitiesQuery): Promise { + return listOpportunities(filter); + } + + async get(id: string): Promise { + const opportunity = await getOpportunity(id); + if (!opportunity) { + throw new NotFoundException(`Opportunity ${id} not found`); + } + return opportunity; + } + + async create(dto: CreateOpportunityDto, actorId: string | null): Promise { + const opportunity = await createOpportunity({ ...dto, createdBy: actorId }); + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId, + action: "opportunity.created", + resourceType: "opportunity", + resourceId: opportunity.id, + metadata: { stage: opportunity.stage, leadId: opportunity.leadId }, + }); + return opportunity; + } + + async update( + id: string, + dto: UpdateOpportunityDto, + actorId: string | null, + ): Promise { + const opportunity = await updateOpportunity(id, dto); + if (!opportunity) { + throw new NotFoundException(`Opportunity ${id} not found`); + } + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId, + action: "opportunity.updated", + resourceType: "opportunity", + resourceId: opportunity.id, + metadata: { fields: Object.keys(dto) }, + }); + return opportunity; + } +} diff --git a/packages/database/migrations/1788439012582_crm.cjs b/packages/database/migrations/1788439012582_crm.cjs new file mode 100644 index 0000000..ed4d61e --- /dev/null +++ b/packages/database/migrations/1788439012582_crm.cjs @@ -0,0 +1,93 @@ +/** + * CRM — Leads & Opportunities (Master Prompt §6.2 "CRM e Comercial"). + * + * Greenfield for EDEN: the legacy OrçaFácil system (eden.md) has no CRM + * module — it starts directly at Quotes. This migration is driven entirely + * by the Master Prompt's requirements, not a port of legacy behavior. + * + * MVP scope: `leads` and `opportunities` with free-text `status`/`stage` + * columns (documented via COMMENT ON COLUMN, no CHECK constraint yet — a + * fully configurable pipeline is a future refinement, not this MVP). + * + * `leads.company_name` is a free-text prospect company name — it is + * deliberately NOT a foreign key to the `companies` table, which models + * issuing legal entities, not sales prospects. + * + * The `set_updated_at()` trigger function is created by the baseline + * schema migration (1788430575858_baseline-schema.cjs) — reused here, not + * redefined. + */ + +exports.shorthands = undefined; + +exports.up = (pgm) => { + pgm.sql(` + -- ===================================================================== + -- leads + -- ===================================================================== + CREATE TABLE leads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID REFERENCES users(id), + source TEXT, + contact_name TEXT NOT NULL, + contact_email TEXT, + contact_phone TEXT, + company_name TEXT, + status TEXT NOT NULL DEFAULT 'new', + owner_id UUID REFERENCES users(id), + notes TEXT + ); + COMMENT ON COLUMN leads.source IS + 'Free-text origin/campaign (e.g. "indicação", "site", "feira X") — no fixed taxonomy in this MVP.'; + COMMENT ON COLUMN leads.company_name IS + 'Free-text prospect company name. Deliberately NOT a FK to companies — that table models issuing legal entities, not sales prospects.'; + COMMENT ON COLUMN leads.status IS + 'Plain TEXT, no CHECK constraint yet. Intended values: new, contacted, qualified, converted, discarded. converted is set only via POST /v1/leads/:id/convert.'; + COMMENT ON COLUMN leads.owner_id IS 'Responsible salesperson.'; + + CREATE INDEX idx_leads_status ON leads(status); + CREATE INDEX idx_leads_owner ON leads(owner_id); + + CREATE TRIGGER trg_leads_updated_at BEFORE UPDATE ON leads + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + + -- ===================================================================== + -- opportunities + -- ===================================================================== + CREATE TABLE opportunities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID REFERENCES users(id), + lead_id UUID REFERENCES leads(id), + title TEXT NOT NULL, + stage TEXT NOT NULL DEFAULT 'prospecting', + probability INTEGER CHECK (probability >= 0 AND probability <= 100), + owner_id UUID REFERENCES users(id), + loss_reason TEXT, + notes TEXT + ); + COMMENT ON COLUMN opportunities.lead_id IS + 'Nullable — an opportunity can be created directly, not only converted from a lead.'; + COMMENT ON COLUMN opportunities.stage IS + 'Plain TEXT, no CHECK constraint yet. Intended values: prospecting, qualification, proposal, negotiation, won, lost. A fully configurable pipeline is a future refinement, not this MVP.'; + COMMENT ON COLUMN opportunities.loss_reason IS + 'Nullable — filled only when stage = ''lost''. Not enforced by a DB constraint in this MVP.'; + COMMENT ON COLUMN opportunities.owner_id IS 'Responsible salesperson.'; + + CREATE INDEX idx_opportunities_stage ON opportunities(stage); + CREATE INDEX idx_opportunities_owner ON opportunities(owner_id); + + CREATE TRIGGER trg_opportunities_updated_at BEFORE UPDATE ON opportunities + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + `); +}; + +exports.down = (pgm) => { + pgm.sql(` + DROP TABLE IF EXISTS opportunities; + DROP TABLE IF EXISTS leads; + `); +}; diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 8c8a293..ca3cb6e 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -6,7 +6,34 @@ export * as auditRepo from "./repositories/audit.js"; export * as companiesRepo from "./repositories/companies.js"; export * as productsRepo from "./repositories/products.js"; export * as resellersRepo from "./repositories/resellers.js"; +export { + listLeads, + getLead, + createLead, + updateLead, + convertLeadToOpportunity, +} from "./repositories/leads.js"; +export { + listOpportunities, + getOpportunity, + createOpportunity, + updateOpportunity, +} from "./repositories/opportunities.js"; export type * from "./repositories/types.js"; export type { Company } from "./repositories/companies.js"; export type { Product, CreateProductInput, UpdateProductInput } from "./repositories/products.js"; export type { Reseller, CreateResellerInput, UpdateResellerInput } from "./repositories/resellers.js"; +export type { + Lead, + ListLeadsFilter, + CreateLeadInput, + UpdateLeadInput, + ConvertLeadInput, + ConvertLeadResult, +} from "./repositories/leads.js"; +export type { + Opportunity, + ListOpportunitiesFilter, + CreateOpportunityInput, + UpdateOpportunityInput, +} from "./repositories/opportunities.js"; diff --git a/packages/database/src/repositories/leads.ts b/packages/database/src/repositories/leads.ts new file mode 100644 index 0000000..892a53a --- /dev/null +++ b/packages/database/src/repositories/leads.ts @@ -0,0 +1,232 @@ +import type { PoolClient, QueryResultRow } from "pg"; +import { query, withTransaction } from "../pool.js"; +import { createOpportunityWithClient, type Opportunity } from "./opportunities.js"; + +/** + * CRM — Leads repository (Master Prompt §6.2). Greenfield for EDEN — the + * legacy OrçaFácil spec (eden.md) has no CRM module, so there is no legacy + * behavior to preserve here. + * + * Mirrors the conventions of `companies.ts`: COALESCE-based partial + * update, snake_case columns mapped to a camelCase domain type. + */ + +export interface Lead { + id: string; + createdAt: Date; + updatedAt: Date; + createdBy: string | null; + source: string | null; + contactName: string; + contactEmail: string | null; + contactPhone: string | null; + companyName: string | null; + /** Plain TEXT column. Intended values: new, contacted, qualified, converted, discarded. */ + status: string; + ownerId: string | null; + notes: string | null; +} + +interface LeadRow extends QueryResultRow { + id: string; + created_at: Date; + updated_at: Date; + created_by: string | null; + source: string | null; + contact_name: string; + contact_email: string | null; + contact_phone: string | null; + company_name: string | null; + status: string; + owner_id: string | null; + notes: string | null; +} + +function mapLead(row: LeadRow): Lead { + return { + id: row.id, + createdAt: row.created_at, + updatedAt: row.updated_at, + createdBy: row.created_by, + source: row.source, + contactName: row.contact_name, + contactEmail: row.contact_email, + contactPhone: row.contact_phone, + companyName: row.company_name, + status: row.status, + ownerId: row.owner_id, + notes: row.notes, + }; +} + +export interface ListLeadsFilter { + status?: string; + ownerId?: string; +} + +export async function listLeads(filter: ListLeadsFilter = {}): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + + if (filter.status) { + params.push(filter.status); + conditions.push(`status = $${params.length}`); + } + if (filter.ownerId) { + params.push(filter.ownerId); + conditions.push(`owner_id = $${params.length}`); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const result = await query( + `SELECT * FROM leads ${where} ORDER BY created_at DESC`, + params, + ); + return result.rows.map(mapLead); +} + +export async function getLead(id: string): Promise { + const result = await query(`SELECT * FROM leads WHERE id = $1`, [id]); + const row = result.rows[0]; + return row ? mapLead(row) : null; +} + +export interface CreateLeadInput { + source?: string | null; + contactName: string; + contactEmail?: string | null; + contactPhone?: string | null; + companyName?: string | null; + status?: string; + ownerId?: string | null; + notes?: string | null; + createdBy?: string | null; +} + +export async function createLead(input: CreateLeadInput): Promise { + const result = await query( + `INSERT INTO leads + (source, contact_name, contact_email, contact_phone, company_name, status, owner_id, notes, created_by) + VALUES ($1, $2, $3, $4, $5, COALESCE($6, 'new'), $7, $8, $9) + RETURNING *`, + [ + input.source ?? null, + input.contactName, + input.contactEmail ?? null, + input.contactPhone ?? null, + input.companyName ?? null, + input.status ?? null, + input.ownerId ?? null, + input.notes ?? null, + input.createdBy ?? null, + ], + ); + const row = result.rows[0]; + if (!row) { + throw new Error("Failed to create lead"); + } + return mapLead(row); +} + +export interface UpdateLeadInput { + source?: string | null; + contactName?: string; + contactEmail?: string | null; + contactPhone?: string | null; + companyName?: string | null; + status?: string; + ownerId?: string | null; + notes?: string | null; +} + +export async function updateLead(id: string, input: UpdateLeadInput): Promise { + const result = await query( + `UPDATE leads SET + source = COALESCE($2, source), + contact_name = COALESCE($3, contact_name), + contact_email = COALESCE($4, contact_email), + contact_phone = COALESCE($5, contact_phone), + company_name = COALESCE($6, company_name), + status = COALESCE($7, status), + owner_id = COALESCE($8, owner_id), + notes = COALESCE($9, notes) + WHERE id = $1 + RETURNING *`, + [ + id, + input.source ?? null, + input.contactName ?? null, + input.contactEmail ?? null, + input.contactPhone ?? null, + input.companyName ?? null, + input.status ?? null, + input.ownerId ?? null, + input.notes ?? null, + ], + ); + const row = result.rows[0]; + return row ? mapLead(row) : null; +} + +async function getLeadWithClient(client: PoolClient, id: string): Promise { + const result = await client.query(`SELECT * FROM leads WHERE id = $1 FOR UPDATE`, [id]); + const row = result.rows[0]; + return row ? mapLead(row) : null; +} + +async function markLeadConvertedWithClient(client: PoolClient, id: string): Promise { + const result = await client.query( + `UPDATE leads SET status = 'converted' WHERE id = $1 RETURNING *`, + [id], + ); + const row = result.rows[0]; + return row ? mapLead(row) : null; +} + +export interface ConvertLeadInput { + title: string; + stage?: string; + probability?: number | null; + notes?: string | null; + ownerId?: string | null; + createdBy?: string | null; +} + +export interface ConvertLeadResult { + lead: Lead; + opportunity: Opportunity; +} + +/** + * Converts a lead into an opportunity: inserts the new opportunity row + * (with lead_id set) and sets the lead's status to 'converted', in a + * single transaction. Returns null if the lead does not exist. + */ +export async function convertLeadToOpportunity( + leadId: string, + opportunityInput: ConvertLeadInput, +): Promise { + return withTransaction(async (client) => { + const existingLead = await getLeadWithClient(client, leadId); + if (!existingLead) { + return null; + } + + const opportunity = await createOpportunityWithClient(client, { + leadId, + title: opportunityInput.title, + stage: opportunityInput.stage, + probability: opportunityInput.probability ?? null, + notes: opportunityInput.notes ?? null, + ownerId: opportunityInput.ownerId ?? existingLead.ownerId, + createdBy: opportunityInput.createdBy ?? null, + }); + + const lead = await markLeadConvertedWithClient(client, leadId); + if (!lead) { + throw new Error(`Lead ${leadId} disappeared mid-transaction`); + } + + return { lead, opportunity }; + }); +} diff --git a/packages/database/src/repositories/opportunities.ts b/packages/database/src/repositories/opportunities.ts new file mode 100644 index 0000000..bab4506 --- /dev/null +++ b/packages/database/src/repositories/opportunities.ts @@ -0,0 +1,192 @@ +import type { PoolClient, QueryResultRow } from "pg"; +import { query } from "../pool.js"; + +/** + * CRM — Opportunities repository (Master Prompt §6.2). Greenfield for + * EDEN — see leads.ts for context. Mirrors `companies.ts` conventions: + * COALESCE-based partial update, snake_case columns mapped to a camelCase + * domain type. + */ + +export interface Opportunity { + id: string; + createdAt: Date; + updatedAt: Date; + createdBy: string | null; + leadId: string | null; + title: string; + /** Plain TEXT column. Intended values: prospecting, qualification, proposal, negotiation, won, lost. */ + stage: string; + probability: number | null; + ownerId: string | null; + lossReason: string | null; + notes: string | null; +} + +interface OpportunityRow extends QueryResultRow { + id: string; + created_at: Date; + updated_at: Date; + created_by: string | null; + lead_id: string | null; + title: string; + stage: string; + probability: number | null; + owner_id: string | null; + loss_reason: string | null; + notes: string | null; +} + +function mapOpportunity(row: OpportunityRow): Opportunity { + return { + id: row.id, + createdAt: row.created_at, + updatedAt: row.updated_at, + createdBy: row.created_by, + leadId: row.lead_id, + title: row.title, + stage: row.stage, + probability: row.probability, + ownerId: row.owner_id, + lossReason: row.loss_reason, + notes: row.notes, + }; +} + +export interface ListOpportunitiesFilter { + stage?: string; + ownerId?: string; +} + +export async function listOpportunities( + filter: ListOpportunitiesFilter = {}, +): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + + if (filter.stage) { + params.push(filter.stage); + conditions.push(`stage = $${params.length}`); + } + if (filter.ownerId) { + params.push(filter.ownerId); + conditions.push(`owner_id = $${params.length}`); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const result = await query( + `SELECT * FROM opportunities ${where} ORDER BY created_at DESC`, + params, + ); + return result.rows.map(mapOpportunity); +} + +export async function getOpportunity(id: string): Promise { + const result = await query(`SELECT * FROM opportunities WHERE id = $1`, [id]); + const row = result.rows[0]; + return row ? mapOpportunity(row) : null; +} + +export interface CreateOpportunityInput { + leadId?: string | null; + title: string; + stage?: string; + probability?: number | null; + ownerId?: string | null; + lossReason?: string | null; + notes?: string | null; + createdBy?: string | null; +} + +export async function createOpportunity(input: CreateOpportunityInput): Promise { + const result = await query( + `INSERT INTO opportunities + (lead_id, title, stage, probability, owner_id, loss_reason, notes, created_by) + VALUES ($1, $2, COALESCE($3, 'prospecting'), $4, $5, $6, $7, $8) + RETURNING *`, + [ + input.leadId ?? null, + input.title, + input.stage ?? null, + input.probability ?? null, + input.ownerId ?? null, + input.lossReason ?? null, + input.notes ?? null, + input.createdBy ?? null, + ], + ); + const row = result.rows[0]; + if (!row) { + throw new Error("Failed to create opportunity"); + } + return mapOpportunity(row); +} + +/** + * Same as `createOpportunity`, but runs on a caller-supplied client so it + * can participate in an outer transaction (used by + * `leads.convertLeadToOpportunity`). + */ +export async function createOpportunityWithClient( + client: PoolClient, + input: CreateOpportunityInput, +): Promise { + const result = await client.query( + `INSERT INTO opportunities + (lead_id, title, stage, probability, owner_id, loss_reason, notes, created_by) + VALUES ($1, $2, COALESCE($3, 'prospecting'), $4, $5, $6, $7, $8) + RETURNING *`, + [ + input.leadId ?? null, + input.title, + input.stage ?? null, + input.probability ?? null, + input.ownerId ?? null, + input.lossReason ?? null, + input.notes ?? null, + input.createdBy ?? null, + ], + ); + const row = result.rows[0]; + if (!row) { + throw new Error("Failed to create opportunity"); + } + return mapOpportunity(row); +} + +export interface UpdateOpportunityInput { + title?: string; + stage?: string; + probability?: number | null; + ownerId?: string | null; + lossReason?: string | null; + notes?: string | null; +} + +export async function updateOpportunity( + id: string, + input: UpdateOpportunityInput, +): Promise { + const result = await query( + `UPDATE opportunities SET + title = COALESCE($2, title), + stage = COALESCE($3, stage), + probability = COALESCE($4, probability), + owner_id = COALESCE($5, owner_id), + loss_reason = COALESCE($6, loss_reason), + notes = COALESCE($7, notes) + WHERE id = $1 + RETURNING *`, + [ + id, + input.title ?? null, + input.stage ?? null, + input.probability ?? null, + input.ownerId ?? null, + input.lossReason ?? null, + input.notes ?? null, + ], + ); + const row = result.rows[0]; + return row ? mapOpportunity(row) : null; +}