first commit
This commit is contained in:
59
migrations/000001_initial_schema.down.sql
Normal file
59
migrations/000001_initial_schema.down.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- ============================================================================
|
||||
-- Krow — initial schema, reversed.
|
||||
--
|
||||
-- Drops exactly what 000001_initial_schema.up.sql created, in reverse
|
||||
-- dependency order, and nothing else. Every DROP is qualified to `public` and
|
||||
-- named explicitly — there is no CASCADE on a schema, no DROP SCHEMA, and no
|
||||
-- DROP DATABASE anywhere in this file.
|
||||
-- ============================================================================
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
-- Tables, children before parents. Indexes and table-level constraints are
|
||||
-- dropped implicitly with their table.
|
||||
DROP TABLE IF EXISTS public.user_activity;
|
||||
DROP TABLE IF EXISTS public.evidence;
|
||||
DROP TABLE IF EXISTS public.shift_records;
|
||||
DROP TABLE IF EXISTS public.assignments;
|
||||
DROP TABLE IF EXISTS public.staff;
|
||||
DROP TABLE IF EXISTS public.ai_interviews;
|
||||
DROP TABLE IF EXISTS public.job_applications;
|
||||
DROP TABLE IF EXISTS public.worker_profiles;
|
||||
DROP TABLE IF EXISTS public.job_postings;
|
||||
DROP TABLE IF EXISTS public.learning_paths;
|
||||
DROP TABLE IF EXISTS public.courses;
|
||||
DROP TABLE IF EXISTS public.badges;
|
||||
DROP TABLE IF EXISTS public.certifications;
|
||||
DROP TABLE IF EXISTS public.role_categories;
|
||||
DROP TABLE IF EXISTS public.user_preferences;
|
||||
DROP TABLE IF EXISTS public.users;
|
||||
DROP TABLE IF EXISTS public.organizations;
|
||||
|
||||
-- Enums, after every table that referenced them is gone.
|
||||
DROP TYPE IF EXISTS public.badge_verification;
|
||||
DROP TYPE IF EXISTS public.badge_level;
|
||||
DROP TYPE IF EXISTS public.evidence_verdict;
|
||||
DROP TYPE IF EXISTS public.challenge_type;
|
||||
DROP TYPE IF EXISTS public.skill_level;
|
||||
DROP TYPE IF EXISTS public.course_status;
|
||||
DROP TYPE IF EXISTS public.shift_status;
|
||||
DROP TYPE IF EXISTS public.assignment_status;
|
||||
DROP TYPE IF EXISTS public.profile_tier;
|
||||
DROP TYPE IF EXISTS public.staff_status;
|
||||
DROP TYPE IF EXISTS public.interview_verdict;
|
||||
DROP TYPE IF EXISTS public.application_status;
|
||||
DROP TYPE IF EXISTS public.english_level;
|
||||
DROP TYPE IF EXISTS public.posting_priority;
|
||||
DROP TYPE IF EXISTS public.posting_status;
|
||||
|
||||
-- The citext extension is deliberately NOT dropped.
|
||||
--
|
||||
-- CREATE EXTENSION IF NOT EXISTS is a no-op when the extension already exists,
|
||||
-- so the up migration cannot tell whether it created citext or inherited it.
|
||||
-- Dropping it on the way down would therefore risk removing an object this
|
||||
-- migration never owned, and would break any other schema that has since
|
||||
-- adopted the type. Leaving it is harmless: it holds no data, and re-applying
|
||||
-- the up migration is unaffected.
|
||||
--
|
||||
-- To remove it by hand on a database where nothing else uses it:
|
||||
-- DROP EXTENSION IF EXISTS citext;
|
||||
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;
|
||||
|
||||
17
migrations/000002_application_interview_id.down.sql
Normal file
17
migrations/000002_application_interview_id.down.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Reverses 000002, dropping each added column. Indexes and the check
|
||||
-- constraint go with their columns.
|
||||
SET search_path = public;
|
||||
|
||||
DROP INDEX IF EXISTS public.job_applications_interview_idx;
|
||||
|
||||
ALTER TABLE public.job_applications
|
||||
DROP COLUMN IF EXISTS interview_id;
|
||||
|
||||
ALTER TABLE public.courses
|
||||
DROP COLUMN IF EXISTS training_outline;
|
||||
|
||||
ALTER TABLE public.worker_profiles
|
||||
DROP CONSTRAINT IF EXISTS worker_profiles_score_breakdown_object;
|
||||
|
||||
ALTER TABLE public.worker_profiles
|
||||
DROP COLUMN IF EXISTS score_breakdown;
|
||||
82
migrations/000002_application_interview_id.up.sql
Normal file
82
migrations/000002_application_interview_id.up.sql
Normal file
@@ -0,0 +1,82 @@
|
||||
-- ============================================================================
|
||||
-- Frontend reconciliation gaps
|
||||
--
|
||||
-- Three columns the frontend reads or writes but migration 000001 had no place
|
||||
-- for. All three were found by sweeping runtime write paths, not just seed data.
|
||||
--
|
||||
-- ── 1. job_applications.interview_id ──────────────────────────────────────
|
||||
--
|
||||
-- WRITE components/krow/AIInterviewModal.jsx:180
|
||||
-- PATCH {status:'interview', interview_id, ai_score} — reachable from
|
||||
-- admin/CandidateProfile.jsx, admin/Candidates.jsx, PositionDetail.jsx
|
||||
-- READ components/krow/CandidateExpandedDetails.jsx:41,142
|
||||
-- via CandidateCard.jsx ← admin/Candidates.jsx, PositionDetail.jsx
|
||||
-- SEED 5 of 24 applications in src/api/seed.js carry it
|
||||
--
|
||||
-- Deliberately NOT a foreign key. `app_devon` references `int_devon`, for which
|
||||
-- no AIInterview record exists in the seed; a REFERENCES constraint would
|
||||
-- either fail the seed or force that value to NULL, and nulling it would be
|
||||
-- silently transforming source data. The column is a soft reference, which is
|
||||
-- also how the frontend treats it — every read is a truthiness check, never a
|
||||
-- lookup.
|
||||
-- ============================================================================
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
ALTER TABLE public.job_applications
|
||||
ADD COLUMN interview_id uuid;
|
||||
|
||||
COMMENT ON COLUMN public.job_applications.interview_id IS
|
||||
'Soft reference to ai_interviews.id. Intentionally unconstrained: seed data '
|
||||
'contains a dangling reference, and the frontend only ever tests it for '
|
||||
'presence.';
|
||||
|
||||
CREATE INDEX job_applications_interview_idx
|
||||
ON public.job_applications (interview_id)
|
||||
WHERE interview_id IS NOT NULL;
|
||||
|
||||
|
||||
-- ── 2. courses.training_outline ───────────────────────────────────────────
|
||||
--
|
||||
-- WRITE components/forge/AddSkillTraining.jsx:103 ← pages/University.jsx
|
||||
-- (mounted at /admin/university)
|
||||
-- READ components/forge/challengeMeta.js:139, ai-assistant/insights.js:177,
|
||||
-- ai-assistant/capabilities/admin.js:1547, lib/skills/actions.js:369
|
||||
-- SEED 0 of 40 courses carry it — the Forge authoring flow writes it, and
|
||||
-- every shipped course predates that flow.
|
||||
--
|
||||
-- text[] rather than jsonb: the writer is
|
||||
-- `form.outline.map((s) => s.trim()).filter(Boolean)`, a plain list of strings,
|
||||
-- and every reader guards with Array.isArray then filters. This matches the
|
||||
-- shape of completion_criteria and verification_criteria on the same table.
|
||||
|
||||
ALTER TABLE public.courses
|
||||
ADD COLUMN training_outline text[] NOT NULL DEFAULT '{}';
|
||||
|
||||
COMMENT ON COLUMN public.courses.training_outline IS
|
||||
'Ordered written training steps, authored through the Forge flow. Empty for '
|
||||
'every course that predates it.';
|
||||
|
||||
|
||||
-- ── 3. worker_profiles.score_breakdown ────────────────────────────────────
|
||||
--
|
||||
-- WRITE lib/krowScore.js:64 recalcProfilePatch() → spread into the profile
|
||||
-- patch at lib/krowHooks.js:682, the live challenge-submission path
|
||||
-- reached through CourseDetail.jsx.
|
||||
-- READ No reader consumes it *from a profile*: every score_breakdown reader
|
||||
-- in the frontend works on a job application. The column exists so the
|
||||
-- write lands rather than being silently discarded.
|
||||
--
|
||||
-- jsonb, mirroring job_applications.score_breakdown, which holds the output of
|
||||
-- the same scoring engine.
|
||||
|
||||
ALTER TABLE public.worker_profiles
|
||||
ADD COLUMN score_breakdown jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
ALTER TABLE public.worker_profiles
|
||||
ADD CONSTRAINT worker_profiles_score_breakdown_object
|
||||
CHECK (jsonb_typeof(score_breakdown) = 'object');
|
||||
|
||||
COMMENT ON COLUMN public.worker_profiles.score_breakdown IS
|
||||
'Per-dimension KROW score detail from recalcProfilePatch(). Written by the '
|
||||
'challenge flow; no frontend reader consumes it from a profile yet.';
|
||||
12
migrations/000003_drop_screened_consistent_check.down.sql
Normal file
12
migrations/000003_drop_screened_consistent_check.down.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- Restores the constraint dropped by 000003.
|
||||
--
|
||||
-- NOTE: this will FAIL on any database holding the seeded demo dataset, because
|
||||
-- that data is what the constraint rejects. That is the point of dropping it.
|
||||
-- Roll back only on a database whose applications satisfy the predicate.
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
ALTER TABLE public.job_applications
|
||||
ADD CONSTRAINT job_applications_screened_consistent CHECK (
|
||||
status = 'applied' OR screened_at IS NOT NULL OR ai_score = 0
|
||||
);
|
||||
28
migrations/000003_drop_screened_consistent_check.up.sql
Normal file
28
migrations/000003_drop_screened_consistent_check.up.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- ============================================================================
|
||||
-- Drop job_applications_screened_consistent
|
||||
--
|
||||
-- A defect in migration 000001. The constraint reads:
|
||||
--
|
||||
-- status = 'applied' OR screened_at IS NOT NULL OR ai_score = 0
|
||||
--
|
||||
-- which asserts that a screened application records *when* it was screened.
|
||||
-- The frontend makes no such promise: `screened_at` is never read and never
|
||||
-- written anywhere in the repository, and none of the 24 seeded applications
|
||||
-- carries it. The column and the constraint both came out of the backend
|
||||
-- blueprint rather than out of repository evidence.
|
||||
--
|
||||
-- The effect was that 9 of 24 seeded applications were rejected — exactly the
|
||||
-- 9 AI-scored ones behind the "9 scored, averaging 76" regression anchor.
|
||||
--
|
||||
-- 000001 is already applied and is immutable, so the fix lands here. All 52
|
||||
-- other CHECK constraints from 000001 were verified against all 245 seeded
|
||||
-- records and hold.
|
||||
--
|
||||
-- The `screened_at` column is kept: it is nullable and unused, and removing it
|
||||
-- is a separate cleanup rather than part of this fix.
|
||||
-- ============================================================================
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
ALTER TABLE public.job_applications
|
||||
DROP CONSTRAINT IF EXISTS job_applications_screened_consistent;
|
||||
16
migrations/000004_auth_sessions.down.sql
Normal file
16
migrations/000004_auth_sessions.down.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Reverses 000004.
|
||||
--
|
||||
-- Drops exactly what the up migration created and nothing else: no CASCADE on
|
||||
-- a schema, no DROP SCHEMA, no DROP DATABASE, and no touch to users beyond
|
||||
-- removing the index 000004 added. users_org_email_key predates this migration
|
||||
-- and is left alone.
|
||||
--
|
||||
-- Dropping `sessions` logs everyone out. That is the correct meaning of
|
||||
-- rolling back the authentication foundation, and it destroys no application
|
||||
-- data: every row in this table is a credential, not a record.
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
DROP TABLE IF EXISTS public.sessions;
|
||||
|
||||
DROP INDEX IF EXISTS public.users_email_global_key;
|
||||
119
migrations/000004_auth_sessions.up.sql
Normal file
119
migrations/000004_auth_sessions.up.sql
Normal file
@@ -0,0 +1,119 @@
|
||||
-- ============================================================================
|
||||
-- Krow — authentication foundation
|
||||
--
|
||||
-- Phase 3B. This migration adds the two schema facts authentication needs and
|
||||
-- nothing else:
|
||||
--
|
||||
-- 1. users.email is globally unique, because login identifies a user by
|
||||
-- email alone.
|
||||
-- 2. a `sessions` table, because sessions are server-side and opaque.
|
||||
--
|
||||
-- Deliberately NOT here, per the Phase 3B decisions:
|
||||
-- roles, permissions, organization_members, credentials, refresh_tokens.
|
||||
-- `users.role` is already the authorization field and `users.password_hash`
|
||||
-- already exists; neither needs a table of its own.
|
||||
--
|
||||
-- No login, logout, middleware or enforcement ships with this migration. It is
|
||||
-- schema only.
|
||||
--
|
||||
-- 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;
|
||||
|
||||
|
||||
-- ── users.email — global uniqueness ─────────────────────────────────────────
|
||||
--
|
||||
-- 000001 constrains (org_id, email). That is the right key for a tenant-scoped
|
||||
-- directory, and the wrong key for a login form: `POST /auth/login` will be
|
||||
-- given an email and a password and nothing else, so an email that resolved to
|
||||
-- two users in two organizations would have no single answer.
|
||||
--
|
||||
-- The column is `citext`, so this index is case-insensitive for free —
|
||||
-- "Demo@Krow.app" and "demo@krow.app" collide, which is what a login form
|
||||
-- needs. A plain btree over a citext column uses the type's own comparison; no
|
||||
-- lower() expression is required, and using one here would in fact build a
|
||||
-- *different*, case-sensitive index.
|
||||
--
|
||||
-- users_org_email_key is left in place. It is now implied by this index and
|
||||
-- therefore redundant, but dropping it is a change to the existing table that
|
||||
-- authentication does not need, and a redundant unique constraint costs one
|
||||
-- index write per user row — of which there is currently one.
|
||||
--
|
||||
-- Verified before writing this migration: no two rows in the target database
|
||||
-- share an email, so the index builds without a conflict.
|
||||
CREATE UNIQUE INDEX users_email_global_key ON public.users (email);
|
||||
|
||||
COMMENT ON INDEX public.users_email_global_key IS
|
||||
'Login identity. Email must resolve to exactly one user across every '
|
||||
'organization, because the login form supplies no organization.';
|
||||
|
||||
|
||||
-- ── sessions ────────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- A session is a row, not a token payload. The browser holds an opaque random
|
||||
-- string in an HttpOnly cookie; this table holds only SHA-256 of that string,
|
||||
-- so a dump of this table cannot be replayed as a login.
|
||||
--
|
||||
-- token_hash is `text` holding lowercase hex rather than bytea: it is 64 ASCII
|
||||
-- bytes either way after TOAST considerations, it is greppable in psql during
|
||||
-- development, and it matches how password_hash is already stored. The CHECK
|
||||
-- pins the format, so a caller cannot accidentally store a raw token here —
|
||||
-- a raw token is base64url of 32 bytes and fails the pattern.
|
||||
--
|
||||
-- Two expiries, because Phase 3B decision 3 allows sliding expiry and also
|
||||
-- requires that a session cannot live forever:
|
||||
--
|
||||
-- expires_at moves forward as the session is used (the sliding
|
||||
-- window: 12 hours normally, 30 days with Remember Me).
|
||||
-- absolute_expires_at is fixed at creation and never moves. Once it passes,
|
||||
-- the session is dead no matter how recently it was
|
||||
-- used, and the user authenticates again.
|
||||
--
|
||||
-- Without the second column the first can be slid indefinitely, which is
|
||||
-- exactly the "session that lives forever" the decision rules out.
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
token_hash text NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
absolute_expires_at timestamptz NOT NULL,
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- UNIQUE is implemented by PostgreSQL as a btree index on token_hash, which
|
||||
-- is also the index every session lookup uses: authentication hashes the
|
||||
-- cookie and probes this one column. It is both the uniqueness guarantee and
|
||||
-- the lookup path; a second index on the same column would be dead weight.
|
||||
CONSTRAINT sessions_token_hash_key UNIQUE (token_hash),
|
||||
|
||||
CONSTRAINT sessions_token_hash_sha256 CHECK (token_hash ~ '^[0-9a-f]{64}$'),
|
||||
CONSTRAINT sessions_absolute_after_created CHECK (absolute_expires_at > created_date),
|
||||
CONSTRAINT sessions_within_absolute CHECK (expires_at <= absolute_expires_at)
|
||||
);
|
||||
|
||||
-- ON DELETE CASCADE, not SET NULL and not RESTRICT: a deleted user must not
|
||||
-- leave a live session behind that still authenticates as them.
|
||||
|
||||
-- Revoking every session for one user, and the FK's own cascade check.
|
||||
CREATE INDEX sessions_user_idx ON sessions (user_id);
|
||||
|
||||
-- The periodic sweep of dead rows. Ordered by the column it filters on.
|
||||
CREATE INDEX sessions_expires_idx ON sessions (expires_at);
|
||||
|
||||
COMMENT ON TABLE sessions IS
|
||||
'Server-side sessions. The raw token exists only in the client HttpOnly '
|
||||
'cookie; this table stores SHA-256 of it and never the token itself.';
|
||||
|
||||
COMMENT ON COLUMN sessions.token_hash IS
|
||||
'Lowercase hex SHA-256 of the session token. Never the token.';
|
||||
|
||||
COMMENT ON COLUMN sessions.expires_at IS
|
||||
'Sliding expiry. Moved forward on use; never past absolute_expires_at.';
|
||||
|
||||
COMMENT ON COLUMN sessions.absolute_expires_at IS
|
||||
'Hard ceiling, fixed at creation. A session cannot outlive it.';
|
||||
22
migrations/000005_agent_skill_definitions.down.sql
Normal file
22
migrations/000005_agent_skill_definitions.down.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- Reverses 000005.
|
||||
--
|
||||
-- Drops exactly the two tables it created and nothing else. No CASCADE on a
|
||||
-- schema, no DROP SCHEMA, no DROP DATABASE, and no touch to any table that
|
||||
-- predates this migration. Indexes and constraints go with their tables.
|
||||
--
|
||||
-- No enum types were created by 000005 — the two status vocabularies are text
|
||||
-- with CHECK constraints — so there is nothing left behind to clean up.
|
||||
--
|
||||
-- Rolling this back destroys every authored agent and skill definition. That is
|
||||
-- the correct meaning of reversing the migration that introduced them, and it
|
||||
-- reaches nothing else: shipped definitions live in Git, and the account
|
||||
-- preferences these tables replaced are untouched by both directions of 000005.
|
||||
--
|
||||
-- Dropped in reverse creation order. The two tables do not reference each
|
||||
-- other, so the order is convention rather than necessity.
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
DROP TABLE IF EXISTS public.skill_definitions;
|
||||
|
||||
DROP TABLE IF EXISTS public.agent_definitions;
|
||||
293
migrations/000005_agent_skill_definitions.up.sql
Normal file
293
migrations/000005_agent_skill_definitions.up.sql
Normal file
@@ -0,0 +1,293 @@
|
||||
-- ============================================================================
|
||||
-- Krow — authored agent and skill definitions
|
||||
--
|
||||
-- Phase 4C. Two tables, and deliberately only two.
|
||||
--
|
||||
-- WHAT THIS IS FOR
|
||||
--
|
||||
-- An agent and a skill are each a Markdown file with YAML frontmatter. Three
|
||||
-- tiers of them exist, and only two live here:
|
||||
--
|
||||
-- shipped src/agents/**/*.md, src/skills/**/*.md — product source,
|
||||
-- versioned in Git, bundled at build time. NO ROWS HERE. They
|
||||
-- are code: putting them in a table would trade `git log`,
|
||||
-- code review and atomic deploy for nothing, and would make
|
||||
-- every shipped-definition change a data migration.
|
||||
-- organization authored in the app, shared across one tenant.
|
||||
-- personal authored in the app, private to one user.
|
||||
--
|
||||
-- Before this migration the last two lived in `user_preferences.extra`, a
|
||||
-- jsonb blob with no owner, no tenancy, no size bound, no server-side
|
||||
-- validation and no query surface — and returned in full by GET /api/v1/me on
|
||||
-- every page load. This migration is that move.
|
||||
--
|
||||
-- WHY TWO TABLES AND NOT ONE
|
||||
--
|
||||
-- Agents and skills do not share a lifecycle, and the difference is not
|
||||
-- incidental:
|
||||
--
|
||||
-- agents status draft | published | archived, plus an integer version that
|
||||
-- only goes up. They are published artefacts.
|
||||
-- skills status active | inactive, and NO version at all — the frontend has
|
||||
-- no notion of a skill version and none is invented here.
|
||||
--
|
||||
-- One table would need a union CHECK permitting `version 5, status inactive`,
|
||||
-- and a version column that is forever 1 for half the rows. Two tables cost a
|
||||
-- little repetition and buy a schema where every row is meaningful.
|
||||
--
|
||||
-- WHAT IS DELIBERATELY ABSENT
|
||||
--
|
||||
-- definition_versions nothing retains prior Markdown; no rollback
|
||||
-- feature exists to serve.
|
||||
-- definition_permissions the `permissions:` frontmatter block stays inside
|
||||
-- the Markdown, parsed and unenforced, until its
|
||||
-- semantics are defined (Phase 4H).
|
||||
-- agent_skills `skills:` names ids in a namespace that includes
|
||||
-- agent_subagents SHIPPED definitions, which have no rows here. A
|
||||
-- join table would need foreign keys to rows that do
|
||||
-- not exist. Resolution stays in the registry.
|
||||
-- agent_knowledge embedded in frontmatter; no corpus exists.
|
||||
-- conversations deferred.
|
||||
--
|
||||
-- `disabledSkills` and `removedSkills` also stay where they are, in
|
||||
-- user_preferences.extra. They are per-account arrays of skill *ids* — mostly
|
||||
-- shipped ids — so they are suppression preferences over a namespace, not
|
||||
-- definitions, and they are already in the right place.
|
||||
--
|
||||
-- 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;
|
||||
|
||||
|
||||
-- ── agent_definitions ───────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE agent_definitions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- The author-facing id — `id:` in the frontmatter, the address that skills,
|
||||
-- subagents and the shadow-by-id merge all refer to. NOT globally unique:
|
||||
-- the whole point of shadowing is that a personal definition may carry the
|
||||
-- same id as an organization one, which may carry the same id as a shipped
|
||||
-- one. See the two partial unique indexes below for what IS unique.
|
||||
definition_id text NOT NULL,
|
||||
|
||||
-- Tenancy. NOT NULL on a personal definition too: a user belongs to exactly
|
||||
-- one organization, so a personal definition is always inside a tenant, and
|
||||
-- carrying org_id means the organization predicate applies to every read
|
||||
-- whether or not the ownership predicate does. Defence in depth for one
|
||||
-- column.
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
|
||||
visibility text NOT NULL,
|
||||
|
||||
-- Ownership, in two columns because the two tiers have genuinely different
|
||||
-- deletion semantics and one column cannot carry both:
|
||||
--
|
||||
-- owner_user_id set ONLY for a personal definition. CASCADE: a personal
|
||||
-- definition dies with its owner, because there is nobody
|
||||
-- else it could belong to.
|
||||
-- created_by always the author. SET NULL: an organization-shared
|
||||
-- definition must survive its author leaving the company.
|
||||
-- Nullable for exactly that reason, and because that is
|
||||
-- already this schema's pattern — job_postings.created_by.
|
||||
owner_user_id uuid REFERENCES users (id) ON DELETE CASCADE,
|
||||
created_by uuid REFERENCES users (id) ON DELETE SET NULL,
|
||||
|
||||
-- The definition, verbatim. THIS IS THE AUTHORITATIVE ARTEFACT: a definition
|
||||
-- must survive a round trip to a .md file on disk unchanged, so the Markdown
|
||||
-- is the record and the columns below are derived from it.
|
||||
markdown text NOT NULL,
|
||||
|
||||
-- ── Projections ──────────────────────────────────────────────────────────
|
||||
-- Everything below is parsed OUT of `markdown` by the server, never accepted
|
||||
-- from a request body, and rebuildable by re-parsing every row. They exist so
|
||||
-- that "this organization's published agents" is a query rather than a parse
|
||||
-- of every blob, and so version conflicts can be detected with a predicate
|
||||
-- rather than a read-modify-write in the browser.
|
||||
status text NOT NULL DEFAULT 'draft',
|
||||
|
||||
-- Monotonic. A first publish keeps its version; republishing moves it on, so
|
||||
-- "what is live" is always a specific number.
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
|
||||
name text NOT NULL DEFAULT '',
|
||||
description text NOT NULL DEFAULT '',
|
||||
-- text[] rather than jsonb, matching courses.training_outline: this is a
|
||||
-- plain list of strings and every reader treats it as one.
|
||||
pages text[] NOT NULL DEFAULT '{}',
|
||||
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
updated_date timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- The format the frontend validator already enforces, restated here so the
|
||||
-- database refuses what the application would have refused. Lower-case
|
||||
-- letters, digits and dashes, not starting with a dash.
|
||||
CONSTRAINT agent_definitions_definition_id_format
|
||||
CHECK (definition_id ~ '^[a-z0-9][a-z0-9-]*$'),
|
||||
|
||||
CONSTRAINT agent_definitions_visibility_check
|
||||
CHECK (visibility IN ('personal', 'organization')),
|
||||
|
||||
-- The ownership invariant, stated once and in both directions: a personal
|
||||
-- definition HAS an owner, an organization definition has NONE. Written as an
|
||||
-- equality of two booleans rather than two OR'd implications, because that is
|
||||
-- the whole rule in one line and cannot be half-satisfied.
|
||||
CONSTRAINT agent_definitions_visibility_owner
|
||||
CHECK ((visibility = 'personal') = (owner_user_id IS NOT NULL)),
|
||||
|
||||
-- text + CHECK rather than a PostgreSQL enum, following users.role and
|
||||
-- users.status. A status vocabulary that may grow is easier to widen with an
|
||||
-- ALTER of a constraint than with ALTER TYPE ... ADD VALUE, and it keeps the
|
||||
-- down migration to a table drop with no type left behind.
|
||||
CONSTRAINT agent_definitions_status_check
|
||||
CHECK (status IN ('draft', 'published', 'archived')),
|
||||
|
||||
CONSTRAINT agent_definitions_version_check
|
||||
CHECK (version >= 1),
|
||||
|
||||
-- A bound on the blob, which is the other half of moving definitions out of
|
||||
-- user_preferences.extra. The largest definition shipped with the product is
|
||||
-- 3,156 bytes and the median is 1,354, so 65,536 is roughly twenty times the
|
||||
-- biggest thing anyone has actually written — unreachable by legitimate
|
||||
-- authoring, and low enough that no single row can be used to bloat a
|
||||
-- response. An empty definition cannot parse, so zero length is refused too.
|
||||
--
|
||||
-- `length()` counts CHARACTERS, which is the semantic this schema already
|
||||
-- uses (organizations_name_not_blank, users_email_not_blank). A worst-case
|
||||
-- 4-byte-per-character document would therefore be up to 256 KiB on disk;
|
||||
-- that is accepted deliberately in exchange for one consistent rule.
|
||||
CONSTRAINT agent_definitions_markdown_size
|
||||
CHECK (length(markdown) BETWEEN 1 AND 65536)
|
||||
);
|
||||
|
||||
-- Uniqueness, per tier. Partial rather than whole-table because the two tiers
|
||||
-- are keyed on different columns: a personal definition is unique to its owner,
|
||||
-- an organization definition to its tenant. Partial also keeps each index to
|
||||
-- only the rows it governs.
|
||||
--
|
||||
-- (A plain UNIQUE (owner_user_id, definition_id) would technically also work,
|
||||
-- because NULLs are distinct by default and organization rows all have a NULL
|
||||
-- owner — but it would be relying on a subtlety to express a rule, which is
|
||||
-- how the rule gets misread later.)
|
||||
CREATE UNIQUE INDEX agent_definitions_personal_key
|
||||
ON agent_definitions (owner_user_id, definition_id)
|
||||
WHERE visibility = 'personal';
|
||||
|
||||
CREATE UNIQUE INDEX agent_definitions_org_key
|
||||
ON agent_definitions (org_id, definition_id)
|
||||
WHERE visibility = 'organization';
|
||||
|
||||
-- Listing one organization's definitions, split by tier. Also covers the
|
||||
-- org_id foreign key, which PostgreSQL does not index on its own.
|
||||
CREATE INDEX agent_definitions_org_visibility_idx
|
||||
ON agent_definitions (org_id, visibility);
|
||||
|
||||
-- Listing one user's own definitions, and the owner_user_id foreign key's
|
||||
-- cascade check.
|
||||
CREATE INDEX agent_definitions_owner_idx
|
||||
ON agent_definitions (owner_user_id);
|
||||
|
||||
-- The runtime's own query: the agents that are actually live in a tenant. An
|
||||
-- unpublished agent contributes nothing at runtime, so the index carries only
|
||||
-- published rows — the same shape as job_postings_org_active_idx.
|
||||
CREATE INDEX agent_definitions_published_idx
|
||||
ON agent_definitions (org_id, visibility)
|
||||
WHERE status = 'published';
|
||||
|
||||
-- There is deliberately NO index on created_by. It is attribution only: no
|
||||
-- listing is keyed by it, and its ON DELETE SET NULL scan happens when a user
|
||||
-- is deleted, which is rare and against a small table. An index would cost a
|
||||
-- write on every definition change to serve nothing.
|
||||
|
||||
COMMENT ON TABLE agent_definitions IS
|
||||
'Agent definitions authored in the application. Shipped agents live in Git '
|
||||
'under src/agents/ and have no rows here.';
|
||||
|
||||
COMMENT ON COLUMN agent_definitions.definition_id IS
|
||||
'Author-facing id from the frontmatter. Unique per owner or per organization, '
|
||||
'never globally: shadow-by-id is the point.';
|
||||
|
||||
COMMENT ON COLUMN agent_definitions.markdown IS
|
||||
'The definition verbatim, and the authoritative record. Every other column '
|
||||
'except the identity and ownership ones is parsed out of this.';
|
||||
|
||||
COMMENT ON COLUMN agent_definitions.owner_user_id IS
|
||||
'Set only when visibility = personal. Organization definitions have none.';
|
||||
|
||||
COMMENT ON COLUMN agent_definitions.created_by IS
|
||||
'The author, for attribution. Nullable so a shared definition survives its '
|
||||
'author being deleted.';
|
||||
|
||||
|
||||
-- ── skill_definitions ───────────────────────────────────────────────────────
|
||||
--
|
||||
-- The same shape, minus `version`. Skills have no version and no publish step
|
||||
-- in the product: a skill is active or inactive, and that is the whole of its
|
||||
-- lifecycle. Adding a version column "for symmetry" would be inventing a
|
||||
-- concept the frontend does not have and cannot set.
|
||||
|
||||
CREATE TABLE skill_definitions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
definition_id text NOT NULL,
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
visibility text NOT NULL,
|
||||
owner_user_id uuid REFERENCES users (id) ON DELETE CASCADE,
|
||||
created_by uuid REFERENCES users (id) ON DELETE SET NULL,
|
||||
markdown text NOT NULL,
|
||||
|
||||
-- Projections, as above.
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
name text NOT NULL DEFAULT '',
|
||||
description text NOT NULL DEFAULT '',
|
||||
pages text[] NOT NULL DEFAULT '{}',
|
||||
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
updated_date timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT skill_definitions_definition_id_format
|
||||
CHECK (definition_id ~ '^[a-z0-9][a-z0-9-]*$'),
|
||||
|
||||
CONSTRAINT skill_definitions_visibility_check
|
||||
CHECK (visibility IN ('personal', 'organization')),
|
||||
|
||||
CONSTRAINT skill_definitions_visibility_owner
|
||||
CHECK ((visibility = 'personal') = (owner_user_id IS NOT NULL)),
|
||||
|
||||
CONSTRAINT skill_definitions_status_check
|
||||
CHECK (status IN ('active', 'inactive')),
|
||||
|
||||
CONSTRAINT skill_definitions_markdown_size
|
||||
CHECK (length(markdown) BETWEEN 1 AND 65536)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX skill_definitions_personal_key
|
||||
ON skill_definitions (owner_user_id, definition_id)
|
||||
WHERE visibility = 'personal';
|
||||
|
||||
CREATE UNIQUE INDEX skill_definitions_org_key
|
||||
ON skill_definitions (org_id, definition_id)
|
||||
WHERE visibility = 'organization';
|
||||
|
||||
CREATE INDEX skill_definitions_org_visibility_idx
|
||||
ON skill_definitions (org_id, visibility);
|
||||
|
||||
CREATE INDEX skill_definitions_owner_idx
|
||||
ON skill_definitions (owner_user_id);
|
||||
|
||||
-- The runtime loads active skills; an inactive one is registered and switched
|
||||
-- off. Partial for the same reason as the agent index above.
|
||||
CREATE INDEX skill_definitions_active_idx
|
||||
ON skill_definitions (org_id, visibility)
|
||||
WHERE status = 'active';
|
||||
|
||||
COMMENT ON TABLE skill_definitions IS
|
||||
'Skill definitions authored in the application. Shipped skills live in Git '
|
||||
'under src/skills/ and have no rows here. Skills have no version: their '
|
||||
'lifecycle is active or inactive.';
|
||||
|
||||
COMMENT ON COLUMN skill_definitions.markdown IS
|
||||
'The definition verbatim, and the authoritative record.';
|
||||
Reference in New Issue
Block a user