Files
rootnode-academy/platform/api/src/offerings.js
T
ced4568 b0e07bb17f feat(commerce): add payment-provider abstraction, commerce layer, and pricing schema
- migration 037: offerings.price_amount/price_currency, offering_registrations.reservation_expires_at,
  payment_transactions, payment_webhook_events (webhook_event_id as real PK — the idempotency mechanism)
- payment-provider.js: StripeProvider (real SDK, HMAC webhook verification) + TestProvider
  (deterministic, in-process, no network) behind one interface, selected via PAYMENT_PROVIDER
- commerce.js: createCheckoutSession (server-side price, never caller-supplied), processWebhookEvent
  (idempotent), handlePaymentSuccess/Failure/CheckoutAbandoned/Refund, reservation reaper support
- offerings.js: computeReservationExpiry, updateOffering (allowlisted PATCH), readiness gained
  price/payment_provider checks, convertRegistrationToEnrollment now fires the activation email
  every other enrollment path already triggers, and links the registration's guardian_id into
  student_guardians on conversion (real bug: a converted minor had no guardian link, so the new
  activation email had nowhere to resolve a recipient to)
- server.js: raw-body middleware scoped to /api/webhooks/payment, reservation-reaper poller
2026-08-27 09:05:19 -05:00

600 lines
38 KiB
JavaScript

// 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');
const notificationsLib = require('./notifications');
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, priceAmount, priceCurrency }) {
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, price_amount, price_currency)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,COALESCE($20,'usd')) 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, priceAmount ?? null, priceCurrency || 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;
}
// Phase 9 §28 — a draft offering is created with the bare minimum, then completed over time as
// real business decisions (dates, venue, capacity, price) land — this is the one place those
// get filled in later without recreating the row. Only ever touches columns explicitly present
// in `fields`, so calling it with just `{ priceAmount, priceCurrency }` never disturbs anything
// else. Never allows changing `status` (transitionOffering is the only path for that, with its
// own server-defined transition table) or `slug`/`program_id` (identity fields).
const OFFERING_EDITABLE_FIELDS = {
name: 'name', description: 'description', startsAt: 'starts_at', endsAt: 'ends_at',
enrollmentOpensAt: 'enrollment_opens_at', enrollmentClosesAt: 'enrollment_closes_at', capacity: 'capacity',
locationId: 'location_id', organizationId: 'organization_id', cohortId: 'cohort_id',
recommendedMinAge: 'recommended_min_age', recommendedMaxAge: 'recommended_max_age',
curriculumPackageId: 'curriculum_package_id', priceAmount: 'price_amount', priceCurrency: 'price_currency',
};
async function updateOffering(pool, offeringId, fields) {
const sets = [];
const values = [offeringId];
for (const [key, column] of Object.entries(OFFERING_EDITABLE_FIELDS)) {
if (!(key in fields)) continue;
values.push(fields[key]);
sets.push(`${column} = $${values.length}`);
}
if (!sets.length) throw err('VALIDATION', 'No editable fields provided.');
const { rows } = await pool.query(
`UPDATE offerings SET ${sets.join(', ')}, version = version + 1, updated_at = now() WHERE offering_id = $1 RETURNING *`,
values
);
if (!rows[0]) throw err('NOT_FOUND', 'No such offering.');
return rows[0];
}
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: [],
};
// Phase 8 §21 — registration availability is server-authoritative, checked here (the one place
// a registration row is actually created), not left to a website's own hidden-button logic.
// Distinct error codes per reason so a caller (website or admin UI) can show something more
// useful than a generic failure.
function assertRegistrationOpen(offering) {
if (offering.status === 'draft') throw err('OFFERING_NOT_OPEN', 'This offering is not yet open for registration.');
if (offering.status === 'cancelled') throw err('OFFERING_CANCELLED', 'This offering has been cancelled.');
if (offering.status === 'completed') throw err('OFFERING_COMPLETED', 'This offering has already completed.');
if (offering.status === 'full') throw err('OFFERING_FULL', 'This offering has no remaining capacity.');
if (!['published', 'enrollment_open'].includes(offering.status)) throw err('OFFERING_NOT_OPEN', 'This offering is not currently open for registration.');
const now = new Date();
if (offering.enrollment_opens_at && now < new Date(offering.enrollment_opens_at)) throw err('REGISTRATION_NOT_YET_OPEN', 'Registration for this offering has not opened yet.');
if (offering.enrollment_closes_at && now > new Date(offering.enrollment_closes_at)) throw err('REGISTRATION_CLOSED', 'Registration for this offering has closed.');
}
// Field validation -> seat reservation -> insert -> notification, strictly in that order. A
// notification is only ever enqueued for a registration that is FULLY, durably committed —
// enqueueing one earlier (e.g. before capacity is confirmed) created a real bug found during
// this phase's own regression testing: a rolled-back registration whose row got deleted after a
// failed seat reservation left behind a notification_events row still pointing at it, and the
// DELETE itself then failed on the very foreign key that row created. Reserving the seat FIRST
// (rolling it back if the insert itself somehow fails) means nothing is ever created that later
// needs to be un-created out from under a row something else already references.
// Phase 9 §26/§27 — a registration against a PAID offering carries a reservation_expires_at,
// giving the registrant a real but bounded window to complete checkout before the seat releases
// automatically (server.js's reservation reaper). A free offering never sets this — nothing new
// to expire, identical to Phase 7/8 behavior.
const RESERVATION_TIMEOUT_MINUTES = Number(process.env.REGISTRATION_RESERVATION_TIMEOUT_MINUTES) || 30;
function computeReservationExpiry(offering) {
return offering.price_amount === null ? null : new Date(Date.now() + RESERVATION_TIMEOUT_MINUTES * 60 * 1000);
}
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.');
assertRegistrationOpen(offering);
await reserveSeat(pool, offeringId); // throws OFFERING_FULL if genuinely exhausted; nothing created yet if so
let reg;
try {
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, reservation_expires_at)
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9,COALESCE($10,'admin'),$11,$12) RETURNING *`,
[offeringId, guardianId || null, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, accessibilityNotes || null, emergencyContactName || null, emergencyContactPhone || null, source || null, externalReference || null, computeReservationExpiry(offering)]
);
reg = rows[0];
} catch (e) {
await releaseSeat(pool, offeringId).catch(() => {});
if (e.code === '23505') throw err('DUPLICATE_REGISTRATION', 'A registration with this external_reference already exists for this offering.');
throw e;
}
// Best-effort — a notification failure must never fail the registration itself (matches
// enrollment.js's own precedent of never letting notification concerns block the academic
// action they're merely announcing). Safe to fire now: reg is fully, durably committed.
await notificationsLib.enqueueForRegistration(pool, {
registration: reg, notificationType: 'registration_received',
metadata: { display_name: reg.registrant_display_name, offering_name: offering.name },
}).catch((e) => console.error("[offerings] registration_received notification failed:", e.message));
return reg;
}
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]);
const updated = rows[0];
// Best-effort lifecycle notifications — see createRegistration's own comment on why a
// notification failure never blocks the real state change it's merely announcing.
const offering = await getOffering(pool, reg.offering_id);
if (toStatus === 'confirmed') {
await notificationsLib.enqueueForRegistration(pool, {
registration: updated, notificationType: 'registration_confirmed',
metadata: { display_name: updated.registrant_display_name, offering_name: offering?.name },
}).catch((e) => console.error("[offerings] registration lifecycle notification failed:", e.message));
}
if (toStatus === 'agreements_pending') {
const status = await getRegistrationAgreementStatus(pool, registrationId);
for (const missing of status.missing) {
await notificationsLib.enqueueForRegistration(pool, {
registration: updated, notificationType: 'agreement_required',
metadata: { display_name: updated.registrant_display_name, offering_name: offering?.name, agreement_title: missing.title },
}).catch((e) => console.error("[offerings] agreement_required notification failed:", e.message));
}
}
return updated;
}
// ── Offering agreement requirements (Step 18) ────────────────────────────────────────────────
async function addAgreementRequirement(pool, offeringId, agreementId) {
const { rows: agreementRows } = await pool.query(`SELECT 1 FROM agreement_definitions WHERE agreement_id = $1`, [agreementId]);
if (!agreementRows[0]) throw err('AGREEMENT_NOT_FOUND', 'No such agreement.');
const { rows } = await pool.query(
`INSERT INTO offering_agreement_requirements (offering_id, agreement_id) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING *`,
[offeringId, agreementId]
);
return rows[0] || { offering_id: offeringId, agreement_id: agreementId };
}
async function listAgreementRequirementsForOffering(pool, offeringId) {
const { rows } = await pool.query(
`SELECT ad.* FROM offering_agreement_requirements oar JOIN agreement_definitions ad ON ad.agreement_id = oar.agreement_id
WHERE oar.offering_id = $1 ORDER BY ad.title`,
[offeringId]
);
return rows;
}
// Required vs. accepted vs. missing, for one registration — the exact shape both the
// agreements_pending notification loop above and the admin/registration-detail UI need.
async function getRegistrationAgreementStatus(pool, registrationId) {
const reg = await getRegistration(pool, registrationId);
if (!reg) throw err('NOT_FOUND', 'No such registration.');
const required = await listAgreementRequirementsForOffering(pool, reg.offering_id);
const accepted = await listAcceptancesForRegistration(pool, registrationId);
const acceptedIds = new Set(accepted.map((a) => a.agreement_id));
const missing = required.filter((r) => !acceptedIds.has(r.agreement_id));
return { required, accepted, missing };
}
// 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.');
// Step 18 — a required agreement with no matching acceptance blocks conversion, always (this
// offering's own configured requirements, not a global policy toggle this phase invents).
const agreementStatus = await getRegistrationAgreementStatus(pool, registrationId);
if (agreementStatus.missing.length) {
throw err('AGREEMENTS_INCOMPLETE', `Missing required agreement(s): ${agreementStatus.missing.map((m) => m.title).join(', ')}`);
}
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,
}); // no returnToken here — notifyForEnrollment below issues the ONE real token that
// actually gets used; pre-issuing a second one here would just have it silently
// overwritten (found while wiring Phase 9's activation-notification call — see that
// call's own comment).
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]
);
}
// Phase 9 — the registration's own guardian (set at registration time when a guardian object
// was submitted) never used to survive conversion into the relational student_guardians model,
// which is what resolveRecipient's guardian fallback (and any future guardian-facing feature)
// actually reads. Without this, a brand-new minor student converts with no guardian link at
// all, and the activation email below has no recipient to resolve to.
if (reg.is_minor && reg.guardian_id) {
await linkStudentGuardian(pool, {
studentId: student.student_id, guardianId: reg.guardian_id,
relationship: 'guardian', isPrimaryContact: true, canReceiveCommunications: true,
});
}
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]
);
// Phase 9 §9/§20 — the same activation-or-confirmation email every OTHER enrollment path
// (the admin manual-enroll route, the website's /api/integrations/enrollments) already
// triggers automatically. A registration-converted enrollment was the one path that never
// called this — closed here so a new camp student's real path to logging in doesn't depend
// on an admin manually relaying `student._activationToken` by hand.
const programsLib = require('./programs');
const program = await programsLib.getProgram(pool, offering.program_id);
let activationToken = null;
try {
const notification = await notificationsLib.notifyForEnrollment(pool, studentsAuth, {
studentId: student.student_id, enrollmentId: enrollment.enrollment_id, programName: program?.name || offering.name,
});
activationToken = notification?._activationToken || null;
} catch (e) {
console.error('[offerings] enrollment-activation notification failed:', e.message);
}
return { student: activationToken ? { ...student, _activationToken: activationToken } : 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 listAgreementDefinitions(pool) {
const { rows } = await pool.query(`SELECT * FROM agreement_definitions ORDER BY title`);
return rows;
}
// accepted_by_type: 'student' | 'guardian' | 'instructor_on_behalf' | 'registrant'. 'registrant'
// (Step 7's fix) is the one case allowed with neither a student nor a guardian id — an adult
// self-accepting AT REGISTRATION TIME, before their eventual student_id exists, tied only to
// registrationId. Every other type still requires a real student or guardian id — enforced here
// AND by the DB CHECK constraint (035_phase8_...sql), so this can never be bypassed by a future
// caller that skips this function.
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 (!['student', 'guardian', 'instructor_on_behalf', 'registrant'].includes(acceptedByType)) {
throw err('VALIDATION', "accepted_by_type must be one of: student, guardian, instructor_on_behalf, registrant.");
}
if (acceptedByType === 'registrant') {
if (!registrationId) throw err('VALIDATION', 'A registrant acceptance must be tied to a registrationId.');
} else 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;
}
// ── Website integration — idempotent registration submission (Step 13/14) ──────────────────
// Mirrors enrollment.js's createFromIntegrationEvent exactly: a real DB-level unique index
// (external_reference, 035_phase8_...sql) is what makes concurrent duplicate submissions safe,
// not a SELECT-then-INSERT check. A replay of the same event_id returns the ORIGINAL
// registration, created:false, never a duplicate — same contract shape as the existing
// /api/integrations/enrollments endpoint website code already knows how to handle.
async function createRegistrationFromIntegrationEvent(pool, { eventId, offeringSlug, registrantDisplayName, registrantEmail, isMinor, guardianDisplayName, guardianEmail, guardianPhone, readinessNotes, source }) {
if (!eventId) throw err('EVENT_ID_REQUIRED', 'event_id is required for idempotent registration events.');
if (!offeringSlug) throw err('OFFERING_SLUG_REQUIRED', 'offering_slug is required.');
if (!registrantDisplayName) throw err('VALIDATION', 'registrant display name is required.');
if (isMinor && !guardianEmail) throw err('GUARDIAN_REQUIRED', 'A minor registration requires a guardian email.');
const existing = await pool.query(`SELECT * FROM offering_registrations WHERE external_reference = $1`, [eventId]);
if (existing.rows[0]) return { registration: existing.rows[0], created: false };
const { rows: offeringRows } = await pool.query(`SELECT * FROM offerings WHERE slug = $1`, [offeringSlug]);
const offering = offeringRows[0];
if (!offering) throw err('OFFERING_NOT_FOUND', 'Unknown offering_slug.');
assertRegistrationOpen(offering);
let guardianId = null;
if (isMinor) {
// Match an existing guardian by email first (a returning family shouldn't get a duplicate
// guardian row per sibling/per camp) — same "match before create" principle
// enrollment.js's resolveOrCreateStudent already established for students.
const existingGuardian = await pool.query(`SELECT guardian_id FROM guardians WHERE email = $1`, [String(guardianEmail).toLowerCase().trim()]);
if (existingGuardian.rows[0]) {
guardianId = existingGuardian.rows[0].guardian_id;
} else {
guardianId = `guardian-${eventId}`;
await createGuardian(pool, { guardianId, displayName: guardianDisplayName || guardianEmail, email: guardianEmail, phone: guardianPhone });
}
}
try {
await reserveSeat(pool, offering.offering_id);
} catch (e) {
if (e.code === 'OFFERING_FULL') throw e;
throw e;
}
try {
const { rows } = await pool.query(
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, source, external_reference, reservation_expires_at)
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9)
ON CONFLICT (external_reference) WHERE external_reference IS NOT NULL DO NOTHING
RETURNING *`,
[offering.offering_id, guardianId, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, source || 'website', eventId, computeReservationExpiry(offering)]
);
if (rows[0]) {
await notificationsLib.enqueueForRegistration(pool, {
registration: rows[0], notificationType: 'registration_received',
metadata: { display_name: rows[0].registrant_display_name, offering_name: offering.name },
}).catch((e) => console.error("[offerings] website registration_received notification failed:", e.message));
return { registration: rows[0], created: true };
}
// Lost the ON CONFLICT race (concurrent identical replay) — release the seat we just
// reserved (the winner already holds theirs) and return the winner's row.
await releaseSeat(pool, offering.offering_id);
const { rows: won } = await pool.query(`SELECT * FROM offering_registrations WHERE external_reference = $1`, [eventId]);
return { registration: won[0], created: false };
} catch (e) {
await releaseSeat(pool, offering.offering_id).catch(() => {});
throw e;
}
}
// ── Launch readiness (Step 20) — server-derived, never left to frontend logic alone ─────────
async function computeOfferingReadiness(pool, offeringId) {
const offering = await getOffering(pool, offeringId);
if (!offering) throw err('NOT_FOUND', 'No such offering.');
const checks = [];
checks.push({ key: 'curriculum', label: 'Curriculum', ready: !!offering.program_id });
const needsInstructor = offering.delivery_mode !== 'self_paced';
if (needsInstructor) {
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM instructor_offerings WHERE offering_id = $1`, [offeringId]);
checks.push({ key: 'instructor', label: 'Instructor assigned', ready: rows[0].n > 0 });
}
const needsSchedule = !['self_paced'].includes(offering.delivery_mode);
if (needsSchedule) checks.push({ key: 'schedule', label: 'Dates', ready: !!(offering.starts_at && offering.ends_at) });
const needsLocation = !['self_paced'].includes(offering.delivery_mode);
if (needsLocation) checks.push({ key: 'location', label: 'Location', ready: !!offering.location_id || offering.delivery_mode === 'community' });
checks.push({ key: 'capacity', label: 'Capacity', ready: offering.capacity !== null || offering.delivery_mode === 'self_paced' });
checks.push({ key: 'registration_window', label: 'Registration window', ready: !!(offering.enrollment_opens_at && offering.enrollment_closes_at) });
const requirements = await listAgreementRequirementsForOffering(pool, offeringId);
checks.push({ key: 'agreements', label: 'Agreements configured', ready: requirements.length > 0 });
if (offering.curriculum_package_id) {
const pkg = await getCurriculumPackage(pool, offering.curriculum_package_id);
checks.push({ key: 'curriculum_package', label: 'Curriculum package valid', ready: !!pkg && pkg.items.length > 0 });
}
// Phase 9 §29 — a PAID offering (price_amount set) additionally needs a real price and a
// configured payment provider before it's genuinely launch-ready; a free offering never
// needs either, unchanged from Phase 8.
if (offering.price_amount !== null) {
checks.push({ key: 'price', label: 'Price configured', ready: offering.price_amount > 0 && !!offering.price_currency });
const { getPaymentProvider } = require('./payment-provider');
let providerConfigured = false;
try { const p = getPaymentProvider(); providerConfigured = p.name === 'test' || !!(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_WEBHOOK_SECRET); } catch { providerConfigured = false; }
checks.push({ key: 'payment_provider', label: 'Payment provider configured', ready: providerConfigured });
}
const allReady = checks.every((c) => c.ready);
return { offering_id: offeringId, ready_to_open_registration: allReady, checks };
}
module.exports = {
createOrganization, listOrganizations,
createLocation, listLocations,
createGuardian, linkStudentGuardian, listGuardiansForStudent,
createCurriculumPackage, addCurriculumPackageItem, getCurriculumPackage,
createOffering, getOffering, updateOffering, listOfferings, transitionOffering, assignInstructorToOffering, reserveSeat, releaseSeat,
createRegistration, getRegistration, listRegistrationsForOffering, transitionRegistration, convertRegistrationToEnrollment,
createAgreementDefinition, listAgreementDefinitions, recordAgreementAcceptance, listAcceptancesForRegistration,
addAgreementRequirement, listAgreementRequirementsForOffering, getRegistrationAgreementStatus,
createRegistrationFromIntegrationEvent, computeOfferingReadiness, assertRegistrationOpen,
};