649 lines
35 KiB
SQL
649 lines
35 KiB
SQL
-- ============================================================================
|
|
-- 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;
|
|
|