255 lines
10 KiB
TypeScript
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;
|