Imported from mprymak2000/TMS (
.claude/skills/tms-roadmap/SKILL.md). Install upstream withnpx skills add mprymak2000/TMS --skill tms-roadmap. Copyright stays with the author.
Priority Order — Path to Single-Tenant Launch
Decided sequence for the current push toward going live (single-tenant; multi-tenant is later,
not a launch blocker). Each open item has its own design doc in .claude/plans/:
Denormalization boundary, stated once here since several items below depend on it: whether a
field is a live FK or a frozen copy is decided by when it gets read, not by what it's about.
Tutor and Contact/Student stay live FKs — editing one is instantly reflected on every booking
referencing it, past and future. BookingLink is a factory, and splits
down the middle: its calendar rules (duration, buffers, caps, lead time, horizon, tutor roster) are
read live on every slot computation — including a customer rescheduling an existing booking —
because they're nobody's promise, they govern the volume and shape of slots you'll offer now;
while its wiring (the booking_type_id kind pointer, cancel/reschedule policy, contact fields, intake
answers) is copied once at creation and never propagated again. The test for which bucket a field
belongs in: is it a promise made to a booker, or an operational setting?
Links are archived only — no hard delete at any child count, so booking_link_id on
Booking/BookingSeries is NOT NULL and never dangles (nothing automatic changes it; an admin may
reassign it to rescue a booking stranded on an archived link). That permanence is what makes it
safe to group on. Bookings therefore have two independent facet dimensions: source
(booking_link_id — what generated this; renaming a link relabels the group, never splits it) and
kind (booking_type_id — an FK into booking_types, frozen at creation so a link changing its type
never moves existing rows, but resolving live so renaming the type relabels every row pointing at it).
Full reasoning:
CLAUDE.md's "BookingLink data model", and "Two entry points, two rule regimes" directly above it —
the latter is what makes soft delete safe, since admin moves are direct dtstart/dtend edits
rather than trips through the slot picker, so a retired link's rules are read by nothing.
-
Finish the filters/facets work — move the "keep a selected filter visible" logic from frontend to backend.— done. Design:.claude/plans/done-facets-selection-kept-backend-move.md. -
Cursor-based pagination cleanup— done. Design:.claude/plans/done-cursor-pagination-and-endpoint-split.md. -
Split— done (Bookings.tsx's three tabs into separate routed componentsBookingsLayout.tsx+ScheduleTab/RecurringTab/RequestsTab). Design:.claude/plans/done-bookings-tabs-to-subroutes.md. -
— done (BookingSerieslifecycle field collapse + immutable reschedule (Pass 1 of the field cleanup)dtstart/dtend/until/status/created/last_modified, reschedule now inserts a new row instead of mutating in place). Design:.claude/plans/done-booking-series-end-date-cleanup.md— that doc's own "Status: not implemented" header is stale, ignore it; the work landed and is tested. -
Deletion safety, part 1— done, all four pieces:Schedule→BookingLinkAvailabilitydelete guard (routers/schedules.py) — 409 if anyBookingLinkAvailabilityrow still references the schedule.Tutorhard-delete guard (routers/tutors.py) — extended the existingLesson-only check to also 409 on any referencingBooking/BookingSeries.Tutor.is_active=Falseenforcement —create_booking/_reschedule_booking/_reschedule_series(routers/bookings.py) now 400 on an inactivetutor_id;get_available_slots(routers/available_slots.py) excludes inactive tutors;extend_single_series(tasks.py) skips materializing further occurrences once a series' tutor goes inactive.- Admin permanent-delete endpoint for
BookingSeries(DELETE /booking-series/{id}/permanent,cascadeparam) — modeled directly onBooking's existing.../permanentendpoint, same predecessor-chain confirm flow. No frontend wiring yet (backend-only this pass, matching how this item was scoped).
-
— all three done. Full design: CLAUDE.md's "BookingLink data model" — source of truth, read it first, along with "Two entry points, two rule regimes" above it. A link is a factory: its calendar rules are live (read throughEventType→BookingLinkrework — three passes.booking_link_idon every slot computation, reschedules included — they're nobody's promise, they govern the volume and shape of slots you'll offer now), while its wiring is frozen (copied at creation, never propagates). Because rules are live, the link must always resolve → archive is the only delete. Each pass leaves the system green; each needs its owndocker compose down -v && up -d(no Alembic).6a — Rename, archive, slug, reassignment.— done..claude/plans/done-booking-link-pass-1-rename-and-archive.md.EventType→BookingLink,BookingLinkAvailability→BookingLinkAvailability,booking_type_id→booking_link_id, routers and pages renamed, route becomes/book/:slug. Plusstatus(active/archived, enum not boolean sopausedslots in later) +archived_at; no hard delete at any child count, no restore; archived links read-only (403 on update).slugunique among active links only (partial index), released on archive, 400 on collision. Four enforcement points, none in the slot generator: public lookup 404s, customer reschedule 404s,create_bookingrequires active (admins included),extend_single_seriesstops generating.get_available_slotsgets no status check at all — an earlier draft added one keyed on client-suppliedexclude_ref; it was unnecessary and fail-open. Reassignment ships here because this pass introduces the thing that needs repairing. Carries most of the test migration; fixes a live 500 (the olddelete_event_typehad no guard).6b — booking type label + second facet.— done..claude/plans/done-booking-link-pass-2-booking-type-and-facets.md. Abooking_typestable (labelglobally unique,color) plus a nullablebooking_type_idFK onBookingLink,Booking, andBookingSeries—ON DELETE SET NULLeverywhere, indexed on the two facet-key columns. Stamped at creation off the link; series occurrences copy off the series in_ensure_occurrence; reschedule carries it forward. Freeze the pointer, never the row — a link switching type reaches future generations only, while renaming a type row relabels every booking pointing at it, past included. That propagation is the whole reason it's a table: a frozen string can't follow a rename, so a typo fix would split the bucket instead of correcting it (the same fragmentation argument that decided archive-vs-delete for links). The picker is the CRUD — one combobox reused in the link editor and on booking/series rows, with inline rename/delete and a create-at-the-bottom, so types never need a page of their own. Additive, not a migration: existing FK-based facet machinery is untouched, a second dimension goes in beside it. Cost: facets run one query per dimension, so this is a fourth query per request.6c — Policy.— done..claude/plans/done-booking-link-pass-3-policy.md. Policy frozen onto bothBookingandBookingSeriesat creation, copied off the series in_ensure_occurrenceand off the old row on both reschedule paths;get_cancel_action/get_reschedule_actionread the row's own columns. No backfill. Two independent levels, because ending an engagement isn't the same promise as dropping one session: the occurrence four (cancel_mode/cancel_notice_minutes/reschedule_mode/reschedule_notice_minutes, full vocabulary incl. window modes) and the series two (series_cancel_mode/series_reschedule_mode,blocked/auto/request, no notice window — there's no instant to measure against when ending a six-month arrangement).not_allowedwas renamedblockedso mode and verdict share one vocabulary. No sentinelNULL— every mode column isNOT NULLwithserver_default='auto'. The verdict is a@computed_fieldonBookingResponserather than a model property, which is what puts virtual occurrences on the same path as materialized ones. Shipped with a full policy editor UI (PolicyModeField,PolicyModal, the Links tab, per-row modals) — the plan's "frontend: nothing required" was wrong. NoPolicytable — see Decisions. The business-wide default onSettingsthat the original design bundled here was split out as a separate future feature.- Ordering: 6a first, everything touches renamed entities. 6b and 6c independent of each other.
- Superseded, do not implement: the four-bucket model (Identity/Calendar rules/Policy/Client info); an
event_type_calendar_rulestable; archive-and-relaunch forking withpredecessor_id; a standalonePolicytable; any policy or identity backfill; hard delete withON DELETE SET NULLand a nullablebooking_link_id; the kind replacing the FK as the sole facet key; a frozenbooking_typestring column on bookings; an immutable/append-onlybooking_typestable where a rename inserts a new row; a separate human "title"/nickname distinct from the slug; and a slug that is unique forever / burned on archive. Three are subtle. (1) Rename fragmentation decides three separate questions the same way. A live FK follows a rename by construction — one bucket, relabelled. A frozen string can't, so it splits one group into old-name and new-name halves with no way to reunite them. That's why source groups onbooking_link_id(not a snapshotted slug), why links are archived rather than hard-deleted with a slug stamped on their children, and why the kind is a table rather than a string. (2) The kind table must be mutable. Append-only was designed and dropped: if editing a type inserts a new row and repoints the link, a link that changes what it produces would relabel nothing, and typo fixes would fork instead of correct. Rows are edited in place; only the pointer is frozen. Deletion is a plain hard delete withSET NULL— no archive state, because a globally unique label plus no unarchive path in a create/edit/delete picker would burn the name permanently. (3) Releasing the slug on archive is deliberate: it lets a retired offering's name be reused by its successor, and the misroute it enables reaches only strangers arriving cold at a stale URL for a new booking — never an existing booking, whose manage link is keyed on its ownpublic_idand whose rules come through the FK.
-
iCal fields on— done.BookingSeriesfreq/interval/countcolumns on bothBookingSeriesandBookingLink,byday/wkstas comments only. §3 was superseded by item 6 and never built. Design:.claude/plans/done-booking-series-recurrence-fields-and-event-type-denormalization.md.COUNTandUNTILare mutually exclusive per RFC5545 (CHECK-enforced on both tables); occurrence walks areinterval * FREQ_DAYS[freq]rather than a literal week;_reschedule_seriesnow does Google's split, giving the new seriescount = original − consumedwhere consumed excludesstatus='rescheduled'rows (their slot moved to the replacement — counting both spends it twice). Both of the doc's open questions resolved: the booker-override ambiguity was designed out by making the two overrides mutually exclusive and mode-scoped, and the excused-cancellation exemption was dropped — cancelled is cancelled, forgiveness is a billing concern. Also landed, beyond scope:recur_weeksrenamed tocountfull-stack, the link editor's "Ends" section rebuilt to Google's three radios withbooker_can_set_countwired through, theavailable_slotsinner sweep made step-driven,require_slot_in_scheduleadded to the write path, andUNTILfixed to emit a UTC date-time as the spec requires. -
Contact/Student identity split— done, backend and frontend. Plan:.claude/plans/contact-identity-split.md. Full model: CLAUDE.md's "Contact identity".contacts+contact_managersshipped;Studentbecame enrollment on acontact_id;Booking/BookingSeriescarrypayer_id/attendee_idNOT NULL and the sixstudent_*/parent_*columns are gone. The attendee facet replaced the(first, last)name-pair matching, and the unauthenticatedemailquery param was removed.routers/contacts.pyis the one place a person's details change. Also landed alongside:sms_opt_in/guest_reminder_phone, andextra="forbid"on every request schema.- Frontend: rows read through
attendee/payer;BookingRow's expanded panel shows one line per person (collapsing to "Client" when they're the same row) plus the booking's own SMS/reminder line; the dead contact-edit modal is gone;/my-bookingsand everyisCustomerbranch are gone, leaving the twopublic_id-keyed Manage pages as the whole customer surface;/clientsis a new roster page. Picked up en route:bookingPayload/seriesPayload(one carry-forward body per update schema instead of three hand-rolled copies),PolicyModalsplit into a shared chromeShellplus two draft-owning dialogs, and filter chips resolving labels from facets rather than rosters. - Deferred deliberately: merge tooling (repoint the booking's attendee, then delete the stray
contact); a
relationshiptype oncontact_managers(no reader); race-safety on the resolvers (see the TODO inbooking_utils.py— find-then-insert can duplicate an emailless attendee under concurrency, and neitherON CONFLICTnorSELECT FOR UPDATEworks on SQLite so tests wouldn't cover it); renamingStudenttoEnrollment. - Immediate follow-ons, all small and all now unblocked:
/clients/:iddetail page — rows aren't clickable yet. A client accumulates bookings, series, managed dependents, enrollment and (later) notes, which outgrows a table row fast.- Enrollments page —
/clientsis contacts only, by decision.Student(rate, start date, grade, is_active) needs its own page; the old "Students — coming soon" placeholder is gone and nothing replaced that half. - Payer facet —
apply_scope_filtersfilters onattendee_idonly, inherited from the old name-pair facet. With one payer covering two dependents there's no way to ask "everything this payer is on," which is the invoicing question. Needs apayer_idsparam and a fifth facet query. Open design question: two separate facets (precise, composes, but two near-identical name lists in the menu) or one "Client" facet matching either column (one list, loses the role distinction, and muddies self-exclusion since one id touches two columns). - Stale Google Calendar summary on reassign/attendee change. The event summary is
"{slug}: {attendee.first} and {tutor.first}"(bookings.py:408), so changingbooking_link_idorattendee_idthrough the plain PUT leaves the calendar title wrong. Not a saga — oneevents().patch({summary, description}). Live bug today, predates this pass.
- Client intake fields belong here, not in the
EventTyperework (item 6) where they were originally scoped.form_fields(shared reusable question library),booking_link_fields(live join: which questions a link asks, in what order, required or not), andevent_field_responses(one answer row per question per booking, with alabel_snapshotof the question text as it was asked, so renaming a question never rewrites an old answer's meaning). Moved because answers attach to a person, and there's noContactentity yet — building them before the identity split means building on sand. Schema sketch is in CLAUDE.md's "BookingLink data model" under Client info. - Open question carried over from item 6: for a series the booker fills the form once but
occurrences are many — do responses attach to the
BookingSeries(occurrences resolve through it), or get copied onto eachBookingat materialization (consistent with every other frozen field, but duplicated N times)? Not yet decided.
- Frontend: rows read through
-
created/last_modifiedonBooking— quick, do it first.BookingSerieshas both;Bookinghas neither, purely because they arrived with the series lifecycle pass (item 4) and were never backfilled. Same declarations:server_default=func.now(), plusonupdate=func.now()onlast_modified. "When was this booked" currently has no answer except the calendar event. The second payoff is optimistic concurrency for the admin edit panel (item 12): the panel sends back thelast_modifiedit loaded and the server 409s if the row moved underneath, which is the clean fix for the stale-PUT hazard (a full-replacement PUT built from stale list data can otherwise silently move a booking). That fix was rejected while planning the panel only because the column didn't exist. Needs a DB wipe — no Alembic — so fold it in with the contact-split reseed rather than paying for a second one. -
Enrollments page —
/clientsis contacts only by decision, soStudentcurrently has no UI at all (the old/studentsplaceholder was removed with the customer routes). Plain CRUD overcontact_id,rate,start_date,is_active,grade,birthday, on a contact picker. Small and self-contained. Also the natural moment to renameStudent→Enrollmentif that's happening, since this is the first code written against it. No plan doc needed. -
Email + auth — Auth gates at the route level (protected-route wrappers), so doing this after the subroute split (3) means gating the final route structure once, not redoing it after a later refactor.
- Email: build only the minimal sending capability (pick a transactional provider, a
thin
send_email(to, subject, body)wrapper) — not the full confirmation/reminder email feature (see "Background jobs" below), which is separate, larger, and not needed for auth. The capability is shared infrastructure either way. - Auth: OTP-based (email a one-time code), not password-based — avoids building/managing password storage and reset flows, and reuses the email capability above rather than adding a second thing to build. Scope narrow for v1: one (or a small handful of) seeded admin user(s), no public self-signup, no forgot-password flow (not applicable — there's no password). Do budget real time for rate-limiting on the code-verify endpoint — an unthrottled short numeric code is brute-forceable in seconds, not a corner to cut under deadline pressure.
- Multi-tenant scoping rule: once
tenant_idexists, it must always be derived from the authenticated session server-side and applied to every query independently — never trusted from client-supplied input (cursor content, query params, body fields). Came up while designing cursor pagination (cursor-pagination-and-endpoint-split.md) — an unsigned cursor is fine precisely because tenant scope will never be sourced from it.
- Email: build only the minimal sending capability (pick a transactional provider, a
thin
-
Admin booking edit — pass 1: occurrences. Plan:
.claude/plans/admin-booking-edit-pass-1-occurrences.md. A right-side detail panel on the bookings list, read-only until you hit Edit, then one Save. Backed byPUT /bookings/{ref}(full DTO, admin only, mutates in place) alongside the existingPOST /bookings/{ref}/reschedule(saga, rules enforced, admin or customer). Quick actions become narrowPATCH /bookings/{ref}calls. Series occurrences are included except the tutor field, which is disabled pending the calendar bug in Known TODOs. Absorbs the policy modal, the reassign modal and the expanded-row contact panel. -
Admin booking edit — pass 2: series. Plan:
.claude/plans/admin-booking-edit-pass-2-series.md— read it for the full reasoning, including the unresolvedtutor_idquestion that needs approval before implementation.Three endpoints, five entry points. The split is mutate-vs-fork, mirroring pass 1:
endpoint scope behaviour PUT /bookings/{ref}this occurrence mutate in place (pass 1) PUT /booking-series/{id}whole series mutate in place, admin only POST /booking-series/{id}/reschedulefrom a pivot onward fork PUT /booking-series/{id}takes metadata and time. A time change is a genuine mutation: patch the Google master (which shifts every instance), mutatedtstart/dtendon the series row, rewrite the occurrence rows onto the new grid — past included. Same row, samepublic_id. This is Google's "all events". Reached from the series card or from an occurrence's all option — same endpoint, two entry points. Today'sPUT /booking-series/{id}is the fork, which the API-cleanup entry already flags as backwards, so pass 2 swaps the two endpoints' meanings.POST /booking-series/{id}/rescheduletakes an optionalpivot, defaulting to now. A customer rescheduling "the series" is "this and following from now", so it's one operation: customer →pivot=nowwith the link's rules enforced; admin "this and following" from an occurrence →pivot= that occurrence's date, rules skipped. The rules difference is authorization on one endpoint — the one legitimate case where role changes enforcement without changing what the operation does. The body describes the new series, so metadata changes ride along naturally. New work:_reschedule_seriescurrently hard-codes the pivot atnow— CLAUDE.md states "the pivot is alwaysnow, never a chosen occurrence" — so it needs a pivot parameter and that line needs updating.
Occurrences already moved individually keep their times when the series time changes — an override stays overridden, matching Google. When rewriting occurrence rows onto the new grid, skip any whose
google_event_iddiffers from the series' (the existingis_exceptiontest). The panel warns: "N sessions were moved individually and will stay where they are."Both
alland a pastpivotcan rewrite delivered sessions, which may haveLessonrows pointing at them. Warn, don't block — admin is king.No scope prompt for customers. Scoped edits are admin-only precisely because admin bypasses policy. A customer's "this and following" would span N bookings each with its own frozen policy and notice window, some past and unconditionally blocked — every rule for resolving that is arbitrary. That is exactly what the series-level policy pair (
series_cancel_mode/series_reschedule_mode, no notice window) exists to avoid.Considered and rejected: deleting
BookingSeriesentirely, moving the recurrence columns ontoBookingas nullables and grouping byseries_id. Appealing — one policy model, no series-vs-occurrence split — but the rule is one fact, not N: copyingfreq/interval/until/count/dtstartonto every occurrence makes a recurrence change an N-row update that can go half-done, and indefinite series need a rule to generate from (extend_all_serieswalks series rows;available_slots.pyreads them to subtract weekly bands in(weekday, time)space before any date resolves). Putting the rule only on the first booking makes that row a series row in disguise, orphaned when it's cancelled. And the "copy Google" argument cuts the other way — Google has a master event carrying the RRULE with instances generated from it, which is this two-level model.Still open: C, changing
tutor_idon a single occurrence — see the separate bug entry in Known TODOs. Google won't move one instance between calendars, so it would mean detaching the occurrence into a standalone event, and a detached booking then outlives a series deletion while a normal exception dies with it. Leaning toward rejecting the operation outright (400) and making the admin cancel-and-rebook, which is explicit and what Google effectively forces.
Known TODOs / Planned Work
Concrete bugs, missing logic, and planned improvements — not yet implemented.
Backend
-
Split booking creation from series creation —
BookingCreatevsBookingSeriesCreate, own endpoints. TodayPOST /bookings/does both: it branches onlink.recurringand either inserts one row or aBookingSeriesplus N occurrences, off one schema carryingrecur_until/recur_countthat are meaningless for half its callers. The read side already draws this line —GET /booking-seriesandGET /bookings/are separate endpoints returning separate entities — so the write side reusing one schema is the inconsistency, not the split. A booking and a series are different things; that bookings can originate from either a standalone create or a series is fine and expected (it's what the timeline view merges).- The objection that kept this open doesn't hold. It was argued that the client can't know which endpoint to call, since
recurringis server state — butBookingPagealready fetches the link before it can render anything (it readsduration_minutes,booker_can_set_recur_until,booker_can_set_count), solink.recurringis in hand well before submit. No server rule gets replicated client-side. - Concrete damage while unsplit: three
model_dump(exclude={...})call sites must each remember to strip the schema-only recurrence fields before hitting the ORM. Addingrecur_countand missing the exclusions broke every create with a 500. ABookingSeriesCreatethat only has the recurrence fields removes the exclusion problem rather than centralising it. - Cheap interim mitigation if the split is deferred again: one
orm_fields()method onBookingCreatedoing the exclusion once.
- The objection that kept this open doesn't hold. It was argued that the client can't know which endpoint to call, since
-
Wire the remaining calendar rules into the write path.
require_slot_in_schedule(booking_utils.py) now gatescreate_booking/_reschedule_booking/_reschedule_seriesso a slot must sit inside the tutor's schedule — previously every calendar rule lived only in/available-slots, which runs before the write and can just be skipped, so a direct POST booked a tutor at 3am on a day they don't work. Schedule is now covered; caps, buffers, lead time and horizon still aren't — though those are unimplemented rather than bypassed (see the six dead limit fields in the MVP list). Wire them at the same guard when they land.- Known consequence: this removed the admin's accidental ability to book outside a tutor's hours, since both entry points share the endpoint and there's no auth to tell them apart. That freedom returns deliberately with the admin-native surface below — which is now load-bearing, not just nice-to-have.
-
Deletion safety, part 2 — enforce "every non-archived link has ≥1 active host" as an invariant.
availabilitycarriesmin_length=1onBookingLinkCreate/Update, so a link can never be created or edited down to zero hosts. Two tutor operations bypass that and silently leave a link resolving, looking active, and generating no slots at all:DELETE /tutors/{id}— 409s onLesson/Booking/BookingSeriesbut never checksBookingLinkAvailability, so a tutor with no bookings is hard-deleted and their availability rows cascade away with their schedules.PUT /tutors/{id}withis_active=False— touches no rows at all, butget_available_slotsexcludes inactive tutors, so deactivating the last host has the same effect by a different mechanism. Easy to miss precisely because nothing is deleted.
Guard the invariant rather than the operation: block either if the tutor is the last active host on any non-archived link. Ignore archived links for the same reason
delete_scheduledoes — archive is terminal, so counting them would make the tutor permanently undeletable. Blocking delete also pushes the admin towardis_active=False, which is the intended offboarding path anyway; the deactivation guard then makes them reassign or archive the stranded link first. (There is no DELETE endpoint onbooking_link_availability— only GET/POST/PUT — so those two are the whole surface.) -
BookingLinkAvailability.schedule_idisCASCADE, so schedule deletion has no DB-level protection — only the app guard indelete_schedule. Bypass the router and the rows vanish silently. It can't simply becomeRESTRICT:delete_tutorrelies on the tutor→schedule→availability cascade chain, and RESTRICT would make that depend on whether Postgres clears the rows viatutor_idbefore it deletes the schedules. The honest fix isRESTRICTplus explicit availability cleanup insidedelete_tutor— more code, but it stops correctness resting on cascade ordering. Low priority: the app guard covers every path that exists today. -
Split
booking_utils.pyby topic. ~700 lines holding five unrelated jobs: occurrence materialization (_ensure_occurrence,_virtual_occurrences), write-path guards (require_slot_in_schedule,require_link_*), recurrence arithmetic (series_step,series_last_date,build_rrule,is_indefinite), filters/facets, and cursor encoding. Suggested:recurrence.pyandcursors.pypeeled off, guards and materialization staying. Pure move plus import fixes, no behaviour change — worth doing on its own rather than folded into a feature pass.- Naming test that makes the split obvious: each new file should be describable in one word. An earlier attempt grouped functions by "has no imports" and wanted to call the result
rules.py— a module named after its dependency profile rather than its contents, which is the sign the grouping is wrong. policy.pyis not part of this; it's a single-purpose module with an accurate name. It has to stay a leaf regardless:schemas.pyimports it to computecancel_action, andbooking_utils.pyimportsschemas.py, so anything it imported back would close a cycle.
- Naming test that makes the split obvious: each new file should be describable in one word. An earlier attempt grouped functions by "has no imports" and wanted to call the result
-
No
BYDAY— a twice-a-week client is two series. A Google weekly event recurs on several weekdays (BYDAY=MO,WE,FR); ours infers one weekday fromDTSTART, so Mon+Wed sessions are two independentBookingSeriesthat cancel, reschedule and count down separately. That isn't what the customer means by "my sessions", and Mon/Wed or Tue/Thu is a common shape in tutoring and PT. Needsbydayas an array column (a scalar placeholder would just get replaced — see the comment onBookingSeries), occurrence walks that step within a week rather than by one stride, and the same reworkavailable_slots'WeekdayTimebands need for the biweekly case. The largest remaining gap against Google's recurrence model. -
Admin-native booking surface — direct-add and edit-in-place. Today the admin has no surface of their own and reschedules through the booking page, so they inherit its rules by accident: availability, buffers, caps, lead time. That's backwards — an admin should be able to place a booking anywhere, including overlapping another, outside any schedule, past the caps, or in the past. Two halves of one feature, the Google Calendar model: direct-add (click the grid, make a booking, no picker) and edit-in-place (write
dtstart/dtendstraight onto the row). Both still patch Google Calendar — they skip validation, not side effects — so for a series occurrence it's the existingevents().instances()patch minus the guards. Not urgent (the booking page covers it for now), but it's the thing that makes "a retired link's rules are inert" true, so CLAUDE.md's model already assumes it. Guardrails: a soft "this overlaps an existing booking" warning the admin can proceed past — never a block.- Field-by-field taxonomy, settled (from the design pass alongside item 8). Three tiers, not two. Pure DB write, no Google call:
booking_type_id, the four policy columns,is_no_show,sms_opt_in,guest_reminder_phone,payer_id. DB write + a Googlepatch, same event id:booking_link_idandattendee_id, since both appear in the event summary and the link also in the description — the stale-summary bug above. Saga:start/end, andtutor_id(the event lives on that tutor'scalendar_id, so a tutor change is delete-there/create-here and yields a newgoogle_event_id). Never editable:id,public_id,series_id,google_event_id,rescheduled_to,timezone(being dropped), andstatus— cancel is the DELETE route because it also patches Google, and there's no un-cancel path to put in a dropdown. - Admin move reuses the reschedule saga; it does not mutate in place. An earlier draft of this said mutate — wrong for a series occurrence.
_ensure_occurrencekeys on(series_id, start), so movingstartleaves the old grid date with no row and the next grid walk re-materializes it; the soft-deleted row is the tombstone. And an occurrence'spublic_idencodes its timestamp, so mutatingstarteither desyncs it or breaks an already-emailed manage link. So the admin path is_reschedule_bookingwith the guards skipped, not a second write path. The rules layer is only two lines at the top of that function (require_link_not_archived+require_slot_in_schedule,bookings.py:502-503); everything below takesstart/endas given and never asks where they came from. Skipping validation and keeping the saga are independent choices — skip the first, keep the second. - Series recurrence has no edit endpoint at all.
freq/interval/until/countcan't be changed — you can't extend or shorten a series without fully rescheduling it. RRULE patch, its own piece of work.
- Field-by-field taxonomy, settled (from the design pass alongside item 8). Three tiers, not two. Pure DB write, no Google call:
-
Make
PUT /bookings/{id}andPUT /booking-series/{id}into PATCH. These are full replacements but no caller wants replacement semantics: every one changes exactly one field and carries six others along out of obligation. That's whybookingPayload/seriesPayloadexist, and PATCH would delete both — reclassify becomes{booking_type_id: 5}, no-show becomes{is_no_show: true}(and stays a field write rather than growing a/no-showsubroute). Cost: both schemas go all-optional withmodel_dump(exclude_unset=True), the router switches tosetattrover the set fields, and the tests that PUT full bodies get updated.extra="forbid"still works and still catches removed fields. Worth doing before more callers accumulate. The subroutes stay subroutes —POST .../reschedulepatches Google, inserts a row, soft-deletes another and setsrescheduled_to, which is nowhere near a field write. -
Email notification on admin-initiated changes — "your session has been moved," including the whole-series case. Pairs with the direct-move surface above: once an admin can silently relocate a booking without the customer initiating it, the customer needs telling. Depends on the minimal
send_emailcapability from item 9. -
pausedas a thirdBookingLink.status— URL 404s likearchived, but calendar rules stay live and editable, existing bookings stay self-reschedulable, and series generation continues. Real jobs archiving can't do: seasonal links (off Sept–April, back in May with rules intact), "booked solid this month, pause new bookings," and an unpublished draft state. The enum from 6a makes this additive — no existing meaning changes. Backend is nearly free (pausedlands correctly on both sides of every check already written:create_bookingwants== 'active', edit/reschedule/generation want!= 'archived'); the cost is UI — a toggle framed as a link setting, not a sibling of Archive, plus list grouping and badges. Open when built: can an admin manually create on a paused link? Current answer no — blocked means blocked, reopen it instead. -
Per-booking policy editing needs the scope prompt before it's complete. The series policy modal deliberately edits only the series-level pair (
series_cancel_mode/series_reschedule_mode). The occurrence-level four are a template for occurrences not yet materialized — so on a finite series, where every occurrence is created up front, editing them silently changes nothing, and on an indefinite one it changes only rows the extender hasn't made yet. A control that works for half of series and no-ops for the rest is worse than none, so it was cut. Consequence until scoped edits land: an indefinite series' not-yet-materialized occurrences have no editable policy — they keep whatever the link stamped. Already-materialized ones are still editable one at a time from the booking row. -
Series-scoped wiring edits — this / this-and-following / all. Google Calendar's recurring-edit prompt, applied to the wiring fields on a series (
booking_type_id, the four policy columns, contact fields, latertitle) — never to slot rules, which live on the link. This is the compensation for wiring being per-row: re-typing fifty occurrences by hand isn't a workflow. The trap:following/allmust update theBookingSeriesrow itself, not just its materializedBookings, because_ensure_occurrencecopies wiring off the series row — miss it and every occurrence Procrastinate generates from the next day onward silently reverts, forever.thismust not touch the series row. "All" including past occurrences is correct here: the admin picked "all", and it's explicit rather than an invisible cascade. Default the prompt to "this and following" (Calendar's default, and the safest — doesn't rewrite history, does fix the future). -
Booking/BookingSeriesneed their own name — deferred. Bookings and series carry no name of their own today; they'd get one generated at creation from a link-supplied template (a string-builder like{first} {last} — {duration}), so it varies per booking rather than being a shared label. Distinct from the kind, which is a shared label pointed at by many rows and used for grouping. Point the template at the generated name, never at the type — templating the grouping key produces a distinct bucket per booking, fragmenting grouping by construction. Not scoped: the template syntax, which variables it exposes (student first/last, tutor, duration, date?), and how literal text between tokens is handled. Also the natural home for the iCalsummaryfield (see theBookingSeriesiCal note above). -
A booking/series blocks itself from its own reschedule slot picker— done.get_available_slotstakesexclude_booking_id/exclude_series_id, filtered out of bothbooking_qand theinf_rulesquery (the series case mattered more —thin_schedule_datelesswas subtracting the whole(weekday, time)band before any date resolved). The endpoint acceptsexclude_ref/exclude_series_refaspublic_ids and resolves them to internal PKs, no-op'ing on an unresolvable ref so a virtual occurrence doesn't 404.BookingPage.tsxpasses them fromlocation.stateon both reschedule paths. Overlapping the original slot is now allowed; rescheduling to the identical slot is rejected by new guards in_reschedule_booking(exactstart/end) and_reschedule_series(same weekday + time-of-day + tutor, since a series' identity is its pattern, not an instant). -
Drop
Booking.timezone. It's a request-time conversion input, not state:BookingCreate/BookingRescheduleuse it to turn client-local into UTC, and after that nothing reads it — both display paths use the viewer's live-detected zone instead. Its only claimed future use was rendering reminder emails in the booker's zone, and that's been rejected: emails render in business time with the zone named explicitly ("4:00 PM ET"), which is unambiguous and doesn't depend on a zone captured months ago still being right. Move it to schema-only (excluded before reaching the ORM, same asfee_override) and drop the column. Needs a migration, so it waits on Alembic. Not to be confused withSchedule.timezone, which stays — a tutor in another zone enters availability in their own local time, and that column is the only record of which zone to convert from; its model TODO calling it redundant describes today's single-local-tutor data, not the design. -
BUG — rescheduling a series occurrence to a different tutor leaves the calendar event on the old tutor's calendar.
_reschedule_booking(routers/bookings.py, theif is_series:branch around line 537) patches the RRULE instance onold_calendar_id, derived fromdb_booking.tutor.calendar_idbefore the change, and never looks at the new tutor's calendar. The row gets the newtutor_id; Google keeps the session on the old tutor. Silent drift — no error, nothing logged. The standalone branch is correct: it inserts ondb_tutor.calendar_idand deletes the old event. Why it isn't a one-liner. You can't move a single RRULE instance to another calendar. The fix is: cancel the instance on the old tutor's calendar, create a standalone event on the new tutor's, and store that new event id — which detaches the occurrence from the series' recurring event. That's the same end state Google reaches when you drag one instance of a recurring event to another calendar, so it's the right model, but it needsgoogle_event_idto stop implying "belongs to the series' RRULE" and it interacts with_ensure_occurrence(which copiesseries.google_event_idonto new occurrences). Found while scoping the admin edit panel, which deliberately disables the tutor field for series occurrences rather than adding a second broken path. Fix this and the panel can enable it. -
Let an admin reopen a cancelled booking.
reschedule_booking(routers/bookings.py) requiresstatus == 'confirmed', so a cancellation is terminal for everyone — an admin can't move a booking a client cancelled by mistake, they can only create a new one, which loses the link to the original. Fix is small: allow the admin PUT to setstatusback to'confirmed'(customer-facing routes stay locked). It also needs the Google Calendar side undone — cancelling patches the RRULE instance tocancelled, so reopening has to patch it back. Cancelled staying terminal is right as the default; this is the admin override, same shape as every other place where admin bypasses a booker-facing rule. -
Adopt Alembic — no migration tool exists.
create_allonly creates missing tables, so today every schema change is answered withdocker compose down -v && up -d. That's fine while the only data is seed data and impossible the day someone is paying. Alembic autogenerates a migration by diffing models against the live DB, versioned and reversible. Several backlog items already assume it exists — theBookingSeries.statusNULL→explicit backfill,Booking.student_id→contact_idNOT NULL,google_event_id→external_event_id. Each of those is written as "needs a real migration" with no tool to write one in. Should land before real data does, and ideally before whichever of those items goes first.- Once it exists, move the Python-side column defaults to
server_default.Column(..., default=...)is applied by SQLAlchemy on ORM writes and bypassed entirely by raw SQL, so any NOT NULL column carrying one is a landmine forinitialize_database*.py, which INSERTs directly. It has already gone off twice:public_id(Pass 1) andfreq/interval(the recurrence pass) both left the example seed dying on a NOT NULL violation with nothing to catch it — no test covers those scripts. Current set:bookings.public_id/timezone/status/is_no_show,booking_series.public_id/freq/interval.public_idis the one exception worth keeping Python-side, since a series occurrence's is the composite{series.public_id}:{ts}rather than a bare UUID.
- Once it exists, move the Python-side column defaults to
-
API shape cleanups — noticed while adding
booking_type_idin 6b. Individually cosmetic, worth doing together since they're the same judgment call.- Fold
POST /{ref}/reassignandPOST /booking-series/{id}/reassigninto the plain-column PUTs. Setting an FK is a column write plus a validation, not an operation — it doesn't create a resource or run a saga, so it doesn't need a verb endpoint. The rule worth holding: one PUT for plain columns; separate routes only for things that create a resource, run a saga, or carry their own policy (reschedule earns it, relabelling doesn't). Left alone in 6b because they're shipped Pass 1 surface with frontend call sites attached. PUT /booking-series/{id}is the reschedule saga, not a plain update — backwards. The bare PUT on a resource should be the ordinary field update; a saga that creates a new series row with a newpublic_idshould bePOST /booking-series/{id}/reschedule(it can't be a PUT at all: a GET afterwards returns the old row, so PUT's contract is broken). Rename, and give the series a real plain-column PUT — it has none today, which is why 6b's type picker on a series row needed a route invented for it.- Foreign keys on
bookings/booking_seriesare unindexed. Postgres auto-indexes primary keys and unique constraints but not FKs (MySQL does, which is where the assumption comes from).booking_link_idandtutor_idare both facet keys hit byIN (...)andSELECT DISTINCTon every list request.booking_type_idgotindex=Truein 6b; the older two didn't and should match.
- Fold
-
RESTful endpoint cleanup on
bookings.py— medium priority, design mostly settled, nothing implemented. Came out of designing the admin edit panel; the routes grew one at a time and several are shaped wrong. Read this whole entry before touching any route, the pieces interact.The rule that decides everything below, worked out from how Stripe/GitHub/Google actually do it:
- Does the operation create something you could
GETafterwards? → plural-noun sub-collection (StripePOST /v1/refunds, GitHubPOST /repos/{o}/{r}/forks). - If not, can it be stated as "make these fields equal these values"? →
PATCH/PUT. - Otherwise → a verb is legitimate: a state transition with preconditions and side effects a field write can't express (Stripe
/capture,/finalize,/void; GitHub/merge; Google Calendar/move— note/movestays a verb precisely because it returns the same event, creating nothing).
Side effects never decide the verb. A
PATCHthat triggers a Google Calendar write is still aPATCH, the same way changing a user's email sends a verification mail and stays aPATCH. What decides it is what happens to the resource.Decided:
DELETE /bookings/{ref}/permanent→DELETE /bookings/{ref}?permanent=true./permanentis an adjective, not a resource — the URL reads "delete the permanent of this booking." Clearest violation, cheapest fix, do it regardless of the rest. Same for the series twin.- Admin edit is
PATCH /bookings/{ref}— it sets fields, so it earns no verb (see the admin-edit plan doc). - Leave
POST /booking-request/{id}/approve|denyalone. Approving doesn't just set a status, it applies the change (reschedules the booking) — a transition with side effects, which is exactly when a verb is right.
Considered, NOT decided — the open question is whether
/reschedulebecomes/reschedules:- The plural noun is only honest if
GET /bookings/{ref}/reschedulesalso works, otherwise the URL promises a collection you can write to but never read. GitHub's/forksis honest because the GET lists forks. - It would work for us — the chain is derivable from
rescheduled_to, so the GET returns the bookings descended from this one. That's also independently useful: the edit panel wants to show "moved from Sep 3." - So: implement the GET and rename to
/reschedules, or keep the verb and accept it's an action. Leaning toward the former, undecided.
Considered and rejected, with reasons (don't re-litigate):
POST /bookings/withrescheduled_from: <ref>instead of a separate reschedule route. Most RESTful on paper — a reschedule does create a booking. Killed by: three fields (payer,attendee,booking_link_id) become required-unless-rescheduling; the rule swap (create runsrequire_link_bookablewhich rejects paused, reschedule runsrequire_link_not_archivedwhich allows it); and fatally, rescheduling a series occurrence creates no calendar event at all — it patches the existing RRULE instance and reuses that instance id. So "a reschedule is a creation" isn't even true at the calendar layer. Two routes sharing helpers underneath beats one route with a mode flag.- An
is_adminboolean on the reschedule endpoint to skip the link's rules. Two problems: the flag would have to come from the request (there's no session yet), which is client-supplied authorization and no authorization at all; and it isn't only the checks that differ — admin edit mutates in place while reschedule forks a row, so the flag would branch the whole function body. Role gates access; it must never change what an endpoint does.
Deferred to the auth pass, where it collapses into something better rather than being renamed twice:
- The four
manage-occurrence/{ref}/*andmanage-series/{ref}/*routes are duplicates of the booking routes with a policy gate bolted on. But "may this caller cancel?" is an authorization question, not a behavioural one — so once sessions exist,DELETE /bookings/{ref}can serve both: admin session → allowed, capability-URL holder → policy-gated. That deletes four endpoints instead of renaming them. A guest holding an unguessablepublic_idis its own capability-auth model and doesn't need a session.
Also worth splitting while in here:
POST /bookings/currently creates both standalone bookings and whole series, branching onbooking_link.recurring. The only reason it's shared is that the caller can't choose — the link decides — but the frontend already knows (it renders the recurrence picker off that flag). Splitting outPOST /booking-series/removes ~60 lines of series-only logic (recurrence-bound resolution,_gen_through, the multi-occurrence conflict loop, N-row generation) from the standalone path. Genuinely shared parts move to helpers: tutor validation, link validation,require_link_bookable,require_slot_in_schedule, contact resolution, the guest-phone freeze, the compensating delete. Independent of the admin PATCH — that edits oneBookingrow either way. - Does the operation create something you could
-
Revisit the hand-rolled Google Calendar saga. Every write path (
create_booking,_reschedule_booking,_reschedule_series) calls Google first, then writes the DB, then issues a compensating delete/patch if the DB fails — and if the compensation also fails, logs a warning and moves on. The order isn't a preference:Booking.google_event_idisNOT NULL, so there's no row to insert until Google has answered. What that invariant buys, and it's real — a booking can never exist without a calendar event, so no row can end up permanently unreschedulable. Don't discard it casually. Downsides of the current shape:- Availability is capped by Google's. If their API is down, no bookings can be taken at all — the business stops because a third party is unavailable, for what is arguably a secondary feature.
- Compensation can itself fail, leaving an orphaned calendar event that nothing tracks or cleans up. A log line is the only record.
- Google is called before the DB has validated anything — a duplicate-occurrence violation or a failed CHECK is discovered only after the event exists, which is exactly the case needing remote compensation.
- Not actually atomic, despite reading like it is. It's two systems and a best-effort undo.
- Duplicated three times, each with its own compensation branch and its own failure logging. Options, cheapest first:
- Reorder: flush before calling Google. Insert rows with a placeholder id,
flush()so every FK/unique/CHECK is validated, then call Google, set the real id, commit. A localROLLBACKreplaces the remote compensating delete — reliable in a way an HTTP DELETE isn't, and orphans become near-unreachable. Cost: holds an open transaction (and row locks) across a network call. Fine at one practitioner's volume, a known anti-pattern under write concurrency. - Add a reconciler. A periodic job comparing calendar events against
google_event_idvalues, cleaning up strays. Complements the saga rather than replacing it; the cheapest fix for the orphan branch specifically. - Outbox via Procrastinate (already in the stack). Commit the booking and an outbox row in one transaction; a worker calls Google and retries. Atomic, no compensation in the request path, survives Google being down. Cost:
google_event_idbecomes nullable, 201 returns before the event exists, and permanent failures need a dead-letter someone actually watches. Converts silent loss into a visible retryable backlog — it does not guarantee success. - Demote Google to a projection (the biggest change, and the one that actually fixes availability). TMS becomes the system of record; the calendar is a downstream mirror with its own sync state. Booking succeeds regardless of Google; the admin UI surfaces what hasn't synced. Customer-facing cost is smaller than it looks —
BookingPage.tsxalready builds its "add to calendar" links client-side (Google template URL, Outlook, ICS blob) with no API call. Complication:check_calendar_conflictsreads Google for busy times, so there's a read dependency too — needs a decision on what to do when the calendar can't be seen (assume free, or block). Precedent — Cal.com does option 4, and documents its cost. TheirBookinghas no calendar-event column at all; external refs live in a child table (BookingReferencewithtype/uid/externalCalendarId, and evenbookingIdnullable), so a booking with zero references is structurally ordinary. The failure mode that produces: #22192 — when calendar creation fails the booking still exists, but every later reschedule fails too, because the reschedule path assumes a reference exists and patches an event that doesn't (sometimes with an emptyuid). #25009 — v2 API bookings landingACCEPTEDwith no calendar sync and no emails. The lesson: making the column nullable is only half the work. The row needs a sync state, and every path touching Google — reschedule, cancel, series patch — has to treat "no reference yet" as a normal case. Cal.com's bug is precisely that missing second half.
-
Delete-protection gaps across Tutor/Schedule/BookingLink/Booking — surfaced while designing the
BookingSerieslifecycle-field cleanup. The link and the tutor turned out to need genuinely different treatment (one's a factory a booking is generated from, the other's an ongoing identity referenced from many tables), settled as follows. All of these are now fixed — the sub-items below are marked individually. Original (pre-fix) behavior, kept for reference: none of it was silently correct, but the way each one broke differed.Tutorand link deletion were both accidentally-blocked-but-ugly: no app-level check existed, so if any referencingSchedule/Booking/BookingSeriesrow existed, Postgres itself rejected the delete with a raw, unhandledIntegrityError(500 crash) rather than a clean 409 — it didn't go through, but it failed loud and ugly instead of failing cleanly.Scheduledeletion was the opposite and more dangerous: the availability junction'sschedule_idhasondelete="CASCADE", so deleting a schedule still linked to a link succeeded silently — no crash, no warning, the junction row just vanished and that link quietly lost its availability. Two things failing loud when they shouldn't have run at all, and one succeeding silently when it should have been blocked.- Link deletion design — see Priority Order item 6 above, and CLAUDE.md's "BookingLink data model" for the full reasoning. Short version: archive only, no hard delete at any child count (
status='archived'+archived_at, permanent in behavior, no restore). The row never leaves, which keepsbooking_link_idNOT NULL and non-dangling — that's both the source facet and, more importantly, the handle you filter and bulk-reassign with to rescue stranded bookings. An archived link's calendar rules go inert (URL 404s, reschedule 404s), which is what makes read-only coherent. Series generation is not affected by any link status — a series generates from its own row, never the link. Five earlier drafts are superseded — RESTRICT-on-active-references; freely-deletable-with-frozen-type-strings; soft-delete justified by "rules must resolve forever"; hard-delete-at-zero-children plusON DELETE SET NULL; and archive-with-the-slug-burned-forever. Kept as a pointer only, so this doesn't drift out of sync again. — done.Tutorhard-delete guard only checkedLessonrowsDELETE /tutors/{id}(routers/tutors.py) now also 409s on any referencingBookingorBookingSeries, past or future — hard delete only succeeds when the tutor has zero bookings of any kind; the only path forward otherwise isis_active=False.— done.Tutor.is_active=Falsehad zero enforcement anywherecreate_booking,_reschedule_booking,_reschedule_series(routers/bookings.py) 400 on an inactivetutor_id;get_available_slots(routers/available_slots.py) excludes inactive tutors from the query;extend_single_series(tasks.py) returns cleanly without materializing further occurrences once a series' tutor has gone inactive. Already-confirmed/already-materialized occurrences are untouched, as designed. No "tutor inactive" indicator added to the admin view yet — cosmetic, not tracked as blocking.Schedule.tutor_idstill has no delete cascade — now a live, easily-hit gap, not just theoretical. Noondeleteon the FK, nocascade=onTutor.schedules. Now that theTutor→Booking/BookingSeriesRESTRICT above is in place, hard-delete's only remaining way to fail is a tutor who hasSchedulerows but zero bookings — a very ordinary state for a newly-configured tutor — which still raises a raw unhandledIntegrityError(500) today. Not fixed this pass; fix is to wireSchedule.tutor_idasondelete="CASCADE"now that the precondition (zero-booking guarantee) actually holds.— done.delete_schedulehad no block for a schedule still wired to anEventTypeDELETE /schedules/{id}(routers/schedules.py) now 409s if anyBookingLinkAvailabilityrow still references the schedule, same shape as the existingis_defaultguard.- ~~Admin permanent-delete endpoint for
BookingSeriesdid
- Link deletion design — see Priority Order item 6 above, and CLAUDE.md's "BookingLink data model" for the full reasoning. Short version: archive only, no hard delete at any child count (
Truncated - read the full file at https://github.com/mprymak2000/TMS/blob/221efae9aa5345c94a06fbebac2cd6034271a092/.claude/skills/tms-roadmap/SKILL.md.