import { ACCOUNT_SIDES, AFFILIATION_KINDS, CUSTOMER_SEGMENTS, DEMAND_STAGES, INTERCONNECT_TYPES, PRODUCT_LINES, SUPPLIER_TYPES, SUPPLY_STAGES, permissionGranted, type AccountSide, type Team, } from '@pig/core'; import type { Database } from '@pig/db'; import { accounts, agentTasks, contacts, demandDeals, sites, supplyDeals } from '@pig/db'; import { desc, eq } from 'drizzle-orm'; import { Hono } from 'hono'; import { z } from 'zod'; import { AuthError, effectivePermissions, type Principal } from '../lib/auth'; import { MutationError, mutation, type ApiEnv, type MutationDefinition } from '../lib/mutation'; import type { NotificationOutbox } from '../services/notification-outbox'; type Transaction = Parameters[0]>[0]; const requiredText = (maximum: number) => z.string().trim().min(1).max(maximum); const nullableText = (maximum: number) => z.string().trim().max(maximum).nullable().optional(); const nullableUrl = z.string().trim().url().max(400).nullable().optional(); const nullableUuid = z.string().uuid().nullable().optional(); const nullableDate = z .union([z.string().date(), z.string().datetime()]) .nullable() .optional() .transform((value) => { if (!value) return value === null ? null : undefined; return new Date(value.length === 10 ? `${value}T12:00:00.000Z` : value); }); const nullablePositiveInteger = z.number().int().positive().nullable().optional(); const nullableNonnegativeInteger = z.number().int().nonnegative().nullable().optional(); const accountFields = { name: requiredText(200), domain: nullableText(200).transform((value) => value?.toLowerCase() || value), website: nullableUrl, description: nullableText(4000), side: z.enum(ACCOUNT_SIDES), supplierType: z.enum(SUPPLIER_TYPES).nullable().optional(), customerSegment: z.enum(CUSTOMER_SEGMENTS).nullable().optional(), country: nullableText(100), region: nullableText(100), jurisdiction: nullableText(100), ultimateParentName: nullableText(200), ultimateParentCountry: nullableText(100), }; const accountCreateSchema = z.object(accountFields).strict(); const accountUpdateSchema = z.object(accountFields).partial().strict(); const contactFields = { accountId: z.string().uuid(), fullName: requiredText(200), firstName: nullableText(100), lastName: nullableText(100), title: nullableText(200), email: z.string().trim().email().max(320).nullable().optional(), phone: nullableText(80), linkedinUrl: nullableUrl, twitterHandle: nullableText(100), githubHandle: nullableText(100), websiteUrl: nullableUrl, affiliation: z.enum(AFFILIATION_KINDS), isDecisionMaker: z.boolean(), confidenceNote: nullableText(2000), }; const contactCreateSchema = z.object(contactFields).strict(); const contactUpdateSchema = z.object(contactFields).partial().strict(); const demandDealFields = { accountId: z.string().uuid(), name: requiredText(240), description: nullableText(4000), productLine: z.enum(PRODUCT_LINES), stage: z.enum(DEMAND_STAGES), primaryContactId: nullableUuid, acvCents: nullableNonnegativeInteger, tcvCents: nullableNonnegativeInteger, currency: z.string().trim().toUpperCase().regex(/^[A-Z]{3}$/), termMonths: nullablePositiveInteger, probability: z.number().min(0).max(1).nullable().optional(), expectedCloseDate: nullableDate, closedReason: nullableText(2000), msaExecuted: z.boolean(), dpaExecuted: z.boolean(), parentDealId: nullableUuid, }; const demandDealCreateSchema = z.object(demandDealFields).strict(); const demandDealUpdateSchema = z.object(demandDealFields).partial().strict(); const supplyDealFields = { accountId: z.string().uuid(), siteId: nullableUuid, name: requiredText(240), stage: z.enum(SUPPLY_STAGES), primaryContactId: nullableUuid, gpuType: nullableText(100), gpuCount: nullablePositiveInteger, interconnectType: z.enum(INTERCONNECT_TYPES).nullable().optional(), targetCostPerGpuHourCents: nullableNonnegativeInteger, termMonths: nullablePositiveInteger, availableFrom: nullableDate, technicalVerdict: nullableText(500), technicalNotes: nullableText(4000), financialVerdict: nullableText(500), financialNotes: nullableText(4000), rejectionReason: nullableText(2000), }; const supplyDealCreateSchema = z.object(supplyDealFields).strict(); const supplyDealUpdateSchema = z.object(supplyDealFields).partial().strict(); export function accountSupportsTeam(side: AccountSide, team: Team): boolean { return team !== 'research' && (side === 'both' || side === team); } function hasDealPermission(principal: Principal, team: 'supply' | 'demand'): boolean { return permissionGranted(effectivePermissions(principal), 'deal:write', team); } function requireAnyDealPermission(principal: Principal): void { if (hasDealPermission(principal, 'supply') || hasDealPermission(principal, 'demand')) return; throw new AuthError( "This principal lacks the 'deal:write' capability for supply or demand.", 403, 'insufficient_permission', ); } function requireAccountPermission(principal: Principal, side: AccountSide): void { if ( (accountSupportsTeam(side, 'supply') && hasDealPermission(principal, 'supply')) || (accountSupportsTeam(side, 'demand') && hasDealPermission(principal, 'demand')) ) { return; } throw new AuthError( `This principal cannot write ${side}-side records.`, 403, 'insufficient_permission', ); } async function findAccount(tx: Transaction, id: string) { const [account] = await tx.select().from(accounts).where(eq(accounts.id, id)).limit(1); if (!account) throw MutationError.notFound('Account'); return account; } async function requireAccountForTeam(tx: Transaction, id: string, team: 'supply' | 'demand') { const account = await findAccount(tx, id); if (!accountSupportsTeam(account.side, team)) { throw new MutationError( 'relationship_mismatch', `The selected account is not on the ${team} side.`, 409, ); } return account; } async function requireContactForAccount( tx: Transaction, contactId: string | null | undefined, accountId: string, ): Promise { if (!contactId) return; const [contact] = await tx.select().from(contacts).where(eq(contacts.id, contactId)).limit(1); if (!contact) throw MutationError.notFound('Contact'); if (contact.accountId !== accountId) { throw new MutationError( 'relationship_mismatch', 'The primary contact must belong to the selected account.', 409, ); } } async function touchAccount(tx: Transaction, accountId: string, now: Date): Promise { await tx .update(accounts) .set({ lastActivityAt: now, updatedAt: now }) .where(eq(accounts.id, accountId)); } const anyDealPermission = { authorize: requireAnyDealPermission } as const; export function createAccountMutationDefinition(): MutationDefinition< typeof accountCreateSchema, typeof accounts.$inferSelect > { return { schema: accountCreateSchema, permission: anyDealPermission, invalidMessage: 'Invalid account.', async mutate({ input, principal, tx, now }) { requireAccountPermission(principal, input.side); const [created] = await tx .insert(accounts) .values({ ...input, ownerUserId: principal.userId, source: 'manual', lastActivityAt: now, updatedAt: now, }) .returning(); if (!created) throw new MutationError('write_failed', 'Account was not created.', 409); await tx.insert(agentTasks).values({ kind: 'enrich_account', subject: created.id, reason: `New account created by ${principal.name}`, requestedByUserId: principal.userId, }); return { data: created, activity: { type: 'note', subject: `Created account — ${created.name}`, accountId: created.id, meta: { action: 'created', recordType: 'account', side: created.side }, }, }; }, }; } export function updateAccountMutationDefinition(): MutationDefinition< typeof accountUpdateSchema, typeof accounts.$inferSelect > { return { schema: accountUpdateSchema, permission: anyDealPermission, invalidMessage: 'Invalid account update.', async mutate({ input, principal, params, tx, now }) { if (!params.id) throw MutationError.notFound('Account'); const before = await findAccount(tx, params.id); const nextSide = input.side ?? before.side; // Both checks matter when a record crosses sides: access to the source // record must not grant permission to move it into another team's book. requireAccountPermission(principal, before.side); requireAccountPermission(principal, nextSide); const [updated] = await tx .update(accounts) .set({ ...input, updatedAt: now, lastActivityAt: now }) .where(eq(accounts.id, before.id)) .returning(); if (!updated) throw MutationError.notFound('Account'); return { data: updated, activity: { type: 'note', subject: `Updated account — ${updated.name}`, accountId: updated.id, meta: { action: 'updated', recordType: 'account', fromSide: before.side, side: updated.side }, }, }; }, }; } export function createContactMutationDefinition(): MutationDefinition< typeof contactCreateSchema, typeof contacts.$inferSelect > { return { schema: contactCreateSchema, permission: anyDealPermission, invalidMessage: 'Invalid contact.', async mutate({ input, principal, tx, now }) { const account = await findAccount(tx, input.accountId); requireAccountPermission(principal, account.side); const [created] = await tx .insert(contacts) .values({ ...input, ownerUserId: principal.userId, source: 'manual', lastActivityAt: now, updatedAt: now, }) .returning(); if (!created) throw new MutationError('write_failed', 'Contact was not created.', 409); await touchAccount(tx, account.id, now); await tx.insert(agentTasks).values({ kind: 'enrich_contact', subject: created.id, reason: `New contact created by ${principal.name}`, requestedByUserId: principal.userId, }); return { data: created, activity: { type: 'note', subject: `Created contact — ${created.fullName}`, accountId: account.id, contactId: created.id, meta: { action: 'created', recordType: 'contact', side: account.side }, }, }; }, }; } function updateContactMutationDefinition(): MutationDefinition< typeof contactUpdateSchema, typeof contacts.$inferSelect > { return { schema: contactUpdateSchema, permission: anyDealPermission, invalidMessage: 'Invalid contact update.', async mutate({ input, principal, params, tx, now }) { if (!params.id) throw MutationError.notFound('Contact'); const [before] = await tx.select().from(contacts).where(eq(contacts.id, params.id)).limit(1); if (!before) throw MutationError.notFound('Contact'); if (!before.accountId && !input.accountId) { throw new MutationError( 'relationship_required', 'Select an account before editing this contact.', 409, ); } if (before.accountId) { const beforeAccount = await findAccount(tx, before.accountId); requireAccountPermission(principal, beforeAccount.side); } const account = await findAccount(tx, input.accountId ?? before.accountId!); requireAccountPermission(principal, account.side); const [updated] = await tx .update(contacts) .set({ ...input, updatedAt: now, lastActivityAt: now }) .where(eq(contacts.id, before.id)) .returning(); if (!updated) throw MutationError.notFound('Contact'); await touchAccount(tx, account.id, now); return { data: updated, activity: { type: 'note', subject: `Updated contact — ${updated.fullName}`, accountId: account.id, contactId: updated.id, meta: { action: 'updated', recordType: 'contact', side: account.side }, }, }; }, }; } export function createDemandDealMutationDefinition(): MutationDefinition< typeof demandDealCreateSchema, typeof demandDeals.$inferSelect > { return { schema: demandDealCreateSchema, permission: { capability: 'deal:write', team: 'demand' }, invalidMessage: 'Invalid demand deal.', async mutate({ input, principal, tx, now }) { const account = await requireAccountForTeam(tx, input.accountId, 'demand'); await requireContactForAccount(tx, input.primaryContactId, account.id); if (input.parentDealId) { const [parent] = await tx .select() .from(demandDeals) .where(eq(demandDeals.id, input.parentDealId)) .limit(1); if (!parent) throw MutationError.notFound('Parent demand deal'); if (parent.accountId !== account.id) { throw new MutationError( 'relationship_mismatch', 'A parent deal must belong to the selected account.', 409, ); } } const closedAt = ['closed_won', 'closed_lost'].includes(input.stage) ? now : null; const probability = input.probability === undefined ? undefined : input.probability === null ? null : String(input.probability); const [created] = await tx .insert(demandDeals) .values({ ...input, probability, ownerUserId: principal.userId, stageChangedAt: now, closedAt, lastActivityAt: now, updatedAt: now, }) .returning(); if (!created) throw new MutationError('write_failed', 'Demand deal was not created.', 409); await touchAccount(tx, account.id, now); return { data: created, activity: { type: 'note', subject: `Created demand deal — ${created.name}`, accountId: account.id, demandDealId: created.id, meta: { action: 'created', recordType: 'demand_deal', stage: created.stage }, }, }; }, }; } export function updateDemandDealMutationDefinition(notifications?: NotificationOutbox): MutationDefinition< typeof demandDealUpdateSchema, typeof demandDeals.$inferSelect > { return { schema: demandDealUpdateSchema, permission: { capability: 'deal:write', team: 'demand' }, invalidMessage: 'Invalid demand deal update.', async mutate({ input, params, tx, now }) { if (!params.id) throw MutationError.notFound('Demand deal'); const [before] = await tx .select() .from(demandDeals) .where(eq(demandDeals.id, params.id)) .limit(1); if (!before) throw MutationError.notFound('Demand deal'); const accountId = input.accountId ?? before.accountId; const account = await requireAccountForTeam(tx, accountId, 'demand'); const contactId = input.primaryContactId === undefined ? before.primaryContactId : input.primaryContactId; await requireContactForAccount(tx, contactId, account.id); const parentId = input.parentDealId === undefined ? before.parentDealId : input.parentDealId; if (parentId) { if (parentId === before.id) { throw new MutationError('relationship_mismatch', 'A deal cannot be its own parent.', 409); } const [parent] = await tx.select().from(demandDeals).where(eq(demandDeals.id, parentId)).limit(1); if (!parent) throw MutationError.notFound('Parent demand deal'); if (parent.accountId !== account.id) { throw new MutationError( 'relationship_mismatch', 'A parent deal must belong to the selected account.', 409, ); } } const stage = input.stage ?? before.stage; const stageChanged = stage !== before.stage; const probability = input.probability === undefined ? undefined : input.probability === null ? null : String(input.probability); const closedAt = stageChanged ? ['closed_won', 'closed_lost'].includes(stage) ? now : null : before.closedAt; const [updated] = await tx .update(demandDeals) .set({ ...input, probability, stageChangedAt: stageChanged ? now : before.stageChangedAt, closedAt, updatedAt: now, lastActivityAt: now, }) .where(eq(demandDeals.id, before.id)) .returning(); if (!updated) throw MutationError.notFound('Demand deal'); await touchAccount(tx, account.id, now); if (stageChanged) { await notifications?.enqueueStageChange(tx, { accountId: account.id, dealId: updated.id, dealSide: 'demand', dealName: updated.name, fromStage: before.stage, toStage: updated.stage, changedAt: now.toISOString(), }); } return { data: updated, activity: { type: stageChanged ? 'stage_change' : 'note', subject: stageChanged ? `${before.stage} → ${updated.stage}` : `Updated demand deal — ${updated.name}`, accountId: account.id, demandDealId: updated.id, meta: { action: 'updated', recordType: 'demand_deal', fromStage: before.stage, stage: updated.stage, }, }, }; }, }; } function createSupplyDealMutationDefinition(): MutationDefinition< typeof supplyDealCreateSchema, typeof supplyDeals.$inferSelect > { return { schema: supplyDealCreateSchema, permission: { capability: 'deal:write', team: 'supply' }, invalidMessage: 'Invalid supply deal.', async mutate({ input, principal, tx, now }) { const account = await requireAccountForTeam(tx, input.accountId, 'supply'); await requireContactForAccount(tx, input.primaryContactId, account.id); if (input.siteId) { const [site] = await tx.select().from(sites).where(eq(sites.id, input.siteId)).limit(1); if (!site) throw MutationError.notFound('Site'); if (site.accountId !== account.id) { throw new MutationError( 'relationship_mismatch', 'The selected site must belong to the selected account.', 409, ); } } const closedAt = ['live', 'churned', 'rejected'].includes(input.stage) ? now : null; const [created] = await tx .insert(supplyDeals) .values({ ...input, ownerUserId: principal.userId, stageChangedAt: now, closedAt, lastActivityAt: now, updatedAt: now, }) .returning(); if (!created) throw new MutationError('write_failed', 'Supply deal was not created.', 409); await touchAccount(tx, account.id, now); return { data: created, activity: { type: 'note', subject: `Created supply deal — ${created.name}`, accountId: account.id, supplyDealId: created.id, meta: { action: 'created', recordType: 'supply_deal', stage: created.stage }, }, }; }, }; } export function updateSupplyDealMutationDefinition(notifications?: NotificationOutbox): MutationDefinition< typeof supplyDealUpdateSchema, typeof supplyDeals.$inferSelect > { return { schema: supplyDealUpdateSchema, permission: { capability: 'deal:write', team: 'supply' }, invalidMessage: 'Invalid supply deal update.', async mutate({ input, principal, params, tx, now }) { if (!params.id) throw MutationError.notFound('Supply deal'); const [before] = await tx.select().from(supplyDeals).where(eq(supplyDeals.id, params.id)).limit(1); if (!before) throw MutationError.notFound('Supply deal'); const accountId = input.accountId ?? before.accountId; const account = await requireAccountForTeam(tx, accountId, 'supply'); const contactId = input.primaryContactId === undefined ? before.primaryContactId : input.primaryContactId; await requireContactForAccount(tx, contactId, account.id); const siteId = input.siteId === undefined ? before.siteId : input.siteId; if (siteId) { const [site] = await tx.select().from(sites).where(eq(sites.id, siteId)).limit(1); if (!site) throw MutationError.notFound('Site'); if (site.accountId !== account.id) { throw new MutationError( 'relationship_mismatch', 'The selected site must belong to the selected account.', 409, ); } } const stage = input.stage ?? before.stage; const stageChanged = stage !== before.stage; const closedAt = stageChanged ? ['live', 'churned', 'rejected'].includes(stage) ? now : null : before.closedAt; const technicalVerdictChanged = input.technicalVerdict !== undefined && input.technicalVerdict !== before.technicalVerdict; const financialVerdictChanged = input.financialVerdict !== undefined && input.financialVerdict !== before.financialVerdict; const verdictUpdate = { technicalVerdictBy: technicalVerdictChanged ? input.technicalVerdict === null ? null : principal.userId : undefined, technicalVerdictAt: technicalVerdictChanged ? input.technicalVerdict === null ? null : now : undefined, financialVerdictBy: financialVerdictChanged ? input.financialVerdict === null ? null : principal.userId : undefined, financialVerdictAt: financialVerdictChanged ? input.financialVerdict === null ? null : now : undefined, }; const [updated] = await tx .update(supplyDeals) .set({ ...input, ...verdictUpdate, stageChangedAt: stageChanged ? now : before.stageChangedAt, closedAt, updatedAt: now, lastActivityAt: now, }) .where(eq(supplyDeals.id, before.id)) .returning(); if (!updated) throw MutationError.notFound('Supply deal'); await touchAccount(tx, account.id, now); if (stageChanged) { await notifications?.enqueueStageChange(tx, { accountId: account.id, dealId: updated.id, dealSide: 'supply', dealName: updated.name, fromStage: before.stage, toStage: updated.stage, changedAt: now.toISOString(), }); } return { data: updated, activity: { type: stageChanged ? 'stage_change' : 'note', subject: stageChanged ? `${before.stage} → ${updated.stage}` : `Updated supply deal — ${updated.name}`, accountId: account.id, supplyDealId: updated.id, meta: { action: 'updated', recordType: 'supply_deal', fromStage: before.stage, stage: updated.stage, }, }, }; }, }; } export function createRecordRoutes(db: Database, notifications?: NotificationOutbox): Hono { const app = new Hono(); app.get('/api/contacts', async (c) => { const accountId = c.req.query('accountId'); const rows = await db .select({ contact: contacts, accountName: accounts.name, accountSide: accounts.side }) .from(contacts) .leftJoin(accounts, eq(accounts.id, contacts.accountId)) .where(accountId ? eq(contacts.accountId, accountId) : undefined) .orderBy(desc(contacts.lastActivityAt), contacts.fullName) .limit(500); return c.json(rows); }); app.post('/api/accounts', mutation(db, createAccountMutationDefinition())); app.patch('/api/accounts/:id', mutation(db, updateAccountMutationDefinition())); app.post('/api/contacts', mutation(db, createContactMutationDefinition())); app.patch('/api/contacts/:id', mutation(db, updateContactMutationDefinition())); app.post('/api/deals/demand', mutation(db, createDemandDealMutationDefinition())); app.patch('/api/deals/demand/:id', mutation(db, updateDemandDealMutationDefinition(notifications))); app.post('/api/deals/supply', mutation(db, createSupplyDealMutationDefinition())); app.patch('/api/deals/supply/:id', mutation(db, updateSupplyDealMutationDefinition(notifications))); return app; }