schema.ts1767 lines · main
1/**
2 * Control-plane meta-DB schema.
3 *
4 * Per CLAUDE.md §8.1: every table has `id` (ULID PK), `created_at`,
5 * `updated_at`, and `deleted_at` (soft-delete). The id column is `text` —
6 * briven-managed rows store prefixed ULIDs (28 chars), Better Auth tables
7 * store its 32-char nanoids, both fit cleanly without a length cap.
8 *
9 * Better Auth also reads / writes `users`, `accounts`, `sessions`, `verifications`
10 * via its drizzle adapter; schema here matches Better Auth's expected shape so
11 * the adapter works without translation.
12 */
13import { sql } from 'drizzle-orm';
14import {
15 bigint,
16 boolean,
17 date,
18 index,
19 integer,
20 jsonb,
21 pgTable,
22 primaryKey,
23 text,
24 timestamp,
25 uniqueIndex,
26 varchar,
27} from 'drizzle-orm/pg-core';
28
29// Per CLAUDE.md §8.1 we use prefixed ULIDs (28 chars) for briven-managed
30// rows, but Better Auth-managed tables use its own 32-char nanoid scheme.
31// Keep the column flexible: `text` accommodates both without truncation.
32const id = () => text('id').primaryKey();
33const ts = (name: string) => timestamp(name, { withTimezone: true, mode: 'date' });
34const createdAt = () => ts('created_at').defaultNow().notNull();
35const updatedAt = () => ts('updated_at').defaultNow().notNull();
36const deletedAt = () => ts('deleted_at');
37
38/* ─── project_auth_origins (per-project allowed login domains / CORS gate) ─── */
39export const projectAuthOrigins = pgTable(
40 'project_auth_origins',
41 {
42 id: id(),
43 projectId: text('project_id')
44 .notNull()
45 .references(() => projects.id, { onDelete: 'cascade' }),
46 origin: text('origin').notNull(),
47 isWildcard: boolean('is_wildcard').notNull().default(false),
48 createdBy: text('created_by').references(() => users.id),
49 createdAt: createdAt(),
50 },
51 (t) => ({
52 projectOriginIdx: uniqueIndex('project_auth_origins_project_origin_idx').on(
53 t.projectId,
54 t.origin,
55 ),
56 originLookupIdx: index('project_auth_origins_origin_idx').on(t.origin),
57 }),
58);
59
60/* ─── storage_keys (per-project scoped MinIO service-account keys) ─── */
61export const storageKeys = pgTable(
62 'storage_keys',
63 {
64 id: id(),
65 projectId: text('project_id')
66 .notNull()
67 .references(() => projects.id, { onDelete: 'cascade' }),
68 name: text('name').notNull(),
69 // The MinIO service-account access key (not secret — the secret is shown once).
70 accessKeyId: text('access_key_id').notNull(),
71 // Last 4 of the secret, for a display hint only.
72 suffix: varchar('suffix', { length: 4 }).notNull(),
73 bucket: text('bucket').notNull(),
74 enabled: boolean('enabled').notNull().default(true),
75 createdBy: text('created_by').references(() => users.id),
76 createdAt: createdAt(),
77 revokedAt: ts('revoked_at'),
78 },
79 (t) => ({
80 accessKeyIdx: uniqueIndex('storage_keys_access_key_idx').on(t.accessKeyId),
81 projectIdx: index('storage_keys_project_idx').on(t.projectId),
82 }),
83);
84
85/* ─── users ──────────────────────────────────────────────────────── */
86export const users = pgTable(
87 'users',
88 {
89 id: id(),
90 email: text('email').notNull(),
91 emailVerified: boolean('email_verified').default(false).notNull(),
92 name: text('name'),
93 image: text('image'),
94 // Platform super-admin. Step-up auth required for every admin action
95 // (CLAUDE.md §5.4). Defaults false; j flips the bit directly in
96 // postgres for the first admin.
97 isAdmin: boolean('is_admin').default(false).notNull(),
98 // Most recent step-up attestation timestamp. The `requireRecentMfa`
99 // middleware accepts requests when this is within the configured
100 // window (default 10 min per CLAUDE.md §5.4). Bumped by
101 // POST /v1/auth/step-up after a successful password re-prompt.
102 lastMfaAt: ts('last_mfa_at'),
103 // Set by an admin to freeze all sign-in attempts + deploys. Sessions
104 // are invalidated on next request.
105 suspendedAt: ts('suspended_at'),
106 /*
107 * EU GDPR / AML billing profile. All fields optional at create time;
108 * required before a paid subscription checkout (enforced at checkout).
109 * Stored in the control plane, never in a customer schema. Address
110 * block is the natural person or legal entity the invoice issues to.
111 */
112 legalName: text('legal_name'),
113 companyName: text('company_name'),
114 // EU business registration number (e.g. French SIREN, German HRB,
115 // Belgian KBO/BCE). Separate from VAT ID — many micro-businesses
116 // have a registration number but no VAT ID.
117 companyRegistrationNumber: text('company_registration_number'),
118 vatId: text('vat_id'),
119 // Set when a vat_id is confirmed valid against VIES. Locks the field
120 // against further self-service edits — changes after this must go
121 // through support (legal/compliance: treat a verified VAT as a
122 // point-in-time attestation we relied on for tax treatment).
123 vatVerifiedAt: ts('vat_verified_at'),
124 addressLine1: text('address_line_1'),
125 addressLine2: text('address_line_2'),
126 addressCity: text('address_city'),
127 addressPostalCode: text('address_postal_code'),
128 addressRegion: text('address_region'),
129 // ISO 3166-1 alpha-2 (e.g. 'BE', 'NL'). Determines VAT treatment.
130 addressCountry: text('address_country'),
131 // KYC — required before paid checkout under EU AML. Stored as
132 // ISO yyyy-mm-dd text; the underlying column is DATE.
133 dateOfBirth: text('date_of_birth'),
134 // ISO 3166-1 alpha-2 (e.g. 'BE'). Separate from address_country —
135 // residency drives VAT, birth drives KYC.
136 countryOfBirth: text('country_of_birth'),
137 // IANA zone name (e.g. 'Europe/Brussels'). Used to render timestamps
138 // and schedule the Pro digest at the user's local 09:00 instead of
139 // a flat UTC time.
140 timezone: text('timezone'),
141 createdAt: createdAt(),
142 updatedAt: updatedAt(),
143 deletedAt: deletedAt(),
144 // Optional free-text reason the user supplied at deletion time.
145 // Surfaced only in audit_logs / admin tools — never replayed back
146 // to the user, never used in cross-user analytics.
147 deletionReason: text('deletion_reason'),
148 },
149 (t) => ({
150 emailIdx: uniqueIndex('users_email_idx').on(t.email),
151 }),
152);
153
154/* ─── accounts (Better Auth: provider-linked credentials) ─────────── */
155export const accounts = pgTable(
156 'accounts',
157 {
158 id: id(),
159 userId: text('user_id')
160 .notNull()
161 .references(() => users.id, { onDelete: 'cascade' }),
162 accountId: text('account_id').notNull(),
163 providerId: text('provider_id').notNull(),
164 accessToken: text('access_token'),
165 refreshToken: text('refresh_token'),
166 idToken: text('id_token'),
167 accessTokenExpiresAt: ts('access_token_expires_at'),
168 refreshTokenExpiresAt: ts('refresh_token_expires_at'),
169 scope: text('scope'),
170 password: text('password'),
171 createdAt: createdAt(),
172 updatedAt: updatedAt(),
173 },
174 (t) => ({
175 userIdx: index('accounts_user_id_idx').on(t.userId),
176 providerIdx: uniqueIndex('accounts_provider_account_idx').on(t.providerId, t.accountId),
177 }),
178);
179
180/* ─── sessions ────────────────────────────────────────────────────── */
181export const sessions = pgTable(
182 'sessions',
183 {
184 id: id(),
185 userId: text('user_id')
186 .notNull()
187 .references(() => users.id, { onDelete: 'cascade' }),
188 token: text('token').notNull(),
189 expiresAt: ts('expires_at').notNull(),
190 ipAddress: text('ip_address'),
191 userAgent: text('user_agent'),
192 createdAt: createdAt(),
193 updatedAt: updatedAt(),
194 },
195 (t) => ({
196 tokenIdx: uniqueIndex('sessions_token_idx').on(t.token),
197 userIdx: index('sessions_user_id_idx').on(t.userId),
198 }),
199);
200
201/* ─── verifications (magic link tokens, email verification) ───────── */
202export const verifications = pgTable(
203 'verifications',
204 {
205 id: id(),
206 identifier: text('identifier').notNull(),
207 value: text('value').notNull(),
208 expiresAt: ts('expires_at').notNull(),
209 createdAt: createdAt(),
210 updatedAt: updatedAt(),
211 },
212 (t) => ({
213 identifierIdx: index('verifications_identifier_idx').on(t.identifier),
214 }),
215);
216
217/* ─── organizations ───────────────────────────────────────────────── */
218export const orgRole = ['owner', 'admin', 'developer', 'viewer'] as const;
219export type OrgRole = (typeof orgRole)[number];
220
221export const organizations = pgTable(
222 'organizations',
223 {
224 id: id(),
225 slug: text('slug').notNull(),
226 name: text('name').notNull(),
227 // True for the auto-created first org per user. Lets the UI keep a
228 // single-org implicit UX until Phase 3 adds a switcher.
229 personal: boolean('personal').notNull().default(false),
230 createdBy: text('created_by')
231 .notNull()
232 .references(() => users.id),
233 createdAt: createdAt(),
234 updatedAt: updatedAt(),
235 deletedAt: deletedAt(),
236 },
237 (t) => ({
238 slugIdx: uniqueIndex('organizations_slug_idx').on(t.slug),
239 }),
240);
241
242export type Organization = typeof organizations.$inferSelect;
243export type NewOrganization = typeof organizations.$inferInsert;
244
245export const orgMembers = pgTable(
246 'org_members',
247 {
248 orgId: text('org_id')
249 .notNull()
250 .references(() => organizations.id, { onDelete: 'cascade' }),
251 userId: text('user_id')
252 .notNull()
253 .references(() => users.id, { onDelete: 'cascade' }),
254 // Stored but not enforced this project — Phase 3 wires RBAC.
255 role: text('role').$type<OrgRole>().notNull().default('developer'),
256 createdAt: createdAt(),
257 updatedAt: updatedAt(),
258 },
259 (t) => ({
260 pk: primaryKey({ columns: [t.orgId, t.userId] }),
261 userIdx: index('org_members_user_id_idx').on(t.userId),
262 }),
263);
264
265/* ─── projects ────────────────────────────────────────────────────── */
266export const projectTier = ['free', 'pro', 'team'] as const;
267export type ProjectTier = (typeof projectTier)[number];
268
269export const projects = pgTable(
270 'projects',
271 {
272 id: id(),
273 slug: text('slug').notNull(),
274 name: text('name').notNull(),
275 orgId: text('org_id')
276 .notNull()
277 .references(() => organizations.id, { onDelete: 'cascade' }),
278 region: text('region').notNull().default('eu-west-1'),
279 tier: text('tier').$type<ProjectTier>().notNull().default('free'),
280 shardId: text('shard_id'),
281 dataSchemaName: text('data_schema_name'),
282 // Set by an admin (manually or via abuse-report auto-suspension) to
283 // freeze every state-changing route on the project. Invokes return
284 // 403 with code=project_suspended; reads stay open so the operator
285 // can investigate via the dashboard. Setting to null re-enables.
286 suspendedAt: ts('suspended_at'),
287 suspendReason: text('suspend_reason'),
288 // Sprint 4 storage admin: per-project overrides of the tier storage caps.
289 // NULL = inherit the tier default from tier_storage_caps. Bytes are
290 // unmeasurable on DoltGres, so limits are expressed as rows + tables.
291 storageMaxRows: bigint('storage_max_rows', { mode: 'number' }),
292 storageMaxTables: bigint('storage_max_tables', { mode: 'number' }),
293 // Sprint 4 Phase 4 — the deferred "block" lever. 'flag' (default) = current
294 // behaviour: surface over-limit in the admin dashboard, never block a
295 // customer. 'block' = an admin opts THIS project in so new writes are
296 // refused once it's over its effective cap. Off by default; fails OPEN.
297 storageEnforcementMode: text('storage_enforcement_mode')
298 .$type<'flag' | 'block'>()
299 .notNull()
300 .default('flag'),
301 /** Customer-owned auth subdomain (e.g. auth.murphus.eu). Cached here for fast lookup. */
302 authDomain: text('auth_domain'),
303 createdAt: createdAt(),
304 updatedAt: updatedAt(),
305 deletedAt: deletedAt(),
306 },
307 (t) => ({
308 slugIdx: uniqueIndex('projects_slug_idx').on(t.slug),
309 orgIdx: index('projects_org_idx').on(t.orgId),
310 }),
311);
312
313/* ─── tier_storage_caps (Sprint 4) — DB-backed, admin-editable storage caps ─
314 * The Free/Pro/Team storage limits live here (not in code) so an admin can
315 * change them from the dashboard without a redeploy. Bytes are unmeasurable on
316 * DoltGres, so caps are rows + tables. Seeded by migration 0033.
317 */
318export const tierStorageCaps = pgTable('tier_storage_caps', {
319 tier: text('tier').$type<ProjectTier>().primaryKey(),
320 maxRows: bigint('max_rows', { mode: 'number' }).notNull(),
321 maxTables: bigint('max_tables', { mode: 'number' }).notNull(),
322 updatedAt: updatedAt(),
323 updatedBy: text('updated_by'),
324});
325
326/* ─── project_members (Phase 3 RBAC — columns exist, roles stubbed) ─ */
327export const memberRole = ['owner', 'admin', 'developer', 'viewer'] as const;
328export type MemberRole = (typeof memberRole)[number];
329
330export const projectMembers = pgTable(
331 'project_members',
332 {
333 projectId: text('project_id')
334 .notNull()
335 .references(() => projects.id, { onDelete: 'cascade' }),
336 userId: text('user_id')
337 .notNull()
338 .references(() => users.id, { onDelete: 'cascade' }),
339 role: text('role').$type<MemberRole>().notNull().default('developer'),
340 createdAt: createdAt(),
341 updatedAt: updatedAt(),
342 },
343 (t) => ({
344 pk: primaryKey({ columns: [t.projectId, t.userId] }),
345 }),
346);
347
348/* ─── project_auth_team_members (Phase 6 — auth dashboard team seats) ─ */
349export const authTeamRole = ['admin', 'viewer'] as const;
350export type AuthTeamRole = (typeof authTeamRole)[number];
351
352export const projectAuthTeamMembers = pgTable(
353 'project_auth_team_members',
354 {
355 projectId: text('project_id')
356 .notNull()
357 .references(() => projects.id, { onDelete: 'cascade' }),
358 userId: text('user_id')
359 .notNull()
360 .references(() => users.id, { onDelete: 'cascade' }),
361 role: text('role').$type<AuthTeamRole>().notNull().default('viewer'),
362 invitedBy: text('invited_by').references(() => users.id, { onDelete: 'set null' }),
363 createdAt: createdAt(),
364 updatedAt: updatedAt(),
365 },
366 (t) => ({
367 pk: primaryKey({ columns: [t.projectId, t.userId] }),
368 }),
369);
370
371/* ─── billing / subscriptions ─────────────────────────────────────── */
372export const subscriptionStatus = ['trialing', 'active', 'past_due', 'canceled'] as const;
373export type SubscriptionStatus = (typeof subscriptionStatus)[number];
374
375export const subscriptions = pgTable(
376 'subscriptions',
377 {
378 id: id(),
379 orgId: text('org_id')
380 .notNull()
381 .references(() => organizations.id, { onDelete: 'cascade' }),
382 polarSubscriptionId: text('polar_subscription_id'),
383 polarCustomerId: text('polar_customer_id'),
384 tier: text('tier').$type<ProjectTier>().notNull().default('free'),
385 status: text('status').$type<SubscriptionStatus>().notNull().default('active'),
386 currentPeriodEnd: ts('current_period_end'),
387 canceledAt: ts('canceled_at'),
388 createdAt: createdAt(),
389 updatedAt: updatedAt(),
390 },
391 (t) => ({
392 orgIdx: uniqueIndex('subscriptions_org_idx').on(t.orgId),
393 polarIdx: index('subscriptions_polar_idx').on(t.polarSubscriptionId),
394 }),
395);
396
397export type Subscription = typeof subscriptions.$inferSelect;
398
399/* ─── project_invitations ────────────────────────────────────────── */
400export const projectInvitations = pgTable(
401 'project_invitations',
402 {
403 id: id(),
404 projectId: text('project_id')
405 .notNull()
406 .references(() => projects.id, { onDelete: 'cascade' }),
407 email: text('email').notNull(),
408 role: text('role').$type<MemberRole>().notNull().default('developer'),
409 // SHA-256 hash of the single-use accept token; plaintext only rides
410 // in the invite email and the recipient's URL.
411 tokenHash: text('token_hash').notNull(),
412 invitedBy: text('invited_by').references(() => users.id),
413 expiresAt: ts('expires_at').notNull(),
414 acceptedAt: ts('accepted_at'),
415 revokedAt: ts('revoked_at'),
416 createdAt: createdAt(),
417 },
418 (t) => ({
419 projectEmailIdx: uniqueIndex('project_invitations_project_email_idx').on(t.projectId, t.email),
420 tokenIdx: uniqueIndex('project_invitations_token_idx').on(t.tokenHash),
421 }),
422);
423
424export type ProjectInvitation = typeof projectInvitations.$inferSelect;
425
426/* ─── org_invitations ─────────────────────────────────────────────── */
427/**
428 * Pending invites to a team org. Mirrors project_invitations but
429 * scoped to an org id + carries an OrgRole instead of MemberRole.
430 * Acceptance creates the org_members row and marks accepted_at.
431 */
432export const orgInvitations = pgTable(
433 'org_invitations',
434 {
435 id: id(),
436 orgId: text('org_id')
437 .notNull()
438 .references(() => organizations.id, { onDelete: 'cascade' }),
439 email: text('email').notNull(),
440 role: text('role').$type<OrgRole>().notNull().default('developer'),
441 // SHA-256 hash of the single-use accept token; plaintext only rides
442 // in the invite email and the recipient's URL.
443 tokenHash: text('token_hash').notNull(),
444 invitedBy: text('invited_by').references(() => users.id),
445 expiresAt: ts('expires_at').notNull(),
446 acceptedAt: ts('accepted_at'),
447 revokedAt: ts('revoked_at'),
448 createdAt: createdAt(),
449 },
450 (t) => ({
451 orgEmailIdx: uniqueIndex('org_invitations_org_email_idx').on(t.orgId, t.email),
452 tokenIdx: uniqueIndex('org_invitations_token_idx').on(t.tokenHash),
453 }),
454);
455
456export type OrgInvitation = typeof orgInvitations.$inferSelect;
457export type NewOrgInvitation = typeof orgInvitations.$inferInsert;
458
459/* ─── project_env_vars ────────────────────────────────────────────── */
460export const projectEnvVars = pgTable(
461 'project_env_vars',
462 {
463 id: id(),
464 projectId: text('project_id')
465 .notNull()
466 .references(() => projects.id, { onDelete: 'cascade' }),
467 key: text('key').notNull(),
468 // AES-256-GCM ciphertext of the value, base64. Never read directly —
469 // always through services/project-env.ts which wraps decrypt.
470 encryptedValue: text('encrypted_value').notNull(),
471 createdBy: text('created_by').references(() => users.id),
472 createdAt: createdAt(),
473 updatedAt: updatedAt(),
474 },
475 (t) => ({
476 projectKeyIdx: uniqueIndex('project_env_vars_project_key_idx').on(t.projectId, t.key),
477 }),
478);
479
480export type ProjectEnvVar = typeof projectEnvVars.$inferSelect;
481
482/* ─── api_keys / deploy keys ──────────────────────────────────────── */
483export const apiKeys = pgTable(
484 'api_keys',
485 {
486 id: id(),
487 projectId: text('project_id')
488 .notNull()
489 .references(() => projects.id, { onDelete: 'cascade' }),
490 createdBy: text('created_by')
491 .notNull()
492 .references(() => users.id),
493 name: text('name').notNull(),
494 // SHA-256 of the plaintext key — we never store the plaintext after creation.
495 hash: text('hash').notNull(),
496 // Last 4 chars of the plaintext — safe to show in the dashboard as a hint.
497 suffix: varchar('suffix', { length: 4 }).notNull(),
498 // Effective role this key carries when authenticating a request. Default
499 // is 'admin' for backward compat with keys minted before per-key role
500 // scoping landed; new keys can be issued with any of the standard roles
501 // (viewer / developer / admin) — owner is never assignable to a key.
502 role: text('role').$type<MemberRole>().notNull().default('admin'),
503 lastUsedAt: ts('last_used_at'),
504 expiresAt: ts('expires_at'),
505 createdAt: createdAt(),
506 revokedAt: ts('revoked_at'),
507 },
508 (t) => ({
509 hashIdx: uniqueIndex('api_keys_hash_idx').on(t.hash),
510 projectIdx: index('api_keys_project_idx').on(t.projectId),
511 }),
512);
513
514/* ─── deployments ─────────────────────────────────────────────────── */
515export const deploymentStatus = ['pending', 'running', 'succeeded', 'failed', 'cancelled'] as const;
516export type DeploymentStatus = (typeof deploymentStatus)[number];
517
518export const deployments = pgTable(
519 'deployments',
520 {
521 id: id(),
522 projectId: text('project_id')
523 .notNull()
524 .references(() => projects.id, { onDelete: 'cascade' }),
525 triggeredBy: text('triggered_by').references(() => users.id),
526 apiKeyId: text('api_key_id').references(() => apiKeys.id),
527 status: text('status').$type<DeploymentStatus>().notNull().default('pending'),
528 schemaDiffSummary: jsonb('schema_diff_summary'),
529 // Full schema definition as declared by the user at deploy time. Every
530 // deployment is a self-contained snapshot so rollbacks and diffs don't
531 // depend on reconstructing from a chain of migrations.
532 schemaSnapshot: jsonb('schema_snapshot'),
533 functionCount: varchar('function_count', { length: 12 }),
534 functionNames: jsonb('function_names'),
535 // Map of `<relative path under briven/functions/>` → TS source. Runtime
536 // fetches this via the internal bundle endpoint and writes the files to
537 // a temp dir before importing. Phase 1 stores raw source; Phase 2 moves
538 // to a content-addressed tarball in MinIO once bundles exceed a few MB.
539 bundle: jsonb('bundle'),
540 errorCode: text('error_code'),
541 errorMessage: text('error_message'),
542 startedAt: ts('started_at'),
543 finishedAt: ts('finished_at'),
544 createdAt: createdAt(),
545 },
546 (t) => ({
547 projectCreatedIdx: index('deployments_project_created_idx').on(t.projectId, t.createdAt),
548 statusIdx: index('deployments_status_idx').on(t.status),
549 }),
550);
551
552/* ─── function_logs ───────────────────────────────────────────────── */
553/*
554 * Durable copy of each invocation envelope that the runtime publishes to
555 * Redis. The async log-fanout worker copies entries from `logs:{projectId}`
556 * streams into this table; the dashboard queries it for the Logs page, and
557 * a daily retention cron trims rows older than the tier-configured window.
558 *
559 * Per CLAUDE.md §5.1 user content fields (`user_logs_json`, `err_message`)
560 * pass through unmodified — they are the user's own data about their own
561 * project, surfaced only to the account owner.
562 */
563export const functionLogs = pgTable(
564 'function_logs',
565 {
566 id: id(),
567 projectId: text('project_id')
568 .notNull()
569 .references(() => projects.id, { onDelete: 'cascade' }),
570 deploymentId: text('deployment_id')
571 .notNull()
572 .references(() => deployments.id, { onDelete: 'cascade' }),
573 invocationId: text('invocation_id').notNull(),
574 functionName: varchar('function_name', { length: 128 }).notNull(),
575 status: varchar('status', { length: 8 }).notNull(),
576 durationMs: varchar('duration_ms', { length: 12 }).notNull(),
577 touchedTables: jsonb('touched_tables').notNull(),
578 userLogsJson: jsonb('user_logs_json').notNull(),
579 errCode: text('err_code'),
580 errMessage: text('err_message'),
581 createdAt: createdAt(),
582 },
583 (t) => ({
584 projectCreatedIdx: index('function_logs_project_created_idx').on(t.projectId, t.createdAt),
585 }),
586);
587
588/* ─── audit_logs ──────────────────────────────────────────────────── */
589export const auditLogs = pgTable(
590 'audit_logs',
591 {
592 id: id(),
593 actorId: text('actor_id').references(() => users.id),
594 projectId: text('project_id').references(() => projects.id),
595 action: text('action').notNull(),
596 // SHA-256 hash of the caller IP — we never store raw IPs (CLAUDE.md §5.1).
597 ipHash: varchar('ip_hash', { length: 64 }),
598 userAgent: text('user_agent'),
599 metadata: jsonb('metadata'),
600 createdAt: createdAt(),
601 },
602 (t) => ({
603 projectCreatedIdx: index('audit_logs_project_created_idx').on(t.projectId, t.createdAt),
604 actorCreatedIdx: index('audit_logs_actor_created_idx').on(t.actorId, t.createdAt),
605 }),
606);
607
608/* ─── mcp_known_answers (briven_ask self-growing knowledge base) ─────── */
609// A platform-WIDE (not per-project) cache of answers the `briven_ask` desk
610// has produced for questions no hand-curated guide matched. When briven's
611// grounded answer-writer composes a fresh answer (only from briven's own
612// docs/guides — never invented), it is stored here keyed by a normalised
613// topic key, so the NEXT agent anywhere gets the same answer instantly
614// instead of re-deriving it or wandering off-platform. A briven session can
615// seed rows (`source = 'seed'`); the writer adds `source = 'auto'`. Read by
616// every project through its own key — the key only gates access; the
617// knowledge itself is shared, exactly like the hand-curated guides.
618export const knownAnswerSource = ['seed', 'auto'] as const;
619
620export const mcpKnownAnswers = pgTable(
621 'mcp_known_answers',
622 {
623 id: id(),
624 // Normalised, de-duplicated, sorted content words of the question — the
625 // stable cache key so paraphrases of the same question collapse to one row.
626 // Uniqueness is enforced by the unique index below (used by ON CONFLICT).
627 topicKey: text('topic_key').notNull(),
628 // A representative raw question (truncated) so a human can review what agents ask.
629 question: text('question').notNull(),
630 // The three-part answer object { howBrivenWorksHere, whatOurToolsGiveYou[],
631 // whatYouBuildInYourProject[], docs } — same shape as a curated guide.
632 answer: jsonb('answer').notNull(),
633 source: text('source', { enum: knownAnswerSource }).notNull(),
634 // Which model composed an `auto` answer (null for `seed`), for provenance.
635 model: text('model'),
636 // How many times this cached answer has been served — surfaces the hottest walls.
637 hitCount: integer('hit_count').default(0).notNull(),
638 createdAt: createdAt(),
639 updatedAt: updatedAt(),
640 },
641 (t) => ({
642 topicKeyIdx: uniqueIndex('mcp_known_answers_topic_key_idx').on(t.topicKey),
643 }),
644);
645
646/* ─── email_suppressions ──────────────────────────────────────────── */
647// Recipients we won't send to. Populated from mittera webhook events
648// (email.bounced+permanent, email.complained, email.suppressed) and
649// optionally by operator action. The outbound send path (lib/email.ts)
650// short-circuits when the recipient is found here.
651export const suppressionReason = [
652 'permanent_bounce',
653 'complaint',
654 'mittera_suppressed',
655 'manual',
656] as const;
657
658export const emailSuppressions = pgTable(
659 'email_suppressions',
660 {
661 id: id(),
662 // Stored lower-case so the lookup is case-insensitive without
663 // requiring an expression index.
664 email: text('email').notNull().unique(),
665 reason: text('reason', { enum: suppressionReason }).notNull(),
666 // Free-form context from the webhook event (bounce.message,
667 // complaint reason text, suppression source). Never includes PII
668 // beyond the email itself.
669 detail: text('detail'),
670 // The mittera event id that produced this row (idempotency).
671 sourceEventId: text('source_event_id'),
672 createdAt: createdAt(),
673 },
674 (t) => ({
675 emailIdx: index('email_suppressions_email_idx').on(t.email),
676 createdIdx: index('email_suppressions_created_idx').on(t.createdAt),
677 }),
678);
679
680/* ─── usage events ───────────────────────────────────────────────── */
681/**
682 * Hourly usage rollups, one row per (project, hour, metric). Populated
683 * by the apps/api usage-aggregation cron — reads function_logs +
684 * pg_total_relation_size + the realtime /metrics endpoint and writes
685 * one row per metric. Survives function_logs retention windows so
686 * historical usage queries beyond 7 days (free tier) still resolve.
687 *
688 * Polar metering push reads from here. The push side is a separate
689 * worker so a Polar outage doesn't block the aggregation cron.
690 */
691export const usageMetric = [
692 'invocations',
693 'storage_bytes',
694 'connection_seconds',
695 // briven auth MAU — distinct end-users active in the trailing 30 days.
696 // Gauge sample (not a delta) snapshotted by the hourly aggregator and
697 // pushed to Polar's `briven_auth_mau` meter for overage billing.
698 'auth_mau',
699 // Phase 5.7 — enterprise SSO per-connection pricing.
700 // auth_sso_connections: gauge of active (non-deactivated) SSO connections.
701 // auth_sso_signins: hourly delta of successful IdP logins.
702 'auth_sso_connections',
703 'auth_sso_signins',
704] as const;
705export type UsageMetric = (typeof usageMetric)[number];
706
707export const usageEvents = pgTable(
708 'usage_events',
709 {
710 id: id(),
711 projectId: text('project_id').notNull(),
712 metric: text('metric', { enum: usageMetric }).notNull(),
713 // First millisecond of the UTC hour this row covers.
714 periodStart: ts('period_start').notNull(),
715 // For counters (invocations, connection_seconds): the delta in this
716 // window. For gauges (storage_bytes): the sample value at period_end.
717 value: text('value').notNull(),
718 // 'pushed' once the Polar meter accepts it. Until then, 'pending'.
719 // The push worker scans for pending rows and batches them.
720 polarPushStatus: text('polar_push_status', {
721 enum: ['pending', 'pushed', 'skipped'],
722 })
723 .notNull()
724 .default('pending'),
725 polarPushedAt: ts('polar_pushed_at'),
726 createdAt: createdAt(),
727 },
728 (t) => ({
729 projectPeriodIdx: uniqueIndex('usage_events_project_period_metric_idx').on(
730 t.projectId,
731 t.periodStart,
732 t.metric,
733 ),
734 pendingIdx: index('usage_events_pending_idx')
735 .on(t.polarPushStatus, t.periodStart)
736 .where(sql`polar_push_status = 'pending'`),
737 }),
738);
739
740/* ─── deploy history ─────────────────────────────────────────────── */
741/**
742 * One row per api boot — the audit trail behind /info.buildSha. Drives
743 * the admin "Deploys" widget (last N rollouts: which sha, when, which
744 * env) so operators can correlate "the bug appeared at 14:32" with
745 * "deploy abc1234 went live at 14:30".
746 *
747 * Written from src/index.ts after migrations succeed; failure to insert
748 * is logged but not fatal (the api still boots — observability is not
749 * load-bearing for the request path).
750 */
751export const deployHistory = pgTable(
752 'deploy_history',
753 {
754 id: id(),
755 service: text('service').notNull(),
756 buildSha: text('build_sha').notNull(),
757 buildAt: text('build_at'),
758 env: text('env').notNull(),
759 // Explicit "booted_at" column name — semantically clearer than the
760 // generic createdAt() helper for a row that records the moment of
761 // process boot. (Also: the helper maps to "created_at" which would
762 // mismatch the SQL migration.)
763 bootedAt: ts('booted_at').defaultNow().notNull(),
764 },
765 (t) => ({
766 serviceBootedIdx: index('deploy_history_service_booted_idx').on(t.service, t.bootedAt),
767 buildShaIdx: index('deploy_history_build_sha_idx').on(t.buildSha),
768 }),
769);
770
771/* ─── project_schedules (cron-triggered function invocations) ─────── */
772export const scheduleRunStatus = ['pending', 'ok', 'error', 'skipped'] as const;
773export type ScheduleRunStatus = (typeof scheduleRunStatus)[number];
774
775export const projectSchedules = pgTable(
776 'project_schedules',
777 {
778 id: id(),
779 projectId: text('project_id')
780 .notNull()
781 .references(() => projects.id, { onDelete: 'cascade' }),
782 name: text('name').notNull(),
783 functionName: text('function_name').notNull(),
784 // 5-field UTC cron expression (minute hour day month dow). The
785 // service validates on write so we don't store anything the
786 // dispatcher can't parse.
787 cronExpression: text('cron_expression').notNull(),
788 args: jsonb('args').$type<Record<string, unknown>>().notNull().default({}),
789 enabled: boolean('enabled').notNull().default(true),
790 // Dispatcher claims rows by checking next_run_at <= now() and
791 // bumping it forward in the same transaction. Indexing the partial
792 // (enabled = true) subset keeps the claim query cheap as the table
793 // grows.
794 nextRunAt: ts('next_run_at').notNull(),
795 lastRunAt: ts('last_run_at'),
796 lastRunStatus: text('last_run_status').$type<ScheduleRunStatus>(),
797 lastRunError: text('last_run_error'),
798 createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
799 createdAt: createdAt(),
800 updatedAt: updatedAt(),
801 deletedAt: deletedAt(),
802 },
803 (t) => ({
804 projectNameIdx: uniqueIndex('project_schedules_project_name_idx')
805 .on(t.projectId, t.name)
806 .where(sql`deleted_at is null`),
807 dueIdx: index('project_schedules_due_idx')
808 .on(t.nextRunAt)
809 .where(sql`enabled = true and deleted_at is null`),
810 }),
811);
812
813/* ─── webhook_endpoints (customer-defined inbound webhooks) ───────── */
814export const webhookDeliveryStatus = ['ok', 'rejected_signature', 'rejected_replay', 'invoke_error', 'disabled'] as const;
815export type WebhookDeliveryStatus = (typeof webhookDeliveryStatus)[number];
816
817export const webhookEndpoints = pgTable(
818 'webhook_endpoints',
819 {
820 id: id(),
821 projectId: text('project_id')
822 .notNull()
823 .references(() => projects.id, { onDelete: 'cascade' }),
824 name: text('name').notNull(),
825 // The function this endpoint dispatches to. Validated on write
826 // against the project's deployed function list; can be edited later
827 // without breaking — invocations to a missing function record an
828 // `invoke_error` delivery and return 502 to the source.
829 functionName: text('function_name').notNull(),
830 // AES-256-GCM ciphertext (same KEK + format as project-env). The
831 // plaintext is HMAC-SHA256'd over `${timestamp}.${rawBody}` to verify
832 // X-Briven-Signature on every inbound request. Rotated by minting a
833 // fresh secret + setting it here in a single UPDATE.
834 signingSecretEncrypted: text('signing_secret_encrypted').notNull(),
835 enabled: boolean('enabled').notNull().default(true),
836 lastDeliveryAt: ts('last_delivery_at'),
837 lastDeliveryStatus: text('last_delivery_status').$type<WebhookDeliveryStatus>(),
838 createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
839 createdAt: createdAt(),
840 updatedAt: updatedAt(),
841 deletedAt: deletedAt(),
842 },
843 (t) => ({
844 projectNameIdx: uniqueIndex('webhook_endpoints_project_name_idx')
845 .on(t.projectId, t.name)
846 .where(sql`deleted_at is null`),
847 projectIdx: index('webhook_endpoints_project_idx')
848 .on(t.projectId)
849 .where(sql`deleted_at is null`),
850 }),
851);
852
853/* ─── webhook_deliveries (per-request audit log) ──────────────────── */
854export const webhookDeliveries = pgTable(
855 'webhook_deliveries',
856 {
857 id: id(),
858 endpointId: text('endpoint_id')
859 .notNull()
860 .references(() => webhookEndpoints.id, { onDelete: 'cascade' }),
861 // Denormalised — saves a join when the dashboard's per-project
862 // "recent deliveries" view loads, and lets the per-project retention
863 // cron prune rows without walking the endpoints table.
864 projectId: text('project_id')
865 .notNull()
866 .references(() => projects.id, { onDelete: 'cascade' }),
867 status: text('status').$type<WebhookDeliveryStatus>().notNull(),
868 // HMAC-SHA256 of the source IP with the audit pepper, same scheme as
869 // audit_logs.ip_hash. Per CLAUDE.md §5.1 we never store raw IPs.
870 sourceIpHash: text('source_ip_hash'),
871 // The function the dispatcher tried to call (snapshot from the
872 // endpoint at delivery time — useful when the endpoint config has
873 // since changed).
874 functionName: text('function_name'),
875 // Inner invoke duration in ms (excludes signature verification time).
876 // Null for deliveries that never reached the runtime (signature reject).
877 durationMs: text('duration_ms'),
878 errorMessage: text('error_message'),
879 createdAt: createdAt(),
880 },
881 (t) => ({
882 endpointIdx: index('webhook_deliveries_endpoint_idx').on(t.endpointId, t.createdAt),
883 projectIdx: index('webhook_deliveries_project_idx').on(t.projectId, t.createdAt),
884 }),
885);
886
887/* ─── abuse_reports (dedicated table; replaces audit-log overload) ── */
888export const abuseSeverity = ['spam', 'phishing', 'malware', 'csam', 'tos', 'other'] as const;
889export type AbuseSeverity = (typeof abuseSeverity)[number];
890
891export const abuseStatus = ['open', 'triaged', 'resolved'] as const;
892export type AbuseStatusValue = (typeof abuseStatus)[number];
893
894export const abuseResolution = ['no_action', 'warned', 'suspended', 'banned'] as const;
895export type AbuseResolutionValue = (typeof abuseResolution)[number];
896
897export const abuseReports = pgTable(
898 'abuse_reports',
899 {
900 id: id(),
901 targetUrl: text('target_url').notNull(),
902 reason: text('reason').notNull(),
903 severity: text('severity').$type<AbuseSeverity>().notNull(),
904 reporterContact: text('reporter_contact'),
905 // ip_hash + user_agent of the submission. Per CLAUDE.md §5.1 we
906 // never store raw IPs even in the abuse pipeline.
907 sourceIpHash: text('source_ip_hash'),
908 sourceUserAgent: text('source_user_agent'),
909 status: text('status').$type<AbuseStatusValue>().notNull().default('open'),
910 resolution: text('resolution').$type<AbuseResolutionValue>(),
911 // Populated by the resolver when the report maps to a real project.
912 // FK is `set null` rather than `cascade` — if the project gets hard
913 // deleted later we keep the report's history intact for audit.
914 projectId: text('project_id').references(() => projects.id, { onDelete: 'set null' }),
915 triagedAt: ts('triaged_at'),
916 triagedBy: text('triaged_by').references(() => users.id, { onDelete: 'set null' }),
917 triageNotes: text('triage_notes'),
918 resolvedAt: ts('resolved_at'),
919 resolvedBy: text('resolved_by').references(() => users.id, { onDelete: 'set null' }),
920 resolveNotes: text('resolve_notes'),
921 createdAt: createdAt(),
922 updatedAt: updatedAt(),
923 },
924 (t) => ({
925 statusIdx: index('abuse_reports_status_idx').on(t.status, t.createdAt),
926 severityIdx: index('abuse_reports_severity_idx').on(t.severity, t.createdAt),
927 projectIdx: index('abuse_reports_project_idx').on(t.projectId).where(sql`project_id is not null`),
928 }),
929);
930
931/* ─── incidents (operator-published status events) ─────────────────── */
932// Hand-curated platform-incident log. An admin opens an incident when
933// something customer-impacting starts, edits the narrative as the
934// situation unfolds, and resolves it when restored. The public status
935// page and RSS feed read from this table.
936
937export const incidentSeverity = ['critical', 'major', 'minor', 'maintenance'] as const;
938export type IncidentSeverity = (typeof incidentSeverity)[number];
939
940export const incidents = pgTable(
941 'incidents',
942 {
943 id: id(),
944 startedAt: ts('started_at').notNull(),
945 resolvedAt: ts('resolved_at'),
946 severity: text('severity').$type<IncidentSeverity>().notNull(),
947 // List of affected services: 'api' | 'realtime' | 'runtime' | 'web'
948 // | 'docs' | 'all'. Stored as jsonb so we can grow the vocabulary
949 // without a migration.
950 services: jsonb('services').$type<readonly string[]>().notNull(),
951 summary: text('summary').notNull(),
952 postmortem: text('postmortem').notNull().default(''),
953 createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
954 createdAt: createdAt(),
955 updatedAt: updatedAt(),
956 },
957 (t) => ({
958 startedIdx: index('incidents_started_idx').on(t.startedAt),
959 activeIdx: index('incidents_active_idx').on(t.startedAt).where(sql`resolved_at is null`),
960 }),
961);
962
963/* ─── migration_requests (customer-initiated platform import intake) ── */
964// One row per dashboard-wizard submission. The wizard at
965// /dashboard/projects/new/migrate collects source platform + scale +
966// credentials/notes; this row is then triaged by an operator via
967// /dashboard/admin/migrations and either auto-migrated (once the
968// adapter for the source ships) or hand-migrated for free during beta.
969export const migrationSources = [
970 'convex',
971 'supabase',
972 'firebase',
973 'mongodb',
974 'drizzle',
975 'prisma',
976 'postgres',
977 'hasura',
978 'nextauth',
979 'other',
980] as const;
981export type MigrationSource = (typeof migrationSources)[number];
982
983export const migrationUrgencies = [
984 'direct',
985 'this_week',
986 'this_month',
987 'this_quarter',
988 'exploring',
989] as const;
990export type MigrationUrgency = (typeof migrationUrgencies)[number];
991
992export const migrationStatuses = [
993 'new',
994 'contacted',
995 'scheduled',
996 'in_progress',
997 'completed',
998 'cancelled',
999] as const;
1000export type MigrationStatus = (typeof migrationStatuses)[number];
1001
1002export const migrationRequests = pgTable(
1003 'migration_requests',
1004 {
1005 id: id(),
1006 // Nullable to support unauthenticated leads submitted via the
1007 // /migrate marketing form. When the operator promotes a lead (or
1008 // the customer signs up later), the operator patches user_id from
1009 // the admin triage row.
1010 userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }),
1011 orgId: text('org_id').references(() => organizations.id, { onDelete: 'set null' }),
1012 source: text('source').$type<MigrationSource>().notNull(),
1013 sourceUrl: text('source_url'),
1014 sourceNotes: text('source_notes').notNull().default(''),
1015 estimatedTables: integer('estimated_tables'),
1016 // bigint serialized as string in the wire format; jsonb-style number
1017 // is unsafe for >2^53. We accept up to ~10^15 rows (no real customer
1018 // hits that, but the column shouldn't artificially cap it).
1019 estimatedRows: bigint('estimated_rows', { mode: 'bigint' }),
1020 estimatedFunctions: integer('estimated_functions'),
1021 urgency: text('urgency').$type<MigrationUrgency>().notNull().default('exploring'),
1022 status: text('status').$type<MigrationStatus>().notNull().default('new'),
1023 contactEmail: text('contact_email').notNull(),
1024 assignedTo: text('assigned_to').references(() => users.id, { onDelete: 'set null' }),
1025 operatorNotes: text('operator_notes').notNull().default(''),
1026 createdAt: createdAt(),
1027 updatedAt: updatedAt(),
1028 },
1029 (t) => ({
1030 createdIdx: index('migration_requests_created_idx').on(t.createdAt),
1031 userIdx: index('migration_requests_user_idx').on(t.userId, t.createdAt),
1032 openIdx: index('migration_requests_open_idx')
1033 .on(t.createdAt)
1034 .where(sql`status not in ('completed', 'cancelled')`),
1035 }),
1036);
1037
1038/* ─── platform_settings (single-row dashboard-controllable flags) ─── */
1039// Key/value JSONB store for platform-level flags an admin needs to flip
1040// without a container restart. Today: `openSignups` (boolean). Future:
1041// rate-limit overrides, maintenance-mode toggle, feature flags.
1042//
1043// Reads cache in-process for 60s — the auth signup hot path touches
1044// this on every signup attempt and we don't want a DB roundtrip there
1045// per request. Writes invalidate the cache (single-process; on multi-
1046// instance the next read picks up the change within the TTL window).
1047
1048export const platformSettings = pgTable('platform_settings', {
1049 key: text('key').primaryKey(),
1050 value: jsonb('value').$type<unknown>().notNull(),
1051 updatedAt: updatedAt(),
1052 updatedBy: text('updated_by').references(() => users.id, { onDelete: 'set null' }),
1053});
1054
1055/* ─── signup_allowlist (invite-only beta gate) ────────────────────── */
1056// When BRIVEN_OPEN_SIGNUPS=false (the default for the private beta),
1057// Better Auth's user.create hook rejects any email not on this list. An
1058// admin manages entries via /dashboard/admin/allowlist. accepted_at is
1059// stamped once the email signs in for the first time so the operator
1060// can see who's claimed their invite vs who's still pending.
1061
1062export const signupAllowlist = pgTable(
1063 'signup_allowlist',
1064 {
1065 id: id(),
1066 email: text('email').notNull(),
1067 invitedBy: text('invited_by').references(() => users.id, { onDelete: 'set null' }),
1068 invitedAt: createdAt(),
1069 acceptedAt: ts('accepted_at'),
1070 notes: text('notes'),
1071 },
1072 (t) => ({
1073 emailIdx: uniqueIndex('signup_allowlist_email_idx').on(t.email),
1074 }),
1075);
1076
1077/* ─── webhook_subscribers (customer-defined outbound webhooks) ────── */
1078// Platform → customer fan-out: when briven emits an event (abuse report
1079// opened, deploy succeeded/failed, tier changed) we POST a signed payload
1080// to every matching subscriber's target_url. Customers verify our
1081// signature exactly the way external sources verify theirs on the
1082// inbound path — same HMAC scheme, same X-Briven-* headers.
1083
1084export const webhookOutboundStatus = [
1085 'pending',
1086 'ok',
1087 'failed',
1088 'cancelled',
1089] as const;
1090export type WebhookOutboundStatus = (typeof webhookOutboundStatus)[number];
1091
1092export const webhookSubscribers = pgTable(
1093 'webhook_subscribers',
1094 {
1095 id: id(),
1096 projectId: text('project_id')
1097 .notNull()
1098 .references(() => projects.id, { onDelete: 'cascade' }),
1099 name: text('name').notNull(),
1100 targetUrl: text('target_url').notNull(),
1101 // Comma-separated event-type allowlist. `*` matches everything.
1102 // Concrete values today: abuse.report.opened, deploy.succeeded,
1103 // deploy.failed, tier.changed, project.suspended.
1104 eventTypes: text('event_types').notNull().default('*'),
1105 // Same AES-256-GCM scheme as inbound webhook_endpoints. Plaintext
1106 // returned once at create + on rotate.
1107 signingSecretEncrypted: text('signing_secret_encrypted').notNull(),
1108 enabled: boolean('enabled').notNull().default(true),
1109 lastDeliveryAt: ts('last_delivery_at'),
1110 lastDeliveryStatus: text('last_delivery_status').$type<WebhookOutboundStatus>(),
1111 /** Comma-separated IP allowlist. Empty = no restriction. */
1112 allowedIps: text('allowed_ips').notNull().default(''),
1113 createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
1114 createdAt: createdAt(),
1115 updatedAt: updatedAt(),
1116 deletedAt: deletedAt(),
1117 },
1118 (t) => ({
1119 projectNameIdx: uniqueIndex('webhook_subscribers_project_name_idx')
1120 .on(t.projectId, t.name)
1121 .where(sql`deleted_at is null`),
1122 projectIdx: index('webhook_subscribers_project_idx')
1123 .on(t.projectId)
1124 .where(sql`deleted_at is null`),
1125 }),
1126);
1127
1128/* ─── webhook_outbound_deliveries (per-attempt retry log) ─────────── */
1129export const webhookOutboundDeliveries = pgTable(
1130 'webhook_outbound_deliveries',
1131 {
1132 id: id(),
1133 subscriberId: text('subscriber_id')
1134 .notNull()
1135 .references(() => webhookSubscribers.id, { onDelete: 'cascade' }),
1136 projectId: text('project_id')
1137 .notNull()
1138 .references(() => projects.id, { onDelete: 'cascade' }),
1139 // Stable event id — survives across retry rows. The customer's
1140 // function can dedupe on this header (X-Briven-Event-Id).
1141 eventId: text('event_id').notNull(),
1142 eventType: text('event_type').notNull(),
1143 // The serialised JSON we POST. Stored once at publish so retries
1144 // send identical bytes even if upstream state has moved on.
1145 payload: jsonb('payload').$type<Record<string, unknown>>().notNull(),
1146 status: text('status').$type<WebhookOutboundStatus>().notNull().default('pending'),
1147 attemptCount: text('attempt_count').notNull().default('0'),
1148 // Set on every state transition. Dispatcher claims rows where
1149 // next_attempt_at <= now() AND status = 'pending'.
1150 nextAttemptAt: ts('next_attempt_at').notNull(),
1151 lastAttemptAt: ts('last_attempt_at'),
1152 statusCode: text('status_code'),
1153 durationMs: text('duration_ms'),
1154 errorMessage: text('error_message'),
1155 createdAt: createdAt(),
1156 },
1157 (t) => ({
1158 dueIdx: index('webhook_outbound_deliveries_due_idx')
1159 .on(t.nextAttemptAt)
1160 .where(sql`status = 'pending'`),
1161 subscriberIdx: index('webhook_outbound_deliveries_subscriber_idx').on(
1162 t.subscriberId,
1163 t.createdAt,
1164 ),
1165 projectIdx: index('webhook_outbound_deliveries_project_idx').on(t.projectId, t.createdAt),
1166 eventIdIdx: index('webhook_outbound_deliveries_event_id_idx').on(t.eventId),
1167 }),
1168);
1169
1170/* ─── project_files (S3-compatible object storage metadata) ──────── */
1171export const projectFiles = pgTable(
1172 'project_files',
1173 {
1174 id: id(),
1175 projectId: text('project_id')
1176 .notNull()
1177 .references(() => projects.id, { onDelete: 'cascade' }),
1178 // Human-facing name (the original filename when known). Not unique
1179 // — same name twice is fine, identity is the id + object key.
1180 name: text('name').notNull(),
1181 // Storage object key — `projects/<projectId>/<fileId>`. Set once on
1182 // create; never edited. The id alone is enough to derive this but
1183 // we persist it so future-us doesn't depend on the derivation rule.
1184 objectKey: text('object_key').notNull(),
1185 contentType: text('content_type').notNull(),
1186 sizeBytes: text('size_bytes').notNull(),
1187 // sha256 of the object body, populated on confirm. Null while the
1188 // upload is mid-flight. Stays null when we don't compute it.
1189 checksumSha256: text('checksum_sha256'),
1190 uploadedBy: text('uploaded_by').references(() => users.id, { onDelete: 'set null' }),
1191 createdAt: createdAt(),
1192 updatedAt: updatedAt(),
1193 deletedAt: deletedAt(),
1194 },
1195 (t) => ({
1196 projectIdx: index('project_files_project_idx').on(t.projectId).where(sql`deleted_at is null`),
1197 objectKeyIdx: uniqueIndex('project_files_object_key_idx').on(t.objectKey),
1198 }),
1199);
1200
1201/* ─── project_storage_grants (M5 — cross-project storage sharing) ─────
1202 * A single sanctioned exception to strict cross-project isolation: a GRANTER
1203 * project explicitly shares one file (by file id) OR a whole path prefix with a
1204 * GRANTEE project. The grantee can then mint a download URL for exactly the
1205 * granted resource — nothing else. Strict-deny by construction: access is
1206 * allowed ONLY when a matching row exists with `revoked_at IS NULL`. Every
1207 * grant / revoke / shared read is audited. Only the granter can revoke.
1208 */
1209export const projectStorageGrants = pgTable(
1210 'project_storage_grants',
1211 {
1212 id: id(),
1213 // The project that OWNS the resource and is doing the sharing. FK + cascade:
1214 // deleting the granter project sweeps its grants.
1215 granterProjectId: text('granter_project_id')
1216 .notNull()
1217 .references(() => projects.id, { onDelete: 'cascade' }),
1218 // The project being granted read access. Plain text (not FK) — a grant may be
1219 // pre-created for a grantee and must survive independent of that project row.
1220 granteeProjectId: text('grantee_project_id').notNull(),
1221 // Either an exact file id (is_prefix = false) or a path prefix string
1222 // (is_prefix = true) that covers every file whose object path starts with it.
1223 resource: text('resource').notNull(),
1224 isPrefix: boolean('is_prefix').notNull().default(false),
1225 createdBy: text('created_by'),
1226 createdAt: createdAt(),
1227 // Null = active. Set (by the granter) = revoked; enforcement ignores it.
1228 revokedAt: ts('revoked_at'),
1229 },
1230 (t) => ({
1231 // One live grant per (granter, grantee, resource) triple. Revoke sets
1232 // revoked_at rather than deleting, so re-granting the same resource reuses
1233 // the row (see storage-grants service) — the unique index stays satisfied.
1234 grantUniqueIdx: uniqueIndex('project_storage_grants_unique_idx').on(
1235 t.granterProjectId,
1236 t.granteeProjectId,
1237 t.resource,
1238 ),
1239 granteeIdx: index('project_storage_grants_grantee_idx').on(t.granteeProjectId),
1240 }),
1241);
1242
1243/* ─── project_storage_share_links (M5 — tokenized public share-links) ─
1244 * The public-link half of M5. A file OWNER mints a signed public link — a URL
1245 * carrying a cryptographically-random token that ANYONE can open for a LIMITED
1246 * TIME, with NO project/auth needed — and can revoke it at will. Unlike a grant
1247 * (project→project), this exposes ONE file to the open internet, so it is
1248 * strict-deny by construction: `resolveShareLink(token)` returns the file ONLY
1249 * when a row matches the exact token AND revoked_at IS NULL AND expires_at > now.
1250 * The token is the only bearer credential; it is NEVER written to the audit log.
1251 * A link works even for a file that is NOT marked public — that is the point of
1252 * a link — but ONLY via a valid, unexpired, unrevoked token.
1253 */
1254export const projectStorageShareLinks = pgTable(
1255 'project_storage_share_links',
1256 {
1257 id: id(),
1258 // The project that OWNS the file being shared. FK + cascade: deleting the
1259 // project sweeps its links (the link then can never resolve).
1260 projectId: text('project_id')
1261 .notNull()
1262 .references(() => projects.id, { onDelete: 'cascade' }),
1263 // The owned file this link exposes (an exact file id — never a prefix).
1264 fileId: text('file_id').notNull(),
1265 // The bearer credential: a URL-safe, ≥32-byte random token. UNIQUE so a token
1266 // collision can never let one link resolve another link's file.
1267 token: text('token').notNull(),
1268 // Hard expiry — NOT NULL. Enforcement requires expires_at > now().
1269 expiresAt: ts('expires_at').notNull(),
1270 createdBy: text('created_by'),
1271 createdAt: createdAt(),
1272 // Null = active. Set (by the owner) = revoked; enforcement treats it as gone.
1273 revokedAt: ts('revoked_at'),
1274 },
1275 (t) => ({
1276 // The token is the lookup key for public resolution — unique + indexed.
1277 tokenIdx: uniqueIndex('project_storage_share_links_token_idx').on(t.token),
1278 // Owner-scoped list / revoke walk this index.
1279 projectIdx: index('project_storage_share_links_project_idx').on(t.projectId),
1280 }),
1281);
1282
1283/* ─── type exports ────────────────────────────────────────────────── */
1284export type User = typeof users.$inferSelect;
1285export type NewUser = typeof users.$inferInsert;
1286export type Project = typeof projects.$inferSelect;
1287export type NewProject = typeof projects.$inferInsert;
1288export type ApiKey = typeof apiKeys.$inferSelect;
1289export type NewApiKey = typeof apiKeys.$inferInsert;
1290export type Deployment = typeof deployments.$inferSelect;
1291export type NewDeployment = typeof deployments.$inferInsert;
1292export type AuditLog = typeof auditLogs.$inferSelect;
1293export type KnownAnswer = typeof mcpKnownAnswers.$inferSelect;
1294export type NewKnownAnswer = typeof mcpKnownAnswers.$inferInsert;
1295export type FunctionLog = typeof functionLogs.$inferSelect;
1296export type NewFunctionLog = typeof functionLogs.$inferInsert;
1297export type NewAuditLog = typeof auditLogs.$inferInsert;
1298export type EmailSuppression = typeof emailSuppressions.$inferSelect;
1299export type NewEmailSuppression = typeof emailSuppressions.$inferInsert;
1300export type DeployHistoryEntry = typeof deployHistory.$inferSelect;
1301export type NewDeployHistoryEntry = typeof deployHistory.$inferInsert;
1302export type UsageEvent = typeof usageEvents.$inferSelect;
1303export type NewUsageEvent = typeof usageEvents.$inferInsert;
1304export type ProjectSchedule = typeof projectSchedules.$inferSelect;
1305export type NewProjectSchedule = typeof projectSchedules.$inferInsert;
1306export type ProjectFile = typeof projectFiles.$inferSelect;
1307export type NewProjectFile = typeof projectFiles.$inferInsert;
1308export type ProjectStorageGrant = typeof projectStorageGrants.$inferSelect;
1309export type NewProjectStorageGrant = typeof projectStorageGrants.$inferInsert;
1310export type ProjectStorageShareLink = typeof projectStorageShareLinks.$inferSelect;
1311export type NewProjectStorageShareLink = typeof projectStorageShareLinks.$inferInsert;
1312export type WebhookEndpoint = typeof webhookEndpoints.$inferSelect;
1313export type NewWebhookEndpoint = typeof webhookEndpoints.$inferInsert;
1314export type WebhookDelivery = typeof webhookDeliveries.$inferSelect;
1315export type NewWebhookDelivery = typeof webhookDeliveries.$inferInsert;
1316export type WebhookSubscriber = typeof webhookSubscribers.$inferSelect;
1317export type NewWebhookSubscriber = typeof webhookSubscribers.$inferInsert;
1318export type WebhookOutboundDelivery = typeof webhookOutboundDeliveries.$inferSelect;
1319export type NewWebhookOutboundDelivery = typeof webhookOutboundDeliveries.$inferInsert;
1320export type AbuseReport = typeof abuseReports.$inferSelect;
1321export type NewAbuseReport = typeof abuseReports.$inferInsert;
1322export type SignupAllowlistEntry = typeof signupAllowlist.$inferSelect;
1323export type NewSignupAllowlistEntry = typeof signupAllowlist.$inferInsert;
1324export type PlatformSetting = typeof platformSettings.$inferSelect;
1325export type NewPlatformSetting = typeof platformSettings.$inferInsert;
1326export type Incident = typeof incidents.$inferSelect;
1327export type NewIncident = typeof incidents.$inferInsert;
1328export type MigrationRequest = typeof migrationRequests.$inferSelect;
1329export type NewMigrationRequest = typeof migrationRequests.$inferInsert;
1330
1331/* ─── marketing_events (funnel tracking for /migrate) ────────────── */
1332export const marketingEventTypes = [
1333 'migrate_view',
1334 'migrate_lead_submitted',
1335] as const;
1336export type MarketingEventType = (typeof marketingEventTypes)[number];
1337
1338export const marketingEvents = pgTable(
1339 'marketing_events',
1340 {
1341 id: id(),
1342 eventType: text('event_type').$type<MarketingEventType>().notNull(),
1343 source: text('source').notNull(),
1344 ipHash: text('ip_hash'),
1345 userAgent: text('user_agent'),
1346 createdAt: createdAt(),
1347 },
1348 (t) => ({
1349 lookupIdx: index('marketing_events_lookup_idx').on(t.source, t.eventType, t.createdAt),
1350 }),
1351);
1352
1353export type MarketingEvent = typeof marketingEvents.$inferSelect;
1354export type NewMarketingEvent = typeof marketingEvents.$inferInsert;
1355
1356/* ─── briven_auth_sdk_keys (SDK keys issued from the Auth → API Keys panel) ─ */
1357// Different shape than `api_keys` (which is for CLI / deploy auth): SDK
1358// keys carry an `auth`-specific scope vocabulary and a `pk_briven_auth_`
1359// plaintext prefix so they're recognisable in logs + grep. Plaintext is
1360// returned exactly once on creation; only a sha-256 hex digest persists.
1361// The bare `prefix` column is the constant `pk_briven_auth_` — kept as a
1362// column rather than hard-coded so a future v2 key scheme can coexist
1363// without a migration.
1364// NB: schema-diff requires `pnpm --filter @briven/api db:generate` in a
1365// real TTY (road-to-ga.md §2.9) to land an actual migration; until that
1366// runs the column-set above is only authoritative in TypeScript.
1367
1368export const brivenAuthSdkKeyScope = ['read', 'read-write', 'admin'] as const;
1369export type BrivenAuthSdkKeyScope = (typeof brivenAuthSdkKeyScope)[number];
1370
1371export const brivenAuthSdkKeys = pgTable(
1372 'briven_auth_sdk_keys',
1373 {
1374 id: id(),
1375 projectId: text('project_id')
1376 .notNull()
1377 .references(() => projects.id, { onDelete: 'cascade' }),
1378 createdBy: text('created_by')
1379 .notNull()
1380 .references(() => users.id),
1381 name: text('name').notNull(),
1382 // sha-256 hex digest of the plaintext token. Never the plaintext itself.
1383 hash: text('hash').notNull(),
1384 // AES-256-GCM ciphertext of the plaintext (migration 0039), encrypted with
1385 // BRIVEN_ENCRYPTION_KEY. Used only for the audited "copy again" reveal
1386 // path. NULL for pre-0039 keys (those can never be revealed — rotate).
1387 // Auth verification always uses `hash`, never this column.
1388 encryptedKey: text('encrypted_key'),
1389 // Plaintext prefix — currently always `pk_briven_auth_`. Stored so we
1390 // can render `<prefix>•••<suffix>` for the dashboard hint without
1391 // baking the constant into UI code.
1392 prefix: text('prefix').notNull(),
1393 // Last 4 chars of the plaintext — safe to display.
1394 suffix: varchar('suffix', { length: 4 }).notNull(),
1395 scope: text('scope').$type<BrivenAuthSdkKeyScope>().notNull().default('read'),
1396 lastUsedAt: ts('last_used_at'),
1397 expiresAt: ts('expires_at'),
1398 createdAt: createdAt(),
1399 revokedAt: ts('revoked_at'),
1400 },
1401 (t) => ({
1402 hashIdx: uniqueIndex('briven_auth_sdk_keys_hash_idx').on(t.hash),
1403 projectIdx: index('briven_auth_sdk_keys_project_idx').on(t.projectId),
1404 }),
1405);
1406
1407export type BrivenAuthSdkKey = typeof brivenAuthSdkKeys.$inferSelect;
1408export type NewBrivenAuthSdkKey = typeof brivenAuthSdkKeys.$inferInsert;
1409
1410/* ─── project_auto_snapshot_settings (automatic scheduled snapshots) ─ */
1411// One row per project that has automatic save-points configured. Drives
1412// the auto-snapshot worker: a project is "due" when enabled = true and
1413// next_run_at <= now(). The worker takes an `auto` snapshot (see
1414// services/snapshots.ts), then prunes auto snapshots beyond
1415// retention_count — manual snapshots are never touched. Storing the
1416// schedule state here (not in the per-project data-plane schema) keeps
1417// the due-scan a single cheap control-plane query.
1418export const autoSnapshotFrequency = ['daily', 'twice_daily'] as const;
1419export type AutoSnapshotFrequency = (typeof autoSnapshotFrequency)[number];
1420
1421export const autoSnapshotRunStatus = ['ok', 'error', 'skipped'] as const;
1422export type AutoSnapshotRunStatus = (typeof autoSnapshotRunStatus)[number];
1423
1424export const projectAutoSnapshotSettings = pgTable(
1425 'project_auto_snapshot_settings',
1426 {
1427 id: id(),
1428 // One settings row per project. Unique so upsert-on-project is safe.
1429 projectId: text('project_id')
1430 .notNull()
1431 .references(() => projects.id, { onDelete: 'cascade' }),
1432 enabled: boolean('enabled').notNull().default(false),
1433 frequency: text('frequency').$type<AutoSnapshotFrequency>().notNull().default('daily'),
1434 // Keep the last N automatic snapshots; older auto snapshots are pruned
1435 // after each successful run. Manual snapshots are out of scope and
1436 // never counted or pruned.
1437 retentionCount: integer('retention_count').notNull().default(7),
1438 // The worker claims rows by checking next_run_at <= now() and bumping
1439 // it forward in the same transaction. Indexed on the enabled subset so
1440 // the due-scan stays cheap as the table grows.
1441 nextRunAt: ts('next_run_at').notNull(),
1442 lastRunAt: ts('last_run_at'),
1443 lastRunStatus: text('last_run_status').$type<AutoSnapshotRunStatus>(),
1444 lastRunError: text('last_run_error'),
1445 createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
1446 createdAt: createdAt(),
1447 updatedAt: updatedAt(),
1448 },
1449 (t) => ({
1450 projectIdx: uniqueIndex('project_auto_snapshot_settings_project_idx').on(t.projectId),
1451 dueIdx: index('project_auto_snapshot_settings_due_idx')
1452 .on(t.nextRunAt)
1453 .where(sql`enabled = true`),
1454 }),
1455);
1456
1457export type ProjectAutoSnapshotSettings = typeof projectAutoSnapshotSettings.$inferSelect;
1458export type NewProjectAutoSnapshotSettings = typeof projectAutoSnapshotSettings.$inferInsert;
1459
1460/* ─── platform_agents (admin-registered AI agents) ────────────────── */
1461// Platform-level registry of named AI agents (anthropic / openai / ollama /
1462// custom endpoints) that admins wire up from the cockpit. Unlike mcp_keys /
1463// briven_auth_sdk_keys — where WE mint the secret and store only a hash —
1464// the api key here is INPUT by the admin and must be recoverable server-side
1465// (the /test ping and future outbound calls present it to the provider), so
1466// it is stored AES-256-GCM encrypted via services/tenant-secret-store.ts
1467// (HKDF key derivation salted with the agent id). Plaintext never lands in
1468// the database, in logs, or in any response; the dashboard only ever sees
1469// `key_prefix…key_suffix`, mirroring the mcp-access maskKey pattern.
1470export const platformAgentScope = ['read', 'read-write', 'admin'] as const;
1471export type PlatformAgentScope = (typeof platformAgentScope)[number];
1472
1473export const platformAgents = pgTable(
1474 'platform_agents',
1475 {
1476 id: id(),
1477 // Human-facing agent name — the registry identity, so it is unique.
1478 name: text('name').notNull(),
1479 // Freeform provider label ('anthropic' | 'openai' | 'ollama' | 'custom'
1480 // by convention, but not constrained — new providers need no migration).
1481 provider: text('provider').notNull(),
1482 // Optional base URL. Required in practice for ollama / custom providers;
1483 // hosted providers fall back to their well-known API origin.
1484 endpoint: text('endpoint'),
1485 model: text('model').notNull(),
1486 scope: text('scope').$type<PlatformAgentScope>().notNull().default('read'),
1487 enabled: boolean('enabled').notNull().default(true),
1488 // AES-256-GCM ciphertext (base64 iv||tag||body) of the provider api key.
1489 // Null when the agent is keyless (e.g. a local ollama endpoint).
1490 encryptedApiKey: text('encrypted_api_key'),
1491 // Display hint only — first chars + last 4 of the plaintext, safe to show.
1492 keyPrefix: text('key_prefix'),
1493 keySuffix: varchar('key_suffix', { length: 4 }),
1494 createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
1495 createdAt: createdAt(),
1496 updatedAt: updatedAt(),
1497 },
1498 (t) => ({
1499 nameIdx: uniqueIndex('platform_agents_name_idx').on(t.name),
1500 enabledIdx: index('platform_agents_enabled_idx').on(t.enabled),
1501 }),
1502);
1503
1504export type PlatformAgent = typeof platformAgents.$inferSelect;
1505export type NewPlatformAgent = typeof platformAgents.$inferInsert;
1506
1507/* ═══════════════════════════════════════════════════════════════════
1508 * RESTORED AFTER MERGE LOSS (2026-07-02)
1509 * The blocks below were dropped when schema.ts was rewritten during a
1510 * branch merge, but their tables exist in production (created by the
1511 * original migrations) and live services still import them. Restored
1512 * verbatim from: 6f2e4ad (mcp_keys), a66783a (tenant_secrets),
1513 * d48bceb (contact/support-ticket blocks, superset of a77d17c).
1514 * ═══════════════════════════════════════════════════════════════════ */
1515
1516/* ─── mcp_keys (B Phase 5 — MCP / Agent-Access keys) ───────────────────── */
1517// Keys an agent / MCP client presents to reach a project once MCP access is
1518// turned on for it. Same one-time-reveal discipline as api_keys and
1519// briven_auth_sdk_keys: the plaintext is returned exactly once on issue; only
1520// a sha-256 hex digest persists. `prefix` is the constant `pk_briven_mcp_`
1521// (kept as a column so a future v2 scheme can coexist without a migration);
1522// `suffix` is the safe-to-show last 4 chars for the `<prefix>•••<suffix>`
1523// dashboard hint. `enabled` is the per-key live switch — revoke flips it false
1524// AND stamps revoked_at. This is only the access SURFACE; the MCP socket
1525// server that consumes these keys is a separate track.
1526export const mcpKeyScope = ['read', 'read-write', 'admin'] as const;
1527export type McpKeyScope = (typeof mcpKeyScope)[number];
1528
1529export const mcpKeys = pgTable(
1530 'mcp_keys',
1531 {
1532 id: id(),
1533 projectId: text('project_id')
1534 .notNull()
1535 .references(() => projects.id, { onDelete: 'cascade' }),
1536 name: text('name').notNull(),
1537 // sha-256 hex digest of the plaintext token. Never the plaintext itself.
1538 hash: text('hash').notNull(),
1539 // Plaintext prefix — currently always `pk_briven_mcp_`.
1540 prefix: text('prefix').notNull(),
1541 // Last 4 chars of the plaintext — safe to display.
1542 suffix: varchar('suffix', { length: 4 }).notNull(),
1543 scope: text('scope').$type<McpKeyScope>().notNull().default('read'),
1544 enabled: boolean('enabled').notNull().default(true),
1545 createdBy: text('created_by')
1546 .notNull()
1547 .references(() => users.id),
1548 createdAt: createdAt(),
1549 lastUsedAt: ts('last_used_at'),
1550 revokedAt: ts('revoked_at'),
1551 },
1552 (t) => ({
1553 hashIdx: uniqueIndex('mcp_keys_hash_idx').on(t.hash),
1554 projectIdx: index('mcp_keys_project_idx').on(t.projectId),
1555 }),
1556);
1557
1558export type McpKey = typeof mcpKeys.$inferSelect;
1559export type NewMcpKey = typeof mcpKeys.$inferInsert;
1560
1561/* ─── tenant_secrets (per-tenant encrypted secrets — OAuth client secrets) ─ */
1562// Persistence layer for the Layer-2 secret primitive in
1563// services/tenant-secret-store.ts (HKDF-SHA256 per-tenant key +
1564// AES-256-GCM). One row per (project, service, name) secret — e.g. a
1565// project's `google_client_secret` for the `auth` service. The ciphertext
1566// in `encrypted_value` is the base64 blob `encryptTenantSecret` returns;
1567// it is NEVER read directly — always through services/tenant-secrets.ts
1568// which wraps decrypt. `service` stores the TenantService string
1569// ('auth' | 'pay') so a single table serves both briven auth and pay
1570// without colliding (key derivation is service-scoped). Control-plane
1571// table (Postgres 17), so `onConflictDoUpdate` upserts are available.
1572export const tenantSecrets = pgTable(
1573 'tenant_secrets',
1574 {
1575 id: id(),
1576 projectId: text('project_id')
1577 .notNull()
1578 .references(() => projects.id, { onDelete: 'cascade' }),
1579 // TenantService discriminator ('auth' | 'pay'). Stored as text so the
1580 // table doesn't need a migration when a third service appears.
1581 service: text('service').notNull(),
1582 // Logical secret name within the (project, service) namespace, e.g.
1583 // 'google_client_secret', 'github_client_secret'.
1584 name: text('name').notNull(),
1585 // base64 ciphertext from encryptTenantSecret. Never read directly.
1586 encryptedValue: text('encrypted_value').notNull(),
1587 createdBy: text('created_by').references(() => users.id),
1588 createdAt: createdAt(),
1589 updatedAt: updatedAt(),
1590 },
1591 (t) => ({
1592 projectServiceNameIdx: uniqueIndex('tenant_secrets_project_service_name_idx').on(
1593 t.projectId,
1594 t.service,
1595 t.name,
1596 ),
1597 projectServiceIdx: index('tenant_secrets_project_service_idx').on(t.projectId, t.service),
1598 }),
1599);
1600
1601export type TenantSecret = typeof tenantSecrets.$inferSelect;
1602export type NewTenantSecret = typeof tenantSecrets.$inferInsert;
1603
1604/* ─── contact_messages (public /contact form intake) ─────────────── */
1605// Public, unauthenticated contact-form submissions from the /contact
1606// marketing page. The sender's email is collected + stored here so the
1607// operator can reply privately — it is never rendered back to the
1608// website. Triaged out-of-band; `handled_at` is stamped once an operator
1609// has actioned the message.
1610
1611export const contactTopics = [
1612 'general',
1613 'support',
1614 'sales',
1615 'self-host',
1616 'security',
1617 'privacy',
1618 'legal',
1619 'other',
1620] as const;
1621export type ContactTopic = (typeof contactTopics)[number];
1622
1623// Support-ticket lifecycle. A contact submission becomes a ticket only
1624// when the sender tagged it with a routing tag (#support/#billing/etc).
1625// A fresh ticket starts at `no_response`; an operator can move it through
1626// the rest. Non-ticketed contact rows leave ticket_number/topic_code NULL
1627// and carry the default status (never surfaced for them).
1628export const ticketStatuses = ['no_response', 'in_review', 'replied', 'closed'] as const;
1629export type TicketStatus = (typeof ticketStatuses)[number];
1630
1631// Per-topic 3-letter code stamped on a ticket. Derived from the primary
1632// routing tag (support→SUP, billing→BIL, technical→TEC, self-hosting→SLF).
1633export const ticketTopicCodes = ['SUP', 'BIL', 'TEC', 'SLF'] as const;
1634export type TicketTopicCode = (typeof ticketTopicCodes)[number];
1635
1636// Who authored a thread message: the operator (admin reply) or the user.
1637export const ticketReplyAuthors = ['operator', 'user'] as const;
1638export type TicketReplyAuthor = (typeof ticketReplyAuthors)[number];
1639
1640export const contactMessages = pgTable(
1641 'contact_messages',
1642 {
1643 id: id(),
1644 name: text('name').notNull(),
1645 email: text('email').notNull(),
1646 topic: text('topic').$type<ContactTopic>().notNull(),
1647 // Free-text "what's this about" line from the form. Nullable: the
1648 // topic-only flow (and older clients) submit without it. Also holds the
1649 // serialized `#tag` routing chips the support form sends.
1650 subject: text('subject'),
1651 message: text('message').notNull(),
1652 // Visitor country auto-detected from their IP on the /contact page and
1653 // submitted as a locked field — a hint for the operator. Nullable when
1654 // it couldn't be resolved (localhost, unknown block).
1655 country: text('country'),
1656 ipHash: text('ip_hash'),
1657 userAgent: text('user_agent'),
1658 // ── Support-ticket columns (0045) ──
1659 // Lifecycle status. NOT NULL with a default so every row has one; only
1660 // meaningful for ticketed rows (ticket_number IS NOT NULL).
1661 status: text('status').$type<TicketStatus>().notNull().default('no_response'),
1662 // Human-facing ticket number stored WITHOUT the leading '#'
1663 // (e.g. SUP260629-000001). NULL for non-ticketed contact messages.
1664 // UNIQUE — a unique index on a nullable column lets the many
1665 // non-ticket rows keep NULL while ticketed rows stay unique.
1666 ticketNumber: text('ticket_number'),
1667 // Primary topic code (SUP/BIL/TEC/SLF). NULL for non-ticketed rows.
1668 topicCode: text('topic_code').$type<TicketTopicCode>(),
1669 // Operator the ticket is assigned to (free-text handle). NULL = unassigned.
1670 assignedTo: text('assigned_to'),
1671 // Internal operator-only triage notes. NEVER surfaced to the user.
1672 operatorNotes: text('operator_notes'),
1673 createdAt: createdAt(),
1674 handledAt: ts('handled_at'),
1675 },
1676 (t) => ({
1677 createdIdx: index('contact_messages_created_idx').on(t.createdAt),
1678 // Nullable-unique: multiple NULLs allowed (non-ticket rows), ticketed
1679 // rows are globally unique.
1680 ticketNumberIdx: uniqueIndex('contact_messages_ticket_number_idx').on(t.ticketNumber),
1681 }),
1682);
1683export type ContactMessage = typeof contactMessages.$inferSelect;
1684
1685/* ─── ticket_counters (daily, per-topic-code sequence) ───────────── */
1686// One row per (topic_code, day). The counter is atomically incremented by
1687// an INSERT ... ON CONFLICT DO UPDATE on ticket creation, giving a
1688// race-safe, gap-tolerant sequence that resets to 1 each new day per code.
1689export const ticketCounters = pgTable(
1690 'ticket_counters',
1691 {
1692 topicCode: text('topic_code').notNull(),
1693 // Calendar day (UTC) the sequence belongs to. String mode → 'YYYY-MM-DD'.
1694 day: date('day', { mode: 'string' }).notNull(),
1695 counter: integer('counter').notNull().default(0),
1696 },
1697 (t) => ({
1698 pk: primaryKey({ columns: [t.topicCode, t.day] }),
1699 }),
1700);
1701export type TicketCounter = typeof ticketCounters.$inferSelect;
1702
1703/* ─── contact_message_replies (ticket thread) ────────────────────── */
1704// Append-only thread of messages on a ticket. An operator reply emails the
1705// sender; a user reply (future inbound path) is recorded too. Cascades when
1706// the parent contact_messages row is deleted.
1707export const contactMessageReplies = pgTable(
1708 'contact_message_replies',
1709 {
1710 id: id(),
1711 messageId: text('message_id')
1712 .notNull()
1713 .references(() => contactMessages.id, { onDelete: 'cascade' }),
1714 author: text('author').$type<TicketReplyAuthor>().notNull(),
1715 body: text('body').notNull(),
1716 createdAt: createdAt(),
1717 },
1718 (t) => ({
1719 messageIdx: index('contact_message_replies_message_idx').on(t.messageId),
1720 }),
1721);
1722export type ContactMessageReply = typeof contactMessageReplies.$inferSelect;
1723
1724/* ─── auth_signup_geo (platform-wide sign-up IP + geo, admin-only SEO) ─── */
1725// One row per end-user sign-up across ALL briven-auth tenant projects.
1726// Written fire-and-forget from the per-tenant Better Auth user.create hook
1727// (services/auth-tenant-pool.ts → services/signup-geo.ts). Feeds the
1728// admin-only "sign-ups · geo" dashboard for the future SEO AI Agency —
1729// "which countries / cities are sign-ups coming from".
1730//
1731// DELIBERATE privacy posture (flndrn-approved): the RAW ip is stored here,
1732// in the control plane, admin-side only. This is INDEPENDENT of Better
1733// Auth's own session.ip_address (which stays disabled via
1734// disableIpTracking) and of the per-project customer users page, which
1735// never sees these rows. Never surface this table in a customer-facing view.
1736//
1737// No FK on user_id — the end-user lives in a per-tenant customer database,
1738// not the control-plane users table, so a control-plane FK would be wrong.
1739// project_id DOES FK to projects (cascade) so cleaning up a project reaps
1740// its analytics rows.
1741export const authSignupGeo = pgTable(
1742 'auth_signup_geo',
1743 {
1744 id: id(),
1745 projectId: text('project_id')
1746 .notNull()
1747 .references(() => projects.id, { onDelete: 'cascade' }),
1748 // The end-user id as minted by the tenant's Better Auth instance.
1749 userId: text('user_id'),
1750 email: text('email'),
1751 // Raw visitor IP at sign-up. Nullable — private/unresolvable requests
1752 // (localhost, missing proxy header) store null rather than a fake value.
1753 ip: text('ip'),
1754 country: text('country'),
1755 city: text('city'),
1756 region: text('region'),
1757 createdAt: createdAt(),
1758 },
1759 (t) => ({
1760 createdIdx: index('auth_signup_geo_created_idx').on(t.createdAt),
1761 projectCreatedIdx: index('auth_signup_geo_project_created_idx').on(t.projectId, t.createdAt),
1762 countryIdx: index('auth_signup_geo_country_idx').on(t.country),
1763 }),
1764);
1765
1766export type AuthSignupGeo = typeof authSignupGeo.$inferSelect;
1767export type NewAuthSignupGeo = typeof authSignupGeo.$inferInsert;