Skip to content

Classes: sections + shifts vocabulary, one teacher per subject, /class alias - #231

Merged
hatafatif merged 2 commits into
developfrom
bd-2720-08207
Aug 14, 2026
Merged

Classes: sections + shifts vocabulary, one teacher per subject, /class alias#231
hatafatif merged 2 commits into
developfrom
bd-2720-08207

Conversation

@hatafatif

Copy link
Copy Markdown
Collaborator

Follow-up to #230, on the same branch. Three rules from the operator, plus two bugs they exposed and one reported from staging.

The rules

  1. Sections are a closed set (A–E). The one axis still free text. Because section is part of class identity, a spelling variance doesn't just mislabel — it splits a class and divides the roster. A table, not a CHECK, because "ask NIETE support to add one" has to be cheap; a CHECK makes it a deploy, so the answer becomes "no" in practice.
  2. Shift is a new axis of identity. Morning and evening are different classes with different students and teachers. Identity is now (school, grade, section, shift, session). shift_code is NOT NULL defaulting to morning — a nullable shift is an "unspecified" third value that merges with neither.
  3. One teacher per subject per class. Needed the class on the subject row, so class_id is denormalised onto class_teacher_subjects to carry a real unique index. An application-level check would let two simultaneous submissions both pass.

Bugs these exposed

  • The mirror merged distinct classes. The legacy roster name came from the grade alone (Grade 9), and student_lists is unique on (user_id, LOWER(class_name), academic_year) — so one teacher's 4-A and 4-B collided and the second silently adopted the first's roster. Shift would have made it worse. The name now carries section and shift. Consequence faced squarely: adoption now matches only rows this code wrote; a hand-named roster is left alone rather than claimed on a fuzzy match, because claiming it would move someone else's students.
  • A declined claim cost her the work. Reported earlier for the class-teacher role; the same shape applies to subjects. A subject a colleague already teaches is now declined individually — the free subjects are still assigned and subjectsTaken names what wasn't. The internal API's 409 is gone: the class was saved, and a 409 lost the work and read as "nothing happened".

Also fixed

/classes worked and /class didn't. The rule now lives in class-command.js with its near misses as tests: slash forms match as a prefix, plain words only as the whole message — so "my class is too noisy" still reaches the assistant instead of opening a form, attendance/roll call/حاضری stay with the attendance router, and "Classroom Management" (a real course title) doesn't hijack.

Surfaces

The section TextInput becomes a Dropdown on both WhatsApp and the portal — free text is now refused by the database, so a text box was an error loop rather than a nicety. Shift joins both forms. Helper text names the support route, because a closed set without a stated escape hatch is a dead end.

Verified on staging, not asserted

V1.1.4 applied to the staging database (separate Supabase project; the runner asserts the staging ref and hard-stops on production's). Then, against that live database:

Check Result
section F / ALPHA refused (409)
shift night refused (409)
evening 8-D alongside morning 8-D accepted — distinct class
duplicate morning 8-D refused (409)
two teachers on one class allowed
teacher B claiming Maths (A has it) refused
teacher B claiming Urdu allowed
same subject in the evening class allowed

Every test row cleaned up; zero orphan subject rows.

Tests

302 in the model suite + 13 portal, all green; tsc --noEmit clean. Full run: 31 failing suites against develop's 32 — zero newly failing, one improved.

Sequencing note

The Flow JSON changed, so it needs a re-upload and republish after this deploys — register-all-flows skips flows that already exist, so the registrar alone won't pick it up. Doing that after the deploy lands, since Meta probes the endpoint on publish.

🤖 Generated with Claude Code

hatafatif and others added 2 commits August 14, 2026 18:28
…r costs the class

TWO BUGS, both found by using it rather than reading it.

1. `/classes` opened the manager and `/class` did not. Rather than appending one
   more alternative to an inline regex — which is how that gap appeared — the rule
   now lives in class-command.js with its near misses as tests.

   The rule: slash forms match as a PREFIX (`/class`, `/classes`, `/classes
   please`); plain words match only as the WHOLE message (`class`, `my classes`,
   `add a class`, `show my classes`, plus Urdu جماعت / جماعتیں / jamaat).

   The whole-message restriction is the load-bearing half. "my class is too noisy"
   is a teacher talking, and answering it with a form is a hijack — she asked a
   question and got a spreadsheet. Two collisions the tests pin: the attendance
   router is checked LATER in the handler, so `attendance` / `roll call` / حاضری
   must never match here; and "Classroom Management" is a real training course
   title, so the pattern anchors on the word and never the substring (which also
   rules out `classy`, `subclass`, `first class`).

2. A teacher joining a class a COLLEAGUE already created, while ticking "I am the
   class teacher", got NOTHING: no class_teachers row, her chosen subject
   discarded, the class absent from her list — and a stray legacy mirror row
   written for a class she was not assigned to. She would reasonably conclude the
   feature was broken.

   Found by creating the same class as two different teachers against staging, not
   by inspection. assignTeacher() returned early on the role conflict, before
   creating her assignment.

   A REFUSED ROLE MUST NOT COST HER THE CLASS. The role is now simply declined —
   she is assigned as a subject teacher, her subjects are kept, the original class
   teacher stays put — and `classTeacherTaken` tells the caller what to say. The
   invariant is unchanged: still exactly one class teacher per class, still
   enforced by the partial unique index.

   The earlier test asserting `error: 'class_teacher_exists'` encoded the old
   contract and is updated to assert the new one, keeping the invariant it cared
   about.

TESTS  274 across the model, all green (52 new for the command rule, 5 for the
join-a-colleague's-class case).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… subject

Three rules from the operator, and two bugs they exposed.

RULE 1 — SECTIONS ARE A CLOSED SET (A-E). They were the one axis still free text,
which is the same disease grades and subjects were just cured of — except worse.
Section is part of class IDENTITY, so a spelling variance does not merely mislabel:
it SPLITS a class in two, with the roster divided between them. A table rather than
a CHECK because "ask NIETE support to add one" has to be cheap; a CHECK would make
it a migration and a deploy, so the answer would become "no" in practice.

RULE 2 — SHIFT IS A NEW AXIS OF IDENTITY. Morning and evening are different classes
with different students and teachers, so identity is now
(school, grade, section, shift, session). shift_code is NOT NULL defaulting to
morning: a nullable shift would be an "unspecified" third value that merges with
neither.

RULE 3 — ONE TEACHER PER SUBJECT PER CLASS. Two teachers could both be recorded
teaching Maths to 4-A. Enforcing it needed the class ON the subject row, so class_id
is denormalised onto class_teacher_subjects to carry a real unique index — an
application check would let two simultaneous submissions both pass and both insert.

BUG 1 — THE MIRROR MERGED DISTINCT CLASSES. The legacy roster name came from the
GRADE alone ("Grade 9"), and student_lists is unique on
(user_id, LOWER(class_name), academic_year). So one teacher's 4-A and 4-B collided
and the second class silently ADOPTED the first's roster. Shift would have made it
worse. The mirror name now carries section and shift.

  Consequence, faced squarely: adoption now matches only rows this code wrote. A
  roster the teacher hand-named ("4th grade morning") is left ALONE rather than
  claimed on a fuzzy match — claiming it would silently move someone else's
  students. Tested both ways.

BUG 2 — A DECLINED CLAIM COST HER THE WORK. Following the class-teacher fix, a
subject a colleague already teaches is now declined INDIVIDUALLY: the free subjects
are still assigned, and `subjectsTaken` names what was not. This deliberately
differs from `unknown_subject`, which stays all-or-nothing — that is a caller bug,
whereas this is a legitimate clash between colleagues. The internal API's 409 is
gone: the class WAS saved, and a 409 lost the work and read as "nothing happened".

SURFACES  The section TextInput becomes a Dropdown on both WhatsApp and the portal —
free text would now be REFUSED by the database, so a text box was an error loop, not
a nicety. Shift joins both forms. The helper text names the support route, because a
closed set without a stated escape hatch is a dead end. SAVED and the portal toast
now confirm the save AND name any declined claim, additively.

VERIFIED ON STAGING, not asserted: section F and ALPHA refused, shift "night"
refused, evening 8-D accepted as a distinct class, duplicate morning 8-D refused,
two teachers on one class allowed, teacher B refused Maths but granted Urdu, and the
same subject accepted in the evening class. Every test row cleaned up, no orphans.

TESTS  302 in the model suite + 13 portal, all green. tsc clean. Full run: 31
failing suites against develop's 32 — zero newly failing, one improved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hatafatif
hatafatif merged commit ebc4303 into develop Aug 14, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant