feat(platform-api): split lightweight platform service from Lab image
New platform/api/ service owns students, cohorts, curriculum, progress, scenario attempts, and student/admin sessions. No k3s, no --privileged, no terminal runtime — starts instantly, unlike the old identity service which booted an unused k3s cluster just to serve roster/progress APIs. Provisioner calls (student and admin) are consolidated here too, in provisioner-client.js, so it's the only service that talks to the Provisioner directly.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
-- students — migrated off the Lab image's SQLite (Phase 1/2A). This is the FIRST table in
|
||||
-- Postgres to become authoritative for platform academic/identity data — environments.student_id
|
||||
-- (Provisioner schema) stays an opaque TEXT reference, no cross-database FK games; a real FK
|
||||
-- from environments to this table is a natural follow-up once both are settled, not required
|
||||
-- for Phase 2B.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS students (
|
||||
student_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'inactive'
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_active_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_students_status ON students(status);
|
||||
@@ -0,0 +1,28 @@
|
||||
-- cohorts + cohort_enrollments — replaces the old students.cohort TEXT column with a real
|
||||
-- many-to-many-capable model (a student can move between cohorts over time; the old model
|
||||
-- couldn't represent that at all).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cohorts (
|
||||
cohort_id TEXT PRIMARY KEY, -- slug, e.g. 'root-fall-2026'
|
||||
name TEXT NOT NULL,
|
||||
code TEXT UNIQUE, -- optional short join-style code, distinct from cohort_id
|
||||
tier TEXT, -- e.g. 'root' — free text, not an enum yet (YAGNI)
|
||||
start_date DATE,
|
||||
end_date DATE,
|
||||
status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'archived'
|
||||
seat_target INTEGER,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cohorts_status ON cohorts(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cohort_enrollments (
|
||||
cohort_id TEXT NOT NULL REFERENCES cohorts(cohort_id),
|
||||
student_id TEXT NOT NULL REFERENCES students(student_id),
|
||||
enrollment_status TEXT NOT NULL DEFAULT 'enrolled', -- 'enrolled' | 'removed'
|
||||
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (cohort_id, student_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_enrollments_student ON cohort_enrollments(student_id);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- curriculum_assignments — which curriculum path a cohort or an individual student is on.
|
||||
-- Only one curriculum exists today (scenarios/rootnode-path.json, "RootNode Learning Path") —
|
||||
-- this table exists so assigning it is a real, visible instructor action (and so a second path
|
||||
-- can be added later without a schema change), not because multiple paths exist yet.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS curriculum_assignments (
|
||||
assignment_id BIGSERIAL PRIMARY KEY,
|
||||
cohort_id TEXT REFERENCES cohorts(cohort_id),
|
||||
student_id TEXT REFERENCES students(student_id),
|
||||
curriculum_path TEXT NOT NULL DEFAULT 'rootnode-path',
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT curriculum_assignment_target CHECK (cohort_id IS NOT NULL OR student_id IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_curriculum_cohort ON curriculum_assignments(cohort_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_curriculum_student ON curriculum_assignments(student_id);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- progress — same shape as the SQLite table it replaces (student_id, scenario_id composite
|
||||
-- key), migrated data-for-data. Postgres is authoritative for this after migration runs.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS progress (
|
||||
student_id TEXT NOT NULL REFERENCES students(student_id),
|
||||
scenario_id TEXT NOT NULL,
|
||||
status TEXT,
|
||||
attempts INTEGER DEFAULT 0,
|
||||
last_validated TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
started_at TIMESTAMPTZ,
|
||||
notes TEXT,
|
||||
time_spent_seconds INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (student_id, scenario_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_progress_student ON progress(student_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_progress_status ON progress(status);
|
||||
@@ -0,0 +1,29 @@
|
||||
-- scenario_attempts — new capability, did not exist before Phase 2B. The old model only ever
|
||||
-- kept aggregate progress (status + a bare attempts counter); this is a per-attempt log,
|
||||
-- giving instructors real "validation outcomes where recorded" visibility instead of just a
|
||||
-- final status.
|
||||
--
|
||||
-- hint_count is deliberately nullable and unpopulated: the existing Lab engine's hints are
|
||||
-- static UI content only (frontend/src/components/ScenarioPanel.jsx renders them from the
|
||||
-- scenario JSON) — nothing server-side ever records a hint being viewed. Do not invent a
|
||||
-- number here. If hint-view tracking is added to the Lab engine later, this column is ready
|
||||
-- for it without a schema change.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scenario_attempts (
|
||||
attempt_id BIGSERIAL PRIMARY KEY,
|
||||
student_id TEXT NOT NULL REFERENCES students(student_id),
|
||||
scenario_id TEXT NOT NULL,
|
||||
environment_id UUID REFERENCES environments(environment_id),
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
result TEXT NOT NULL, -- 'passed' | 'failed'
|
||||
validation_count INTEGER, -- number of validation checks run this attempt
|
||||
hint_count INTEGER, -- NULL — not measurable today, see note above
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attempts_student ON scenario_attempts(student_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attempts_scenario ON scenario_attempts(scenario_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attempts_environment ON scenario_attempts(environment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attempts_created ON scenario_attempts(created_at);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- exam_sessions — historical import of the old SQLite `sessions` table (exam mode records).
|
||||
-- Scope note (see docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md "Known limitations"): exam mode's
|
||||
-- LIVE session bookkeeping (start/submit/shuffle/abandon) remains Lab-image-local for Phase
|
||||
-- 2B — it's tightly coupled to a single Lab's live cluster state the same way scenario
|
||||
-- validation is, and productionizing it would need the same relay pattern progress got in
|
||||
-- Phase 2A, which is scoped out here (not in the Phase 2B acceptance test). This table exists
|
||||
-- so historical exam records survive the SQLite migration and are visible to instructors, not
|
||||
-- because new exam sessions get written here yet.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS exam_sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
student_id TEXT REFERENCES students(student_id),
|
||||
bundle_id TEXT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
submitted_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
exam_minutes INTEGER NOT NULL DEFAULT 120,
|
||||
duration_secs INTEGER,
|
||||
snapshot JSONB,
|
||||
scenario_ids JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_exam_sessions_student ON exam_sessions(student_id);
|
||||
Reference in New Issue
Block a user