Data Model
The portal’s schema lives in a single file, schema.sql, at the top of the
repository. That file always represents the complete current schema;
migrations in db_migrations/ exist only to upgrade older production
installations and are tracked in the schema_migrations table. The database is
MySQL (InnoDB, utf8mb4), and the schema is heavily commented — the comments in
schema.sql are the authoritative source for anything this documentation
summarizes.
There are 33 tables, documented here in eight categories:
| Category | Tables |
|---|---|
| Users, Roles & Profiles | users, student_profiles, teacher_profiles, parenthood, instruments, student_instruments, teacher_instruments |
| Semesters & Locations | semesters, locations, semester_locations, semester_location_dates, semester_location_teachers |
| Reservations, Lessons & Hold Blocks | semester_lesson_reservations, lessons, semester_hold_block_reservations, semester_hold_blocks |
| Lesson Notes & Resources | lesson_notes, lesson_resources |
| Billing & Payments | ledger_entries, stripe_webhook_events |
| Leads & Intake | leads, lead_students, lead_notes, incomplete_inquiries, incomplete_inquiry_notes |
| Email & Announcements | emails_sent, email_templates, announcements |
| Files & Infrastructure | public_files, private_files, settings, schema_migrations, activity_log |
Cross-cutting conventions
A handful of patterns repeat throughout the schema and are worth internalizing before reading the per-category pages.
Soft delete everywhere
History is never destroyed. Deleting a person sets users.is_deleted = 1;
deleting a reservation or hold block sets its status to 'deleted'. The rows
and everything that hangs off them stay in place — a deleted user simply cannot
sign in and disappears from lists and role resolution, and a deleted
reservation keeps its past lessons while its future ones are removed.
Reservations generate occurrences
The central mechanic of the schedule: a reservation row describes a weekly
recurring slot, and confirming (or, for hold blocks, creating) it generates one
occurrence row per real calendar date, driven by the location’s active-date
calendar. The occurrence tables (lessons, semester_hold_blocks) store only
per-occurrence facts and overrides; teacher, student, and location normally come
from the reservation.
Both occurrence tables also support one-offs booked straight onto the
calendar: the reservation FK is NULL, the identifying columns
(semester_id, teacher_user_id, student_user_id, location_id) are set
directly on the row, and every read COALESCEs the reservation’s value over
the row’s own.
Money is integer cents, tied to a semester
ledger_entries.amount_cents is an unsigned integer — exact math, and the same
unit Stripe uses. Nearly every entry carries a semester_id, because the
parent portal groups balances by term and judges “is this family on schedule?”
per term. Charge-posting and payment-recording are idempotent (unique keys on
Stripe identifiers; per student + semester + entry-type checks for charges).
Leads are a staging area
The public forms write to leads/lead_students and touch nothing else. No
users rows, no profiles, no reservations exist until an admin explicitly
converts the lead. Conversion is itself idempotent and re-enterable.
Foreign-key delete behavior is consistent
ON DELETE CASCADE for ownership relationships (a lesson belongs to its
reservation; a profile belongs to its user), and ON DELETE SET NULL for
attribution columns (created_by_user_id and friends) — losing the author of a
record should never delete the record.
