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