Integra módulo de CRM (leads/opportunities); corrige 2 bugs reais do agente

- Wire LeadsModule/OpportunitiesModule em app.module.ts; adiciona os
  exports correspondentes ao barrel @eden/database.

Bugs reais encontrados e corrigidos antes do commit (não pegos pelo
próprio agente porque ele não tinha o módulo de auth disponível no
worktree dele para testar de verdade — só stubado e revertido):

1. Os controllers de Leads/Opportunities liam `req.user?.id`, mas nosso
   JwtAuthGuard real popula `request.edenUser`, nunca `request.user` —
   todo evento de auditoria de lead/oportunidade estaria gravando
   actorId: null sempre, perdendo a atribuição de quem fez a ação.
   Trocado para o padrão real (@CurrentUser() + EdenAuthenticatedUser).
2. As chamadas appendAuditEvent() nos dois services omitiam o campo
   obrigatório `actorType` — corrigido para "user" em todas as 6
   ocorrências.

Também corrigidos: import de guards/decorators via um barrel
"../auth" inexistente (trocado pelos caminhos reais), e os dois módulos
não importavam AuthModule (necessário para o Nest resolver as
dependências do JwtAuthGuard via DI).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 11:02:40 -03:00
16 changed files with 1036 additions and 0 deletions

View File

@@ -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],
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Lead[]> {
return listLeads(filter);
}
async get(id: string): Promise<Lead> {
const lead = await getLead(id);
if (!lead) {
throw new NotFoundException(`Lead ${id} not found`);
}
return lead;
}
async create(dto: CreateLeadDto, actorId: string | null): Promise<Lead> {
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<Lead> {
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<ConvertLeadResult> {
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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Opportunity[]> {
return listOpportunities(filter);
}
async get(id: string): Promise<Opportunity> {
const opportunity = await getOpportunity(id);
if (!opportunity) {
throw new NotFoundException(`Opportunity ${id} not found`);
}
return opportunity;
}
async create(dto: CreateOpportunityDto, actorId: string | null): Promise<Opportunity> {
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<Opportunity> {
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;
}
}

View File

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

View File

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

View File

@@ -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<Lead[]> {
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<LeadRow>(
`SELECT * FROM leads ${where} ORDER BY created_at DESC`,
params,
);
return result.rows.map(mapLead);
}
export async function getLead(id: string): Promise<Lead | null> {
const result = await query<LeadRow>(`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<Lead> {
const result = await query<LeadRow>(
`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<Lead | null> {
const result = await query<LeadRow>(
`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<Lead | null> {
const result = await client.query<LeadRow>(`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<Lead | null> {
const result = await client.query<LeadRow>(
`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<ConvertLeadResult | null> {
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 };
});
}

View File

@@ -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<Opportunity[]> {
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<OpportunityRow>(
`SELECT * FROM opportunities ${where} ORDER BY created_at DESC`,
params,
);
return result.rows.map(mapOpportunity);
}
export async function getOpportunity(id: string): Promise<Opportunity | null> {
const result = await query<OpportunityRow>(`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<Opportunity> {
const result = await query<OpportunityRow>(
`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<Opportunity> {
const result = await client.query<OpportunityRow>(
`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<Opportunity | null> {
const result = await query<OpportunityRow>(
`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;
}