// 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, };