first commit
This commit is contained in:
648
migrations/000001_initial_schema.up.sql
Normal file
648
migrations/000001_initial_schema.up.sql
Normal file
@@ -0,0 +1,648 @@
|
||||
-- ============================================================================
|
||||
-- Krow — initial schema
|
||||
--
|
||||
-- Source of truth: the Krow Backend Blueprint §05, reconciled against the
|
||||
-- frontend repository (krow-demo) on 2026-08-21. Every table here corresponds
|
||||
-- to an entity the frontend actually reads or writes through
|
||||
-- `src/api/base44Client.js` (ENTITY_NAMES, lines 16-30), except `organizations`
|
||||
-- and `user_preferences` — see the notes below.
|
||||
--
|
||||
-- Deliberately NOT in this migration (Phase 2+):
|
||||
-- sessions, definitions, definition_versions, conversations,
|
||||
-- conversation_messages, conversation_feedback, file_assets,
|
||||
-- documents, document_chunks, the `vector` extension.
|
||||
--
|
||||
-- Target schema: public. No system schema is read or written.
|
||||
-- ============================================================================
|
||||
|
||||
-- Atomicity comes from golang-migrate: the postgres driver sends this file as a
|
||||
-- single simple query, which Postgres executes inside one implicit transaction.
|
||||
-- Any failure below rolls the whole migration back.
|
||||
SET search_path = public;
|
||||
|
||||
-- citext gives case-insensitive email equality. Email is a real join key in
|
||||
-- this domain, not a convenience: assignments, shift_records and evidence all
|
||||
-- key the worker by email, and the frontend looks profiles up with
|
||||
-- filter({ email }). The extension is created here but deliberately NOT
|
||||
-- dropped by the down migration — see 000001_initial_schema.down.sql.
|
||||
CREATE EXTENSION IF NOT EXISTS citext WITH SCHEMA public;
|
||||
|
||||
-- ── Enums ───────────────────────────────────────────────────────────────────
|
||||
-- Every value below was enumerated from the frontend, not from the blueprint.
|
||||
-- `src/components/ds/StatusBadge.jsx` STATUS_MAP is the authoritative vocabulary.
|
||||
|
||||
CREATE TYPE posting_status AS ENUM ('draft', 'active', 'paused', 'closed');
|
||||
CREATE TYPE posting_priority AS ENUM ('urgent', 'high', 'normal');
|
||||
CREATE TYPE english_level AS ENUM ('basic', 'conversational', 'fluent', 'native');
|
||||
|
||||
-- 'assigned' is written by useAssignWorkers but is absent from STAGE_ORDER and
|
||||
-- from STATUS_MAP, so those candidates vanish from funnel counts. That is
|
||||
-- blueprint decision D3, still open. The value is included because the code
|
||||
-- writes it; the funnel bug is a separate fix.
|
||||
CREATE TYPE application_status AS ENUM (
|
||||
'applied', 'ai_screened', 'shortlisted', 'interview', 'hired', 'rejected', 'assigned'
|
||||
);
|
||||
|
||||
-- aiEngine.js:436 — `overall >= 78 ? 'hire' : overall >= 55 ? 'maybe' : 'no'`.
|
||||
-- The blueprint said 'yes'; the frontend says 'hire'. The frontend wins.
|
||||
CREATE TYPE interview_verdict AS ENUM ('hire', 'maybe', 'no');
|
||||
|
||||
-- RetentionMetrics.jsx and hiringRecords.js:93 both test for 'inactive'.
|
||||
-- The blueprint said 'ended'; no such value exists in the frontend.
|
||||
CREATE TYPE staff_status AS ENUM ('onboarding', 'active', 'inactive');
|
||||
CREATE TYPE profile_tier AS ENUM ('Beginner', 'Cross-Trained', 'Skilled');
|
||||
|
||||
CREATE TYPE assignment_status AS ENUM ('active', 'completed', 'cancelled');
|
||||
|
||||
-- attendanceSeed.js writes exactly these four. The blueprint also listed
|
||||
-- 'excused', which appears nowhere in the frontend, so it is omitted.
|
||||
-- ALTER TYPE ... ADD VALUE can add it later without a table rewrite.
|
||||
CREATE TYPE shift_status AS ENUM ('present', 'late', 'absent', 'no_show');
|
||||
|
||||
CREATE TYPE course_status AS ENUM ('active', 'inactive');
|
||||
CREATE TYPE skill_level AS ENUM ('beginner', 'intermediate', 'advanced', 'expert');
|
||||
CREATE TYPE challenge_type AS ENUM ('roleplay', 'video', 'photo_identify');
|
||||
|
||||
-- provingGround.js:6 declares enum ['verified','needs_work','failed'].
|
||||
-- The blueprint listed only the first two.
|
||||
CREATE TYPE evidence_verdict AS ENUM ('verified', 'needs_work', 'failed');
|
||||
|
||||
CREATE TYPE badge_level AS ENUM ('bronze', 'silver', 'gold', 'platinum');
|
||||
CREATE TYPE badge_verification AS ENUM ('pending', 'verified', 'expired');
|
||||
|
||||
|
||||
-- ── Tenancy ─────────────────────────────────────────────────────────────────
|
||||
-- No frontend evidence. Carried from day one per blueprint decision D1: a
|
||||
-- single-tenant deployment simply has one row, and retrofitting org_id across
|
||||
-- 16 tables later is far more expensive than carrying it now.
|
||||
|
||||
CREATE TABLE organizations (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
slug citext NOT NULL UNIQUE,
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
updated_date timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT organizations_name_not_blank CHECK (length(btrim(name)) > 0)
|
||||
);
|
||||
|
||||
|
||||
-- ── Users ───────────────────────────────────────────────────────────────────
|
||||
-- `User` is one of the 15 frontend entities; auth.me / updateMe / preferences
|
||||
-- are live in base44Client.js. This is schema only — no authentication is
|
||||
-- implemented in Phase 1. `password_hash` is nullable and stays NULL until
|
||||
-- auth is built.
|
||||
|
||||
CREATE TABLE users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
legacy_id text UNIQUE,
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
email citext NOT NULL,
|
||||
full_name text NOT NULL DEFAULT '',
|
||||
password_hash text,
|
||||
role text NOT NULL DEFAULT 'admin',
|
||||
account_type text NOT NULL DEFAULT 'employer',
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
last_login_at timestamptz,
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
updated_date timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT users_org_email_key UNIQUE (org_id, email),
|
||||
CONSTRAINT users_role_check CHECK (role IN ('admin', 'employer', 'talent')),
|
||||
CONSTRAINT users_status_check CHECK (status IN ('active', 'suspended')),
|
||||
CONSTRAINT users_email_not_blank CHECK (length(btrim(email::text)) > 0)
|
||||
);
|
||||
|
||||
-- Lifted out of the User JSON blob so preferences are queryable and small,
|
||||
-- while auth.preferences() can still return a single merged object.
|
||||
CREATE TABLE user_preferences (
|
||||
user_id uuid PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
|
||||
owliver_default boolean NOT NULL DEFAULT true,
|
||||
compact_density boolean NOT NULL DEFAULT false,
|
||||
email_digest boolean NOT NULL DEFAULT true,
|
||||
extra jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_date timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
|
||||
-- ── Reference data ──────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE role_categories (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
legacy_id text UNIQUE,
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
updated_date timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT role_categories_org_name_key UNIQUE (org_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE certifications (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
legacy_id text UNIQUE,
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
updated_date timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT certifications_org_name_key UNIQUE (org_id, name)
|
||||
);
|
||||
|
||||
-- Reference table only. The blueprint flags Badge as "inferred dead": the UI
|
||||
-- reads every badge it displays from worker_profiles.earned_badges, and
|
||||
-- useBadges is exported but never imported. The entity and its seed rows exist,
|
||||
-- so the table is created; nothing reads it yet.
|
||||
CREATE TABLE badges (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
legacy_id text UNIQUE,
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
image_url text NOT NULL DEFAULT '',
|
||||
level badge_level NOT NULL DEFAULT 'bronze',
|
||||
requirements text NOT NULL DEFAULT '',
|
||||
expiration_months int,
|
||||
verification_status badge_verification NOT NULL DEFAULT 'pending',
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
updated_date timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT badges_org_name_key UNIQUE (org_id, name),
|
||||
CONSTRAINT badges_expiration_positive CHECK (expiration_months IS NULL OR expiration_months > 0)
|
||||
);
|
||||
|
||||
|
||||
-- ── Training ────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE courses (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
legacy_id text UNIQUE,
|
||||
-- NULL org_id = platform-wide course library, shared by every organization.
|
||||
org_id uuid REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
category text NOT NULL DEFAULT '',
|
||||
difficulty text NOT NULL DEFAULT 'beginner',
|
||||
xp int NOT NULL DEFAULT 0,
|
||||
estimated_minutes int NOT NULL DEFAULT 0,
|
||||
badge_reward text,
|
||||
proof_skill text NOT NULL DEFAULT '',
|
||||
skill_id text,
|
||||
target_level skill_level,
|
||||
required_level skill_level,
|
||||
completion_criteria text[] NOT NULL DEFAULT '{}',
|
||||
verification_criteria text[] NOT NULL DEFAULT '{}',
|
||||
challenge jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
unlock_requirements jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
quiz jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
pass_score int NOT NULL DEFAULT 70,
|
||||
status course_status NOT NULL DEFAULT 'active',
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
updated_date timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT courses_title_not_blank CHECK (length(btrim(title)) > 0),
|
||||
CONSTRAINT courses_xp_nonneg CHECK (xp >= 0),
|
||||
CONSTRAINT courses_minutes_nonneg CHECK (estimated_minutes >= 0),
|
||||
CONSTRAINT courses_pass_score_pct CHECK (pass_score BETWEEN 0 AND 100)
|
||||
);
|
||||
|
||||
CREATE INDEX courses_skill_level_idx ON courses (skill_id, target_level);
|
||||
CREATE INDEX courses_org_status_idx ON courses (org_id, status);
|
||||
|
||||
CREATE TABLE learning_paths (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
legacy_id text UNIQUE,
|
||||
org_id uuid REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
target_role text NOT NULL DEFAULT '',
|
||||
description text NOT NULL DEFAULT '',
|
||||
difficulty text NOT NULL DEFAULT 'beginner',
|
||||
-- [{ order, course_id, course_title }] — a json reference list, not an FK.
|
||||
steps jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
updated_date timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT learning_paths_name_not_blank CHECK (length(btrim(name)) > 0),
|
||||
CONSTRAINT learning_paths_steps_is_array CHECK (jsonb_typeof(steps) = 'array')
|
||||
);
|
||||
|
||||
|
||||
-- ── Hiring: postings ────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE job_postings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
legacy_id text UNIQUE,
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
created_by uuid REFERENCES users (id) ON DELETE SET NULL,
|
||||
|
||||
-- Free text today: the client a role is staffed for. Blueprint decision D2
|
||||
-- (promote to a `clients` table) is still open, so it stays text.
|
||||
company text NOT NULL DEFAULT '',
|
||||
title text NOT NULL,
|
||||
-- Matched to role_categories.name BY NAME, exactly as the frontend does.
|
||||
role_category text NOT NULL DEFAULT '',
|
||||
description text NOT NULL DEFAULT '',
|
||||
responsibilities text[] NOT NULL DEFAULT '{}',
|
||||
qualifications text[] NOT NULL DEFAULT '{}',
|
||||
nice_to_haves text[] NOT NULL DEFAULT '{}',
|
||||
custom_requirements text NOT NULL DEFAULT '',
|
||||
physical_requirements text NOT NULL DEFAULT '',
|
||||
leadership_expectations text NOT NULL DEFAULT '',
|
||||
attendance_expectations text NOT NULL DEFAULT '',
|
||||
|
||||
min_experience_years int NOT NULL DEFAULT 0,
|
||||
english_required english_level NOT NULL DEFAULT 'basic',
|
||||
certifications_required text[] NOT NULL DEFAULT '{}',
|
||||
-- [{ skill_id, level, weight }]
|
||||
skill_requirements jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
pay_range_min int NOT NULL DEFAULT 0,
|
||||
pay_range_max int NOT NULL DEFAULT 0,
|
||||
location text NOT NULL DEFAULT '',
|
||||
|
||||
status posting_status NOT NULL DEFAULT 'draft',
|
||||
ai_generated boolean NOT NULL DEFAULT false,
|
||||
headcount int NOT NULL DEFAULT 1,
|
||||
start_date date,
|
||||
duration_months numeric(4,1),
|
||||
priority posting_priority NOT NULL DEFAULT 'normal',
|
||||
vetting_criteria jsonb NOT NULL DEFAULT
|
||||
'{"experience":25,"english":20,"reliability":20,"certifications":20,"availability":15}'::jsonb,
|
||||
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
updated_date timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT job_postings_title_not_blank CHECK (length(btrim(title)) > 0),
|
||||
CONSTRAINT job_postings_experience_range CHECK (min_experience_years BETWEEN 0 AND 40),
|
||||
CONSTRAINT job_postings_headcount_min CHECK (headcount >= 1),
|
||||
CONSTRAINT job_postings_pay_nonneg CHECK (pay_range_min >= 0 AND pay_range_max >= 0),
|
||||
-- A max of 0 means "unspecified", so it is exempt from the ordering rule.
|
||||
CONSTRAINT job_postings_pay_ordered CHECK (pay_range_max = 0 OR pay_range_max >= pay_range_min),
|
||||
CONSTRAINT job_postings_duration_positive CHECK (duration_months IS NULL OR duration_months > 0),
|
||||
CONSTRAINT job_postings_skill_reqs_array CHECK (jsonb_typeof(skill_requirements) = 'array'),
|
||||
CONSTRAINT job_postings_vetting_object CHECK (jsonb_typeof(vetting_criteria) = 'object')
|
||||
);
|
||||
|
||||
CREATE INDEX job_postings_org_created_idx ON job_postings (org_id, created_date DESC);
|
||||
CREATE INDEX job_postings_org_active_idx ON job_postings (org_id, status) WHERE status = 'active';
|
||||
CREATE INDEX job_postings_org_category_idx ON job_postings (org_id, role_category);
|
||||
CREATE INDEX job_postings_skill_reqs_gin ON job_postings USING gin (skill_requirements jsonb_path_ops);
|
||||
|
||||
|
||||
-- ── Workforce: profiles ─────────────────────────────────────────────────────
|
||||
-- Declared before job_applications because that table references it.
|
||||
|
||||
CREATE TABLE worker_profiles (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
legacy_id text UNIQUE,
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
user_id uuid REFERENCES users (id) ON DELETE SET NULL,
|
||||
full_name text NOT NULL,
|
||||
email citext NOT NULL,
|
||||
phone text NOT NULL DEFAULT '',
|
||||
address text NOT NULL DEFAULT '',
|
||||
selfie_url text NOT NULL DEFAULT '',
|
||||
languages text[] NOT NULL DEFAULT '{}',
|
||||
availability text[] NOT NULL DEFAULT '{}',
|
||||
transportation text NOT NULL DEFAULT '',
|
||||
certifications text[] NOT NULL DEFAULT '{}',
|
||||
-- [{ company, role, years }]
|
||||
experience jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
experience_years int NOT NULL DEFAULT 0,
|
||||
current_position text NOT NULL DEFAULT '',
|
||||
desired_position text NOT NULL DEFAULT '',
|
||||
career_goals text NOT NULL DEFAULT '',
|
||||
skills text[] NOT NULL DEFAULT '{}',
|
||||
industries text[] NOT NULL DEFAULT '{}',
|
||||
personality text NOT NULL DEFAULT '',
|
||||
strengths text[] NOT NULL DEFAULT '{}',
|
||||
weaknesses text[] NOT NULL DEFAULT '{}',
|
||||
communication_style text NOT NULL DEFAULT '',
|
||||
salary_expectations text NOT NULL DEFAULT '',
|
||||
leadership_potential int NOT NULL DEFAULT 0,
|
||||
ai_interview_score int NOT NULL DEFAULT 0,
|
||||
krow_score int NOT NULL DEFAULT 0,
|
||||
reliability_score int NOT NULL DEFAULT 0,
|
||||
profile_completion int NOT NULL DEFAULT 0,
|
||||
xp int NOT NULL DEFAULT 0,
|
||||
completed_courses jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
earned_badges jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
capabilities jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
shifts_completed int NOT NULL DEFAULT 0,
|
||||
attendance_score int NOT NULL DEFAULT 100,
|
||||
performance_score int NOT NULL DEFAULT 0,
|
||||
client_rating numeric(2,1) NOT NULL DEFAULT 0,
|
||||
supervisor_rating numeric(2,1) NOT NULL DEFAULT 0,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
updated_date timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- Email IS the lookup key: useWorkerProfile resolves a profile by
|
||||
-- filter({ email }). Enforcing it here makes that assumption safe.
|
||||
CONSTRAINT worker_profiles_org_email_key UNIQUE (org_id, email),
|
||||
CONSTRAINT worker_profiles_name_not_blank CHECK (length(btrim(full_name)) > 0),
|
||||
CONSTRAINT worker_profiles_email_not_blank CHECK (length(btrim(email::text)) > 0),
|
||||
CONSTRAINT worker_profiles_scores_pct CHECK (
|
||||
krow_score BETWEEN 0 AND 100 AND
|
||||
reliability_score BETWEEN 0 AND 100 AND
|
||||
profile_completion BETWEEN 0 AND 100 AND
|
||||
attendance_score BETWEEN 0 AND 100 AND
|
||||
performance_score BETWEEN 0 AND 100 AND
|
||||
ai_interview_score BETWEEN 0 AND 100 AND
|
||||
leadership_potential BETWEEN 0 AND 100
|
||||
),
|
||||
CONSTRAINT worker_profiles_ratings_range CHECK (
|
||||
client_rating BETWEEN 0 AND 5 AND supervisor_rating BETWEEN 0 AND 5
|
||||
),
|
||||
CONSTRAINT worker_profiles_experience_nonneg CHECK (experience_years >= 0),
|
||||
CONSTRAINT worker_profiles_xp_nonneg CHECK (xp >= 0),
|
||||
CONSTRAINT worker_profiles_shifts_nonneg CHECK (shifts_completed >= 0),
|
||||
CONSTRAINT worker_profiles_json_arrays CHECK (
|
||||
jsonb_typeof(experience) = 'array' AND
|
||||
jsonb_typeof(completed_courses) = 'array' AND
|
||||
jsonb_typeof(earned_badges) = 'array' AND
|
||||
jsonb_typeof(capabilities) = 'array'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX worker_profiles_org_score_idx ON worker_profiles (org_id, krow_score DESC);
|
||||
CREATE INDEX worker_profiles_courses_gin ON worker_profiles USING gin (completed_courses jsonb_path_ops);
|
||||
|
||||
|
||||
-- ── Hiring: applications ────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE job_applications (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
legacy_id text UNIQUE,
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
job_posting_id uuid NOT NULL REFERENCES job_postings (id) ON DELETE CASCADE,
|
||||
worker_profile_id uuid REFERENCES worker_profiles (id) ON DELETE SET NULL,
|
||||
|
||||
-- Denormalised: the UI reads job_title straight off the application.
|
||||
job_title text NOT NULL DEFAULT '',
|
||||
applicant_name text NOT NULL,
|
||||
email citext NOT NULL,
|
||||
phone text NOT NULL DEFAULT '',
|
||||
years_experience int NOT NULL DEFAULT 0,
|
||||
english_level english_level NOT NULL DEFAULT 'basic',
|
||||
certifications text[] NOT NULL DEFAULT '{}',
|
||||
availability text[] NOT NULL DEFAULT '{}',
|
||||
skills text[] NOT NULL DEFAULT '{}',
|
||||
companies_worked text[] NOT NULL DEFAULT '{}',
|
||||
client_rating numeric(2,1) NOT NULL DEFAULT 0,
|
||||
professional_summary text NOT NULL DEFAULT '',
|
||||
cover_letter text NOT NULL DEFAULT '',
|
||||
selfie_url text NOT NULL DEFAULT '',
|
||||
|
||||
status application_status NOT NULL DEFAULT 'applied',
|
||||
ai_score int NOT NULL DEFAULT 0,
|
||||
ai_match_label text NOT NULL DEFAULT '',
|
||||
ai_summary text NOT NULL DEFAULT '',
|
||||
ai_strengths text[] NOT NULL DEFAULT '{}',
|
||||
ai_gaps text[] NOT NULL DEFAULT '{}',
|
||||
ai_recommendation text NOT NULL DEFAULT '',
|
||||
score_breakdown jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
screened_at timestamptz,
|
||||
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
updated_date timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- useAssignWorkers already looks an application up by
|
||||
-- (job_posting_id, lowercased email) before creating one, so it assumes this.
|
||||
CONSTRAINT job_applications_posting_email_key UNIQUE (job_posting_id, email),
|
||||
CONSTRAINT job_applications_name_not_blank CHECK (length(btrim(applicant_name)) > 0),
|
||||
CONSTRAINT job_applications_ai_score_pct CHECK (ai_score BETWEEN 0 AND 100),
|
||||
CONSTRAINT job_applications_rating_range CHECK (client_rating BETWEEN 0 AND 5),
|
||||
CONSTRAINT job_applications_experience_nonneg CHECK (years_experience >= 0),
|
||||
CONSTRAINT job_applications_breakdown_object CHECK (jsonb_typeof(score_breakdown) = 'object'),
|
||||
-- A screened application must carry the timestamp that says when.
|
||||
CONSTRAINT job_applications_screened_consistent CHECK (
|
||||
status = 'applied' OR screened_at IS NOT NULL OR ai_score = 0
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX job_applications_org_score_idx ON job_applications (org_id, ai_score DESC);
|
||||
CREATE INDEX job_applications_posting_status_idx ON job_applications (job_posting_id, status);
|
||||
CREATE INDEX job_applications_org_status_idx ON job_applications (org_id, status, created_date DESC);
|
||||
CREATE INDEX job_applications_org_email_idx ON job_applications (org_id, email);
|
||||
CREATE INDEX job_applications_profile_idx ON job_applications (worker_profile_id)
|
||||
WHERE worker_profile_id IS NOT NULL;
|
||||
|
||||
|
||||
-- ── Hiring: AI interviews ───────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE ai_interviews (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
legacy_id text UNIQUE,
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
application_id uuid NOT NULL REFERENCES job_applications (id) ON DELETE CASCADE,
|
||||
job_posting_id uuid NOT NULL REFERENCES job_postings (id) ON DELETE CASCADE,
|
||||
job_title text NOT NULL DEFAULT '',
|
||||
candidate_name text NOT NULL DEFAULT '',
|
||||
messages jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
overall_interview_score int NOT NULL DEFAULT 0,
|
||||
verdict interview_verdict NOT NULL DEFAULT 'maybe',
|
||||
hire_recommendation text NOT NULL DEFAULT '',
|
||||
integrity_score int NOT NULL DEFAULT 100,
|
||||
ai_flags text[] NOT NULL DEFAULT '{}',
|
||||
category_scores jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
strengths text[] NOT NULL DEFAULT '{}',
|
||||
concerns text[] NOT NULL DEFAULT '{}',
|
||||
best_fit_roles text[] NOT NULL DEFAULT '{}',
|
||||
summary text NOT NULL DEFAULT '',
|
||||
reasoning text NOT NULL DEFAULT '',
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
updated_date timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT ai_interviews_score_pct CHECK (overall_interview_score BETWEEN 0 AND 100),
|
||||
CONSTRAINT ai_interviews_integrity_pct CHECK (integrity_score BETWEEN 0 AND 100),
|
||||
CONSTRAINT ai_interviews_messages_array CHECK (jsonb_typeof(messages) = 'array'),
|
||||
CONSTRAINT ai_interviews_categories_object CHECK (jsonb_typeof(category_scores) = 'object')
|
||||
);
|
||||
|
||||
CREATE INDEX ai_interviews_application_idx ON ai_interviews (application_id);
|
||||
CREATE INDEX ai_interviews_org_created_idx ON ai_interviews (org_id, created_date DESC);
|
||||
|
||||
|
||||
-- ── Hiring: staff (the hire record) ─────────────────────────────────────────
|
||||
|
||||
CREATE TABLE staff (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
legacy_id text UNIQUE,
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
application_id uuid REFERENCES job_applications (id) ON DELETE SET NULL,
|
||||
job_posting_id uuid REFERENCES job_postings (id) ON DELETE SET NULL,
|
||||
worker_profile_id uuid REFERENCES worker_profiles (id) ON DELETE SET NULL,
|
||||
name text NOT NULL,
|
||||
email citext NOT NULL,
|
||||
phone text NOT NULL DEFAULT '',
|
||||
role text NOT NULL DEFAULT '',
|
||||
profile_tier profile_tier NOT NULL DEFAULT 'Beginner',
|
||||
hire_date date NOT NULL,
|
||||
ai_score int NOT NULL DEFAULT 0,
|
||||
status staff_status NOT NULL DEFAULT 'onboarding',
|
||||
client_rating numeric(2,1) NOT NULL DEFAULT 0,
|
||||
endorsement_text text NOT NULL DEFAULT '',
|
||||
endorsed_skills text[] NOT NULL DEFAULT '{}',
|
||||
review_date date,
|
||||
reviewer_name text NOT NULL DEFAULT '',
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
updated_date timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT staff_name_not_blank CHECK (length(btrim(name)) > 0),
|
||||
CONSTRAINT staff_ai_score_pct CHECK (ai_score BETWEEN 0 AND 100),
|
||||
CONSTRAINT staff_rating_range CHECK (client_rating BETWEEN 0 AND 5),
|
||||
CONSTRAINT staff_review_after_hire CHECK (review_date IS NULL OR review_date >= hire_date)
|
||||
);
|
||||
|
||||
CREATE INDEX staff_org_created_idx ON staff (org_id, created_date DESC);
|
||||
CREATE INDEX staff_org_email_idx ON staff (org_id, email);
|
||||
CREATE INDEX staff_posting_idx ON staff (job_posting_id) WHERE job_posting_id IS NOT NULL;
|
||||
|
||||
|
||||
-- ── Workforce: assignments ──────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE assignments (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
legacy_id text UNIQUE,
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
job_posting_id uuid NOT NULL REFERENCES job_postings (id) ON DELETE CASCADE,
|
||||
application_id uuid REFERENCES job_applications (id) ON DELETE SET NULL,
|
||||
worker_profile_id uuid REFERENCES worker_profiles (id) ON DELETE SET NULL,
|
||||
-- The email columns stay and stay populated: lib/workforce.js and
|
||||
-- lib/attendance.js join on them today.
|
||||
worker_email citext NOT NULL,
|
||||
worker_name text NOT NULL DEFAULT '',
|
||||
starts_at timestamptz NOT NULL,
|
||||
ends_at timestamptz,
|
||||
status assignment_status NOT NULL DEFAULT 'active',
|
||||
source text NOT NULL DEFAULT 'owliver',
|
||||
match_score int,
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
updated_date timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT assignments_period_ordered CHECK (ends_at IS NULL OR ends_at > starts_at),
|
||||
CONSTRAINT assignments_match_score_pct CHECK (match_score IS NULL OR match_score BETWEEN 0 AND 100)
|
||||
);
|
||||
|
||||
CREATE INDEX assignments_posting_active_idx ON assignments (job_posting_id) WHERE status = 'active';
|
||||
CREATE INDEX assignments_org_worker_idx ON assignments (org_id, worker_email, starts_at);
|
||||
-- "Is this person free between X and Y" — lib/workforce.js availability checks.
|
||||
CREATE INDEX assignments_period_gist ON assignments
|
||||
USING gist (tstzrange(starts_at, COALESCE(ends_at, 'infinity'::timestamptz)));
|
||||
|
||||
|
||||
-- ── Workforce: shift records ────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE shift_records (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
legacy_id text UNIQUE,
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
staff_id uuid REFERENCES staff (id) ON DELETE CASCADE,
|
||||
assignment_id uuid REFERENCES assignments (id) ON DELETE SET NULL,
|
||||
job_posting_id uuid REFERENCES job_postings (id) ON DELETE SET NULL,
|
||||
worker_name text NOT NULL DEFAULT '',
|
||||
worker_email citext NOT NULL,
|
||||
role text NOT NULL DEFAULT '',
|
||||
role_category text NOT NULL DEFAULT '',
|
||||
shift_date date NOT NULL,
|
||||
scheduled_start timestamptz NOT NULL,
|
||||
scheduled_end timestamptz NOT NULL,
|
||||
scheduled_hours numeric(5,2) NOT NULL,
|
||||
actual_start timestamptz,
|
||||
actual_end timestamptz,
|
||||
actual_hours numeric(5,2) NOT NULL DEFAULT 0,
|
||||
overtime_hours numeric(5,2) NOT NULL DEFAULT 0,
|
||||
minutes_late int NOT NULL DEFAULT 0,
|
||||
status shift_status NOT NULL DEFAULT 'present',
|
||||
notes text NOT NULL DEFAULT '',
|
||||
-- NOT defaulted: for a shift this is the instant the shift was worked, and
|
||||
-- attendanceSeed.js documents that as load-bearing. Writers must supply it.
|
||||
created_date timestamptz NOT NULL,
|
||||
updated_date timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT shift_records_schedule_ordered CHECK (scheduled_end > scheduled_start),
|
||||
CONSTRAINT shift_records_actual_ordered CHECK (
|
||||
actual_start IS NULL OR actual_end IS NULL OR actual_end >= actual_start
|
||||
),
|
||||
CONSTRAINT shift_records_hours_nonneg CHECK (
|
||||
scheduled_hours >= 0 AND actual_hours >= 0 AND overtime_hours >= 0
|
||||
),
|
||||
CONSTRAINT shift_records_minutes_late_nonneg CHECK (minutes_late >= 0),
|
||||
-- An absence has no hours on the clock and nobody was late for it.
|
||||
CONSTRAINT shift_records_absence_has_no_hours CHECK (
|
||||
status NOT IN ('absent', 'no_show') OR (actual_hours = 0 AND minutes_late = 0)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX shift_records_org_created_idx ON shift_records (org_id, created_date DESC);
|
||||
CREATE INDEX shift_records_org_staff_idx ON shift_records (org_id, staff_id, shift_date DESC);
|
||||
CREATE INDEX shift_records_org_category_idx ON shift_records (org_id, role_category, shift_date);
|
||||
CREATE INDEX shift_records_org_exception_idx ON shift_records (org_id, status)
|
||||
WHERE status IN ('absent', 'no_show', 'late');
|
||||
|
||||
|
||||
-- ── Proving Ground: evidence ────────────────────────────────────────────────
|
||||
-- `media_asset_id` and the `file_assets` table are deliberately absent: object
|
||||
-- storage is Phase 2. `media_url` is the column the frontend actually writes.
|
||||
|
||||
CREATE TABLE evidence (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
legacy_id text UNIQUE,
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
course_id uuid REFERENCES courses (id) ON DELETE SET NULL,
|
||||
worker_profile_id uuid REFERENCES worker_profiles (id) ON DELETE CASCADE,
|
||||
course_title text NOT NULL DEFAULT '',
|
||||
skill text NOT NULL DEFAULT '',
|
||||
worker_email citext NOT NULL,
|
||||
worker_name text NOT NULL DEFAULT '',
|
||||
type challenge_type NOT NULL,
|
||||
media_url text NOT NULL DEFAULT '',
|
||||
transcript text NOT NULL DEFAULT '',
|
||||
ai_verdict evidence_verdict NOT NULL DEFAULT 'needs_work',
|
||||
ai_score int NOT NULL DEFAULT 0,
|
||||
ai_rubric jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
ai_feedback text NOT NULL DEFAULT '',
|
||||
supervisor_verified boolean NOT NULL DEFAULT false,
|
||||
supervisor_name text NOT NULL DEFAULT '',
|
||||
verified_date timestamptz,
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
updated_date timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT evidence_ai_score_pct CHECK (ai_score BETWEEN 0 AND 100),
|
||||
CONSTRAINT evidence_rubric_object CHECK (jsonb_typeof(ai_rubric) = 'object'),
|
||||
-- A verification must record who did it and when.
|
||||
CONSTRAINT evidence_verification_complete CHECK (
|
||||
supervisor_verified = false
|
||||
OR (verified_date IS NOT NULL AND length(btrim(supervisor_name)) > 0)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX evidence_org_worker_idx ON evidence (org_id, worker_email, created_date DESC);
|
||||
CREATE INDEX evidence_course_idx ON evidence (course_id) WHERE course_id IS NOT NULL;
|
||||
|
||||
|
||||
-- ── Activity log ────────────────────────────────────────────────────────────
|
||||
-- Append-only. The reference columns are flat and unconstrained on purpose:
|
||||
-- the frontend calls filter({ position_id }) directly against them, and an
|
||||
-- activity row must survive the deletion of whatever it describes.
|
||||
|
||||
CREATE TABLE user_activity (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
legacy_id text UNIQUE,
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
event_type text NOT NULL,
|
||||
user_id uuid REFERENCES users (id) ON DELETE SET NULL,
|
||||
user_email citext NOT NULL DEFAULT 'anonymous',
|
||||
user_name text NOT NULL DEFAULT '',
|
||||
account_type text NOT NULL DEFAULT 'unknown',
|
||||
details text NOT NULL DEFAULT '',
|
||||
position_id uuid,
|
||||
application_id uuid,
|
||||
candidate_id uuid,
|
||||
interview_id uuid,
|
||||
worker_email citext,
|
||||
metadata jsonb,
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT user_activity_event_type_not_blank CHECK (length(btrim(event_type)) > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX user_activity_org_created_idx ON user_activity (org_id, created_date DESC);
|
||||
CREATE INDEX user_activity_org_event_idx ON user_activity (org_id, event_type, created_date DESC);
|
||||
CREATE INDEX user_activity_org_user_idx ON user_activity (org_id, user_email, created_date DESC);
|
||||
CREATE INDEX user_activity_position_idx ON user_activity (position_id) WHERE position_id IS NOT NULL;
|
||||
CREATE INDEX user_activity_application_idx ON user_activity (application_id) WHERE application_id IS NOT NULL;
|
||||
|
||||
Reference in New Issue
Block a user