/** * 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>(), /** 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().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;