Files
pig/packages/db/src/schema/crm.ts
T
2026-08-13 01:39:01 -07:00

255 lines
10 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(),
/** Slack channel ids are unique only inside a workspace. Buzz uses ''. */
workspaceId: text('workspace_id').notNull().default(''),
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([]),
linkedByUserId: uuid('linked_by_user_id').references(() => users.id, {
onDelete: 'set null',
}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('channel_links_platform_workspace_channel_key').on(
t.platform,
t.workspaceId,
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;