Files
selfphoneng/apps/api/src/db/schema.ts
T
iamdoubz 6f87d6f663 api: spam check settings, lookups and cache
SD1 (14 §9): the spam/lookups tables and calls columns, the
IPQualityScore and FTC lookups with the cache, the spam settings with
write-only keys, the provider Test routes and Settings → Spam check.
2026-10-27 15:32:31 -05:00

862 lines
26 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// Mirrors docs/spec/02-data-model.md §2 exactly. Column keys are camelCase; the db client and
// drizzle-kit both use `casing: 'snake_case'`, so `displayName` is the column `display_name`.
import { sql } from 'drizzle-orm';
import {
bigint,
bigserial,
boolean,
check,
customType,
date,
index,
inet,
integer,
jsonb,
pgEnum,
pgTable,
primaryKey,
smallint,
text,
timestamp,
unique,
uuid,
} from 'drizzle-orm/pg-core';
const citext = customType<{ data: string }>({ dataType: () => 'citext' });
const tstz = () => timestamp({ withTimezone: true });
const createdAt = () => tstz().notNull().defaultNow();
const timestamps = {
createdAt: createdAt(),
updatedAt: tstz()
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
};
const id = () => uuid().primaryKey().defaultRandom();
const emptyTextArray = () =>
text()
.array()
.notNull()
.default(sql`'{}'`);
// ───────────── settings ─────────────
export const settings = pgTable('settings', {
key: text().primaryKey(),
value: jsonb().notNull(),
...timestamps,
});
// ───────────── people and auth ─────────────
export const userRole = pgEnum('user_role', ['admin', 'user']);
export const users = pgTable('users', {
id: id(),
email: citext().notNull().unique(),
displayName: text().notNull(),
/** argon2id; null: no password, signs in with OpenID only (12 §3.3). */
passwordHash: text(),
role: userRole().notNull().default('user'),
isActive: boolean().notNull().default(true),
totpSecretEnc: text(),
/** sha256 of the unused recovery codes (08 §2.4). */
totpRecoveryHashes: emptyTextArray(),
/** The last 30 s step a code was accepted for: the same code can't sign in twice. */
totpLastStep: bigint({ mode: 'number' }),
mustChangePassword: boolean().notNull().default(false),
lastLoginAt: tstz(),
/** Contrack mode (11 §4.3): this person's own Contrack personal token, encrypted. */
contrackTokenEnc: text(),
/** 11 §4.4: my calls on my Contrack contacts' timelines, from contrackLogSince on. */
contrackLogCalls: boolean().notNull().default(true),
/** …with each recorded call's summary (10 §3). */
contrackLogSummaries: boolean().notNull().default(false),
contrackLogSince: tstz(),
...timestamps,
});
export const sessions = pgTable(
'sessions',
{
id: text().primaryKey(), // sha256(token) hex; raw token only in the cookie
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expiresAt: tstz().notNull(),
ip: inet(),
userAgent: text(),
/** How it was made (12 §2): password, ldap or oidc. */
method: text().notNull().default('password'),
/** OpenID sessions (12 §3.5): refresh token for the leaver check, ID token for signing out. */
idpRefreshEnc: text(),
idpIdTokenEnc: text(),
idpCheckedAt: tstz(),
...timestamps,
},
(t) => [
index().on(t.userId),
check('sessions_method_check', sql`${t.method} in ('password', 'ldap', 'oidc')`),
],
);
// One more way to sign in to a user (12 §2): a connected OpenID account or directory entry.
export const identityKind = pgEnum('identity_kind', ['oidc', 'ldap']);
export const userIdentities = pgTable(
'user_identities',
{
id: id(),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
kind: identityKind().notNull(),
/** oidc: "<iss> <sub>"; ldap: the entry's UUID attribute. */
subject: text().notNull(),
/** As the provider last said. */
email: text(),
/** oidc: groupsClaim values at the last sign-in (12 §3.4). */
groups: emptyTextArray(),
createdAt: createdAt(),
lastUsedAt: tstz(),
},
(t) => [unique().on(t.kind, t.subject), unique().on(t.userId, t.kind)],
);
export const apiTokens = pgTable('api_tokens', {
id: id(),
name: text().notNull(),
tokenPrefix: text().notNull(),
tokenHash: text().notNull().unique(),
createdBy: uuid()
.notNull()
.references(() => users.id),
lastUsedAt: tstz(),
revokedAt: tstz(),
...timestamps,
});
/** Paired phones (13 §3): a row waits with a pairing code, then holds the device token. */
export const mobileDevices = pgTable(
'mobile_devices',
{
id: id(),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text(),
platform: text(),
tokenPrefix: text(),
tokenHash: text().unique(),
pairCodeHash: text().unique(),
pairExpiresAt: tstz(),
lastUsedAt: tstz(),
...timestamps,
},
(t) => [
index().on(t.userId),
check('mobile_devices_platform_check', sql`${t.platform} in ('android', 'ios')`),
],
);
// ───────────── extensions ─────────────
export const outboundClass = pgEnum('outbound_class', [
'internal',
'local',
'national',
'international',
]);
export const recordMode = pgEnum('record_mode', ['never', 'inbound', 'outbound', 'all']);
export const callReport = pgEnum('call_report', ['off', 'hourly', 'daily', 'weekly']);
export const extensions = pgTable('extensions', {
id: id(),
number: text().notNull().unique(),
userId: uuid()
.unique()
.references(() => users.id, { onDelete: 'set null' }),
name: text().notNull(),
sipPasswordEnc: text().notNull(),
webrtcEnabled: boolean().notNull().default(true),
maxContacts: integer().notNull().default(5),
ringSeconds: integer().notNull().default(25),
voicemailEnabled: boolean().notNull().default(true),
voicemailPinEnc: text().notNull(),
voicemailEmail: boolean().notNull().default(true),
voicemailAttachAudio: boolean().notNull().default(true),
voicemailDeleteAfterEmail: boolean().notNull().default(false),
outboundClass: outboundClass().notNull().default('national'),
outboundCallerIdDidId: uuid().references(() => dids.id, { onDelete: 'set null' }),
emergencyCallerIdDidId: uuid().references(() => dids.id, { onDelete: 'set null' }),
emergencyLocation: text(),
recordMode: recordMode().notNull().default('never'),
pickupGroup: text(),
language: text().notNull().default('en'),
mohClass: text().notNull().default('default'),
blfNumbers: emptyTextArray(),
/** The call report email (05 §4): how often, at what local time (HH:MM) and weekday (0 = Sunday). */
callReport: callReport().notNull().default('off'),
callReportTime: text().notNull().default('08:00'),
callReportWeekday: integer().notNull().default(1),
callReportSummaries: boolean().notNull().default(false),
/** The end of the last period reported on (or when the schedule last changed). */
callReportSentAt: tstz(),
...timestamps,
});
export const userRuntimeState = pgTable('user_runtime_state', {
extensionId: uuid()
.primaryKey()
.references(() => extensions.id, { onDelete: 'cascade' }),
dnd: boolean().notNull().default(false),
cfu: text(),
cfb: text(),
cfna: text(),
...timestamps,
});
// ───────────── trunks and numbers ─────────────
export const trunkProvider = pgEnum('trunk_provider', ['voipms', 'generic']);
export const sipTransport = pgEnum('sip_transport', ['udp', 'tcp', 'tls']);
export const trunkAuthMode = pgEnum('trunk_auth_mode', ['registration', 'ip']);
export const trunks = pgTable('trunks', {
id: id(),
name: text().notNull().unique(),
provider: trunkProvider().notNull().default('voipms'),
enabled: boolean().notNull().default(true),
serverHost: text().notNull(),
serverPort: integer().notNull().default(5060),
transport: sipTransport().notNull().default('udp'),
authMode: trunkAuthMode().notNull().default('registration'),
username: text(),
passwordEnc: text(),
fromUser: text(),
fromDomain: text(),
codecs: text()
.array()
.notNull()
.default(sql`'{ulaw}'`),
srtp: boolean().notNull().default(false),
maxChannels: integer(),
voipmsApiUsername: text(),
voipmsApiPasswordEnc: text(),
...timestamps,
});
export const dids = pgTable('dids', {
id: id(),
number: text().notNull().unique(),
trunkId: uuid()
.notNull()
.references(() => trunks.id, { onDelete: 'restrict' }),
label: text(),
destination: jsonb().notNull(),
callerIdNamePrefix: text(),
recordInbound: boolean().notNull().default(false),
smsEnabled: boolean().notNull().default(false),
e911Address: text(),
...timestamps,
});
export const didSmsUsers = pgTable(
'did_sms_users',
{
didId: uuid()
.notNull()
.references(() => dids.id, { onDelete: 'cascade' }),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
...timestamps,
},
(t) => [primaryKey({ columns: [t.didId, t.userId] })],
);
export const outboundRoutes = pgTable('outbound_routes', {
id: id(),
name: text().notNull(),
position: integer().notNull(),
enabled: boolean().notNull().default(true),
patterns: jsonb().notNull(),
requiredClass: outboundClass().notNull(),
trunkIds: uuid().array().notNull(),
callerIdDidId: uuid().references(() => dids.id, { onDelete: 'set null' }),
...timestamps,
});
export const blockedNumbers = pgTable('blocked_numbers', {
id: id(),
number: text().notNull().unique(),
reason: text(),
...timestamps,
});
// ───────────── call handling objects ─────────────
export const ringStrategy = pgEnum('ring_strategy', ['ringall', 'hunt']);
export const ringGroups = pgTable('ring_groups', {
id: id(),
number: text().notNull().unique(),
name: text().notNull(),
strategy: ringStrategy().notNull().default('ringall'),
ringSeconds: integer().notNull().default(25),
callerIdNamePrefix: text(),
musicInsteadOfRing: boolean().notNull().default(false),
noAnswerDestination: jsonb().notNull(),
...timestamps,
});
export const ringGroupMembers = pgTable(
'ring_group_members',
{
ringGroupId: uuid()
.notNull()
.references(() => ringGroups.id, { onDelete: 'cascade' }),
extensionId: uuid()
.notNull()
.references(() => extensions.id, { onDelete: 'cascade' }),
position: integer().notNull(),
...timestamps,
},
(t) => [primaryKey({ columns: [t.ringGroupId, t.extensionId] })],
);
export const queueStrategy = pgEnum('queue_strategy', [
'ringall',
'leastrecent',
'fewestcalls',
'random',
'rrmemory',
'linear',
]);
export const queues = pgTable('queues', {
id: id(),
number: text().notNull().unique(),
name: text().notNull(),
strategy: queueStrategy().notNull().default('rrmemory'),
memberRingSeconds: integer().notNull().default(15),
retrySeconds: integer().notNull().default(5),
wrapupSeconds: integer().notNull().default(10),
maxWaitSeconds: integer().notNull().default(300),
maxCallers: integer().notNull().default(0),
mohClass: text().notNull().default('default'),
announcePosition: boolean().notNull().default(true),
announceEverySeconds: integer().notNull().default(60),
joinWhenNoAgents: boolean().notNull().default(false),
record: boolean().notNull().default(false),
slaSeconds: integer().notNull().default(20),
callerIdNamePrefix: text(),
timeoutDestination: jsonb().notNull(),
...timestamps,
});
export const queueMembers = pgTable(
'queue_members',
{
queueId: uuid()
.notNull()
.references(() => queues.id, { onDelete: 'cascade' }),
extensionId: uuid()
.notNull()
.references(() => extensions.id, { onDelete: 'cascade' }),
penalty: integer().notNull().default(0),
...timestamps,
},
(t) => [primaryKey({ columns: [t.queueId, t.extensionId] })],
);
export const queueMemberState = pgTable(
'queue_member_state',
{
queueId: uuid()
.notNull()
.references(() => queues.id, { onDelete: 'cascade' }),
extensionId: uuid()
.notNull()
.references(() => extensions.id, { onDelete: 'cascade' }),
paused: boolean().notNull().default(false),
...timestamps,
},
(t) => [primaryKey({ columns: [t.queueId, t.extensionId] })],
);
export const queueOutcome = pgEnum('queue_outcome', ['answered', 'abandoned', 'timed_out']);
/** One caller's stay in a queue, from app_queue's AMI events (stats, 04 §7.3). */
export const queueCalls = pgTable(
'queue_calls',
{
id: bigserial({ mode: 'number' }).primaryKey(),
queueId: uuid()
.notNull()
.references(() => queues.id, { onDelete: 'cascade' }),
/** The caller's channel. */
uniqueid: text().notNull(),
linkedid: text().notNull(),
joinedAt: tstz().notNull().defaultNow(),
/** null while waiting. */
outcome: queueOutcome(),
waitSeconds: integer(),
agentExtensionId: uuid().references(() => extensions.id, { onDelete: 'set null' }),
},
(t) => [index().on(t.queueId, t.joinedAt), index().on(t.uniqueid)],
);
export const mohClasses = pgTable('moh_classes', {
id: id(),
name: text().notNull().unique(),
/** Play the files in random order; otherwise in `sound_files.position` order (03 §8). */
shuffle: boolean().notNull().default(false),
...timestamps,
});
export const soundFiles = pgTable(
'sound_files',
{
id: id(),
name: text().notNull(),
kind: text().notNull(),
mohClassId: uuid().references(() => mohClasses.id, { onDelete: 'cascade' }),
source: text().notNull(),
ttsText: text(),
originalFilename: text(),
asteriskName: text().notNull().unique(),
durationMs: integer().notNull(),
/** Music on hold: where it plays in its class's list (0 first). Prompts don't use it. */
position: integer().notNull().default(0),
...timestamps,
},
(t) => [
check('sound_files_kind_check', sql`${t.kind} in ('prompt', 'moh')`),
check('sound_files_source_check', sql`${t.source} in ('upload', 'tts', 'phone')`),
],
);
export const ivrs = pgTable('ivrs', {
id: id(),
number: text().notNull().unique(),
name: text().notNull(),
greetingSoundId: uuid()
.notNull()
.references(() => soundFiles.id, { onDelete: 'restrict' }),
invalidSoundId: uuid().references(() => soundFiles.id, { onDelete: 'set null' }),
timeoutSeconds: integer().notNull().default(5),
maxRetries: integer().notNull().default(3),
allowDirectDial: boolean().notNull().default(true),
timeoutDestination: jsonb().notNull(),
invalidDestination: jsonb().notNull(),
...timestamps,
});
export const ivrOptions = pgTable(
'ivr_options',
{
ivrId: uuid()
.notNull()
.references(() => ivrs.id, { onDelete: 'cascade' }),
digit: text().notNull(),
destination: jsonb().notNull(),
...timestamps,
},
(t) => [
primaryKey({ columns: [t.ivrId, t.digit] }),
check('ivr_options_digit_check', sql`${t.digit} ~ '^[0-9*#]$'`),
],
);
export const timeConditions = pgTable(
'time_conditions',
{
id: id(),
name: text().notNull(),
timezone: text().notNull(),
openRanges: jsonb().notNull(),
useHolidays: boolean().notNull().default(true),
overrideDigit: smallint().unique(),
overrideState: text(),
openDestination: jsonb().notNull(),
closedDestination: jsonb().notNull(),
holidayDestination: jsonb(),
...timestamps,
},
(t) => [
check('time_conditions_override_digit_check', sql`${t.overrideDigit} between 1 and 9`),
check('time_conditions_override_state_check', sql`${t.overrideState} in ('open', 'closed')`),
],
);
export const holidays = pgTable('holidays', {
id: id(),
name: text().notNull(),
startDate: date().notNull(),
endDate: date().notNull(),
recurringYearly: boolean().notNull().default(false),
...timestamps,
});
export const announcements = pgTable('announcements', {
id: id(),
name: text().notNull(),
soundId: uuid()
.notNull()
.references(() => soundFiles.id, { onDelete: 'restrict' }),
destination: jsonb().notNull(),
...timestamps,
});
export const conferences = pgTable('conferences', {
id: id(),
number: text().notNull().unique(),
name: text().notNull(),
pinEnc: text(),
adminPinEnc: text(),
maxMembers: integer().notNull().default(20),
record: boolean().notNull().default(false),
...timestamps,
});
export const pagingGroups = pgTable('paging_groups', {
id: id(),
number: text().notNull().unique(),
name: text().notNull(),
duplex: boolean().notNull().default(false),
...timestamps,
});
export const pagingGroupMembers = pgTable(
'paging_group_members',
{
pagingGroupId: uuid()
.notNull()
.references(() => pagingGroups.id, { onDelete: 'cascade' }),
extensionId: uuid()
.notNull()
.references(() => extensions.id, { onDelete: 'cascade' }),
...timestamps,
},
(t) => [primaryKey({ columns: [t.pagingGroupId, t.extensionId] })],
);
// ───────────── devices (provisioning) ─────────────
export const phoneVendor = pgEnum('phone_vendor', ['yealink', 'grandstream', 'polycom', 'generic']);
export const devices = pgTable('devices', {
id: id(),
extensionId: uuid()
.notNull()
.references(() => extensions.id, { onDelete: 'cascade' }),
mac: text().notNull().unique(),
vendor: phoneVendor().notNull(),
model: text(),
label: text(),
provisioningPasswordEnc: text().notNull(),
lastProvisionedAt: tstz(),
lastProvisionedIp: inet(),
...timestamps,
});
// ───────────── history, media, messaging ─────────────
export const cdr = pgTable(
'cdr',
{
id: bigserial({ mode: 'number' }).primaryKey(),
createdAt: createdAt(),
uniqueid: text().notNull(),
linkedid: text().notNull(),
startAt: tstz(),
answerAt: tstz(),
endAt: tstz(),
duration: integer(),
billsec: integer(),
src: text(),
dst: text(),
dcontext: text(),
clid: text(),
channel: text(),
dstchannel: text(),
lastapp: text(),
lastdata: text(),
disposition: text(),
accountcode: text(),
userfield: text(),
spDid: text(),
spDirection: text(),
spRecording: text(),
raw: jsonb().notNull(),
},
(t) => [index().on(t.linkedid)],
);
export const callDirection = pgEnum('call_direction', ['inbound', 'outbound', 'internal']);
export const callStatus = pgEnum('call_status', [
'answered',
'missed',
'voicemail',
'abandoned',
'busy',
'failed',
]);
export const recordings = pgTable('recordings', {
id: id(),
linkedid: text().notNull(),
filePath: text().notNull().unique(),
startedAt: tstz().notNull(),
durationMs: integer(),
sizeBytes: bigint({ mode: 'number' }),
/** Spec 10 §3: [{ side: 'caller' | 'other', at: seconds, text }], in time order. */
transcript: jsonb().$type<{ side: 'caller' | 'other'; at: number; text: string }[]>(),
summary: text(),
/** When the transcript job finished with it (null: not yet); aiError: why it has none. */
aiDoneAt: tstz(),
aiError: text(),
...timestamps,
});
export const calls = pgTable(
'calls',
{
id: id(),
linkedid: text().notNull().unique(),
direction: callDirection().notNull(),
status: callStatus().notNull(),
startedAt: tstz().notNull(),
answeredAt: tstz(),
endedAt: tstz().notNull(),
fromNumber: text(),
fromName: text(),
toNumber: text(),
didId: uuid().references(() => dids.id, { onDelete: 'set null' }),
answeredByExtensionId: uuid().references(() => extensions.id, { onDelete: 'set null' }),
talkSeconds: integer().notNull().default(0),
recordingId: uuid().references(() => recordings.id, { onDelete: 'set null' }),
/** The spam check of an incoming call's number (14 §6); null = not checked. */
spamRisk: text(),
spamInfo: jsonb(),
...timestamps,
},
(t) => [index().on(t.startedAt.desc())],
);
// ───────────── spam check (14 §7) ─────────────
/** IPQualityScore answers, so a caller who rings again costs no credit (14 §4). */
export const spamLookups = pgTable(
'spam_lookups',
{
number: text().primaryKey(),
provider: text().notNull(),
risk: text().notNull(),
info: jsonb().notNull(),
expiresAt: tstz().notNull(),
...timestamps,
},
(t) => [
index().on(t.expiresAt),
check('spam_lookups_risk_check', sql`${t.risk} in ('low', 'medium', 'high')`),
],
);
/** The local copy of the FTC's Do Not Call complaints (14 §2.2). */
export const ftcComplaints = pgTable(
'ftc_complaints',
{
id: text().primaryKey(),
number: text().notNull(),
reportedAt: tstz().notNull(),
subject: text(),
robocall: boolean(),
createdAt: createdAt(),
},
(t) => [index().on(t.number), index().on(t.reportedAt)],
);
export const callExtensions = pgTable(
'call_extensions',
{
callId: uuid()
.notNull()
.references(() => calls.id, { onDelete: 'cascade' }),
extensionId: uuid()
.notNull()
.references(() => extensions.id, { onDelete: 'cascade' }),
role: text().notNull(),
...timestamps,
},
(t) => [
primaryKey({ columns: [t.callId, t.extensionId, t.role] }),
check('call_extensions_role_check', sql`${t.role} in ('caller', 'rang', 'answered')`),
],
);
export const voicemailMessages = pgTable('voicemail_messages', {
id: id(),
extensionId: uuid()
.notNull()
.references(() => extensions.id, { onDelete: 'cascade' }),
msgId: text().notNull().unique(),
folder: text().notNull(),
callerNumber: text(),
callerName: text(),
durationS: integer().notNull(),
receivedAt: tstz().notNull(),
filePath: text().notNull(),
emailedAt: tstz(),
deletedAt: tstz(),
/** Speech to text (spec 10), when ai.voicemailTranscripts is on; null: none (yet). */
transcript: text(),
...timestamps,
});
export const contacts = pgTable('contacts', {
id: id(),
ownerUserId: uuid().references(() => users.id, { onDelete: 'cascade' }),
name: text().notNull(),
company: text(),
email: text(),
numbers: jsonb().notNull(),
notes: text(),
...timestamps,
});
/** A call put on (or checked for) a person's Contrack timeline (11 §4.4): never twice. */
export const contrackCallLog = pgTable(
'contrack_call_log',
{
callId: uuid()
.notNull()
.references(() => calls.id, { onDelete: 'cascade' }),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** Contrack's interaction id; null: no contact has that number, or it couldn't be written. */
interactionId: text(),
error: text(),
loggedAt: tstz().notNull().defaultNow(),
},
(t) => [primaryKey({ columns: [t.callId, t.userId] })],
);
/** A contact offered to a colleague, waiting for Accept or Deny (11 §2.1). */
export const contactShares = pgTable(
'contact_shares',
{
id: id(),
fromUserId: uuid().references(() => users.id, { onDelete: 'set null' }),
toUserId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** The shared contact's id in the sender's book: offering it again replaces a pending offer. */
sourceId: text().notNull(),
/** A copy taken when shared: { name, company, email, numbers, notes }. */
contact: jsonb().notNull(),
status: text().notNull().default('pending'),
decidedAt: tstz(),
...timestamps,
},
(t) => [
index()
.on(t.toUserId)
.where(sql`${t.status} = 'pending'`),
check('contact_shares_status_check', sql`${t.status} in ('pending', 'accepted', 'denied')`),
],
);
export const smsMessages = pgTable(
'sms_messages',
{
id: id(),
didId: uuid()
.notNull()
.references(() => dids.id, { onDelete: 'cascade' }),
direction: text().notNull(),
remoteNumber: text().notNull(),
body: text().notNull(),
media: jsonb().notNull().default([]),
status: text().notNull(),
providerId: text(),
error: text(),
sentByUserId: uuid().references(() => users.id, { onDelete: 'set null' }),
readAt: tstz(),
...timestamps,
},
(t) => [
index().on(t.didId, t.remoteNumber, t.createdAt.desc()),
check('sms_messages_direction_check', sql`${t.direction} in ('in', 'out')`),
],
);
// ───────────── integration, audit, system ─────────────
export const webhooks = pgTable('webhooks', {
id: id(),
url: text().notNull(),
secretEnc: text().notNull(),
events: text().array().notNull(),
enabled: boolean().notNull().default(true),
...timestamps,
});
export const webhookDeliveries = pgTable('webhook_deliveries', {
id: bigserial({ mode: 'number' }).primaryKey(),
createdAt: createdAt(),
webhookId: uuid()
.notNull()
.references(() => webhooks.id, { onDelete: 'cascade' }),
event: text().notNull(),
payload: jsonb().notNull(),
attempt: integer().notNull().default(0),
status: text().notNull(),
responseCode: integer(),
nextAttemptAt: tstz(),
});
export const emergencyEvents = pgTable('emergency_events', {
id: id(),
extensionNumber: text().notNull(),
dialed: text().notNull(),
callerIdUsed: text().notNull(),
location: text(),
notified: jsonb().notNull().default([]),
...timestamps,
});
/** System page's event log: what the system noticed (trunks, telephony, backups…), not admin edits. */
export const systemEvents = pgTable(
'system_events',
{
id: bigserial({ mode: 'number' }).primaryKey(),
createdAt: createdAt(),
severity: text().notNull(),
/** Stable id of the kind of event, e.g. trunk.down. */
code: text().notNull(),
source: text().notNull(),
message: text().notNull(),
},
(t) => [
index().on(t.createdAt),
check('system_events_severity_check', sql`${t.severity} in ('info', 'warning', 'error')`),
],
);
export const auditLog = pgTable('audit_log', {
id: bigserial({ mode: 'number' }).primaryKey(),
createdAt: createdAt(),
actorUserId: uuid().references(() => users.id, { onDelete: 'set null' }),
actorIp: inet(),
action: text().notNull(),
entityType: text(),
entityId: text(),
summary: text().notNull(),
diff: jsonb(),
});
export const generatorRuns = pgTable('generator_runs', {
id: bigserial({ mode: 'number' }).primaryKey(),
createdAt: createdAt(),
trigger: text().notNull(),
filesChanged: text().array().notNull(),
commands: text().array().notNull(),
ok: boolean().notNull(),
error: text(),
durationMs: integer().notNull(),
});