feat(platform): business/product architecture — organizations, guardians, offerings, registrations
- organizations, locations, guardians, student_guardians (033) — additive, no existing table's meaning changes. Widens programs.family to accept 'bootcamp'. - curriculum_packages/curriculum_package_items, offerings, instructor_offerings, offering_registrations, agreement_definitions/agreement_acceptances (034) — the full purchasable-delivery model. Server-defined status transitions only (no client-controlled offering/registration status). Capacity uses a single atomic UPDATE (reserved_seats), never a SELECT-count-then-INSERT race. - offerings.js — business logic: reserveSeat/releaseSeat, transitionOffering/ transitionRegistration (fixed transition tables), convertRegistrationToEnrollment (reuses enrollment.js's existing resolveOrCreateStudent/createManualEnrollment rather than duplicating student-matching logic). - enrollment.js — resolveOrCreateStudent gains an optional, default-off returnToken param (used only by offerings.js's admin-only conversion route; the public website integration endpoint's contract is completely unchanged, per docs/WEBSITE_ENROLLMENT_INTEGRATION.md). - admin-offerings-routes.js — the full admin/owner API surface. Registration/guardian detail is admin/owner only; offering/location listing is any instructor (access_notes stripped for a plain instructor). - A real bug found and fixed during this phase's own smoke testing before any commit: the registration route originally reserved a seat BEFORE validating the registration's own fields, letting a validation failure consume a real seat and get masked behind a false OFFERING_FULL under a full offering. Fixed: validate (create) first, reserve second, roll back the registration row if reservation then genuinely fails. See docs/ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md and docs/OFFERING_AND_REGISTRATION_MODEL.md. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -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));
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user