feat(db): Drizzle DAL with withUser/asAdmin GUC wrapper (Phase 2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 15:50:57 +08:00
co-authored by Claude Sonnet 4.6
parent e5fd2436fa
commit a95273b182
9 changed files with 1640 additions and 2 deletions
+28
View File
@@ -0,0 +1,28 @@
import { sql } from 'drizzle-orm'
import type { DrizzleTransaction } from './with-user'
export type { DrizzleTransaction }
/**
* Writes to audit_log via the write_audit_log() SECURITY DEFINER function.
* Must be called inside a withUser() transaction — the function reads app_current_user_id().
* Signature mirrors the DB function exactly.
*/
export async function writeAuditLog(
tx: DrizzleTransaction,
tableName: string,
recordId: string,
action: string,
newValue?: Record<string, unknown> | null,
oldValue?: Record<string, unknown> | null
): Promise<void> {
await tx.execute(
sql`SELECT write_audit_log(
${tableName},
${recordId}::uuid,
${action},
${newValue ? JSON.stringify(newValue) : null}::jsonb,
${oldValue ? JSON.stringify(oldValue) : null}::jsonb
)`
)
}
+29
View File
@@ -0,0 +1,29 @@
import 'server-only'
import { Pool } from 'pg'
import { drizzle } from 'drizzle-orm/node-postgres'
import * as schema from './schema'
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL not configured')
}
if (!process.env.DATABASE_URL_ADMIN) {
throw new Error('DATABASE_URL_ADMIN not configured')
}
// app_user pool: RLS enforced. Used for all normal user operations.
const userPool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
})
// app_admin pool: BYPASSRLS. Used for admin user CRUD and service operations.
const adminPool = new Pool({
connectionString: process.env.DATABASE_URL_ADMIN,
max: 5,
})
export const userDb = drizzle(userPool, { schema })
export const adminDb = drizzle(adminPool, { schema })
export type UserDb = typeof userDb
export type AdminDb = typeof adminDb
+234
View File
@@ -0,0 +1,234 @@
import {
pgTable,
pgEnum,
uuid,
text,
boolean,
timestamp,
smallint,
integer,
date,
jsonb,
customType,
} from 'drizzle-orm/pg-core'
// ---------------------------------------------------------------------------
// Custom type: pgvector
// ---------------------------------------------------------------------------
const vector = customType<{ data: number[]; driverData: string }>({
dataType(config) {
return `vector(${(config as { dimensions?: number }).dimensions ?? 1536})`
},
})
// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------
export const userRoleEnum = pgEnum('user_role', [
'reporter', 'supervisor', 'hse', 'capa_owner', 'management', 'admin',
])
export const incidentTypeEnum = pgEnum('incident_type', [
'injury', 'near_miss', 'hazard', 'asset_damage', 'environmental',
'security', 'fire', 'transport',
])
export const incidentStatusEnum = pgEnum('incident_status', [
'reported', 'triaged', 'investigating', 'capa_pending', 'verification', 'closed',
])
export const medicalStatusEnum = pgEnum('medical_status', [
'none', 'first_aid', 'medical_treatment', 'lti',
])
export const evidenceStageEnum = pgEnum('evidence_stage', [
'report', 'response', 'investigation', 'capa', 'verification',
])
export const rcaMethodEnum = pgEnum('rca_method', ['five_why', 'fishbone', 'other'])
export const capaPriorityEnum = pgEnum('capa_priority', ['low', 'med', 'high'])
export const capaStatusEnum = pgEnum('capa_status', [
'open', 'in_progress', 'overdue', 'pending_verification',
'verified', 'reopened', 'closed',
])
export const doshFormTypeEnum = pgEnum('dosh_form_type', ['jkkp6', 'jkkp7', 'jkkp8'])
export const doshStatusEnum = pgEnum('dosh_status', ['not_required', 'pending', 'submitted'])
export const notificationChannelEnum = pgEnum('notification_channel', [
'email', 'whatsapp', 'in_app',
])
// ---------------------------------------------------------------------------
// Tables (dependency order)
// ---------------------------------------------------------------------------
export const sites = pgTable('sites', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
address: text('address'),
region: text('region'),
active: boolean('active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const zones = pgTable('zones', {
id: uuid('id').primaryKey().defaultRandom(),
siteId: uuid('site_id').notNull().references(() => sites.id),
name: text('name').notNull(),
qrCodeToken: text('qr_code_token').notNull().unique(),
active: boolean('active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().default(''),
email: text('email').notNull().default('').unique(),
phone: text('phone'),
role: userRoleEnum('role').notNull().default('reporter'),
department: text('department'),
siteId: uuid('site_id').references(() => sites.id),
active: boolean('active').notNull().default(true),
passwordHash: text('password_hash').notNull().default(''),
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
lastLoginAt: timestamp('last_login_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const trucks = pgTable('trucks', {
id: uuid('id').primaryKey().defaultRandom(),
truckNo: text('truck_no').notNull().unique(),
carrier: text('carrier'),
active: boolean('active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const incidents = pgTable('incidents', {
id: uuid('id').primaryKey().defaultRandom(),
referenceNo: text('reference_no').unique(),
incidentType: incidentTypeEnum('incident_type').notNull(),
siteId: uuid('site_id').notNull().references(() => sites.id),
zoneId: uuid('zone_id').references(() => zones.id),
truckId: uuid('truck_id').references(() => trucks.id),
reportedBy: uuid('reported_by').notNull().references(() => users.id),
reportedAt: timestamp('reported_at', { withTimezone: true }).notNull().defaultNow(),
description: text('description').notNull(),
severity: smallint('severity'),
status: incidentStatusEnum('status').notNull().default('reported'),
injuryInvolved: boolean('injury_involved').notNull().default(false),
assetInvolved: boolean('asset_involved').notNull().default(false),
medicalStatus: medicalStatusEnum('medical_status'),
lostDays: integer('lost_days'),
isFatality: boolean('is_fatality').notNull().default(false),
isSeriousBodilyInjury: boolean('is_serious_bodily_injury').notNull().default(false),
isDangerousOccurrence: boolean('is_dangerous_occurrence').notNull().default(false),
isOccupationalDisease: boolean('is_occupational_disease').notNull().default(false),
triageNotes: text('triage_notes'),
triagedBy: uuid('triaged_by').references(() => users.id),
triagedAt: timestamp('triaged_at', { withTimezone: true }),
typeDetails: jsonb('type_details'),
embedding: vector('embedding', { dimensions: 768 }),
closedAt: timestamp('closed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const evidenceFiles = pgTable('evidence_files', {
id: uuid('id').primaryKey().defaultRandom(),
incidentId: uuid('incident_id').notNull().references(() => incidents.id),
stage: evidenceStageEnum('stage').notNull(),
fileUrl: text('file_url').notNull(),
fileType: text('file_type').notNull(),
fileHash: text('file_hash').notNull(),
uploadedBy: uuid('uploaded_by').notNull().references(() => users.id),
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).notNull().defaultNow(),
deleted: boolean('deleted').notNull().default(false),
})
export const investigations = pgTable('investigations', {
id: uuid('id').primaryKey().defaultRandom(),
incidentId: uuid('incident_id').notNull().references(() => incidents.id),
investigatorId: uuid('investigator_id').notNull().references(() => users.id),
method: rcaMethodEnum('method').notNull().default('five_why'),
findingsText: text('findings_text'),
rootCauseSummary: text('root_cause_summary'),
alcoholTestResult: text('alcohol_test_result'),
urineTestResult: text('urine_test_result'),
witnessStatementRefs: text('witness_statement_refs').array(),
fiveWhySteps: jsonb('five_why_steps'),
fishboneCategories: jsonb('fishbone_categories'),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const capaActions = pgTable('capa_actions', {
id: uuid('id').primaryKey().defaultRandom(),
incidentId: uuid('incident_id').notNull().references(() => incidents.id),
rootCauseRef: text('root_cause_ref'),
description: text('description').notNull(),
ownerUserId: uuid('owner_user_id').notNull().references(() => users.id),
department: text('department').notNull(),
dueDate: date('due_date').notNull(),
priority: capaPriorityEnum('priority').notNull().default('med'),
status: capaStatusEnum('status').notNull().default('open'),
completedAt: timestamp('completed_at', { withTimezone: true }),
verifiedBy: uuid('verified_by').references(() => users.id),
verifiedAt: timestamp('verified_at', { withTimezone: true }),
effectivenessRecheckDate: date('effectiveness_recheck_date'),
effectivenessRecheckRound: integer('effectiveness_recheck_round').notNull().default(0),
ownerNotes: text('owner_notes'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const doshReports = pgTable('dosh_reports', {
id: uuid('id').primaryKey().defaultRandom(),
incidentId: uuid('incident_id').notNull().references(() => incidents.id),
formType: doshFormTypeEnum('form_type').notNull(),
status: doshStatusEnum('status').notNull().default('not_required'),
submittedAt: timestamp('submitted_at', { withTimezone: true }),
submittedBy: uuid('submitted_by').references(() => users.id),
fileUrl: text('file_url'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const notificationsLog = pgTable('notifications_log', {
id: uuid('id').primaryKey().defaultRandom(),
incidentId: uuid('incident_id').references(() => incidents.id),
capaId: uuid('capa_id').references(() => capaActions.id),
channel: notificationChannelEnum('channel').notNull(),
recipient: text('recipient').notNull(),
recipientUserId: uuid('recipient_user_id').references(() => users.id),
readAt: timestamp('read_at', { withTimezone: true }),
title: text('title'),
link: text('link'),
sentAt: timestamp('sent_at', { withTimezone: true }).notNull().defaultNow(),
status: text('status').notNull().default('sent'),
})
export const auditLog = pgTable('audit_log', {
id: uuid('id').primaryKey().defaultRandom(),
tableName: text('table_name').notNull(),
recordId: uuid('record_id').notNull(),
action: text('action').notNull(),
changedBy: uuid('changed_by').references(() => users.id),
changedAt: timestamp('changed_at', { withTimezone: true }).notNull().defaultNow(),
oldValue: jsonb('old_value'),
newValue: jsonb('new_value'),
})
export const appSettings = pgTable('app_settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
updatedBy: uuid('updated_by').references(() => users.id),
})
export const incidentAddenda = pgTable('incident_addenda', {
id: uuid('id').primaryKey().defaultRandom(),
incidentId: uuid('incident_id').notNull().references(() => incidents.id),
author: uuid('author').notNull().references(() => users.id),
body: text('body').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
+29
View File
@@ -0,0 +1,29 @@
import 'server-only'
import { sql } from 'drizzle-orm'
import { userDb, adminDb, type UserDb, type AdminDb } from './index'
export type DrizzleTransaction = Parameters<Parameters<typeof userDb.transaction>[0]>[0]
/**
* Runs fn inside a transaction with app.user_id SET LOCAL to userId.
* All RLS policies read this GUC via app_current_user_id().
* The GUC is LOCAL so it is automatically cleared when the transaction ends.
*/
export async function withUser<T>(
userId: string,
fn: (tx: DrizzleTransaction) => Promise<T>
): Promise<T> {
return userDb.transaction(async (tx) => {
await tx.execute(sql`SELECT set_config('app.user_id', ${userId}, true)`)
return fn(tx)
})
}
/**
* Runs fn against the admin (BYPASSRLS) pool.
* Use only for: admin user CRUD, service operations that legitimately bypass RLS.
* Never use for regular user data access.
*/
export async function asAdmin<T>(fn: (db: AdminDb) => Promise<T>): Promise<T> {
return fn(adminDb)
}