feat: implement incident reporting and management system
- Add incident reporting routes and logic to handle creation, updating, and retrieval of incidents. - Implement visibility rules for users based on roles (reporters, supervisors). - Create tests for incident reporting functionality to ensure correctness and reliability. - Introduce site and area management routes, including validation for area hierarchy. - Establish user management routes with role-based access control for creating and updating users. - Add setup routes for initializing the application with an organization and admin user. - Implement shared enums and schemas for consistent validation and data handling across the application. - Create a test helper for setting up an in-memory database and API client for testing. - Add TypeScript configuration for server-side code with strict type checking and module resolution.
This commit is contained in:
1 parent
a382fa904a
commit
c69eccb5c1
47 files changed
+6297
-45
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import { PGlite } from '@electric-sql/pglite'
|
||||
import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres'
|
||||
import { migrate as migratePg } from 'drizzle-orm/node-postgres/migrator'
|
||||
import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core'
|
||||
import { drizzle as drizzlePglite } from 'drizzle-orm/pglite'
|
||||
import { migrate as migratePglite } from 'drizzle-orm/pglite/migrator'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import pg from 'pg'
|
||||
import * as schema from './schema.ts'
|
||||
|
||||
export type Db = PgDatabase<PgQueryResultHKT, typeof schema>
|
||||
|
||||
export interface Database {
|
||||
db: Db
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
const migrationsFolder = join(import.meta.dirname, 'migrations')
|
||||
|
||||
/**
|
||||
* Opens the database and applies pending migrations.
|
||||
*
|
||||
* - `postgres://…` connects to PostgreSQL (production).
|
||||
* - `pglite://<dir>` uses embedded PostgreSQL stored in `<dir>` (local development, very small installs).
|
||||
* - `memory://` uses a throwaway in-memory database (tests).
|
||||
*/
|
||||
export async function openDatabase(url: string): Promise<Database> {
|
||||
if (/^postgres(ql)?:\/\//.test(url)) {
|
||||
const pool = new pg.Pool({ connectionString: url })
|
||||
const db = drizzlePg({ client: pool, schema, casing: 'snake_case' })
|
||||
await migratePg(db, { migrationsFolder })
|
||||
return { db: db as unknown as Db, close: () => pool.end() }
|
||||
}
|
||||
|
||||
if (url !== 'memory://' && !url.startsWith('pglite://')) {
|
||||
throw new Error(`Unsupported DATABASE_URL: expected postgres://, pglite:// or memory://`)
|
||||
}
|
||||
const dataDir = url === 'memory://' ? undefined : url.slice('pglite://'.length)
|
||||
if (dataDir) mkdirSync(dataDir, { recursive: true })
|
||||
const client = new PGlite(dataDir)
|
||||
const db = drizzlePglite({ client, schema, casing: 'snake_case' })
|
||||
await migratePglite(db, { migrationsFolder })
|
||||
return { db: db as unknown as Db, close: () => client.close() }
|
||||
}
|
||||
|
||||
/** True for a unique-constraint violation from either driver (Drizzle wraps driver errors in `cause`). */
|
||||
export function isUniqueViolation(error: unknown): boolean {
|
||||
let current: unknown = error
|
||||
for (let depth = 0; current && typeof current === 'object' && depth < 5; depth++) {
|
||||
if ((current as { code?: unknown }).code === '23505') return true
|
||||
current = (current as { cause?: unknown }).cause
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
CREATE TYPE "public"."action_kind" AS ENUM('corrective', 'preventive');--> statement-breakpoint
|
||||
CREATE TYPE "public"."action_status" AS ENUM('open', 'in_progress', 'done', 'verified', 'cancelled');--> statement-breakpoint
|
||||
CREATE TYPE "public"."area_kind" AS ENUM('building', 'area', 'line', 'other');--> statement-breakpoint
|
||||
CREATE TYPE "public"."employment_type" AS ENUM('employee', 'temporary', 'contractor', 'visitor');--> statement-breakpoint
|
||||
CREATE TYPE "public"."incident_status" AS ENUM('reported', 'triage', 'investigation', 'actions', 'verification', 'closed');--> statement-breakpoint
|
||||
CREATE TYPE "public"."incident_type" AS ENUM('near_miss', 'hazard', 'injury', 'illness', 'property_damage', 'environmental');--> statement-breakpoint
|
||||
CREATE TYPE "public"."rca_method" AS ENUM('five_whys', 'fishbone', 'other');--> statement-breakpoint
|
||||
CREATE TYPE "public"."role" AS ENUM('reporter', 'supervisor', 'safety_manager', 'admin');--> statement-breakpoint
|
||||
CREATE TYPE "public"."root_cause_category" AS ENUM('people', 'process', 'equipment', 'materials', 'environment', 'management');--> statement-breakpoint
|
||||
CREATE TYPE "public"."severity" AS ENUM('low', 'medium', 'high', 'critical');--> statement-breakpoint
|
||||
CREATE TYPE "public"."treatment_level" AS ENUM('none', 'first_aid', 'medical_treatment', 'hospitalized', 'fatality');--> statement-breakpoint
|
||||
CREATE TABLE "areas" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"site_id" uuid NOT NULL,
|
||||
"parent_id" uuid,
|
||||
"name" text NOT NULL,
|
||||
"kind" "area_kind" DEFAULT 'area' NOT NULL,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "attachments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"incident_id" uuid NOT NULL,
|
||||
"file_name" text NOT NULL,
|
||||
"content_type" text NOT NULL,
|
||||
"size_bytes" integer NOT NULL,
|
||||
"storage_key" text NOT NULL,
|
||||
"uploaded_by_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "attachments_storageKey_unique" UNIQUE("storage_key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "audit_log" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"actor_id" uuid,
|
||||
"entity_type" text NOT NULL,
|
||||
"entity_id" text NOT NULL,
|
||||
"action" text NOT NULL,
|
||||
"changes" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "comments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"incident_id" uuid NOT NULL,
|
||||
"author_id" uuid,
|
||||
"body" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "corrective_actions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"incident_id" uuid NOT NULL,
|
||||
"root_cause_id" uuid,
|
||||
"kind" "action_kind" DEFAULT 'corrective' NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"description" text,
|
||||
"assignee_id" uuid,
|
||||
"due_date" date,
|
||||
"status" "action_status" DEFAULT 'open' NOT NULL,
|
||||
"completed_at" timestamp with time zone,
|
||||
"verified_by_id" uuid,
|
||||
"verified_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hours_worked" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"site_id" uuid NOT NULL,
|
||||
"month" date NOT NULL,
|
||||
"hours" numeric(12, 2) NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "incident_counters" (
|
||||
"organization_id" uuid PRIMARY KEY NOT NULL,
|
||||
"last_number" integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "incidents" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"number" integer NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"type" "incident_type" NOT NULL,
|
||||
"severity" "severity" NOT NULL,
|
||||
"status" "incident_status" DEFAULT 'reported' NOT NULL,
|
||||
"occurred_at" timestamp with time zone NOT NULL,
|
||||
"site_id" uuid NOT NULL,
|
||||
"area_id" uuid,
|
||||
"location_detail" text,
|
||||
"description" text,
|
||||
"immediate_action" text,
|
||||
"reporter_id" uuid,
|
||||
"anonymous" boolean DEFAULT false NOT NULL,
|
||||
"closed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "injured_persons" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"incident_id" uuid NOT NULL,
|
||||
"user_id" uuid,
|
||||
"name" text NOT NULL,
|
||||
"job_title" text,
|
||||
"employment_type" "employment_type" DEFAULT 'employee' NOT NULL,
|
||||
"body_part" text,
|
||||
"nature_of_injury" text,
|
||||
"treatment" "treatment_level" DEFAULT 'none' NOT NULL,
|
||||
"days_away" integer DEFAULT 0 NOT NULL,
|
||||
"days_restricted" integer DEFAULT 0 NOT NULL,
|
||||
"privacy_case" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "investigations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"incident_id" uuid NOT NULL,
|
||||
"lead_id" uuid,
|
||||
"method" "rca_method" DEFAULT 'five_whys' NOT NULL,
|
||||
"summary" text,
|
||||
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "investigations_incidentId_unique" UNIQUE("incident_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "organizations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "root_causes" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"investigation_id" uuid NOT NULL,
|
||||
"category" "root_cause_category" NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"whys" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sessions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sites" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"address" text,
|
||||
"timezone" text DEFAULT 'UTC' NOT NULL,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"role" "role" DEFAULT 'reporter' NOT NULL,
|
||||
"password_hash" text,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"last_login_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "areas" ADD CONSTRAINT "areas_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "areas" ADD CONSTRAINT "areas_parent_id_areas_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."areas"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_uploaded_by_id_users_id_fk" FOREIGN KEY ("uploaded_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "corrective_actions" ADD CONSTRAINT "corrective_actions_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "corrective_actions" ADD CONSTRAINT "corrective_actions_root_cause_id_root_causes_id_fk" FOREIGN KEY ("root_cause_id") REFERENCES "public"."root_causes"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "corrective_actions" ADD CONSTRAINT "corrective_actions_assignee_id_users_id_fk" FOREIGN KEY ("assignee_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "corrective_actions" ADD CONSTRAINT "corrective_actions_verified_by_id_users_id_fk" FOREIGN KEY ("verified_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hours_worked" ADD CONSTRAINT "hours_worked_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "incident_counters" ADD CONSTRAINT "incident_counters_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_area_id_areas_id_fk" FOREIGN KEY ("area_id") REFERENCES "public"."areas"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_reporter_id_users_id_fk" FOREIGN KEY ("reporter_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD CONSTRAINT "injured_persons_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD CONSTRAINT "injured_persons_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "investigations" ADD CONSTRAINT "investigations_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "investigations" ADD CONSTRAINT "investigations_lead_id_users_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "root_causes" ADD CONSTRAINT "root_causes_investigation_id_investigations_id_fk" FOREIGN KEY ("investigation_id") REFERENCES "public"."investigations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sites" ADD CONSTRAINT "sites_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "areas_site_idx" ON "areas" USING btree ("site_id");--> statement-breakpoint
|
||||
CREATE INDEX "areas_parent_idx" ON "areas" USING btree ("parent_id");--> statement-breakpoint
|
||||
CREATE INDEX "attachments_incident_idx" ON "attachments" USING btree ("incident_id");--> statement-breakpoint
|
||||
CREATE INDEX "audit_log_entity_idx" ON "audit_log" USING btree ("organization_id","entity_type","entity_id");--> statement-breakpoint
|
||||
CREATE INDEX "comments_incident_idx" ON "comments" USING btree ("incident_id");--> statement-breakpoint
|
||||
CREATE INDEX "corrective_actions_incident_idx" ON "corrective_actions" USING btree ("incident_id");--> statement-breakpoint
|
||||
CREATE INDEX "corrective_actions_assignee_status_idx" ON "corrective_actions" USING btree ("assignee_id","status");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "hours_worked_site_month_unique" ON "hours_worked" USING btree ("site_id","month");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "incidents_organization_number_unique" ON "incidents" USING btree ("organization_id","number");--> statement-breakpoint
|
||||
CREATE INDEX "incidents_organization_occurred_idx" ON "incidents" USING btree ("organization_id","occurred_at");--> statement-breakpoint
|
||||
CREATE INDEX "incidents_organization_status_idx" ON "incidents" USING btree ("organization_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "incidents_reporter_idx" ON "incidents" USING btree ("reporter_id");--> statement-breakpoint
|
||||
CREATE INDEX "injured_persons_incident_idx" ON "injured_persons" USING btree ("incident_id");--> statement-breakpoint
|
||||
CREATE INDEX "root_causes_investigation_idx" ON "root_causes" USING btree ("investigation_id");--> statement-breakpoint
|
||||
CREATE INDEX "sessions_user_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "sites_organization_name_unique" ON "sites" USING btree ("organization_id","name");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "users_email_unique" ON "users" USING btree ("email");--> statement-breakpoint
|
||||
CREATE INDEX "users_organization_idx" ON "users" USING btree ("organization_id");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1791306770507,
|
||||
"tag": "0000_initial",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { bigserial, boolean, date, index, integer, jsonb, numeric, pgEnum, pgTable, text, timestamp, uniqueIndex, uuid, type AnyPgColumn } from 'drizzle-orm/pg-core'
|
||||
import * as vocab from '../../shared/enums.ts'
|
||||
|
||||
export const roleEnum = pgEnum('role', vocab.roles)
|
||||
export const incidentTypeEnum = pgEnum('incident_type', vocab.incidentTypes)
|
||||
export const severityEnum = pgEnum('severity', vocab.severities)
|
||||
export const incidentStatusEnum = pgEnum('incident_status', vocab.incidentStatuses)
|
||||
export const areaKindEnum = pgEnum('area_kind', vocab.areaKinds)
|
||||
export const employmentTypeEnum = pgEnum('employment_type', vocab.employmentTypes)
|
||||
export const treatmentLevelEnum = pgEnum('treatment_level', vocab.treatmentLevels)
|
||||
export const rcaMethodEnum = pgEnum('rca_method', vocab.rcaMethods)
|
||||
export const rootCauseCategoryEnum = pgEnum('root_cause_category', vocab.rootCauseCategories)
|
||||
export const actionKindEnum = pgEnum('action_kind', vocab.actionKinds)
|
||||
export const actionStatusEnum = pgEnum('action_status', vocab.actionStatuses)
|
||||
|
||||
const id = () => uuid().primaryKey().defaultRandom()
|
||||
const timestamptz = () => timestamp({ withTimezone: true })
|
||||
const createdAt = () => timestamptz().notNull().defaultNow()
|
||||
const updatedAt = () =>
|
||||
timestamptz()
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
|
||||
export const organizations = pgTable('organizations', {
|
||||
id: id(),
|
||||
name: text().notNull(),
|
||||
createdAt: createdAt(),
|
||||
})
|
||||
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
{
|
||||
id: id(),
|
||||
organizationId: uuid()
|
||||
.notNull()
|
||||
.references(() => organizations.id),
|
||||
/** Stored lower-cased. */
|
||||
email: text().notNull(),
|
||||
name: text().notNull(),
|
||||
role: roleEnum().notNull().default('reporter'),
|
||||
passwordHash: text(),
|
||||
active: boolean().notNull().default(true),
|
||||
lastLoginAt: timestamptz(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [uniqueIndex('users_email_unique').on(t.email), index('users_organization_idx').on(t.organizationId)],
|
||||
)
|
||||
|
||||
export const sessions = pgTable(
|
||||
'sessions',
|
||||
{
|
||||
/** SHA-256 of the session token; the token itself is never stored. */
|
||||
id: text().primaryKey(),
|
||||
userId: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
expiresAt: timestamptz().notNull(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index('sessions_user_idx').on(t.userId)],
|
||||
)
|
||||
|
||||
export const sites = pgTable(
|
||||
'sites',
|
||||
{
|
||||
id: id(),
|
||||
organizationId: uuid()
|
||||
.notNull()
|
||||
.references(() => organizations.id),
|
||||
name: text().notNull(),
|
||||
address: text(),
|
||||
timezone: text().notNull().default('UTC'),
|
||||
archivedAt: timestamptz(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [uniqueIndex('sites_organization_name_unique').on(t.organizationId, t.name)],
|
||||
)
|
||||
|
||||
/** Location tree within a site: building → area → line. */
|
||||
export const areas = pgTable(
|
||||
'areas',
|
||||
{
|
||||
id: id(),
|
||||
siteId: uuid()
|
||||
.notNull()
|
||||
.references(() => sites.id, { onDelete: 'cascade' }),
|
||||
parentId: uuid().references((): AnyPgColumn => areas.id, { onDelete: 'cascade' }),
|
||||
name: text().notNull(),
|
||||
kind: areaKindEnum().notNull().default('area'),
|
||||
archivedAt: timestamptz(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index('areas_site_idx').on(t.siteId), index('areas_parent_idx').on(t.parentId)],
|
||||
)
|
||||
|
||||
/** Per-organization counter for gap-free, human-friendly incident numbers. */
|
||||
export const incidentCounters = pgTable('incident_counters', {
|
||||
organizationId: uuid()
|
||||
.primaryKey()
|
||||
.references(() => organizations.id),
|
||||
lastNumber: integer().notNull().default(0),
|
||||
})
|
||||
|
||||
export const incidents = pgTable(
|
||||
'incidents',
|
||||
{
|
||||
id: id(),
|
||||
organizationId: uuid()
|
||||
.notNull()
|
||||
.references(() => organizations.id),
|
||||
number: integer().notNull(),
|
||||
title: text().notNull(),
|
||||
type: incidentTypeEnum().notNull(),
|
||||
severity: severityEnum().notNull(),
|
||||
status: incidentStatusEnum().notNull().default('reported'),
|
||||
occurredAt: timestamptz().notNull(),
|
||||
siteId: uuid()
|
||||
.notNull()
|
||||
.references(() => sites.id),
|
||||
areaId: uuid().references(() => areas.id, { onDelete: 'set null' }),
|
||||
locationDetail: text(),
|
||||
description: text(),
|
||||
immediateAction: text(),
|
||||
/** Null when reported anonymously. */
|
||||
reporterId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
anonymous: boolean().notNull().default(false),
|
||||
closedAt: timestamptz(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('incidents_organization_number_unique').on(t.organizationId, t.number),
|
||||
index('incidents_organization_occurred_idx').on(t.organizationId, t.occurredAt),
|
||||
index('incidents_organization_status_idx').on(t.organizationId, t.status),
|
||||
index('incidents_reporter_idx').on(t.reporterId),
|
||||
],
|
||||
)
|
||||
|
||||
export const injuredPersons = pgTable(
|
||||
'injured_persons',
|
||||
{
|
||||
id: id(),
|
||||
incidentId: uuid()
|
||||
.notNull()
|
||||
.references(() => incidents.id, { onDelete: 'cascade' }),
|
||||
/** Set when the person has a user account. */
|
||||
userId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
name: text().notNull(),
|
||||
jobTitle: text(),
|
||||
employmentType: employmentTypeEnum().notNull().default('employee'),
|
||||
bodyPart: text(),
|
||||
natureOfInjury: text(),
|
||||
treatment: treatmentLevelEnum().notNull().default('none'),
|
||||
daysAway: integer().notNull().default(0),
|
||||
daysRestricted: integer().notNull().default(0),
|
||||
/** OSHA privacy case: name withheld from the 300 log. */
|
||||
privacyCase: boolean().notNull().default(false),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index('injured_persons_incident_idx').on(t.incidentId)],
|
||||
)
|
||||
|
||||
export const attachments = pgTable(
|
||||
'attachments',
|
||||
{
|
||||
id: id(),
|
||||
incidentId: uuid()
|
||||
.notNull()
|
||||
.references(() => incidents.id, { onDelete: 'cascade' }),
|
||||
fileName: text().notNull(),
|
||||
contentType: text().notNull(),
|
||||
sizeBytes: integer().notNull(),
|
||||
storageKey: text().notNull().unique(),
|
||||
uploadedById: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index('attachments_incident_idx').on(t.incidentId)],
|
||||
)
|
||||
|
||||
export const investigations = pgTable('investigations', {
|
||||
id: id(),
|
||||
incidentId: uuid()
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => incidents.id, { onDelete: 'cascade' }),
|
||||
leadId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
method: rcaMethodEnum().notNull().default('five_whys'),
|
||||
summary: text(),
|
||||
startedAt: timestamptz().notNull().defaultNow(),
|
||||
completedAt: timestamptz(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
})
|
||||
|
||||
export const rootCauses = pgTable(
|
||||
'root_causes',
|
||||
{
|
||||
id: id(),
|
||||
investigationId: uuid()
|
||||
.notNull()
|
||||
.references(() => investigations.id, { onDelete: 'cascade' }),
|
||||
category: rootCauseCategoryEnum().notNull(),
|
||||
description: text().notNull(),
|
||||
/** 5 Whys chain, first "why" first. */
|
||||
whys: jsonb().$type<string[]>().notNull().default([]),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index('root_causes_investigation_idx').on(t.investigationId)],
|
||||
)
|
||||
|
||||
export const correctiveActions = pgTable(
|
||||
'corrective_actions',
|
||||
{
|
||||
id: id(),
|
||||
incidentId: uuid()
|
||||
.notNull()
|
||||
.references(() => incidents.id, { onDelete: 'cascade' }),
|
||||
rootCauseId: uuid().references(() => rootCauses.id, { onDelete: 'set null' }),
|
||||
kind: actionKindEnum().notNull().default('corrective'),
|
||||
title: text().notNull(),
|
||||
description: text(),
|
||||
assigneeId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
dueDate: date({ mode: 'string' }),
|
||||
status: actionStatusEnum().notNull().default('open'),
|
||||
completedAt: timestamptz(),
|
||||
verifiedById: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
verifiedAt: timestamptz(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index('corrective_actions_incident_idx').on(t.incidentId), index('corrective_actions_assignee_status_idx').on(t.assigneeId, t.status)],
|
||||
)
|
||||
|
||||
export const comments = pgTable(
|
||||
'comments',
|
||||
{
|
||||
id: id(),
|
||||
incidentId: uuid()
|
||||
.notNull()
|
||||
.references(() => incidents.id, { onDelete: 'cascade' }),
|
||||
authorId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
body: text().notNull(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index('comments_incident_idx').on(t.incidentId)],
|
||||
)
|
||||
|
||||
export const auditLog = pgTable(
|
||||
'audit_log',
|
||||
{
|
||||
id: bigserial({ mode: 'number' }).primaryKey(),
|
||||
organizationId: uuid()
|
||||
.notNull()
|
||||
.references(() => organizations.id),
|
||||
actorId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
entityType: text().notNull(),
|
||||
entityId: text().notNull(),
|
||||
action: text().notNull(),
|
||||
/** `{ field: [before, after] }` for updates, the created record for creates. */
|
||||
changes: jsonb(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index('audit_log_entity_idx').on(t.organizationId, t.entityType, t.entityId)],
|
||||
)
|
||||
|
||||
/** Monthly hours worked per site, for TRIR / DART rate calculations. */
|
||||
export const hoursWorked = pgTable(
|
||||
'hours_worked',
|
||||
{
|
||||
id: id(),
|
||||
siteId: uuid()
|
||||
.notNull()
|
||||
.references(() => sites.id, { onDelete: 'cascade' }),
|
||||
/** First day of the month, e.g. `2026-10-01`. */
|
||||
month: date({ mode: 'string' }).notNull(),
|
||||
hours: numeric({ precision: 12, scale: 2 }).notNull(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [uniqueIndex('hours_worked_site_month_unique').on(t.siteId, t.month)],
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
// Loads demo data into an empty database: `npm run db:seed`. Not for production use.
|
||||
import { hashPassword } from '../auth/password.ts'
|
||||
import { loadConfig } from '../config.ts'
|
||||
import { openDatabase } from './client.ts'
|
||||
import { areas, incidentCounters, incidents, organizations, sites, users } from './schema.ts'
|
||||
|
||||
const DEMO_PASSWORD = 'safetylog-demo'
|
||||
|
||||
const config = loadConfig()
|
||||
const { db, close } = await openDatabase(config.databaseUrl)
|
||||
|
||||
try {
|
||||
if ((await db.select({ id: users.id }).from(users).limit(1)).length > 0) {
|
||||
console.error('Database already has users; seeding is only for empty databases.')
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
await db.transaction(async (tx) => {
|
||||
const [org] = await tx.insert(organizations).values({ name: 'Northstar Co.' }).returning()
|
||||
const passwordHash = await hashPassword(DEMO_PASSWORD)
|
||||
const people = await tx
|
||||
.insert(users)
|
||||
.values([
|
||||
{ name: 'Jordan Lee', email: 'admin@example.com', role: 'admin' as const },
|
||||
{ name: 'Priya Shah', email: 'manager@example.com', role: 'safety_manager' as const },
|
||||
{ name: 'Sam Rivera', email: 'supervisor@example.com', role: 'supervisor' as const },
|
||||
{ name: 'Maya Chen', email: 'reporter@example.com', role: 'reporter' as const },
|
||||
].map((u) => ({ ...u, organizationId: org.id, passwordHash })))
|
||||
.returning()
|
||||
const person = (email: string) => people.find((p) => p.email === email)!.id
|
||||
|
||||
const layout: Record<string, string[]> = {
|
||||
'North Campus': ['Building A', 'Warehouse 04', 'Lab 2'],
|
||||
'South Campus': ['Fabrication 02', 'Yard', 'Office block'],
|
||||
'West Campus': ['Warehouse 01', 'Loading dock'],
|
||||
}
|
||||
const areaIds: Record<string, { siteId: string; areaId: string }> = {}
|
||||
for (const [siteName, buildings] of Object.entries(layout)) {
|
||||
const [site] = await tx.insert(sites).values({ organizationId: org.id, name: siteName }).returning()
|
||||
for (const name of buildings) {
|
||||
const [area] = await tx.insert(areas).values({ siteId: site.id, name, kind: 'building' }).returning()
|
||||
areaIds[name] = { siteId: site.id, areaId: area.id }
|
||||
}
|
||||
}
|
||||
|
||||
const daysAgo = (days: number) => new Date(Date.now() - days * 86_400_000)
|
||||
const demo = [
|
||||
{ title: 'Damaged guardrail on mezzanine', type: 'property_damage', severity: 'low', status: 'closed', building: 'Warehouse 01', locationDetail: 'Mezzanine east', reporter: 'manager@example.com', days: 9 },
|
||||
{ title: 'Hand laceration during material handling', type: 'injury', severity: 'high', status: 'investigation', building: 'Fabrication 02', locationDetail: 'Line 3', reporter: 'supervisor@example.com', days: 7 },
|
||||
{ title: 'Wet floor near employee entrance', type: 'hazard', severity: 'medium', status: 'actions', building: 'Building A', locationDetail: 'Ground floor lobby', reporter: 'reporter@example.com', days: 6 },
|
||||
{ title: 'Forklift reversed into pallet stack', type: 'near_miss', severity: 'high', status: 'triage', building: 'Warehouse 04', locationDetail: 'Loading bay C', reporter: 'admin@example.com', days: 5 },
|
||||
] as const
|
||||
await tx.insert(incidents).values(
|
||||
demo.map((item, index) => ({
|
||||
organizationId: org.id,
|
||||
number: index + 1,
|
||||
title: item.title,
|
||||
type: item.type,
|
||||
severity: item.severity,
|
||||
status: item.status,
|
||||
occurredAt: daysAgo(item.days),
|
||||
...areaIds[item.building],
|
||||
locationDetail: item.locationDetail,
|
||||
reporterId: person(item.reporter),
|
||||
closedAt: item.status === 'closed' ? daysAgo(item.days - 3) : null,
|
||||
})),
|
||||
)
|
||||
await tx.insert(incidentCounters).values({ organizationId: org.id, lastNumber: demo.length })
|
||||
})
|
||||
console.log(`Seeded demo data. Sign in as admin@example.com, manager@example.com, supervisor@example.com or reporter@example.com with password "${DEMO_PASSWORD}".`)
|
||||
}
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
Reference in new issue
Block a user