Add CRM leads/opportunities module (Master Prompt §6.2 MVP)

Greenfield for EDEN — eden.md has no CRM module, so this is driven by the
Master Prompt's requirements, not a legacy port.

- Migration 1788439012582_crm.cjs: leads + opportunities tables, free-text
  status/stage (documented via COMMENT ON COLUMN, no CHECK yet — a
  configurable pipeline is a future refinement), indexes on status/owner_id
  and stage/owner_id, set_updated_at triggers reusing the baseline
  schema's trigger function.
- packages/database/src/repositories/{leads,opportunities}.ts: list/get/
  create/update per companies.ts conventions (COALESCE-based partial
  update), plus convertLeadToOpportunity(leadId, input) wrapped in
  withTransaction — inserts the opportunity (lead_id set) and marks the
  lead 'converted' atomically.
- apps/api/src/{leads,opportunities}/: NestJS modules mirroring the
  Companies module structure (controller/service/module/dto). Routes
  v1/leads and v1/opportunities. Because leads/opportunities carry
  prospect PII (unlike the open-read Companies/Products catalog), reads
  are also gated behind @RequirePermission('<resource>', 'view'), not
  just JwtAuthGuard. Writes require '<resource>', 'edit'.
  POST /v1/leads/:id/convert gated by 'leads'/'edit'. Audit events:
  lead.created/updated/converted, opportunity.created/updated.
- Added class-validator/class-transformer to apps/api/package.json
  (needed for the DTOs; not previously a direct dependency).

Known cross-agent dependency gap (by design, not a bug in this code):
this worktree branched before any sibling agent's work landed, so
apps/api/src/auth/ (JwtAuthGuard, PermissionGuard, RequirePermission) and
the Companies module referenced as this task's template do not exist yet
here, and packages/database/src/index.ts / apps/api/src/app.module.ts are
explicitly off-limits for me to edit (merge-conflict avoidance across the
4 parallel agents). `pnpm --filter @eden/database typecheck/build` pass
unconditionally. `pnpm --filter @eden/api typecheck/build` currently fail
with exactly two error classes: "Cannot find module '../auth'" and
"@eden/database has no exported member <leads/opportunities repo fns,
auditRepo>" — both expected to resolve once the auth module and the
index.ts/app.module.ts wiring land at merge time. Verified locally (not
committed) by stubbing both gaps: with the stub in place, both typecheck
and build pass with zero errors, confirming this module's own code is
correct against the documented interfaces.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 10:57:28 -03:00
parent 44510bd019
commit a8f77e4068
16 changed files with 1075 additions and 14 deletions

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