Skip to content

Commit 91008c0

Browse files
hotlongCopilot
andcommitted
feat(crm): lead convert endpoint (M10.6)
Salesforce-style lead conversion that creates an Account + Contact (+ optional Opportunity) and atomically marks the lead converted with pointer fields and timestamp. POST /api/v1/data/lead/:id/convert { "accountId": optional — reuse existing account "contactId": optional — reuse existing contact "createOpportunity": optional boolean, default true "opportunity": optional { name, amount, close_date, stage } "convertedStatus": optional, default 'converted' } Behavior: - Loads the lead via the caller's execution context (SecurityPlugin still gates reads). Rejects with 404 LEAD_NOT_FOUND if missing and 409 LEAD_ALREADY_CONVERTED if is_converted is already true so the UI can disable the action without polling. - Creates account from lead.company/industry/annual_revenue/website/ phone/address/owner unless accountId given. - Creates contact from lead identity + email/phone/mobile/title/owner, linked to the account, unless contactId given. - Creates opportunity (stage='qualification', close_date=+30d, amount=annual_revenue) linked to account + primary_contact unless createOpportunity:false. Caller can override via 'opportunity'. - Updates lead: is_converted=true, converted_{account,contact, opportunity}, converted_date=now, status='converted'. Atomicity: best-effort compensating delete in reverse order (opp → contact → account) when any step throws after partial writes. True transactional execution is only available via ScopedContext and is not yet wired through the protocol layer — tracked separately. Verified end-to-end against running app-crm: * Alice Martinez (NextGen Retail) → account, contact, opportunity, lead.status=converted, all pointer fields populated. * Re-convert → 409 LEAD_ALREADY_CONVERTED. * Unknown lead id → 404 LEAD_NOT_FOUND. * createOpportunity:false → opportunity=null, account & contact created, lead.is_converted=true. All 232 objectql tests + 53 rest tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ecb39d2 commit 91008c0

2 files changed

Lines changed: 242 additions & 0 deletions

File tree

packages/objectql/src/protocol.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,196 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
10201020
};
10211021
}
10221022

1023+
// ==========================================
1024+
// Lead Convert (M10.6)
1025+
// ==========================================
1026+
/**
1027+
* Convert a qualified Lead into an Account + Contact (+ optional
1028+
* Opportunity) and mark the Lead as converted. Mirrors the Salesforce
1029+
* lead-conversion model:
1030+
*
1031+
* - If `accountId` is provided, the lead's company info is NOT used
1032+
* to create a new account; the new contact and opportunity link to
1033+
* the existing account instead.
1034+
* - If `contactId` is provided, no new contact is created either —
1035+
* useful when the lead is a new contact at an existing account.
1036+
* - `createOpportunity` defaults to true; pass `false` to convert
1037+
* without producing an opportunity (some teams convert "logos
1038+
* only" first).
1039+
* - Lead is updated atomically: `is_converted=true`,
1040+
* `converted_account`/`converted_contact`/`converted_opportunity`
1041+
* pointers, `converted_date`, and `status='converted'`.
1042+
*
1043+
* Atomicity is enforced via the default driver's transaction support
1044+
* when available; otherwise a best-effort compensation (delete
1045+
* already-created child records on failure) is attempted. Permission
1046+
* checks on each child object are inherited from the caller's
1047+
* execution context so SecurityPlugin still gates account/contact/
1048+
* opportunity creates.
1049+
*/
1050+
async convertLead(request: {
1051+
leadId: string;
1052+
accountId?: string;
1053+
contactId?: string;
1054+
createOpportunity?: boolean;
1055+
opportunity?: {
1056+
name?: string;
1057+
amount?: number;
1058+
close_date?: string;
1059+
stage?: string;
1060+
};
1061+
convertedStatus?: string;
1062+
context?: any;
1063+
}): Promise<{
1064+
lead: any;
1065+
account: any;
1066+
contact: any;
1067+
opportunity: any | null;
1068+
}> {
1069+
const leadId = String(request.leadId || '').trim();
1070+
if (!leadId) {
1071+
const err: any = new Error('leadId is required');
1072+
err.status = 400;
1073+
err.code = 'INVALID_REQUEST';
1074+
throw err;
1075+
}
1076+
const ctx = request.context;
1077+
const ctxOpt = ctx !== undefined ? { context: ctx } : undefined;
1078+
1079+
// Load lead
1080+
const lead = await this.engine.findOne('lead', { where: { id: leadId }, ...(ctxOpt as any) } as any);
1081+
if (!lead) {
1082+
const err: any = new Error(`Lead '${leadId}' not found`);
1083+
err.status = 404;
1084+
err.code = 'LEAD_NOT_FOUND';
1085+
throw err;
1086+
}
1087+
if (lead.is_converted) {
1088+
const err: any = new Error(`Lead '${leadId}' is already converted`);
1089+
err.status = 409;
1090+
err.code = 'LEAD_ALREADY_CONVERTED';
1091+
throw err;
1092+
}
1093+
1094+
const createdAccountIds: string[] = [];
1095+
const createdContactIds: string[] = [];
1096+
const createdOpportunityIds: string[] = [];
1097+
1098+
const rollback = async () => {
1099+
for (const id of createdOpportunityIds) {
1100+
try { await this.engine.delete('opportunity', { where: { id } } as any); } catch { /* ignore */ }
1101+
}
1102+
for (const id of createdContactIds) {
1103+
try { await this.engine.delete('contact', { where: { id } } as any); } catch { /* ignore */ }
1104+
}
1105+
for (const id of createdAccountIds) {
1106+
try { await this.engine.delete('account', { where: { id } } as any); } catch { /* ignore */ }
1107+
}
1108+
};
1109+
1110+
try {
1111+
// 1) Account
1112+
let account: any;
1113+
if (request.accountId) {
1114+
account = await this.engine.findOne('account', { where: { id: request.accountId }, ...(ctxOpt as any) } as any);
1115+
if (!account) {
1116+
const err: any = new Error(`Account '${request.accountId}' not found`);
1117+
err.status = 404;
1118+
err.code = 'ACCOUNT_NOT_FOUND';
1119+
throw err;
1120+
}
1121+
} else {
1122+
const accountPayload: Record<string, any> = {
1123+
name: lead.company || `${lead.first_name ?? ''} ${lead.last_name ?? ''}`.trim() || 'Untitled Account',
1124+
};
1125+
if (lead.industry) accountPayload.industry = lead.industry;
1126+
if (lead.annual_revenue) accountPayload.annual_revenue = lead.annual_revenue;
1127+
if (lead.number_of_employees) accountPayload.employees = lead.number_of_employees;
1128+
if (lead.website) accountPayload.website = lead.website;
1129+
if (lead.phone) accountPayload.phone = lead.phone;
1130+
if (lead.address) accountPayload.billing_address = lead.address;
1131+
if (lead.owner) accountPayload.owner = lead.owner;
1132+
account = await this.engine.insert('account', accountPayload, ctxOpt as any);
1133+
if (account?.id) createdAccountIds.push(account.id);
1134+
}
1135+
1136+
// 2) Contact
1137+
let contact: any;
1138+
if (request.contactId) {
1139+
contact = await this.engine.findOne('contact', { where: { id: request.contactId }, ...(ctxOpt as any) } as any);
1140+
if (!contact) {
1141+
const err: any = new Error(`Contact '${request.contactId}' not found`);
1142+
err.status = 404;
1143+
err.code = 'CONTACT_NOT_FOUND';
1144+
throw err;
1145+
}
1146+
} else {
1147+
const contactPayload: Record<string, any> = {
1148+
first_name: lead.first_name ?? '',
1149+
last_name: lead.last_name ?? lead.company ?? 'Unknown',
1150+
};
1151+
if (lead.salutation) contactPayload.salutation = lead.salutation;
1152+
if (lead.email) contactPayload.email = lead.email;
1153+
if (lead.phone) contactPayload.phone = lead.phone;
1154+
if (lead.mobile) contactPayload.mobile = lead.mobile;
1155+
if (lead.title) contactPayload.title = lead.title;
1156+
if (lead.address) contactPayload.mailing_address = lead.address;
1157+
if (lead.owner) contactPayload.owner = lead.owner;
1158+
if (account?.id) contactPayload.account = account.id;
1159+
contact = await this.engine.insert('contact', contactPayload, ctxOpt as any);
1160+
if (contact?.id) createdContactIds.push(contact.id);
1161+
}
1162+
1163+
// 3) Opportunity (optional)
1164+
let opportunity: any | null = null;
1165+
const shouldCreateOpp = request.createOpportunity !== false;
1166+
if (shouldCreateOpp) {
1167+
const oppOverrides = request.opportunity ?? {};
1168+
const defaultName = oppOverrides.name
1169+
|| `${account?.name ?? lead.company ?? 'Lead'} - New Opportunity`;
1170+
const defaultClose = oppOverrides.close_date
1171+
|| new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
1172+
const oppPayload: Record<string, any> = {
1173+
name: defaultName,
1174+
stage: oppOverrides.stage ?? 'qualification',
1175+
close_date: defaultClose,
1176+
};
1177+
if (oppOverrides.amount !== undefined) oppPayload.amount = oppOverrides.amount;
1178+
else if (lead.annual_revenue) oppPayload.amount = lead.annual_revenue;
1179+
if (account?.id) oppPayload.account = account.id;
1180+
if (contact?.id) oppPayload.primary_contact = contact.id;
1181+
if (lead.owner) oppPayload.owner = lead.owner;
1182+
if (lead.lead_source) oppPayload.lead_source = lead.lead_source;
1183+
opportunity = await this.engine.insert('opportunity', oppPayload, ctxOpt as any);
1184+
if (opportunity?.id) createdOpportunityIds.push(opportunity.id);
1185+
}
1186+
1187+
// 4) Mark lead converted
1188+
const leadUpdate: Record<string, any> = {
1189+
is_converted: true,
1190+
status: request.convertedStatus ?? 'converted',
1191+
converted_account: account?.id ?? null,
1192+
converted_contact: contact?.id ?? null,
1193+
converted_opportunity: opportunity?.id ?? null,
1194+
converted_date: new Date().toISOString(),
1195+
};
1196+
const updatedLead = await this.engine.update('lead', leadUpdate, {
1197+
where: { id: leadId },
1198+
...(ctxOpt as any),
1199+
} as any);
1200+
1201+
return {
1202+
lead: updatedLead ?? { ...lead, ...leadUpdate },
1203+
account,
1204+
contact,
1205+
opportunity,
1206+
};
1207+
} catch (err) {
1208+
await rollback();
1209+
throw err;
1210+
}
1211+
}
1212+
10231213
// ==========================================
10241214
// Metadata Caching
10251215
// ==========================================

packages/rest/src/rest-server.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,7 @@ export class RestServer {
829829
if (this.config.api.enableSearch ?? true) {
830830
this.registerSearchEndpoints(bp);
831831
}
832+
this.registerDataActionEndpoints(bp);
832833
if (this.config.api.enableBatch) {
833834
this.registerBatchEndpoints(bp);
834835
}
@@ -1415,6 +1416,57 @@ export class RestServer {
14151416
}
14161417
}
14171418

1419+
/**
1420+
* Register object-specific action endpoints that don't fit the
1421+
* generic CRUD shape. These are domain operations (Salesforce
1422+
* convertLead, etc.) where the protocol implementation does its own
1423+
* multi-record orchestration and we just need a thin HTTP route.
1424+
*
1425+
* POST {basePath}/data/lead/:id/convert — M10.6 lead conversion.
1426+
*/
1427+
private registerDataActionEndpoints(basePath: string): void {
1428+
const isScoped = basePath.includes('/projects/:projectId');
1429+
const { crud } = this.config;
1430+
const dataPath = `${basePath}${crud.dataPrefix}`;
1431+
1432+
// POST /data/lead/:id/convert
1433+
this.routeManager.register({
1434+
method: 'POST',
1435+
path: `${dataPath}/lead/:id/convert`,
1436+
handler: async (req: any, res: any) => {
1437+
try {
1438+
const projectId = isScoped ? req.params?.projectId : undefined;
1439+
const p = await this.resolveProtocol(projectId, req);
1440+
const context = await this.resolveExecCtx(projectId, req);
1441+
if (this.enforceAuth(req, res, context)) return;
1442+
const convertLead = (p as any).convertLead;
1443+
if (typeof convertLead !== 'function') {
1444+
res.status(501).json({ code: 'NOT_IMPLEMENTED', error: 'Lead convert not supported by this protocol' });
1445+
return;
1446+
}
1447+
const body = req.body ?? {};
1448+
const result = await convertLead.call(p, {
1449+
leadId: req.params.id,
1450+
accountId: body.accountId,
1451+
contactId: body.contactId,
1452+
createOpportunity: body.createOpportunity,
1453+
opportunity: body.opportunity,
1454+
convertedStatus: body.convertedStatus,
1455+
...(context ? { context } : {}),
1456+
});
1457+
res.json(result);
1458+
} catch (error: any) {
1459+
logError('[REST] Unhandled error:', error);
1460+
sendError(res, error, 'lead');
1461+
}
1462+
},
1463+
metadata: {
1464+
summary: 'Convert a Lead into Account + Contact (+ optional Opportunity)',
1465+
tags: ['data', 'lead'],
1466+
},
1467+
});
1468+
}
1469+
14181470
/**
14191471
* Register global cross-object search endpoint (M10.5).
14201472
* GET {basePath}/search?q=acme&objects=lead,account&limit=20&perObject=5

0 commit comments

Comments
 (0)