first commit

This commit is contained in:
2026-08-24 13:06:29 +05:30
commit 7d12ebef3d
86 changed files with 39996 additions and 0 deletions

View 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;