diff --git a/platform/api/migrations/033_phase7_organizations_locations_guardians.sql b/platform/api/migrations/033_phase7_organizations_locations_guardians.sql new file mode 100644 index 0000000..7a176b0 --- /dev/null +++ b/platform/api/migrations/033_phase7_organizations_locations_guardians.sql @@ -0,0 +1,88 @@ +-- Phase 7 §7/§8/§10 — the business/product architecture's first-class organization, location, +-- and guardian concepts. Additive only: no existing table's meaning changes, no existing row's +-- data moves. See docs/ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md and +-- docs/STUDENT_GUARDIAN_DATA_MODEL.md for the full reasoning. + +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +-- programs.family widened for BOOTCAMP_ (Step 15/21) — a program row can now describe a +-- seasonal/intensive offering family too. No existing row's family value changes; this is a +-- pure widening of what's allowed going forward. +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +ALTER TABLE programs DROP CONSTRAINT IF EXISTS programs_family_check; +ALTER TABLE programs ADD CONSTRAINT programs_family_check CHECK (family IN ('init', 'compile', 'root', 'bootcamp')); + +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +-- organizations — a business/delivery relationship (a school, library, homeschool group, +-- community partner, workforce-development partner), NOT a SaaS tenant. RootNode Academy +-- remains one Academy; no other table gets an organization_id foreign key added to it wholesale +-- — only offerings and locations reference one, and only optionally (Step 7's explicit +-- "do not move all RootNode data under organization_id"). +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS organizations ( + organization_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'other' CHECK (type IN ('school', 'library', 'homeschool_group', 'community', 'workforce_partner', 'other')), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + contact_name TEXT, + contact_email TEXT, + contact_phone TEXT, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +-- locations — a physical or virtual delivery location. organization_id is nullable: a location +-- can be a RootNode-run venue with no partner organization at all (e.g. a rented community- +-- center room booked directly), or "online" (type='online', every address field null). +-- access_notes is deliberately never exposed on any student-facing route — internal +-- operational detail only (parking instructions, building access codes, etc). +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS locations ( + location_id TEXT PRIMARY KEY, + organization_id TEXT REFERENCES organizations(organization_id), + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'online' CHECK (type IN ('school', 'library', 'community_center', 'partner_facility', 'online', 'other')), + address_line1 TEXT, + address_line2 TEXT, + city TEXT, + region TEXT, + postal_code TEXT, + country TEXT, + timezone TEXT, + capacity INTEGER, + access_notes TEXT, -- internal/operational only — never returned by a student-facing route + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +-- guardians / student_guardians — the proper foundation replacing the Phase 5C +-- `enrollments.metadata.contact_email` workaround (left in place, unchanged, for backward +-- compatibility with the live website integration — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md). +-- A guardian is its own identity, separate from students.email, so a parent with multiple +-- enrolled children is ONE guardian row linked to MANY students, not a duplicated email per +-- child. Explicitly NOT a login/auth identity in this phase — no password, no session, no +-- portal access — see docs/STUDENT_GUARDIAN_DATA_MODEL.md for what was deliberately deferred. +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS guardians ( + guardian_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + email TEXT, + phone TEXT, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS student_guardians ( + student_id TEXT NOT NULL REFERENCES students(student_id), + guardian_id TEXT NOT NULL REFERENCES guardians(guardian_id), + relationship TEXT NOT NULL DEFAULT 'guardian' CHECK (relationship IN ('parent', 'guardian', 'authorized_contact', 'emergency_contact')), + is_primary_contact BOOLEAN NOT NULL DEFAULT false, + can_receive_communications BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (student_id, guardian_id) +); +CREATE INDEX IF NOT EXISTS idx_student_guardians_guardian ON student_guardians(guardian_id); diff --git a/platform/api/migrations/034_phase7_offerings_registrations_agreements.sql b/platform/api/migrations/034_phase7_offerings_registrations_agreements.sql new file mode 100644 index 0000000..b4cf3e4 --- /dev/null +++ b/platform/api/migrations/034_phase7_offerings_registrations_agreements.sql @@ -0,0 +1,148 @@ +-- Phase 7 §3/§12/§14/§20 — offerings (a purchasable/enrollable delivery of a program), +-- registrations (pre-enrollment application/interest, separate from academic entitlement), +-- agreements (versioned acceptance records, not legal text), and curriculum packages (a named, +-- versioned reference list into EXISTING courses — never a content clone). See +-- docs/OFFERING_AND_REGISTRATION_MODEL.md for the full reasoning and lifecycle diagrams. + +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +-- curriculum_packages / curriculum_package_items — a named, versioned SUBSET reference into +-- existing courses. curriculum_package_items stores no content of its own, only which +-- course_id(s) (whole courses, matching how V1's own course granularity already works) belong +-- to the package and in what order — exactly the same "reference, never clone" principle +-- COURSE_CONTENT_MODEL.md already established for lab activities referencing scenario_id. +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS curriculum_packages ( + curriculum_package_id TEXT PRIMARY KEY, -- slug, e.g. 'winter-linux-systems-v1' + name TEXT NOT NULL, + program_id TEXT NOT NULL REFERENCES programs(program_id), + description TEXT, + version INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'retired')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS curriculum_package_items ( + curriculum_package_id TEXT NOT NULL REFERENCES curriculum_packages(curriculum_package_id), + course_id TEXT NOT NULL REFERENCES courses(course_id), + sequence INTEGER NOT NULL, + PRIMARY KEY (curriculum_package_id, course_id) +); + +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +-- offerings — connects a program (+ optional curriculum_package subset) to a delivery format, +-- schedule, capacity, and location/organization/cohort. Status transitions are validated +-- server-side only (offerings.js) — this table has no trigger enforcing them because the +-- application layer is the single writer (admin routes), matching every other status-machine +-- table in this schema (enrollments, classroom_sessions) which also rely on server-side +-- transition logic rather than a DB trigger. +-- +-- reserved_seats is a maintained counter, not derived via COUNT(*) at read time — capacity +-- reservation (offerings.js's reserveSeat()) increments it with a single atomic +-- `UPDATE ... WHERE reserved_seats < capacity RETURNING ...`, avoiding the +-- SELECT-count-then-INSERT race explicitly flagged in this phase's authorization. +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS offerings ( + offering_id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + program_id TEXT NOT NULL REFERENCES programs(program_id), + curriculum_package_id TEXT REFERENCES curriculum_packages(curriculum_package_id), + delivery_mode TEXT NOT NULL CHECK (delivery_mode IN ('self_paced', 'cohort', 'hybrid', 'camp', 'workshop', 'school', 'after_school', 'homeschool', 'community', 'one_to_one')), + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'enrollment_open', 'full', 'in_progress', 'completed', 'cancelled')), + starts_at TIMESTAMPTZ, + ends_at TIMESTAMPTZ, + enrollment_opens_at TIMESTAMPTZ, + enrollment_closes_at TIMESTAMPTZ, + capacity INTEGER, -- NULL = unlimited (typical for self_paced) + reserved_seats INTEGER NOT NULL DEFAULT 0, + location_id TEXT REFERENCES locations(location_id), + organization_id TEXT REFERENCES organizations(organization_id), + cohort_id TEXT REFERENCES cohorts(cohort_id), + recommended_min_age INTEGER, -- guidance only, never an authorization gate (Step 21) + recommended_max_age INTEGER, + description TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, -- room for descriptive price/marketing fields later, never payment data + version INTEGER NOT NULL DEFAULT 1, + created_by UUID REFERENCES instructors(instructor_id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (capacity IS NULL OR reserved_seats <= capacity) +); +CREATE INDEX IF NOT EXISTS idx_offerings_program ON offerings(program_id); +CREATE INDEX IF NOT EXISTS idx_offerings_status ON offerings(status); + +CREATE TABLE IF NOT EXISTS instructor_offerings ( + offering_id TEXT NOT NULL REFERENCES offerings(offering_id), + instructor_id UUID NOT NULL REFERENCES instructors(instructor_id), + role TEXT NOT NULL DEFAULT 'teacher', -- reuses cohort_instructors' own role_in_cohort vocabulary — see admin-offerings-routes.js + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (offering_id, instructor_id) +); + +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +-- offering_registrations — pre-enrollment interest/application, separate from `enrollments` +-- (academic entitlement, unchanged). A registration MAY exist with no student_id yet (a +-- guardian registering a not-yet-created student) and is converted to a real enrollment only at +-- `confirmed` -> `enrollment_activated`. No payment fields — that boundary belongs to a future +-- commerce layer entirely (Step 23). +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS offering_registrations ( + registration_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + offering_id TEXT NOT NULL REFERENCES offerings(offering_id), + student_id TEXT REFERENCES students(student_id), -- null until matched/created + guardian_id TEXT REFERENCES guardians(guardian_id), -- who registered, if a minor + registrant_display_name TEXT NOT NULL, -- captured even before a student record exists + registrant_email TEXT, + is_minor BOOLEAN NOT NULL DEFAULT false, + status TEXT NOT NULL DEFAULT 'started' CHECK (status IN ('started', 'submitted', 'reviewed', 'agreements_pending', 'payment_pending', 'confirmed', 'enrollment_activated', 'cancelled')), + readiness_notes TEXT, + accessibility_notes TEXT, + emergency_contact_name TEXT, + emergency_contact_phone TEXT, + enrollment_id UUID REFERENCES enrollments(enrollment_id), -- set once converted + source TEXT NOT NULL DEFAULT 'admin', + external_reference TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (NOT is_minor OR guardian_id IS NOT NULL) -- a minor's registration must always carry a responsible guardian +); +CREATE INDEX IF NOT EXISTS idx_offering_registrations_offering ON offering_registrations(offering_id); +CREATE INDEX IF NOT EXISTS idx_offering_registrations_status ON offering_registrations(status); + +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +-- agreement_definitions / agreement_acceptances — a versioned ACCEPTANCE RECORD model only. +-- No legal text is authored here (body_summary is a plain-language description for admin UI +-- display, explicitly NOT attorney-approved legal language — see +-- docs/STUDENT_GUARDIAN_DATA_MODEL.md's "requires legal review" list). accepted_version is +-- captured on the acceptance row (not just looked up from the live definition) so a later +-- edit to an agreement's text/version never retroactively reinterprets what someone actually +-- agreed to — the same "session snapshots its own version" principle +-- COURSE_CONTENT_MODEL.md §6b already established for assessments. +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS agreement_definitions ( + agreement_id TEXT PRIMARY KEY, -- slug, e.g. 'participation-agreement' + title TEXT NOT NULL, + body_summary TEXT, -- plain-language description, NOT legal text + version INTEGER NOT NULL DEFAULT 1, + requires_guardian_for_minor BOOLEAN NOT NULL DEFAULT true, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'retired')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS agreement_acceptances ( + acceptance_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agreement_id TEXT NOT NULL REFERENCES agreement_definitions(agreement_id), + accepted_version INTEGER NOT NULL, + accepted_by_type TEXT NOT NULL CHECK (accepted_by_type IN ('student', 'guardian', 'instructor_on_behalf')), + accepted_by_student_id TEXT REFERENCES students(student_id), + accepted_by_guardian_id TEXT REFERENCES guardians(guardian_id), + registration_id UUID REFERENCES offering_registrations(registration_id), + enrollment_id UUID REFERENCES enrollments(enrollment_id), + ip_address TEXT, + accepted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (accepted_by_student_id IS NOT NULL OR accepted_by_guardian_id IS NOT NULL) +); +CREATE INDEX IF NOT EXISTS idx_agreement_acceptances_registration ON agreement_acceptances(registration_id); diff --git a/platform/api/src/admin-offerings-routes.js b/platform/api/src/admin-offerings-routes.js new file mode 100644 index 0000000..a865154 --- /dev/null +++ b/platform/api/src/admin-offerings-routes.js @@ -0,0 +1,182 @@ +// Phase 7 — admin/owner routes for organizations, locations, guardians, curriculum packages, +// offerings, registrations, and agreements. RBAC convention matches admin-programs-routes.js: +// authoring (create/transition) is admin/owner only; any instructor can list/view non-sensitive +// data (organizations, locations, offerings). Guardian and registration detail are admin/owner +// only — Step 17's explicit "plain instructors should not automatically get access to guardian/ +// private registration details" — a plain instructor still sees offering rosters/progress +// through the existing cohort-scoped routes, just not guardian contact info or registration +// notes (readiness/accessibility/emergency-contact) directly. + +const offerings = require('./offerings'); +const studentsAuth = require('./students-auth'); // same direct-require convention integration-routes.js already uses + +function errStatus(code) { + if (code === 'NOT_FOUND' || code === 'PROGRAM_NOT_FOUND' || code === 'OFFERING_NOT_FOUND' || code === 'COURSE_NOT_FOUND' || code === 'AGREEMENT_NOT_FOUND') return 404; + if (code === 'VALIDATION' || code === 'GUARDIAN_REQUIRED') return 400; + if (code === 'DUPLICATE_SLUG' || code === 'OFFERING_FULL' || code === 'INVALID_TRANSITION' || code === 'INVALID_STATE') return 409; + return 500; +} + +module.exports = function registerAdminOfferingsRoutes(app, deps) { + const { instructorSession, rbac, getPool, recordAudit } = deps; + const { requireAnyInstructor, requireAdminOrOwner } = rbac; + + function wrap(fn) { + return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; + } + const admin = { + get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)), + post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)), + }; + function handleError(res, e) { + if (e.code) return res.status(errStatus(e.code)).json({ error: e.code, message: e.message }); + throw e; + } + + // ── Organizations / Locations ────────────────────────────────────────────────────────── + admin.post('/api/admin/organizations', requireAdminOrOwner, async (req, res) => { + try { + const org = await offerings.createOrganization(getPool(), req.body); + await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'organization_create', resourceType: 'organization', resourceId: org.organization_id, result: 'success', sourceIp: req.ip }); + res.status(201).json(org); + } catch (e) { handleError(res, e); } + }); + admin.get('/api/admin/organizations', requireAnyInstructor, async (req, res) => { + res.json(await offerings.listOrganizations(getPool())); + }); + + admin.post('/api/admin/locations', requireAdminOrOwner, async (req, res) => { + try { + const loc = await offerings.createLocation(getPool(), req.body); + await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'location_create', resourceType: 'location', resourceId: loc.location_id, result: 'success', sourceIp: req.ip }); + res.status(201).json(loc); + } catch (e) { handleError(res, e); } + }); + admin.get('/api/admin/locations', requireAnyInstructor, async (req, res) => { + // access_notes only for admin/owner — a plain instructor sees the location, not its + // internal operational notes (Step 8). + const forInstructor = req.instructor.role === 'admin' || req.instructor.role === 'owner'; + res.json(await offerings.listLocations(getPool(), { forInstructor })); + }); + + // ── Guardians (admin/owner only — sensitive contact data) ────────────────────────────── + admin.post('/api/admin/guardians', requireAdminOrOwner, async (req, res) => { + try { + const g = await offerings.createGuardian(getPool(), req.body); + await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'guardian_create', resourceType: 'guardian', resourceId: g.guardian_id, result: 'success', sourceIp: req.ip }); + res.status(201).json(g); + } catch (e) { handleError(res, e); } + }); + admin.post('/api/admin/students/:id/guardians', requireAdminOrOwner, async (req, res) => { + try { + const link = await offerings.linkStudentGuardian(getPool(), { ...req.body, studentId: req.params.id }); + res.status(201).json(link); + } catch (e) { handleError(res, e); } + }); + admin.get('/api/admin/students/:id/guardians', requireAdminOrOwner, async (req, res) => { + res.json(await offerings.listGuardiansForStudent(getPool(), req.params.id)); + }); + + // ── Curriculum packages ────────────────────────────────────────────────────────────────── + admin.post('/api/admin/curriculum-packages', requireAdminOrOwner, async (req, res) => { + try { + const pkg = await offerings.createCurriculumPackage(getPool(), req.body); + res.status(201).json(pkg); + } catch (e) { handleError(res, e); } + }); + admin.post('/api/admin/curriculum-packages/:id/items', requireAdminOrOwner, async (req, res) => { + try { + const item = await offerings.addCurriculumPackageItem(getPool(), { ...req.body, curriculumPackageId: req.params.id }); + res.status(201).json(item); + } catch (e) { handleError(res, e); } + }); + admin.get('/api/admin/curriculum-packages/:id', requireAnyInstructor, async (req, res) => { + const pkg = await offerings.getCurriculumPackage(getPool(), req.params.id); + if (!pkg) return res.status(404).json({ error: 'NOT_FOUND' }); + res.json(pkg); + }); + + // ── Offerings ───────────────────────────────────────────────────────────────────────────── + admin.post('/api/admin/offerings', requireAdminOrOwner, async (req, res) => { + try { + const offering = await offerings.createOffering(getPool(), { ...req.body, createdBy: req.instructor.instructor_id }); + await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'offering_create', resourceType: 'offering', resourceId: offering.offering_id, result: 'success', sourceIp: req.ip }); + res.status(201).json(offering); + } catch (e) { handleError(res, e); } + }); + admin.get('/api/admin/offerings', requireAnyInstructor, async (req, res) => { + res.json(await offerings.listOfferings(getPool(), { status: req.query.status })); + }); + admin.get('/api/admin/offerings/:id', requireAnyInstructor, async (req, res) => { + const offering = await offerings.getOffering(getPool(), req.params.id); + if (!offering) return res.status(404).json({ error: 'NOT_FOUND' }); + res.json(offering); + }); + admin.post('/api/admin/offerings/:id/transition', requireAdminOrOwner, async (req, res) => { + try { + const offering = await offerings.transitionOffering(getPool(), req.params.id, req.body.status); + await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'offering_transition', resourceType: 'offering', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { to: req.body.status } }); + res.json(offering); + } catch (e) { handleError(res, e); } + }); + admin.post('/api/admin/offerings/:id/instructors', requireAdminOrOwner, async (req, res) => { + try { + const link = await offerings.assignInstructorToOffering(getPool(), req.params.id, req.body.instructor_id, req.body.role); + res.status(201).json(link); + } catch (e) { handleError(res, e); } + }); + + // ── Registrations (admin/owner only) ──────────────────────────────────────────────────── + // Validate-then-reserve, not reserve-then-validate — reserving the seat first would let a + // request that fails plain field validation (e.g. a minor with no guardianId) consume a real + // seat before ever being rejected, masking the actual validation error behind a false + // OFFERING_FULL once the last seat is gone. Found during this phase's own smoke testing. + admin.post('/api/admin/offerings/:id/registrations', requireAdminOrOwner, async (req, res) => { + try { + const reg = await offerings.createRegistration(getPool(), { ...req.body, offeringId: req.params.id }); + try { + await offerings.reserveSeat(getPool(), req.params.id); + } catch (seatError) { + // Field validation passed but capacity is genuinely exhausted — the registration row + // it already created must not silently exist as a phantom "started" entry with no + // seat behind it. + await getPool().query(`DELETE FROM offering_registrations WHERE registration_id = $1`, [reg.registration_id]); + throw seatError; + } + res.status(201).json(reg); + } catch (e) { handleError(res, e); } + }); + admin.get('/api/admin/offerings/:id/registrations', requireAdminOrOwner, async (req, res) => { + res.json(await offerings.listRegistrationsForOffering(getPool(), req.params.id)); + }); + admin.post('/api/admin/registrations/:id/transition', requireAdminOrOwner, async (req, res) => { + try { + const reg = await offerings.transitionRegistration(getPool(), req.params.id, req.body.status); + res.json(reg); + } catch (e) { handleError(res, e); } + }); + admin.post('/api/admin/registrations/:id/convert', requireAdminOrOwner, async (req, res) => { + try { + const result = await offerings.convertRegistrationToEnrollment(getPool(), studentsAuth, req.params.id, req.body || {}); + await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'registration_convert', resourceType: 'registration', resourceId: req.params.id, result: 'success', sourceIp: req.ip }); + res.status(201).json(result); + } catch (e) { handleError(res, e); } + }); + + // ── Agreements ──────────────────────────────────────────────────────────────────────────── + admin.post('/api/admin/agreements', requireAdminOrOwner, async (req, res) => { + try { + const def = await offerings.createAgreementDefinition(getPool(), req.body); + res.status(201).json(def); + } catch (e) { handleError(res, e); } + }); + admin.post('/api/admin/registrations/:id/agreements', requireAdminOrOwner, async (req, res) => { + try { + const acceptance = await offerings.recordAgreementAcceptance(getPool(), { ...req.body, registrationId: req.params.id, ipAddress: req.ip }); + res.status(201).json(acceptance); + } catch (e) { handleError(res, e); } + }); + admin.get('/api/admin/registrations/:id/agreements', requireAdminOrOwner, async (req, res) => { + res.json(await offerings.listAcceptancesForRegistration(getPool(), req.params.id)); + }); +}; diff --git a/platform/api/src/enrollment.js b/platform/api/src/enrollment.js index c9a026a..a35bc8f 100644 --- a/platform/api/src/enrollment.js +++ b/platform/api/src/enrollment.js @@ -146,7 +146,14 @@ async function createFromIntegrationEvent(pool, studentsAuth, { eventId, source, // handed back so a future email/notification step — not built in this phase — could relay it). // A second concurrent call resolving the "same new student" case falls back to re-matching // after a unique-constraint hit, the identical insert/on-conflict-reselect pattern used above. -async function resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studentLoginIdentifier, studentDisplayName }) { +// `returnToken` (default false) attaches the raw activation token to the returned row as +// `_activationToken` when a NEW student is actually created. Default OFF and never passed by +// createFromIntegrationEvent — the public website integration endpoint's own contract +// (docs/WEBSITE_ENROLLMENT_INTEGRATION.md) explicitly never returns this value; only Phase 7's +// admin-only registration-conversion route (offerings.js) opts in, the same trust level an +// instructor manually creating a student already has via POST /api/admin/students (which +// already returns activation_token directly). +async function resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studentLoginIdentifier, studentDisplayName }, { returnToken = false } = {}) { const identifier = studentEmail || studentLoginIdentifier; if (!identifier) throw err('STUDENT_IDENTIFIER_REQUIRED', 'student.email or student.login_identifier is required.'); const existing = await studentsAuth.getStudentByIdentifier(pool, identifier); @@ -160,8 +167,8 @@ async function resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studen `INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4) RETURNING *`, [studentId, displayName, studentEmail ? String(studentEmail).toLowerCase().trim() : null, studentLoginIdentifier ? String(studentLoginIdentifier).trim() : null] ); - await studentsAuth.issueCredentialToken(pool, studentId, 'activation'); // token itself isn't returned by the integration endpoint — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md - return rows[0]; + const { token } = await studentsAuth.issueCredentialToken(pool, studentId, 'activation'); // token itself isn't returned by the integration endpoint — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md + return returnToken ? { ...rows[0], _activationToken: token } : rows[0]; } catch (e) { if (e.code === '23505') { const reMatched = await studentsAuth.getStudentByIdentifier(pool, identifier); @@ -241,4 +248,8 @@ module.exports = { deriveEnrollmentAccess, getEnrollment, listEnrollmentsForStudent, listEnrollmentsForProgram, createManualEnrollment, createFromIntegrationEvent, activateEnrollment, cancelEnrollment, maybeCompleteEnrollment, enrollmentContactEmail, + // Phase 7 — offerings.js's registration-to-enrollment conversion reuses this exact + // create-or-match logic rather than duplicating it (a registration's eventual student is + // resolved the same way an integration-event or manual enrollment's student already is). + resolveOrCreateStudent, }; diff --git a/platform/api/src/offerings.js b/platform/api/src/offerings.js new file mode 100644 index 0000000..f439f5e --- /dev/null +++ b/platform/api/src/offerings.js @@ -0,0 +1,307 @@ +// Phase 7 — the business/product architecture's core library: organizations, locations, +// guardians, curriculum packages, offerings, registrations, and agreement acceptances. Same +// error convention as enrollment.js/classroom.js: throw `Error` with a `.code` the route layer +// maps to an HTTP status. See docs/OFFERING_AND_REGISTRATION_MODEL.md for the full lifecycle +// diagrams this file implements, and docs/STUDENT_GUARDIAN_DATA_MODEL.md for the guardian +// model's own reasoning. + +const enrollmentLib = require('./enrollment'); + +function err(code, message) { + return Object.assign(new Error(message || code), { code }); +} + +// ── Organizations / Locations ────────────────────────────────────────────────────────────── +async function createOrganization(pool, { organizationId, name, type, contactName, contactEmail, contactPhone, notes }) { + if (!organizationId || !name) throw err('VALIDATION', 'organizationId and name are required.'); + const { rows } = await pool.query( + `INSERT INTO organizations (organization_id, name, type, contact_name, contact_email, contact_phone, notes) + VALUES ($1, $2, COALESCE($3, 'other'), $4, $5, $6, $7) RETURNING *`, + [organizationId, name, type || null, contactName || null, contactEmail || null, contactPhone || null, notes || null] + ); + return rows[0]; +} +async function listOrganizations(pool) { + const { rows } = await pool.query(`SELECT * FROM organizations ORDER BY name`); + return rows; +} + +async function createLocation(pool, { locationId, organizationId, name, type, addressLine1, addressLine2, city, region, postalCode, country, timezone, capacity, accessNotes }) { + if (!locationId || !name) throw err('VALIDATION', 'locationId and name are required.'); + const { rows } = await pool.query( + `INSERT INTO locations (location_id, organization_id, name, type, address_line1, address_line2, city, region, postal_code, country, timezone, capacity, access_notes) + VALUES ($1, $2, $3, COALESCE($4, 'online'), $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *`, + [locationId, organizationId || null, name, type || null, addressLine1 || null, addressLine2 || null, city || null, region || null, postalCode || null, country || null, timezone || null, capacity || null, accessNotes || null] + ); + return rows[0]; +} +// `forInstructor` gates access_notes exactly like course-progress.js gates instructor_notes — +// internal operational detail (building access codes, parking) never leaves the instructor +// surface. See Step 8's explicit "protect private/internal operational notes." +async function listLocations(pool, { forInstructor = false } = {}) { + const { rows } = await pool.query(`SELECT * FROM locations ORDER BY name`); + if (forInstructor) return rows; + return rows.map(({ access_notes, ...rest }) => rest); +} + +// ── Guardians ─────────────────────────────────────────────────────────────────────────────── +async function createGuardian(pool, { guardianId, displayName, email, phone, notes }) { + if (!guardianId || !displayName) throw err('VALIDATION', 'guardianId and displayName are required.'); + const { rows } = await pool.query( + `INSERT INTO guardians (guardian_id, display_name, email, phone, notes) VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [guardianId, displayName, email ? String(email).toLowerCase().trim() : null, phone || null, notes || null] + ); + return rows[0]; +} +async function linkStudentGuardian(pool, { studentId, guardianId, relationship, isPrimaryContact, canReceiveCommunications }) { + if (!studentId || !guardianId) throw err('VALIDATION', 'studentId and guardianId are required.'); + const { rows } = await pool.query( + `INSERT INTO student_guardians (student_id, guardian_id, relationship, is_primary_contact, can_receive_communications) + VALUES ($1, $2, COALESCE($3, 'guardian'), COALESCE($4, false), COALESCE($5, true)) + ON CONFLICT (student_id, guardian_id) DO UPDATE SET relationship = EXCLUDED.relationship, is_primary_contact = EXCLUDED.is_primary_contact, can_receive_communications = EXCLUDED.can_receive_communications + RETURNING *`, + [studentId, guardianId, relationship || null, isPrimaryContact ?? null, canReceiveCommunications ?? null] + ); + return rows[0]; +} +// A plain instructor sees which students they have real cohort access to (via the caller's own +// RBAC check on the route) — this function itself just returns the linked guardians for one +// student, RBAC is the route layer's job, same separation of concerns as everywhere else. +async function listGuardiansForStudent(pool, studentId) { + const { rows } = await pool.query( + `SELECT g.*, sg.relationship, sg.is_primary_contact, sg.can_receive_communications + FROM student_guardians sg JOIN guardians g ON g.guardian_id = sg.guardian_id + WHERE sg.student_id = $1 ORDER BY sg.is_primary_contact DESC, g.display_name`, + [studentId] + ); + return rows; +} + +// ── Curriculum packages (a reference list into EXISTING courses, never a clone) ───────────── +async function createCurriculumPackage(pool, { curriculumPackageId, name, programId, description }) { + if (!curriculumPackageId || !name || !programId) throw err('VALIDATION', 'curriculumPackageId, name, and programId are required.'); + const { rows: programRows } = await pool.query(`SELECT 1 FROM programs WHERE program_id = $1`, [programId]); + if (!programRows[0]) throw err('PROGRAM_NOT_FOUND', 'No such program.'); + const { rows } = await pool.query( + `INSERT INTO curriculum_packages (curriculum_package_id, name, program_id, description) VALUES ($1, $2, $3, $4) RETURNING *`, + [curriculumPackageId, name, programId, description || null] + ); + return rows[0]; +} +async function addCurriculumPackageItem(pool, { curriculumPackageId, courseId, sequence }) { + const { rows: courseRows } = await pool.query(`SELECT 1 FROM courses WHERE course_id = $1`, [courseId]); + if (!courseRows[0]) throw err('COURSE_NOT_FOUND', 'No such course — a package only ever REFERENCES an existing course, never clones one.'); + const { rows } = await pool.query( + `INSERT INTO curriculum_package_items (curriculum_package_id, course_id, sequence) VALUES ($1, $2, $3) + ON CONFLICT (curriculum_package_id, course_id) DO UPDATE SET sequence = EXCLUDED.sequence RETURNING *`, + [curriculumPackageId, courseId, sequence] + ); + return rows[0]; +} +async function getCurriculumPackage(pool, curriculumPackageId) { + const { rows: pkgRows } = await pool.query(`SELECT * FROM curriculum_packages WHERE curriculum_package_id = $1`, [curriculumPackageId]); + if (!pkgRows[0]) return null; + const { rows: items } = await pool.query( + `SELECT cpi.course_id, cpi.sequence, c.title, c.status FROM curriculum_package_items cpi + JOIN courses c ON c.course_id = cpi.course_id WHERE cpi.curriculum_package_id = $1 ORDER BY cpi.sequence`, + [curriculumPackageId] + ); + return { ...pkgRows[0], items }; +} + +// ── Offerings — server-defined status transitions only (Step 3's explicit requirement) ────── +const OFFERING_TRANSITIONS = { + draft: ['published', 'cancelled'], + published: ['enrollment_open', 'cancelled'], + enrollment_open: ['full', 'in_progress', 'cancelled'], + full: ['enrollment_open', 'in_progress', 'cancelled'], + in_progress: ['completed', 'cancelled'], + completed: [], + cancelled: [], +}; + +async function createOffering(pool, { offeringId, slug, name, programId, curriculumPackageId, deliveryMode, startsAt, endsAt, enrollmentOpensAt, enrollmentClosesAt, capacity, locationId, organizationId, cohortId, recommendedMinAge, recommendedMaxAge, description, createdBy }) { + if (!offeringId || !slug || !name || !programId || !deliveryMode) throw err('VALIDATION', 'offeringId, slug, name, programId, and deliveryMode are required.'); + const { rows: programRows } = await pool.query(`SELECT 1 FROM programs WHERE program_id = $1`, [programId]); + if (!programRows[0]) throw err('PROGRAM_NOT_FOUND', 'No such program.'); + try { + const { rows } = await pool.query( + `INSERT INTO offerings (offering_id, slug, name, program_id, curriculum_package_id, delivery_mode, starts_at, ends_at, enrollment_opens_at, enrollment_closes_at, capacity, location_id, organization_id, cohort_id, recommended_min_age, recommended_max_age, description, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18) RETURNING *`, + [offeringId, slug, name, programId, curriculumPackageId || null, deliveryMode, startsAt || null, endsAt || null, enrollmentOpensAt || null, enrollmentClosesAt || null, capacity ?? null, locationId || null, organizationId || null, cohortId || null, recommendedMinAge ?? null, recommendedMaxAge ?? null, description || null, createdBy || null] + ); + return rows[0]; + } catch (e) { + if (e.code === '23505') throw err('DUPLICATE_SLUG', 'An offering with this slug already exists.'); + throw e; + } +} +async function getOffering(pool, offeringId) { + const { rows } = await pool.query(`SELECT * FROM offerings WHERE offering_id = $1`, [offeringId]); + return rows[0] || null; +} +async function listOfferings(pool, { status } = {}) { + const { rows } = await pool.query( + status ? `SELECT * FROM offerings WHERE status = $1 ORDER BY created_at DESC` : `SELECT * FROM offerings ORDER BY created_at DESC`, + status ? [status] : [] + ); + return rows; +} +async function transitionOffering(pool, offeringId, toStatus) { + const offering = await getOffering(pool, offeringId); + if (!offering) throw err('NOT_FOUND', 'No such offering.'); + const allowed = OFFERING_TRANSITIONS[offering.status] || []; + if (!allowed.includes(toStatus)) throw err('INVALID_TRANSITION', `Cannot move an offering from '${offering.status}' to '${toStatus}'.`); + const { rows } = await pool.query(`UPDATE offerings SET status = $2, version = version + 1, updated_at = now() WHERE offering_id = $1 RETURNING *`, [offeringId, toStatus]); + return rows[0]; +} +async function assignInstructorToOffering(pool, offeringId, instructorId, role) { + const { rows } = await pool.query( + `INSERT INTO instructor_offerings (offering_id, instructor_id, role) VALUES ($1, $2, COALESCE($3, 'teacher')) + ON CONFLICT (offering_id, instructor_id) DO UPDATE SET role = EXCLUDED.role RETURNING *`, + [offeringId, instructorId, role || null] + ); + return rows[0]; +} + +// Single atomic UPDATE, not SELECT-count-then-INSERT — see 034_phase7_....sql's own header +// comment for why this specific shape avoids the race the authorization explicitly warned +// against. A NULL capacity offering (typical for self_paced) never needs reservation at all. +async function reserveSeat(pool, offeringId) { + const offering = await getOffering(pool, offeringId); + if (!offering) throw err('NOT_FOUND', 'No such offering.'); + if (offering.capacity === null) return offering; // unlimited — nothing to reserve + const { rows } = await pool.query( + `UPDATE offerings SET reserved_seats = reserved_seats + 1, updated_at = now() + WHERE offering_id = $1 AND reserved_seats < capacity RETURNING *`, + [offeringId] + ); + if (!rows[0]) throw err('OFFERING_FULL', 'This offering has no remaining capacity.'); + return rows[0]; +} +async function releaseSeat(pool, offeringId) { + const { rows } = await pool.query( + `UPDATE offerings SET reserved_seats = GREATEST(reserved_seats - 1, 0), updated_at = now() WHERE offering_id = $1 RETURNING *`, + [offeringId] + ); + return rows[0]; +} + +// ── Registrations ──────────────────────────────────────────────────────────────────────────── +const REGISTRATION_TRANSITIONS = { + started: ['submitted', 'cancelled'], + submitted: ['reviewed', 'agreements_pending', 'payment_pending', 'confirmed', 'cancelled'], + reviewed: ['agreements_pending', 'payment_pending', 'confirmed', 'cancelled'], + agreements_pending: ['payment_pending', 'confirmed', 'cancelled'], + payment_pending: ['confirmed', 'cancelled'], + confirmed: ['enrollment_activated', 'cancelled'], + enrollment_activated: [], + cancelled: [], +}; + +async function createRegistration(pool, { offeringId, guardianId, registrantDisplayName, registrantEmail, isMinor, readinessNotes, accessibilityNotes, emergencyContactName, emergencyContactPhone, source, externalReference }) { + if (!offeringId || !registrantDisplayName) throw err('VALIDATION', 'offeringId and registrantDisplayName are required.'); + if (isMinor && !guardianId) throw err('GUARDIAN_REQUIRED', 'A minor registration must include a guardianId.'); + const offering = await getOffering(pool, offeringId); + if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.'); + const { rows } = await pool.query( + `INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, accessibility_notes, emergency_contact_name, emergency_contact_phone, source, external_reference) + VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9,COALESCE($10,'admin'),$11) RETURNING *`, + [offeringId, guardianId || null, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, accessibilityNotes || null, emergencyContactName || null, emergencyContactPhone || null, source || null, externalReference || null] + ); + return rows[0]; +} +async function getRegistration(pool, registrationId) { + const { rows } = await pool.query(`SELECT * FROM offering_registrations WHERE registration_id = $1`, [registrationId]); + return rows[0] || null; +} +async function listRegistrationsForOffering(pool, offeringId) { + const { rows } = await pool.query(`SELECT * FROM offering_registrations WHERE offering_id = $1 ORDER BY created_at`, [offeringId]); + return rows; +} +async function transitionRegistration(pool, registrationId, toStatus) { + const reg = await getRegistration(pool, registrationId); + if (!reg) throw err('NOT_FOUND', 'No such registration.'); + const allowed = REGISTRATION_TRANSITIONS[reg.status] || []; + if (!allowed.includes(toStatus)) throw err('INVALID_TRANSITION', `Cannot move a registration from '${reg.status}' to '${toStatus}'.`); + if (toStatus === 'cancelled' && !['enrollment_activated', 'cancelled'].includes(reg.status)) await releaseSeat(pool, reg.offering_id); + const { rows } = await pool.query(`UPDATE offering_registrations SET status = $2, updated_at = now() WHERE registration_id = $1 RETURNING *`, [registrationId, toStatus]); + return rows[0]; +} + +// The one place a registration becomes a real academic enrollment. Reuses +// enrollment.js's resolveOrCreateStudent/createManualEnrollment/activateEnrollment rather than +// duplicating student-matching or enrollment-creation logic — see that file's own comment on +// why resolveOrCreateStudent is now exported. Cohort membership (if the offering has a cohort) +// is created here too, using the existing cohort_enrollments table unchanged. +async function convertRegistrationToEnrollment(pool, studentsAuth, registrationId, { activate = true } = {}) { + const reg = await getRegistration(pool, registrationId); + if (!reg) throw err('NOT_FOUND', 'No such registration.'); + if (reg.status !== 'confirmed') throw err('INVALID_STATE', 'A registration must be confirmed before it can become an enrollment.'); + const offering = await getOffering(pool, reg.offering_id); + if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.'); + + const student = reg.student_id + ? (await pool.query(`SELECT * FROM students WHERE student_id = $1`, [reg.student_id])).rows[0] + : await enrollmentLib.resolveOrCreateStudent(pool, studentsAuth, { + studentEmail: reg.is_minor ? null : reg.registrant_email, + studentLoginIdentifier: reg.is_minor ? `reg-${reg.registration_id}` : null, + studentDisplayName: reg.registrant_display_name, + }, { returnToken: true }); // admin-only surface — see enrollment.js's own comment on why this is safe here but not on the public integration endpoint + + const enrollment = await enrollmentLib.createManualEnrollment(pool, { + studentId: student.student_id, programId: offering.program_id, + status: activate ? 'active' : 'pending', deliveryMode: offering.delivery_mode, + externalReference: reg.external_reference, + metadata: reg.is_minor && reg.registrant_email ? { contact_email: reg.registrant_email } : null, + }); + + if (offering.cohort_id) { + await pool.query( + `INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [offering.cohort_id, student.student_id] + ); + } + + await pool.query( + `UPDATE offering_registrations SET student_id = $2, enrollment_id = $3, status = 'enrollment_activated', updated_at = now() WHERE registration_id = $1`, + [registrationId, student.student_id, enrollment.enrollment_id] + ); + return { student, enrollment }; +} + +// ── Agreements ──────────────────────────────────────────────────────────────────────────────── +async function createAgreementDefinition(pool, { agreementId, title, bodySummary, requiresGuardianForMinor }) { + if (!agreementId || !title) throw err('VALIDATION', 'agreementId and title are required.'); + const { rows } = await pool.query( + `INSERT INTO agreement_definitions (agreement_id, title, body_summary, requires_guardian_for_minor) VALUES ($1, $2, $3, COALESCE($4, true)) RETURNING *`, + [agreementId, title, bodySummary || null, requiresGuardianForMinor ?? null] + ); + return rows[0]; +} +async function recordAgreementAcceptance(pool, { agreementId, acceptedByType, acceptedByStudentId, acceptedByGuardianId, registrationId, enrollmentId, ipAddress }) { + const { rows: defRows } = await pool.query(`SELECT * FROM agreement_definitions WHERE agreement_id = $1`, [agreementId]); + const def = defRows[0]; + if (!def) throw err('AGREEMENT_NOT_FOUND', 'No such agreement.'); + if (!acceptedByStudentId && !acceptedByGuardianId) throw err('VALIDATION', 'An acceptance must be tied to a student or a guardian.'); + const { rows } = await pool.query( + `INSERT INTO agreement_acceptances (agreement_id, accepted_version, accepted_by_type, accepted_by_student_id, accepted_by_guardian_id, registration_id, enrollment_id, ip_address) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, + [agreementId, def.version, acceptedByType, acceptedByStudentId || null, acceptedByGuardianId || null, registrationId || null, enrollmentId || null, ipAddress || null] + ); + return rows[0]; +} +async function listAcceptancesForRegistration(pool, registrationId) { + const { rows } = await pool.query(`SELECT * FROM agreement_acceptances WHERE registration_id = $1 ORDER BY accepted_at`, [registrationId]); + return rows; +} + +module.exports = { + createOrganization, listOrganizations, + createLocation, listLocations, + createGuardian, linkStudentGuardian, listGuardiansForStudent, + createCurriculumPackage, addCurriculumPackageItem, getCurriculumPackage, + createOffering, getOffering, listOfferings, transitionOffering, assignInstructorToOffering, reserveSeat, releaseSeat, + createRegistration, getRegistration, listRegistrationsForOffering, transitionRegistration, convertRegistrationToEnrollment, + createAgreementDefinition, recordAgreementAcceptance, listAcceptancesForRegistration, +}; diff --git a/platform/api/src/server.js b/platform/api/src/server.js index 6b144f8..79aa025 100644 --- a/platform/api/src/server.js +++ b/platform/api/src/server.js @@ -413,6 +413,14 @@ require('./notification-routes')(app, { instructorSession: wrap(instructorSession), rbac, getPool, recordAudit, }); +// Phase 7 — the business/product architecture: organizations, locations, guardians, +// curriculum packages, offerings, registrations, and agreements. Admin/owner surface only — +// no student-facing routes yet (registration is admin-entered this phase; a future public +// registration flow is a Phase 8+ decision, see docs/OFFERING_AND_REGISTRATION_MODEL.md). +require('./admin-offerings-routes')(app, { + instructorSession: wrap(instructorSession), rbac, getPool, recordAudit, +}); + // Fallback to the platform-shell SPA for any unmatched non-API route app.get('*', (req, res) => { res.sendFile(path.join(FRONTEND_DIST, 'index.html'));