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:
93
packages/database/migrations/1788439012582_crm.cjs
Normal file
93
packages/database/migrations/1788439012582_crm.cjs
Normal 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;
|
||||
`);
|
||||
};
|
||||
232
packages/database/src/repositories/leads.ts
Normal file
232
packages/database/src/repositories/leads.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
192
packages/database/src/repositories/opportunities.ts
Normal file
192
packages/database/src/repositories/opportunities.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user