// Phase 8 ยง12 โ€” the PUBLIC, unauthenticated offering-read surface the website reads to build // its own catalog/registration pages. No session of any kind (student or instructor) is // checked or accepted here โ€” this is meant to be reachable by anyone, the same trust level as // the marketing site itself. An explicit allowlist serializer (never SELECT *, never returning // a raw row) is what keeps this safe: internal notes, access_notes, organization contact info, // instructor identity, and anything registration/student/guardian-shaped are never even // queried by this file, let alone serialized. function toPublicOffering(row) { return { slug: row.slug, name: row.name, description: row.description, program_id: row.program_id, delivery_mode: row.delivery_mode, recommended_min_age: row.recommended_min_age, recommended_max_age: row.recommended_max_age, starts_at: row.starts_at, ends_at: row.ends_at, registration_open: row.status === 'enrollment_open', // "availability" rather than an exact seat count โ€” a real number invites scraping/timing // games from a public, unauthenticated endpoint; a coarse status is enough for a website to // build a real "Register" vs "Full" call to action. availability: row.capacity === null ? 'open' : row.reserved_seats >= row.capacity ? 'full' : (row.capacity - row.reserved_seats) <= 3 ? 'limited' : 'open', location_name: row.location_name || null, location_type: row.location_type || null, }; } module.exports = function registerPublicOfferingsRoutes(app, deps) { const { getPool, wrap } = deps; // Only offerings a family/adult should actually be able to see: published or further along, // never draft (an admin still building it out) and never cancelled. const VISIBLE_STATUSES = ['published', 'enrollment_open', 'full', 'in_progress']; app.get('/api/public/offerings', wrap(async (req, res) => { const { rows } = await getPool().query( `SELECT o.slug, o.name, o.description, o.program_id, o.delivery_mode, o.recommended_min_age, o.recommended_max_age, o.starts_at, o.ends_at, o.status, o.capacity, o.reserved_seats, l.name AS location_name, l.type AS location_type FROM offerings o LEFT JOIN locations l ON l.location_id = o.location_id WHERE o.status = ANY($1) ORDER BY o.starts_at NULLS LAST, o.name`, [VISIBLE_STATUSES] ); res.json(rows.map(toPublicOffering)); })); app.get('/api/public/offerings/:slug', wrap(async (req, res) => { const { rows } = await getPool().query( `SELECT o.slug, o.name, o.description, o.program_id, o.delivery_mode, o.recommended_min_age, o.recommended_max_age, o.starts_at, o.ends_at, o.status, o.capacity, o.reserved_seats, l.name AS location_name, l.type AS location_type FROM offerings o LEFT JOIN locations l ON l.location_id = o.location_id WHERE o.slug = $1 AND o.status = ANY($2)`, [req.params.slug, VISIBLE_STATUSES] ); if (!rows[0]) return res.status(404).json({ error: 'not_found' }); res.json(toPublicOffering(rows[0])); })); };