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:
2026-08-26 22:43:52 -05:00
co-authored by Claude Sonnet 5
parent d066410451
commit 3510979196
6 changed files with 747 additions and 3 deletions
+14 -3
View File
@@ -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,
};