QuranFlow — Domain & Data Model Specification
Storage-agnostic domain model for the new QuranFlow backend. Every entity, field,
and rule below traces to either the stakeholder feedback synthesis
(FEEDBACK-SYNTHESIS.md), the adjudicated design review
(../mockup/REVIEW.md), the simplification audit
(../mockup/AUDIT.md), or the Sakīna mockup source it was built from
(../mockup/src/*.jsx). Field names are suggestions, not a schema DDL —
the backend team should map them onto whatever store (relational, document,
etc.) they choose. Where mockup and source docs disagree, the mockup's
adjudicated REVIEW.md resolution wins (per that document's own rule).
Revised Jul 27 2026 (Lejla review). The Jul 27 screen review (decision record:
../../reviews/App-Review-Jul27-Lejla.md) corrects the entry/enrollment model that was encoded on Jul 26. Summary of the model-level changes, each expanded in place below:
- The first assessment is a recorded recitation, modeled as a
Submissionsubtype (FirstAssessment), not anAppointment, routed to a placement pool — a shared placement role/queue that rotating coaches claim from (PROPOSED replacement for today's shared-login first-assessment account). See theSubmissionnote and the newFirstAssessmentsub-entity.enrollment_statusgains a reallevel_0status plus a correction path:recorded → placed(a coach places from the recitation) and an auto-placement path (reminder emails → auto Level 2 for never-submitters). Entry is either checkout (new / Y2 elective / repeat) or auto-enrollment with auto-billing (level graduate → next level; Level-4 graduate → Year-2 mandatory first semester). See §2.- A new
Electiveentity carries per-elective syllabus/lessons and ahas_lessonsflag (Tajweed Practice = false); the only universal Y1→Y2 difference is feedback format.appointment_plan(1|2|3 per week) moves onto the Year-2 enrollment, sourced from checkout — UI copy derives the student's allowance from it, never a universal cap.- Availability is captured once at onboarding for every placed student, as general availability data consumed by circle/QRC/class/1:1 scheduling — no longer a Year-2 booking-only field.
- The script/font preference field is retired — the picker feature is cut.
Sign-in is email + password (welcome-email credentials), not a magic link; the almaghrib.org enrollment pointer is removed (App Store anti-steering). Program brand names and pricing are internal-only.
1. Entity catalogue
User
The single account type; role and per-role fields distinguish behavior.
| Field | Notes |
|---|---|
id |
|
name |
Display name, e.g. "Sarah M." |
role |
y1_student | y2_student | coach | admin. Wishlist: Y1 = submission-centric, Y2 = appointment-centric (FEEDBACK-SYNTHESIS.md "Wishlist requirements"). |
email |
|
timezone |
IANA zone, e.g. America/Toronto. One account = one city (REVIEW.md B3) — no per-device override. All times shown to a user render in this zone; the app never hardcodes EST (AUDIT.md "v3 mockup feedback"). |
level |
Current level, e.g. Level II. Students see content up to their current level; coaches see all levels (FEEDBACK-SYNTHESIS.md wishlist: "Content access"). |
semester_enrollment_id |
FK to the student's current Semester enrollment (below). |
enrollment_status |
Added Jul 26 2026 (product review with Lejla, Jul 24). Entry/placement gate: registered (paid on the web, not yet placed) → assessment_booked → awaiting_placement → placed (Year-1 level) | year2 (returning, auto). Distinct from year: year is only meaningful once placed/year2. A registered/awaiting_placement account must never see the Year-1 loop (§2 Account / enrollment lifecycle). Revised Jul 27 2026 (Lejla review): the assessment-booked path is dropped. Corrected statuses: registered → level_0 (signed up, not yet placed — sees the full app minus lessons) → placed (a coach places from the recorded recitation) | auto_placed (never-submitter auto-placed into Level 2 after reminder emails). year2 is reached only by auto-enrollment (Level-4 graduate → mandatory first semester) or checkout (returning elective), never from a new account. level_0 is a first-class status, not a bare hold. See the revised §2. |
year |
Y1 | Y2 — drives submission-centric vs appointment-centric UI and feature gating (Year-2-only 1:1 rows, REVIEW.md C2). Revised Jul 26 2026 (product review with Lejla, Jul 24): Y2 means no Submission objects at all — appointments replace them, not just extra 1:1 rows layered on the Year-1 loop. See the Submission entity note and Year gating in §3. |
coach_id |
FK to User (role coach) — the student's single assigned coach. One coach per student; two-coach model explicitly rejected (REVIEW.md "Out of scope": "Two-coach model for Year 2 — rejected for now"). Changeable via an explicit "Change coach" affordance (REVIEW.md A4), which reassigns this field but does not alter historical CoachNote/Feedback authorship. Revised Jul 26 2026 (product review with Lejla, Jul 24): this field is the Year-1 review-routing coach — the coach a Year-1 student's submissions go to. Year-2 appointments are not limited to it: each Appointment carries its own coach_id (below), and a Year-2 student books across the team's coaches (one shared pool, default pending Lejla (Q11)). The single-assigned-coach rule and the rejected two-coach assignment model both still stand for review routing; neither constrains Year-2 booking. |
quran_font_preference |
font_size_pt (18–36). From SakFontPicker (sak-extras.jsx).font_size_pt). |
appointment_plan |
Added Jul 27 2026 (Lejla review). Year-2 only: appointments purchased per week at checkout — 1 | 2 | 3. Default 1 for every elective; only the Tajweed Practice elective sells 2–3 today. All UI copy about the weekly appointment allowance derives from this ("Your plan includes N appointments a week") — there is no universal cap. Logically belongs on the Year-2 Semester enrollment (below) rather than the durable User; kept adjacent here for visibility. |
notification_preferences |
Per-type opt in/out — see Notification below. |
created_at / onboarded_at |
Relationships: a coach User has many student Users (via coach_id); a
student has one assigned coach at a time but historical Feedback/CoachNote rows
may reference a different coach than the student's current one (substitute
coaches, prior-semester coaches — see SakCoachStudent's intentional
Ust. Rania reference, REVIEW.md A4). Revised Jul 26 2026 (product review with Lejla,
Jul 24): "one assigned coach" governs Year-1 review routing. A Year-2 student additionally
holds many Appointments, each with its own coach_id, spread across the team — so a
Year-2 student is served by many coaches even though only one is assigned for review
routing.
Semester + Week
| Field | Notes |
|---|---|
Semester.id |
|
Semester.label |
e.g. "Fall 2026" |
Semester.start_date |
|
Semester.total_weeks |
Fixed at 15 — the strip is always total={15} (AUDIT.md "Consistency conventions locked": SakStrip total={15}). |
Semester.state |
pre_semester | active | complete. Pre-semester state renders SakTodayCountdown (sak-feedback.jsx F5) instead of an empty Today tab — countdown to start_date, with prep checklist items (meet coach, pick font, watch welcome lesson). |
Week.id |
|
Week.semester_id |
FK |
Week.number |
1–15 |
Week.starts_at / Week.ends_at |
Week boundary; submission deadline is Sunday 11:59 PM student-local time (REVIEW.md A3: "Due Sunday · 11:59 PM", not a hardcoded EST string). |
A student's "current week" is derived (Semester.active + today's date falls
within Week.starts_at..ends_at), not stored redundantly per user.
Lesson / Video
| Field | Notes |
|---|---|
id |
|
week_id |
FK — lessons are weekly. |
level |
Content gating: students see lessons up to their current level; coaches see all. |
title |
e.g. "Idghām with ghunnah" |
part_number |
Chapter/part within the syllabus TOC. |
videos |
Ordered list of 3 Video sub-records: { n, title, duration_sec, transcript } (sak-extras.jsx SakVideoPlayer: hardcoded 3-video chapter). |
| — | |
| VideoWatchProgress | Per (user, video): percent_watched, resumed_at_sec, watched_at (nullable — set once percent_watched >= 90, the mockup's completion threshold, SakVideoPlayer progress >= 90). Copy consistency rule (AUDIT.md wishlist): a video is either "N of M remaining" or "watched" — pick one vocabulary and use it everywhere, never both on the same screen. |
Elective
Added Jul 27 2026 (Lejla review). A Year-2 course. Every Year-2 course has its own syllabus and lessons — that is the point of electives — with one exception: Tajweed Practice is appointments-only. The only universal Year-1 → Year-2 difference is the feedback format (appointment notes replace submission feedback); lessons are per-elective, not shared.
| Field | Notes |
|---|---|
id |
|
name |
Internal course name — e.g. the mandatory first-semester course read as "Advanced Tajweed Rules" (pending confirmation, Q15), or an elective like "Tajweed Practice". Internal/brand name; a student-facing surface names the elective the student chose, never the program tier. |
is_mandatory_first_semester |
Boolean — the mandatory semester a Level-4 graduate is auto-enrolled into (distinct from the appointments-only "Tajweed Practice" elective; never conflate the two). |
has_lessons |
Boolean. false only for Tajweed Practice (appointments-only — its Today is appointments-led, no lesson hero). true for every other elective. |
syllabus |
Per-elective ordered Lesson list when has_lessons; empty when not. |
default_appointment_plan |
The appointment_plan (1|2|3 per week) this elective sells at checkout — default 1; 2–3 only for Tajweed Practice. Copied onto the student's Year-2 enrollment at checkout. |
Assignment
| Field | Notes |
|---|---|
id |
|
week_id |
FK — one assignment per week per level. |
surah / ayah_range |
e.g. Al-Mulk 67:1–3. Citation string format is Surah CH:V[–V] sentence case, rendered as spaced small caps (SakPassageCard cite prop). |
ayat |
Ordered list of āyah records: { num, text (Arabic), segments } — the same shape SakPassageCard renders and the same object a coach's word-anchored marks attach to (see Feedback.marks below). Scales from 1 to N āyāt; the passage card scrolls internally past ~5 lines rather than growing the screen (sak-feedback.jsx header comment, "LENGTH ROBUSTNESS"). |
rubric_id |
Optional FK — First Assessment / EOC (End of Chapter) rubrics surfaced before review (FEEDBACK-SYNTHESIS.md wishlist: "Assessment rubrics … surfaced before review"). |
Submission
The core Year-1 object. One row per attempt (not per week) — a week can have up
to 2 submissions (the weekly attempt limit) plus any number of revoked/re-recorded
takes that don't count, and any number of practice takes attached afterward
(see PracticeTake below).
Revised Jul 26 2026 (product review with Lejla, Jul 24):
Submissionis Year-1-only. A Year-2 student produces noSubmissionrows at all — the weekly appointment (Appointment/AppointmentOccurrence) replaces the submission, and the coach's post-appointmentAppointmentNote(below) replaces theFeedbackobject. Do not createSubmission,Feedback, orPracticeTakerows for ayear = Y2student.
| Field | Notes |
|---|---|
id |
|
student_id |
FK |
week_id |
FK |
assignment_id |
FK |
attempt_number |
1 or 2 (of the 2/week cap — FEEDBACK-SYNTHESIS.md: "2 submissions/week limit"). A revoked-and-re-recorded submission does not increment this (see revoke window below). |
audio_url |
Student's recording. |
duration_sec |
|
submitted_at |
|
revoke_expires_at |
submitted_at + 5–10 min (exact value configurable; mockup shows a live countdown, e.g. "6:52 left", SakSubmissionPending). While now < revoke_expires_at, the student can revoke-and-re-record without consuming an attempt (FEEDBACK-SYNTHESIS.md: "5–10 min revoke window that does NOT count against the limit"; REVIEW.md A1 — this is the only screen that owns revoke UI, SakSubmissionPending, not the immediately-prior "recording submitted" screen). |
revoked_at |
Nullable — set if the student exercises the revoke window; the submission is voided and does not count toward the weekly cap. |
status |
See state machine in §2. Recut Aug 18 2026 (U-435) — the values are awaiting, inreview, reviewed, rejected, revoked (unsubmitted is the absence of a row). One value, two labels — the pill takes a pov and renders the seat's own words: awaiting = "Awaiting review" (student) / "Needs review" (coach), reviewed = "Feedback ready" / "Feedback sent"; inreview, rejected and revoked read the same to both. Colors do not vary by seat (DESIGN-SYSTEM §5). Store the value; never store a label. The retired heard/practiced pair described the student, not the submission — do not reintroduce it; the legacy review value normalizes to awaiting. |
listened_at |
Nullable — set when the student plays the coach's feedback through. A receipt on the feedback, never a status of the submission (that conflation is what U-435 removed): the coach's roster and student record render it as "listened / not listened yet", and the submission stays reviewed either way. |
Relationships: one Submission has zero-or-one Feedback (coach hasn't
reviewed yet vs has); one Submission may have many PracticeTakes (attached
after feedback, don't consume attempts).
FirstAssessment (Submission subtype)
Added Jul 27 2026 (Lejla review). The first assessment is a recorded recitation
modeled as a Submission subtype — not an Appointment. A new (Level-0) student records
themselves reading through four graded stages, and that recording behaves like a normal
weekly submission (record → review → send → pending → feedback), reusing the same audio
pipeline. What differs is the routing target and the outcome.
| Field | Notes |
|---|---|
kind |
Discriminator on Submission: weekly (default) | first_assessment. A first_assessment row carries no week_id/assignment_id in the weekly sense; its assignment is the graded reading-stages script (letters → words → a short surah → a technical ayah). |
stage_results |
Optional per-stage structure the coach fills while placing (which stages the student cleared) — the raw material for the placement decision. |
routed_to_pool |
placement_pool — instead of the student's assigned coach_id, a first_assessment submission routes to a shared placement pool (see below). |
placement_level |
The level a coach assigns from the recitation; on set, flips User.enrollment_status level_0 → placed and sets User.level/year = Y1. |
PlacementPool / placement queue. PROPOSED replacement for today's shared-login
first-assessment account (a group of coaches sharing one credential). Model the pool as a
role/queue, not a shared user: first_assessment submissions land in one unassigned
queue; any coach with the placement role claims a row (rotating so students are placed
quickly), reviews the recitation in the standard review workspace, and sets
placement_level. This is a queue-scoping concept over the existing coach review model
(see the coach-side "Placements" queue proposal, PRODUCT-SPEC Section C), not a new store.
Never-submitters are handled by the enrollment state machine's auto-placement path
(§2), which sets enrollment_status = auto_placed and level = Level 2 without a
FirstAssessment row ever being sent.
Feedback
Coach's response to a Submission. One-to-one with Submission.
| Field | Notes |
|---|---|
id |
|
submission_id |
FK, unique |
coach_id |
FK |
audio_url |
Coach's spoken feedback — one long conversational note, played start to finish (the ink card on Submission — feedback ready: "2:54" in the shipped walkthrough, "6:48" in the artboard fixture). |
duration_sec |
|
marks |
Word-anchored annotations on the assignment text: list of { ayah_num, word_index (n), tone } where tone is danger | warm (mockup: red = correction needed, gold = praised — PassMark/SAK_MULK_1_3 in sak-feedback.jsx). This is the "visual/annotated feedback synced to audio" coaches ranked #2 (FEEDBACK-SYNTHESIS.md). |
notes |
Optional per-word voice notes. The coach taps an anchored word and records a short note onto it: { mark_ref (n), tone, audio_url, duration_sec }, at most one per marks entry (walkthrough: 0:14 on تَبَارَكَ 67:1, 0:11 on ٱلْمَوْتَ 67:2). Three things this shape deliberately does not carry. No text — no title, no body: coaches speak, they don't type, and the word plus the note is the whole point. No ayah_ref — the mark's position derives it. No timestamp — a note is its own recording, not an offset into the parent audio_url, so there is no audio_timestamp_sec and nothing in the UI seeks the long note. notes may be empty: audio_url alone is a complete review. Provenance, so this row stops accreting readings — the shape above is final and the rest is history, not a layer: the original structured-marks design gave each note a title and body, auto-stamped audio_timestamp_sec at the playhead, and collapsed the list at 3 (SakFeedbackNotes, collapseAt = 3); the Jul 26 2026 revision (product review with Lejla, Jul 24) made one conversational audio_url note the default and the notes optional extras per anchored word, and added a per-note contrast pair (the student's clipped excerpt + a reference clip, "you said → how it should sound"); Kamran retracted that contrast pair on Aug 4 2026 — it mechanized a two-word wishlist phrase — sending "comparison clips" to the v2 wishlist (PRODUCT-SPEC §5) and fixing the word + spoken note shape above. |
private_note |
Coach-only text, never shown to the student (SakCoachReview's "Private — just me" tab). |
student_note |
Optional additional text shown to the student alongside the audio (SakCoachReview's "For Sarah" tab). |
pass_fail |
Optional boolean/enum outcome, distinct from the loop-state status on Submission. |
reviewed_at |
When the coach submitted feedback — this is what flips Submission.status review → reviewed. |
review_duration_sec |
Wall-clock time the coach spent in the review screen; feeds TimeLog/attendance auto-logging (SakCoachReview footer: "Your review hours log themselves." — replaces the manual Time Clock Wizard, FEEDBACK-SYNTHESIS.md coach ask #4). |
The audience split is one decision, not two composers (Aug 3 2026). The walkthrough
now demonstrates it (walkthrough/app/src/screens/coach-review.tsx): the coach records
one note, then a segmented control — "For Sarah" / "Private — just me" — decides where it
lands. "For Sarah" makes it the student-visible feedback and the CTA reads "Send to
Sarah"; "Private — just me" makes it private_note, the CTA becomes "Save private note",
and the write never reaches the student's side — no reviewed_at, no status change,
nothing in her app. Implementation consequence: a private note must not be modeled as a
Feedback row whose visibility is filtered downstream, because saving one leaves the
Submission in review and the coach's queue row standing. The Send action itself is
gated on a recorded note existing.
PracticeTake
A re-recording the student makes after receiving feedback, to practice a
correction. Does not consume a weekly attempt and is not itself submitted for
new review — it attaches to the existing Feedback thread.
| Field | Notes |
|---|---|
id |
|
feedback_id |
FK — attaches to the feedback thread, not a new submission (REVIEW.md C3: "Attach to feedback", not "Submit to Ust. Amina"). |
correction_ref |
Which Feedback.notes[n] this take addresses (the stepper in SakRecPractice walks corrections one at a time, "Correction 1 of 3"). |
audio_url / duration_sec |
|
recorded_at |
|
| — | No attempt_number, no attempts-dots UI — explicitly out of the weekly cap (REVIEW.md C3). |
Session
Live scheduled events: classes, QRC (Qur'an Reading Circle), Office Hours,
and (for Y2) one-to-one appointments (modeled separately, see Appointment,
because appointments have a distinct per-student booking/attendance lifecycle;
Session covers the shared/group event types).
| Field | Notes |
|---|---|
id |
|
type |
qrc | level_class | office_hours (mockup type chips: "QRC", "Class"/"Level Class", "Hours"/"Office Hours" — sak-schedule.jsx typeColors). |
title |
e.g. "Sisters' Reading Circle", "Idghām review & practice". |
coach_id |
FK — session leader. |
starts_at_utc |
Stored UTC; rendered per-viewer in their own timezone. No per-item zone suffix stored or displayed (REVIEW.md B3: delete every per-item "EST"/"EDT" suffix; one zone banner per screen instead). |
duration_min |
|
recurrence_rule |
e.g. weekly on Tuesday — most Sessions repeat every week of the semester. |
zoom_join_url |
|
level_filter |
Optional — restricts which students see/attend (Level II class vs Level I). |
cohort_size / registered_count |
For the "6 of 8 confirmed" cohort display (SakSessionDetail). |
materials |
"Bring with you" list: { icon, title, sub } — e.g. reading passage, prerequisite video (DetailMatRow, SakSessionDetail). |
agenda |
"What we'll cover" ordered list (DetailListRow). |
SessionRecording (child of Session, one per occurrence):
| Field | Notes |
|---|---|
id |
|
session_id |
FK (a specific occurrence, not the recurrence rule) |
occurred_at |
|
video_url |
|
duration_sec |
|
| — | Organized by week in a calendar/grouped view, not a flat list (FEEDBACK-SYNTHESIS.md wishlist: "Recordings organized in a calendar view"; implemented as SakScheduleRecordings's week-grouped list with a day strip). |
SessionWatchProgress (per user, per SessionRecording): percent_watched
(drives the thumbnail progress bar and "N% in" / "watched" chip,
RecordingRow in sak-schedule.jsx).
Availability
Added Jul 27 2026 (Lejla review). Captured once at onboarding for every placed
student (Year 1 levels 1–4 and Year 2 — not Level 0), superseding the earlier model
where SakOnbAvailability was asked only during Year-2 booking (REVIEW.md C5). It is
general availability data: consumed by circle, QRC, level-class, and 1:1 scheduling
alike. The multi-use is internal; UI surfaces it only as the "when could you join a live
circle?" ask.
| Field | Notes |
|---|---|
id |
|
user_id |
FK |
windows |
Set of day × time-band cells the student can join a live session (the 7-day × 5-band grid: Early / Morning / Midday / Evening / Late). "Pick every time that could work — even odd hours." |
captured_at |
Set at onboarding; editable later in settings. |
| — | Scheduling reads this dataset for all live-session types, not just 1:1 appointments — it is not scoped to Year 2 or to appointments. |
Appointment
Year-2-only 1:1 booking. Distinct from Session because it has a per-student
lifecycle (booked → attended/missed → escalation) rather than a shared
roster.
| Field | Notes |
|---|---|
id |
|
student_id |
FK — Y2 only; Y1 accounts never see these (REVIEW.md C2 annotation). |
coach_id |
FK — the coach for this appointment. Revised Jul 26 2026 (product review with Lejla, Jul 24): this is a per-appointment coach, not the student's User.coach_id. A Year-2 student books across the team and different appointments (even in the same week) may have different coaches (one shared pool, default pending Lejla (Q11)). User.coach_id still routes Year-1 review only. |
booking_mode |
recurring | this_week_only — the hybrid model from the teachers' call (FEEDBACK-SYNTHESIS.md "Decisions already made": lock the same weekly slot for all 15 weeks, or book week-to-week). Vocabulary locked to exactly these two words in coach-facing UI (REVIEW.md D1: "Recurring" / "This week only" — not synonyms). Revised Jul 26 2026 (product review with Lejla, Jul 24): the recurring mode is dropped for Year-2 booking — every Year-2 appointment is effectively this_week_only, booked per week (up to 3/week, see §3). Whether to reintroduce an optional recurring convenience is open — Q13, pending Lejla. Keep the field for now; default new Year-2 bookings to this_week_only. |
slot_start_utc |
Stored UTC. 20-minute slots (FEEDBACK-SYNTHESIS.md wishlist: "20-min slots"; SakBookAppointment slot grid). |
duration_min |
Fixed 20. |
week_id |
For this_week_only bookings; null/ignored for recurring (recurring bookings are described relative to the semester, not a single week — cancelling or swapping a specific week's occurrence is handled via AppointmentOccurrence below). |
zoom_join_url |
|
status |
active | cancelled — the recurring booking itself, not a single week's attendance. |
AppointmentOccurrence (one per week, generated from the Appointment
booking or from a one-off this_week_only booking):
| Field | Notes |
|---|---|
id |
|
appointment_id |
FK |
week_id |
FK |
scheduled_at_utc |
Normally inherited from the parent slot; may differ if swapped. |
swapped_from_occurrence_id |
Nullable — set when the student uses "Swap this week's time" (ApptActionRow in sak-appointments.jsx: "Keep your slot for the other weeks"). Swap moves this week's occurrence only; the recurring slot is untouched. |
attendance_status |
scheduled | attended | no_show | cancelled_by_student | cancelled_by_coach. Cancellation is guilt-free and accepted up to the last minute ("Even a minute before is fine — it frees the slot", ApptActionRow). |
cancelled_at |
|
consecutive_absence_count |
Derived/maintained counter across a student's occurrences; 2 consecutive no-shows trigger the auto email escalation (FEEDBACK-SYNTHESIS.md: "auto email escalation after 2 consecutive absences" — see Integration points, Email automation). |
AppointmentNote
Added Jul 26 2026 (product review with Lejla, Jul 24). The Year-2 counterpart to
Feedback: the coach's post-appointment summary, the object a Year-2 student receives in
place of submission feedback. One per AppointmentOccurrence. Produced by the notes
pipeline on the new Coach — appointment notes screen and read on the student's
Appointment — teacher's notes screen.
| Field | Notes |
|---|---|
id |
|
appointment_occurrence_id |
FK, unique — the 1:1 this summary belongs to. |
coach_id |
FK — the coach who ran the appointment (mirrors Appointment.coach_id, not User.coach_id). |
recording_url / recording_duration_sec |
The coach's short spoken debrief, captured on Coach — appointment notes. Nullable if the coach typed rather than recorded. |
transcript |
Auto-transcribed text of the recording (input to the summary). |
summary_points |
Ordered list of short points ({ n, title, body }) — the auto-summary the coach then edits. This is what the student sees (e.g. the three FIXTURES.md points: madd lengths in ayah 2, the qalqalah letters, pace). |
voice_note_url / voice_note_duration_sec |
Optional coach voice note surfaced to the student alongside the points (persona: ~1:40). |
edited_by_coach |
Boolean — set true once the coach edits the auto-summary. The coach's edit is the approval — there is no second approver (confirm, pending Lejla (Q12)). |
state |
See the AppointmentNote lifecycle in §2: recorded → summarized → edited → sent. |
sent_at |
When the coach sends the note to the student (state → sent); this is what makes it visible on Appointment — teacher's notes. |
Relationships: one AppointmentOccurrence has zero-or-one AppointmentNote. Copy is
written in the no-diacritics canon ("Surah al-Mulk"), like the rest of the new screens.
CoachNote
Shared, persistent notes about a student, visible to every coach who has
ever met with that student — not scoped to the currently-assigned coach.
This is what powers SakCoachStudent's "Coach notes" section, explicitly
labeled "visible to all coaches", including the intentional Ust. Rania
substitute-coach example (REVIEW.md A4).
| Field | Notes |
|---|---|
id |
|
student_id |
FK |
author_coach_id |
FK — may not be the student's current assigned coach (substitute/prior coach). |
body |
Free text. |
week_id |
Optional context — "Week 6". |
context_tag |
Optional, e.g. "one-to-one" (distinguishes a note written during an Appointment vs during async Feedback review — SakCoachStudent: "Ust. Rania · Week 6 · one-to-one"). |
created_at |
|
| — | A lightweight derived/rollup view, "Working on" tags (e.g. "Madd length", "Ghunnah hold"), is collated from the coach's last 3 notes — see Derived views §4. |
Message
1:1 async thread between a student and their coach. Text or voice, with a weekly cap and a length guard.
| Field | Notes |
|---|---|
id |
|
thread_id |
One thread per (student, coach) pair — persists across coach reassignment for history, but new messages route to the current coach. |
sender_id |
student or coach User.id |
kind |
text | voice |
body_text |
For text |
audio_url / duration_sec |
For voice — coaches specifically asked for voice replies over the (underused, buggy-truncation) text box (FEEDBACK-SYNTHESIS.md coach ask #5). |
sent_at |
|
read_at |
Nullable |
| — | Weekly cap applies to the student's outbound message count per thread per week (mockup shows "2 of 3 messages left this week", SakMessageThread) — configurable count, resets each Week. Length guard: ~800-char warning on text composition (FEEDBACK-SYNTHESIS.md: "~800-char length warning, possible weekly caps"). Both guardrails are soft (warn, don't hard-block, except the weekly cap itself which does block once exhausted). |
Replaces the old community board; this is 1:1 only (both Y1 and Y2) —
one-to-many broadcast is Announcement, below.
Announcement
One-way board post. Replaces the community board (FEEDBACK-SYNTHESIS.md
decision #2).
| Field | Notes |
|---|---|
id |
|
author_id |
Coach or admin User.id, or a system/"QF Team" author. |
title |
|
body |
|
audience |
everyone | level (+ level_value) | year (+ year_value) — audience filters (FEEDBACK-SYNTHESIS.md: "audience filters by year/level"). Rendered as a chip on each row (AnnRow: "Level II", "Everyone"). |
pinned |
Boolean — pinned posts (e.g. break/schedule-change notices) sort to the top (SakAnnouncements). |
posted_at |
|
| — | AnnouncementRead per (user, announcement): read_at, drives the unread dot and the notif-sheet "Announcements · N pinned" badge count (REVIEW.md C1, B4). |
Business rule (REVIEW.md B4): when an announcement duplicates a fact also
pushed as a Notification (e.g. a schedule change), the copy must be
identical in both places — this is a content-authoring constraint, not a
separate field, but worth encoding as a single source-of-truth: the
Notification row of type announcement should reference the
Announcement.id and render its title, not carry independent copy.
Notification
Per-user delivery record. Types match the mockup's NotifRow type map
(sak-schedule.jsx): feedback, session, deadline, announcement.
| Field | Notes |
|---|---|
id |
|
user_id |
|
type |
feedback | session | deadline | announcement |
title / body |
|
ref_id |
Polymorphic FK to the underlying object (Feedback.id, Session.id/AppointmentOccurrence.id, Week.id for a deadline, Announcement.id). Type-announcement rows deep-link to the board, not to inline content (REVIEW.md B4/C1). |
created_at |
|
read_at |
Nullable |
push_sent_at |
Nullable — when the push notification was actually dispatched (may differ from created_at if batched/delayed by reminder-timing rules). |
NotificationPreference (per user, per type): enabled: boolean.
Announcements default ON (REVIEW.md B4 — the one exception; all other
types' defaults are a product decision left to the backend/product team, but
must be explicit per-type toggles, not one master switch, per
FEEDBACK-SYNTHESIS.md's "per-type preferences" wishlist item and the
mockup's SakNotifSheet/settings screens).
Reminder timing (business rules, not schema): 15-min reminder before student
sessions/appointments (SakWeekAtGlance: "Reminders arrive 15 minutes before
each session"; SakAppointmentDetail: "Reminder 15 minutes before · every
week"); coaches get a 30-min reminder (FEEDBACK-SYNTHESIS.md: "15-min
student / 30-min teacher reminders").
TimeLog / Attendance
Auto-derived, not manually entered — retires the "Time Clock Wizard"
(FEEDBACK-SYNTHESIS.md coach ask #4).
| Field | Notes |
|---|---|
id |
|
coach_id |
|
source |
zoom_session | feedback_review — two distinct auto-log paths. |
ref_id |
FK to the Session occurrence or Feedback.id that generated this entry. |
duration_sec |
For zoom_session: derived from the Zoom join-duration webhook. For feedback_review: wall-clock time spent in the coach review screen (Feedback.review_duration_sec; SakCoachReview's footer states the promise — "Your review hours log themselves." — rather than counting the minutes back at her). |
logged_at |
Attendance (derived view over Session/AppointmentOccurrence + Zoom
webhook data): a participant is marked attended if their Zoom join
duration is ≥ 75% of the scheduled duration (FEEDBACK-SYNTHESIS.md coach
ask #4: "auto-log hours/attendance from Zoom duration ≥75%"). This threshold
directly sets AppointmentOccurrence.attendance_status and a parallel
SessionAttendance { user_id, session_occurrence_id, attended: boolean, zoom_duration_pct } record for group sessions — addressing the "100% of the
time currently, we are going off of assumptions" attendance black hole
(FEEDBACK-SYNTHESIS.md top finding #4).
IssueReport
FEEDBACK-SYNTHESIS.md top finding #5: "Report Issue button on every
screen"; SakReportIssue (sak-comms.jsx F4).
| Field | Notes |
|---|---|
id |
|
user_id |
|
kind |
text | voice — "Narrate it instead" is an equal alternative to typing. |
body_text |
For text |
audio_url |
For voice |
screen_context |
Auto-captured: current screen/route name (e.g. "Today"). |
app_version |
Auto-captured, e.g. "2.0.1". |
account_context |
Auto-captured: user id/role snapshot at time of report (for support triage without asking the user to re-explain who they are). |
submitted_at |
|
| — | All context fields are captured automatically; the user only supplies the description (mockup: "Screen, app version and account attach automatically"). |
2. State machines
Account / enrollment lifecycle
Added Jul 26 2026 (product review with Lejla, Jul 24). Governs User.enrollment_status
and decides whether an account sees the entry lane, the Year-1 loop, or the Year-2 loop. A
registered / assessment_booked / awaiting_placement account must never reach the
Year-1 loop (PRODUCT-SPEC "Account status and entry flow").
Revised Jul 27 2026 (Lejla review). Use the corrected table below in place of the Jul-26 one that follows it. The
assessment_booked/awaiting_placement(booked-1:1) path is dropped;level_0is a first-class status (full app minus lessons); a never-submitter is auto-placed; and entry is either checkout or auto-enrollment with auto-billing.
From To Trigger Notes (none) registered→level_0Web checkout on almaghrib.org (new / Y2-elective / repeat), or auto-enrollment with auto-billing (level graduate → next level; Level-4 graduate → Year-2 mandatory first semester). Both are off-app; nobody registers in the app. Auto-enrollees get an email, not a checkout. Sign-in is email + password from the welcome email (Q4). level_0level_0(unchanged)Student sees the full app minus lessons — assigned live classes, level-0 resources, pending recitation shown as a submission awaiting feedback. Not a bare hold. Waiting is student-paced, days to ~3 weeks (Q2). level_0placedA coach places the level from the student's recorded recitation ( FirstAssessment, claimed from the placement pool).year = Y1, level set, onboarding begins. A new student places into a Year-1 level only (Q3).level_0auto_placedStudent never submits; reminder emails go out after the semester starts, then the system auto-places into Level 2. Placement-result screen uses the quieter auto-placed variant (PRODUCT-SPEC Section E). (none) year2Level-4 graduate auto-enrolled into the mandatory first semester (celebratory hype sequence), or returning elective student via checkout (no hype). No assessment, no entry lane. Repeating a year / re-assessment is handled off-app in v1 (Q9).
| From | To | Trigger | Notes |
|---|---|---|---|
| (none) | registered |
Student completes web checkout on almaghrib.org (off-app). | Sales are off-app (no App Store fee). The app is entered via an emailed sign-in link (default pending Lejla (Q4)). |
registered |
assessment_booked |
Student books the first assessment (entry-book-assessment, reusing the appointment slot-grid). |
Creates an Appointment/occurrence for the assessment (default pending Lejla (Q1): a live 1:1 graded against the First Assessment rubric). |
assessment_booked |
awaiting_placement |
The assessment occurs; the coach has not yet placed the student. | Waiting-for-placement held state — no lessons, no recording. Placement promised within 48 hours (default pending Lejla (Q2)). |
awaiting_placement |
placed |
Coach records a placement level. | year becomes Y1, the level is set, onboarding begins. Default pending Lejla (Q3): a new student places into a Year-1 level only, never directly year2. |
| (none) | year2 |
A returning student signs in for a new semester. | Auto-placed into Year 2 — no assessment, no entry lane (default pending Lejla (Q9); repeating a year / re-assessment is handled off-app in v1). |
Submission lifecycle
Recut Aug 18 2026 (U-435) — decided Aug 11 (U-341), missed in the v13 build, and
applied in full in v14. States: unsubmitted → awaiting → inreview → reviewed,
with rejected and revoked as exits. One model for the student app, the
coach app and the admin portal; SubStatusPill (DESIGN-SYSTEM §5) is the only place
labels are decided.
| From | To | Trigger | Notes |
|---|---|---|---|
| (none, unsubmitted) | awaiting |
Student submits audio for an assignment (submitted_at set). |
Starts the coach's 48-hour reply-window clock (SakSubmissionPending: "Feedback within 48 hours"). The waiting time the coach sees counts from this moment, in days plus hours, with no overdue styling (U-404/405/406). |
awaiting |
revoked |
Student takes the recording back before the coach opens it (revoked_at set). |
The take-back has no timer: it lasts until inreview (U-120, superseding the 5–10 minute window). Nothing is owed and the week stays open, so the pill is the quietest in the set. |
awaiting |
inreview |
Coach opens the submission. | The one transition the coach makes by arriving rather than by acting. It locks the student's screen: the take-back is gone, and S25 says so. |
inreview |
reviewed |
Coach completes Feedback (reviewed_at set). |
"Feedback ready" in plum to the student, "Feedback sent" to the coach — visually distinct from the gold waiting pair, as REVIEW.md A2 mandates. Whether the student then listens is listened_at, a receipt, not a further state. |
inreview |
rejected |
Coach returns the recording with a written reason ("Return this recording instead"). | A returned recording never counts against the week and the week stays open (U-342); the student re-records from S60. Returned, not refused — the chip asks without accusing. |
| any | (new Submission) | Student sends again for the same week (after a take-back or a return), or the following week's assignment. | A new Submission row starts its own awaiting state independent of prior weeks. |
No weekly cap (Aug 11 2026, U-176): the two-a-week allowance and everything that
counted it are struck from the product, so attempt_number above is historical.
Retired with this model: heard and practiced, which described the student's study
habit rather than the submission, and the "Mark as heard" action that set one of them.
Appointment lifecycle
Applies to AppointmentOccurrence.attendance_status, with the parent
Appointment.booking_mode/status governing whether occurrences continue
to be generated.
Revised Jul 26 2026 (product review with Lejla, Jul 24): the transitions below that reference the
recurringbooking mode are historical pending Q13 — Year-2 booking is now per week (every appointment effectivelythis_week_only), so in the current model eachAppointmentgenerates a single occurrence rather than a 15-week series. Read therecurringrows as the deferred option, not the default. Everything else (attendance ≥75%, guilt-free cancel, 2-no-show escalation) is unchanged and carries to multi-coach Year-2 weeks (confirm, pending Lejla (Q7)).
| From | To | Trigger | Notes |
|---|---|---|---|
| (none) | booked (Appointment created, status: active) |
Student completes SakBookAppointment: chooses recurring (locks one weekly slot for all 15 weeks) or this_week_only (picks a slot for the current week alone). |
Hybrid model per the teachers' call (FEEDBACK-SYNTHESIS.md), resolving the "midnight slot-camping" / "students skipping weeks when their slot was taken" complaints. |
| booked | scheduled (occurrence generated) |
Weekly job materializes an AppointmentOccurrence for the upcoming Week, inheriting the booked slot time. |
For this_week_only, exactly one occurrence is created and the parent Appointment does not recur further. |
scheduled |
reminder sent | T-minus 15 min (student) / T-minus 30 min (coach). | Notification dispatch, not a stored state — see TimeLog/Notification integration. |
scheduled |
attended |
Zoom join-duration webhook reports ≥75% of the 20-min slot for the student. | Resets consecutive_absence_count to 0. |
scheduled |
no_show |
Slot time passes with join duration < 75% (or no join at all). | Increments consecutive_absence_count. |
scheduled |
cancelled_by_student |
Student taps "Can't make it? Cancel this week" — accepted up to the last minute, no penalty ("guilt-free cancel"). | Frees the slot for other students immediately; does not increment consecutive_absence_count (a cancellation is not an absence). |
scheduled (this occurrence only) |
swapped | Student taps "Swap this week's time" — books a different slot for this week only. | Sets swapped_from_occurrence_id; the recurring Appointment slot is unaffected for future weeks. |
no_show (×2 consecutive) |
escalation | consecutive_absence_count reaches 2. |
Fires the auto email escalation to program staff (FEEDBACK-SYNTHESIS.md: "auto email escalation after 2 consecutive absences") — see Integration points, Email automation. Does not change attendance_status itself; it's a side-effecting rule layered on the counter. |
| booked | Appointment.status: cancelled |
Student (or coach) cancels the recurring booking itself, not just one week. | Stops future occurrence generation; historical occurrences are untouched. |
Post-session survey loop (flagged in FEEDBACK-SYNTHESIS.md as a priority
even ahead of the app) is out of scope for this model's stored entities beyond
triggering off attendance_status transitions — see Backlog note in §5/6.
AppointmentNote lifecycle
Added Jul 26 2026 (product review with Lejla, Jul 24). Governs AppointmentNote.state
— the record → transcribe → auto-summary → coach-edit → send pipeline behind the new
Coach — appointment notes screen. The coach's edit is the approval; there is no second
approver (confirm, pending Lejla (Q12)).
| From | To | Trigger | Notes |
|---|---|---|---|
| (none) | recorded |
Coach records a short debrief after the 1:1 (or types directly, skipping to summarized). |
recording_url set. |
recorded |
summarized |
Transcription + auto-summary produce transcript and draft summary_points. |
System step; the coach has not yet reviewed. |
summarized |
edited |
Coach edits the summary points (and/or attaches a voice note). | edited_by_coach = true. This edit is the approval. |
edited (or summarized) |
sent |
Coach taps "Send to [student]" (sent_at set). |
Makes the note visible on the student's Appointment — teacher's notes screen. A coach may send without editing if the auto-summary is already right. |
3. Business rules table
| Rule | Source |
|---|---|
FEEDBACK-SYNTHESIS.md wishlist requirements; App-Review-Aug11-Lejla.md |
|
SakSubmissionPending is still the single owning screen for the take-back UI. |
REVIEW.md A1; App-Review-Aug11-Lejla.md |
| Practice takes attached to feedback never consume an attempt (there is nothing left to consume). | REVIEW.md C3 |
Submission status vocabulary is exactly awaiting → inreview → reviewed, with rejected and revoked as exits (recut Aug 18 2026, U-435; supersedes in_review → reviewed → heard → practiced). The two waiting states and reviewed must stay visually and textually distinct. |
REVIEW.md A2; v14 register U-435 |
| Coach reply SLA: 48 hours from submission. | SakSubmissionPending copy ("Feedback within 48 hours"); coach queue's stated promise ("Your 48-hour promise holds", SakCoachQueue) |
One coach per student (single assigned coach model); two-coach Y2 model rejected. Revised Jul 26 2026 (Lejla, Jul 24): the assigned coach governs Year-1 review routing only; Year-2 appointments are booked across the team (per-appointment Appointment.coach_id), one shared pool (default pending Lejla (Q11)). The two-coach assignment model stays rejected — a shared booking pool is not multiple assignments. |
REVIEW.md A4, "Out of scope"; flow map (Lejla, Jul 24 2026) |
| Historical/cross-coach notes remain visible even after a coach change or when a substitute covers a session. | REVIEW.md A4; CoachNote entity |
| All times render in the viewing user's own local timezone; never hardcode EST/EDT. | FEEDBACK-SYNTHESIS.md top finding #3; REVIEW.md A5, B3 |
| No per-item timezone suffix in the UI — one zone banner per screen instead. | REVIEW.md B3 |
Hybrid appointment booking: recurring (lock slot for all 15 weeks) or this_week_only. Vocabulary locked to "Recurring" / "This week only." Revised Jul 26 2026 (Lejla, Jul 24): the recurring mode is dropped for Year-2 — appointments book per week (default this_week_only). Reintroducing an optional recurring convenience is open (Q13, pending Lejla). |
FEEDBACK-SYNTHESIS.md decisions; REVIEW.md D1; flow map (Lejla, Jul 24 2026) |
Added Jul 26 2026 (Lejla, Jul 24): Year 2 replaces submissions with appointments — a Year-2 student records no Submission rows at all; the weekly appointment + its AppointmentNote replace the submission + Feedback. |
flow map (Lejla, Jul 24 2026) |
| Added Jul 26 2026 (Lejla, Jul 24): Year-2 appointment frequency — soft cap 3 per week, 1/week baseline, all included in tuition (default pending Lejla (Q5)). Bookable with any coach on the team. | flow map (Lejla, Jul 24 2026) |
| Added Jul 26 2026 (Lejla, Jul 24): a new (web-registered) student is held until placement — never sees the Year-1 loop before a first assessment; placement result within 48 hours of the assessment (default pending Lejla (Q2)). New students place into a Year-1 level only (Q3); returning students auto-place into Year 2 (Q9). | flow map (Lejla, Jul 24 2026) |
| Appointment slots are 20 minutes. | FEEDBACK-SYNTHESIS.md wishlist |
| Cancelling an appointment (even a minute before) is accepted with no penalty and frees the slot. | SakAppointmentDetail (ApptActionRow copy) |
| Swapping a week's appointment time does not affect other weeks. Revised Jul 26 2026 (Lejla, Jul 24): with per-week booking (recurring dropped, Q13), "swap" is simply rebooking that week's slot; there is no recurring slot to preserve. | SakAppointmentDetail; flow map (Lejla, Jul 24 2026) |
| Two consecutive missed appointments trigger an automated email escalation. Revised Jul 26 2026 (Lejla, Jul 24): the no-show / escalation rules and guilt-free last-minute cancellation carry unchanged to multi-coach Year-2 weeks (confirm, pending Lejla (Q7)). | FEEDBACK-SYNTHESIS.md wishlist; flow map (Lejla, Jul 24 2026) |
| Attendance (sessions and appointments) is auto-derived from Zoom join duration ≥75% of scheduled duration — never self-reported. | FEEDBACK-SYNTHESIS.md coach ask #4 |
| Coach hours are logged automatically from feedback-review wall-clock time and Zoom session duration; no manual Time Clock Wizard entry. | FEEDBACK-SYNTHESIS.md coach ask #4; SakCoachReview |
| 1:1 messages: weekly cap on student-sent messages per thread (mockup default 3/week) and an ~800-char length warning on text composition. | FEEDBACK-SYNTHESIS.md decisions |
Announcements replace the community board; are one-way (student cannot reply on the board — must use Message to reach the coach). |
FEEDBACK-SYNTHESIS.md decisions; SakAnnouncements footer copy |
| Announcement audience filters: everyone / level / year. | FEEDBACK-SYNTHESIS.md wishlist |
| Announcements notification toggle defaults ON (the one type that does). | REVIEW.md B4 |
When the same fact is surfaced as both an Announcement and a Notification, the copy must match exactly. |
REVIEW.md B4 |
| Students see content (lessons/levels) up to their current level; coaches see all levels. | FEEDBACK-SYNTHESIS.md wishlist ("Content access") |
| Assessment rubrics (First Assessment, EOC) must be surfaced before a coach begins a review that includes them. | FEEDBACK-SYNTHESIS.md wishlist |
| Recordings are organized in a calendar/week-grouped view, never a flat list. | FEEDBACK-SYNTHESIS.md wishlist; SakScheduleRecordings |
| Report an Issue: screen name, app version, and account context are captured automatically; the user only supplies free-text or voice description. | FEEDBACK-SYNTHESIS.md top finding #5; SakReportIssue |
| Video watch progress ≥90% marks a video "watched"/complete. | SakVideoPlayer (completed = progress >= 90) |
| Pre-semester state: Today shows a countdown + prep checklist instead of an empty/blocked screen. | SakTodayCountdown (F5); FEEDBACK-SYNTHESIS.md wishlist ("semester calendar with pre-semester countdown state") |
| Year-1 accounts never see one-to-one appointment rows/screens (Year-2-only feature). | REVIEW.md C2 |
| A coach's queue defaults to oldest-submission-first, with filters by level and by fewest-submissions-reviewed. | SakCoachQueue (the coaches' own stated ask, FEEDBACK-SYNTHESIS.md "Unified coach workspace") |
4. Derived views the UI needs
Week at a glance (
SakWeekAtGlance): for a given student + week, allSessionoccurrences andAppointmentOccurrences the student is party to, plus the week's submission deadline, grouped by day, in the student's local timezone, each flagged with whether a 15-min reminder is set. Query shape:sessions ∪ appointments ∪ {deadline}filtered tostudent.enrollment ∩ week, sorted bystarts_at_utc, projected intostudent.timezone.Sessions tab — Upcoming (
SakSchedule): same underlying data as Week at a Glance but rendered as a day-by-day timeline for the selected date, with a day-strip covering the current week (± adjacent days). The Sessions tab's calendar-icon action routes to Week at a Glance — same data source, two density levels (REVIEW.md B2 — intentionally not merged into one component, but the spec should document that both read from the same query).Sessions tab — Recordings (
SakScheduleRecordings):SessionRecordingrows for sessions the student was enrolled in, joined toSessionWatchProgress, grouped byWeekdescending (most recent week first), each row showing type chip, title, coach, date, duration, and watched/partial/unwatched state.Coach review queue (
SakCoachQueue): allSubmissions withstatus = in_reviewwherecoach_id = current coach, default sort oldestsubmitted_atfirst, with the exact filter set coaches asked for: "Oldest first" (default), "By level", "Least submitted first" (students with the fewest submissions this term sorted to the top — the coach questionnaire's "filter by number of submissions" ask, surfacing students at risk of disengagement; the label says which end of the list they land at). Each row surfaces: student name, level, week, wait duration, recording duration, and a warm gold wait figure once the submission nears the 48-hour window — a nudge in the ink-and-gold vocabulary, never a red alarm (DESIGN-SYSTEM §4, count badges).Submissions archive (
SakSubmissions, student-facing): all of a student'sSubmissions across all semesters, each row showing week, āyah range, date, andSubStatusPillstatus. The approved design (fb-05) is one flat semester-labelled card, newest week first, with no progressive disclosure — read the older "This month / Earlier, collapse past ~4 rows" description as superseded (Aug 3 2026), along with its summary stat header (count since semester start, count currentlyin_review, progress strip) — the semester eyebrow carries the framing instead. Grouping returns only as a semester header when there is more than one semester to show. Must support multi-semester history (FEEDBACK-SYNTHESIS.mdwishlist: "full multi-semester history").Coach's student record — submission history (
SakCoachStudent): same underlyingSubmissionrows as the student archive, scoped to one student, visible to the assigned coach (and any coach with aCoachNoteon this student)."Working on" tag rollup (
SakCoachStudent): derived, not stored — collate the last 3CoachNote/Feedback.notesentries for a student into short tag phrases (e.g. "Madd length", "Ghunnah hold"). Explicitly called out in the mockup as AI-collated ("from your last 3 notes") — this is the one AI-assist wishlist item beyond feedback summarization that already appears in a screen; treat it as a lightweight summarization call over the same 3-note window, cached per student.Announcements board (
SakAnnouncements): allAnnouncements whereaudience = everyoneOR (audience = levelANDlevel_value = viewer's level) OR (audience = yearANDyear_value = viewer's year), pinned posts first, then reverse-chronological, with filter chips "All / My level / Schedule" (the third filter is a content-category filter, not an audience filter — likely acategoryfield onAnnouncementdistinct fromaudience, worth clarifying with product before implementation).Notifications inbox (
SakNotifSheet): allNotifications for the viewer, with a pinned "Announcements · N pinned" row above an "New · N" unread group, then "Earlier" — grouped byread_at IS NULLthen reverse chronological. Announcement-type rows deep-link to the Announcements board (REVIEW.md B4), not to inline content.Coach schedule (
SakCoachSchedule): the coach's ownSessions andAppointmentOccurrences for a day, each row taggedRecurringorThis week onlyfor 1:1 rows (REVIEW.md D1), with a live "Join" CTA when a session is imminent, in the coach's own timezone (not the student's). ("Join", not "Start" — host controls are undesigned, so the label promises only what the live-class surface delivers.)Recordings calendar (cross-cutting, feeds both
SakScheduleRecordingsand a coach-side equivalent):SessionRecordinggrouped byWeek, the calendar-view requirement from the wishlist rather than a flat reverse-chronological list.
5. Integration points
Zoom
- Every
Sessionoccurrence andAppointmentOccurrencecarries azoom_join_url(per-occurrence, since recurring meetings may rotate links or use a personal-meeting-room pattern — confirm with IT which Zoom account model is in use). - Join-duration webhook: Zoom's participant join/leave events feed the
Attendancederivation (TimeLog/attendance §1) — percentage of scheduled duration attended, threshold ≥75% for "attended." This webhook is the single source of truth for attendance; no manual RSVP/check-in exists in the mockup. - Recording ingestion: Zoom cloud recording completion webhook creates a
SessionRecordingrow and triggers processing (thumbnail, duration). - Embedded Zoom (in-app join without leaving the app) is explicitly
backlog, not in scope (
FEEDBACK-SYNTHESIS.md"Explicit backlog").
- Every
Push notifications
- Dispatches
Notificationrows per user perNotificationPreference(per-type opt-in/out,announcementdefaulting ON). - Timing rules: 15 minutes before student sessions/appointments; 30 minutes
before coach sessions. Deadline reminders keyed to
Week.ends_at(Sunday 11:59 PM, student-local). - Feedback-ready and message-received notifications fire on
Feedbackcreation andMessagecreation respectively.
- Dispatches
Email automation
- Absence escalation: triggered when
AppointmentOccurrence .consecutive_absence_countreaches 2 for a student — sends an email to program staff (recipient list is a program/admin configuration, not modeled here). This was flagged as a priority even ahead of the app itself (FEEDBACK-SYNTHESIS.mddecisions). - Coach 48-hour SLA breach could analogously trigger an internal alert email, though the mockup only surfaces this as a UI promise/flag in the coach queue, not a confirmed email rule — flag for product confirmation.
- Post-session survey loop ("attended? why not?" with AI-aggregated
trends) is called out as a priority in the IT discussion but has no
corresponding mockup screen; model it as a follow-up
Survey/SurveyResponsepair keyed toAppointmentOccurrence/SessionAttendanceif/when scoped — currently backlog.
- Absence escalation: triggered when
Calendar export (.ics)
- "Add to calendar" / "Add the week to my calendar" / "Add start date to
calendar" actions appear on
SakWeekAtGlance,SakAppointmentDetail,SakSessionDetail(QRC), andSakTodayCountdown. Each should generate an.icsVEVENT (or series, for recurringAppointments) using the same UTCstarts_at/durationfields as the in-app schedule, so the exported calendar and the in-app timezone rendering never disagree — the exact bug class the timezone-math fix (REVIEW.md A5) was created to prevent.
- "Add to calendar" / "Add the week to my calendar" / "Add start date to
calendar" actions appear on
Stripe
- A subscription "passport" per student, per the mockup's Profile →
Subscription screens (
AUDIT.mdper-screen log: "Subscription — Passport card", "Manage subscription — Plan card"). - Model as
Subscription { id, user_id, stripe_customer_id, stripe_subscription_id, plan, status, current_period_end }, with the Manage Subscription screen deep-linking to the Stripe customer portal (mockup dropped a duplicate in-app portal link, keeping one path to Stripe — AUDIT.md: "dropped duplicate Stripe portal link at bottom"). - Payment processing and PCI-relevant data stay entirely on Stripe's side; the backend only stores the customer/subscription IDs and cached status/plan for display.
- A subscription "passport" per student, per the mockup's Profile →
Subscription screens (
6. Explicitly out of scope (backlog)
Carried forward from FEEDBACK-SYNTHESIS.md and REVIEW.md so the schema
doesn't over-build for features not yet committed:
- Two-coach model for Year 2 (rejected for now — single assigned coach only).
- 20 appointment credits per semester (make-up sessions) — not modeled; revisit if scheduling capacity demands it.
- AI feedback summarization and AI post-session-survey trend analysis — beyond the "Working on" tag rollup (§4), which is a small, already-scoped exception.
- Student-centric QRC scheduling algorithm (cost-vs-value unresolved).
- Ready-made lesson asset banks for coaches.
- Peer community channel revival.
- Embedded Zoom (in-app playback without leaving the app).
- Program-strategy items (enrollment policy, email SLA, marketing) — not a data-model concern.