This commit is contained in:
@@ -0,0 +1,711 @@
|
||||
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<Parameters<Database['transaction']>[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<void> {
|
||||
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<void> {
|
||||
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 },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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<ApiEnv> {
|
||||
const app = new Hono<ApiEnv>();
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user