-- User staff profiles (1:1 with users) — HR / verification workflow -- Apply: docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/010_user_staff_profiles.sql DO $$ BEGIN CREATE TYPE profile_verification_status AS ENUM ('draft', 'pending', 'verified', 'rejected'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; CREATE TABLE IF NOT EXISTS academic_titles ( code TEXT PRIMARY KEY, label_vi TEXT NOT NULL, label_en TEXT NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, active BOOLEAN NOT NULL DEFAULT TRUE ); INSERT INTO academic_titles (code, label_vi, label_en, sort_order) VALUES ('professor', 'Giáo sư', 'Professor', 10), ('associate_professor', 'Phó Giáo sư', 'Associate Professor', 20), ('doctor_sc', 'Tiến sĩ', 'Doctor of Science', 30), ('bsckii', 'BSCKII', 'Specialist level II', 35), ('bscki', 'BSCKI', 'Specialist level I', 36), ('master', 'Thạc sĩ', 'Master', 40), ('doctor_md', 'Bác sĩ', 'Physician', 45), ('pharmacist', 'Dược sĩ', 'Pharmacist', 46), ('bachelor', 'Cử nhân', 'Bachelor', 50), ('other', 'Khác (ghi rõ)', 'Other (specify)', 100) ON CONFLICT (code) DO NOTHING; CREATE TABLE IF NOT EXISTS user_staff_profiles ( user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, employee_id TEXT, academic_title_code TEXT REFERENCES academic_titles(code), academic_title_other TEXT, unit_name_freetext TEXT, job_title TEXT, profile_verification_status profile_verification_status NOT NULL DEFAULT 'draft', verification_submitted_at TIMESTAMPTZ, verified_at TIMESTAMPTZ, verified_by_user_id UUID REFERENCES users(id), rejection_reason TEXT, version INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT employee_id_shape CHECK (employee_id IS NULL OR employee_id ~ '^[A-Z0-9-]{3,32}$'), CONSTRAINT academic_title_other_invariant CHECK ( CASE WHEN academic_title_code IS NULL THEN academic_title_other IS NULL WHEN academic_title_code = 'other' THEN academic_title_other IS NOT NULL AND length(trim(academic_title_other)) > 0 ELSE academic_title_other IS NULL END ), CONSTRAINT verified_requires_metadata CHECK ( profile_verification_status <> 'verified' OR (verified_at IS NOT NULL AND verified_by_user_id IS NOT NULL) ), CONSTRAINT rejected_requires_reason CHECK ( profile_verification_status <> 'rejected' OR (rejection_reason IS NOT NULL AND length(trim(rejection_reason)) > 0) ), CONSTRAINT non_terminal_clears_verification CHECK ( profile_verification_status NOT IN ('draft', 'pending') OR (verified_at IS NULL AND verified_by_user_id IS NULL) ), CONSTRAINT rejected_clears_verification_metadata CHECK ( profile_verification_status <> 'rejected' OR (verified_at IS NULL AND verified_by_user_id IS NULL) ), CONSTRAINT verified_clears_rejection CHECK ( profile_verification_status <> 'verified' OR rejection_reason IS NULL ), CONSTRAINT job_title_length CHECK ( job_title IS NULL OR length(job_title) <= 120 ) ); CREATE UNIQUE INDEX IF NOT EXISTS ix_usp_employee_id_unique ON user_staff_profiles (employee_id) WHERE employee_id IS NOT NULL; CREATE INDEX IF NOT EXISTS ix_usp_pending_queue ON user_staff_profiles (verification_submitted_at) WHERE profile_verification_status = 'pending'; CREATE INDEX IF NOT EXISTS ix_usp_verifier_activity ON user_staff_profiles (verified_by_user_id, verified_at DESC) WHERE verified_by_user_id IS NOT NULL; -- Backfill one row per existing user (draft, NULL fields) INSERT INTO user_staff_profiles (user_id, profile_verification_status) SELECT u.id, 'draft'::profile_verification_status FROM users u WHERE NOT EXISTS ( SELECT 1 FROM user_staff_profiles p WHERE p.user_id = u.id ); COMMENT ON TABLE user_staff_profiles IS 'Institutional staff profile and verification state; scalars only — no MinIO.';