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