Files
pig/packages/db/src/schema/crm.ts
T
karti d36762f264 Scaffold PIG and model the compute-GTM ontology
PIG is an agent-native CRM for two-sided AI-compute companies: businesses
that buy GPU capacity from providers and resell it. Their business is the
spread between two pipelines, which is precisely what a generic CRM cannot
represent.

The load-bearing decision is the `allocations` table, joining a
capacity_commitment (what we bought, at a known cost) to a demand_deal
(what we sold, at a known price). Margin, utilisation and idle capacity all
fall out of that one join. Cost is charged against the full commitment
rather than only the hours that sold, because unsold hours are already paid
for and any other treatment flatters a block that is losing money.

Domain decisions worth noting, each grounded in how this market operates:

- Demand stages put `legal` second, not last. Customers do not hand
  workloads to an infrastructure provider before paper is executed.
- Supply qualification splits technical from financial diligence, recorded
  attributably. Accepting capacity is a two-key decision.
- Capacity carries a time SHAPE (intervals + quantities), not a window.
  Commitments ramp and step down; a rectangle reports availability that
  does not exist in the month someone wants it.
- SLAs model three distinct shapes: none, a reliability tier plus credits
  policy, and a negotiated agreement. Aggregators generally cannot promise
  uptime on resold capacity, but negotiate heavyweight paper upstream.
  Remedies include fee abatement, which is materially better than a capped
  credit and is not expressible as one.
- Export control is a predicate on the allocation edge, evaluated against
  the ULTIMATE parent's jurisdiction. Country of incorporation is not a
  valid key, so this cannot live as a flag on an account.
- Agent-derived claims land in `facts` with a confidence band and evidence.
  Only verified claims self-apply; weaker ones await review.
- The API never calls the agent. It writes to a leased queue, guarded by a
  partial unique index on unfinished work.

Verified: typechecks clean, migration generates and applies to Postgres 16
(31 tables, 24 enums, 117 indexes).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:41:41 -07:00

245 lines
9.6 KiB
TypeScript

/**
* The ordinary CRM core: accounts, contacts, activities.
*
* Deliberately unremarkable. The compute-specific intelligence lives in
* `supply.ts`, `demand.ts` and `allocations.ts`; this file exists so that those
* have something conventional to hang from, and so importing from an existing
* CRM is a straight mapping.
*/
import {
boolean,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from 'drizzle-orm/pg-core';
import {
accountSideEnum,
activityTypeEnum,
affiliationKindEnum,
confidenceGradeEnum,
customerSegmentEnum,
recordSourceEnum,
supplierTypeEnum,
} from './enums';
import { users } from './identity';
export const accounts = pgTable(
'accounts',
{
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
/** Primary key for deduplication in practice; more reliable than name. */
domain: text('domain'),
website: text('website'),
description: text('description'),
logoUrl: text('logo_url'),
/**
* Which side of the market this account sits on. `both` is common and
* correct — a neocloud may sell capacity to us and buy managed training
* from us in the same quarter.
*/
side: accountSideEnum('side').notNull().default('demand'),
supplierType: supplierTypeEnum('supplier_type'),
customerSegment: customerSegmentEnum('customer_segment'),
/** Headquarters. Distinct from where their capacity physically sits. */
country: text('country'),
region: text('region'),
/**
* Legal jurisdiction governing the relationship. Export controls make this
* a commercial fact rather than an administrative one — some capacity
* cannot lawfully serve some customers.
*/
jurisdiction: text('jurisdiction'),
/**
* Corporate ownership — and specifically the ULTIMATE parent.
*
* US export controls on advanced computing apply a headquarters test that
* reaches through the corporate tree: an entity may be restricted because
* of where its ultimate parent sits, **even though the entity itself is
* located somewhere unrestricted**. So the country on this record is not
* sufficient to determine whether a sale is lawful, and a CRM that stores
* only `country` cannot answer the question at all.
*
* See compliance.ts, where the determination is made per allocation.
*/
ultimateParentAccountId: uuid('ultimate_parent_account_id'),
ultimateParentName: text('ultimate_parent_name'),
ultimateParentCountry: text('ultimate_parent_country'),
/** When ownership was last actually verified, rather than assumed. */
ownershipVerifiedAt: timestamp('ownership_verified_at', { withTimezone: true }),
linkedinUrl: text('linkedin_url'),
twitterUrl: text('twitter_url'),
githubOrg: text('github_org'),
ownerUserId: uuid('owner_user_id').references(() => users.id, { onDelete: 'set null' }),
source: recordSourceEnum('source').notNull().default('manual'),
/** Where a seeded or enriched claim came from. Shown in the UI. */
sourceUrl: text('source_url'),
confidence: confidenceGradeEnum('confidence').notNull().default('confirmed'),
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('accounts_domain_key').on(t.domain),
index('accounts_side_idx').on(t.side),
index('accounts_owner_idx').on(t.ownerUserId),
index('accounts_name_idx').on(t.name),
],
);
export const contacts = pgTable(
'contacts',
{
id: uuid('id').primaryKey().defaultRandom(),
accountId: uuid('account_id').references(() => accounts.id, { onDelete: 'set null' }),
fullName: text('full_name').notNull(),
firstName: text('first_name'),
lastName: text('last_name'),
title: text('title'),
/**
* Nullable and left null far more often than a generic CRM would expect.
* PIG never infers an address from a name and a domain: a guessed address
* is both unreliable and, when it reaches a real person, rude.
*/
email: text('email'),
phone: text('phone'),
linkedinUrl: text('linkedin_url'),
twitterHandle: text('twitter_handle'),
githubHandle: text('github_handle'),
websiteUrl: text('website_url'),
avatarUrl: text('avatar_url'),
/**
* How this person relates to the account. Being named in a company's
* repository or on its papers does not make someone an employee, and a CRM
* that conflates authorship with employment will embarrass whoever acts on
* it.
*/
affiliation: affiliationKindEnum('affiliation').notNull().default('unknown'),
/** Set when someone has demonstrably moved on. Keeps the record honest. */
departedAt: timestamp('departed_at', { withTimezone: true }),
/** Whether this person can decide, influence, or merely inform a deal. */
isDecisionMaker: boolean('is_decision_maker').notNull().default(false),
ownerUserId: uuid('owner_user_id').references(() => users.id, { onDelete: 'set null' }),
source: recordSourceEnum('source').notNull().default('manual'),
sourceUrl: text('source_url'),
confidence: confidenceGradeEnum('confidence').notNull().default('confirmed'),
/** Free-text note on provenance, shown beside low-confidence records. */
confidenceNote: text('confidence_note'),
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index('contacts_account_idx').on(t.accountId),
index('contacts_email_idx').on(t.email),
index('contacts_name_idx').on(t.fullName),
index('contacts_confidence_idx').on(t.confidence),
],
);
/**
* The activity stream. Polymorphic by nullable foreign key rather than a
* generic subject table: there are only four possible parents, Postgres can
* enforce all four, and the query planner handles the partial indexes well.
*/
export const activities = pgTable(
'activities',
{
id: uuid('id').primaryKey().defaultRandom(),
type: activityTypeEnum('type').notNull(),
subject: text('subject'),
body: text('body'),
accountId: uuid('account_id').references(() => accounts.id, { onDelete: 'cascade' }),
contactId: uuid('contact_id').references(() => contacts.id, { onDelete: 'cascade' }),
/** Set for demand-side deals; see demand.ts. */
demandDealId: uuid('demand_deal_id'),
/** Set for supply-side engagements; see supply.ts. */
supplyDealId: uuid('supply_deal_id'),
/** Null when the actor was Piggy rather than a person. */
actorUserId: uuid('actor_user_id').references(() => users.id, { onDelete: 'set null' }),
/** Set when an agent produced this entry, naming which one. */
actorAgent: text('actor_agent'),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().defaultNow(),
/** Structured payload — stage transitions, Slack permalinks, and so on. */
meta: jsonb('meta').$type<Record<string, unknown>>(),
/** External identity, for idempotent sync from Slack, Buzz or email. */
externalId: text('external_id'),
source: recordSourceEnum('source').notNull().default('manual'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index('activities_account_idx').on(t.accountId, t.occurredAt),
index('activities_contact_idx').on(t.contactId, t.occurredAt),
index('activities_demand_deal_idx').on(t.demandDealId, t.occurredAt),
index('activities_supply_deal_idx').on(t.supplyDealId, t.occurredAt),
uniqueIndex('activities_external_id_key').on(t.externalId),
],
);
/**
* Communication channels linked to an account — a Slack channel, a Buzz room.
* This is what lets a deal alert land where the deal is actually discussed
* rather than in a generic firehose nobody reads.
*/
export const channelLinks = pgTable(
'channel_links',
{
id: uuid('id').primaryKey().defaultRandom(),
/** 'slack' | 'buzz' — kept as text so a new platform needs no migration. */
platform: text('platform').notNull(),
channelId: text('channel_id').notNull(),
channelName: text('channel_name'),
accountId: uuid('account_id').references(() => accounts.id, { onDelete: 'cascade' }),
/** Notify on stage changes, idle-capacity alerts, renewals due. */
notifyOn: jsonb('notify_on').$type<string[]>().notNull().default([]),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('channel_links_platform_channel_key').on(t.platform, t.channelId),
index('channel_links_account_idx').on(t.accountId),
],
);
/** Denormalised counter, kept for cheap list rendering. */
export const accountStats = pgTable('account_stats', {
accountId: uuid('account_id')
.primaryKey()
.references(() => accounts.id, { onDelete: 'cascade' }),
openDeals: integer('open_deals').notNull().default(0),
contactCount: integer('contact_count').notNull().default(0),
refreshedAt: timestamp('refreshed_at', { withTimezone: true }).notNull().defaultNow(),
});
export type Account = typeof accounts.$inferSelect;
export type NewAccount = typeof accounts.$inferInsert;
export type Contact = typeof contacts.$inferSelect;
export type NewContact = typeof contacts.$inferInsert;
export type Activity = typeof activities.$inferSelect;