# Classrooms, enrolment, and roles

How AGS Vocab organises students into classes, how they join, and how
roles determine what each user can do.

> Phase 1 complete: schema in place, QR-driven join flow live, role
> gates enforced. Self-service signup (Phase 2-equivalent) and richer
> teacher workflows (assignments, attendance) come later.

---

## 1. The three-level role model

There are exactly four roles in the system, organised by **scope** — the
level at which the role applies:

| Role | Scope | Lives in |
|---|---|---|
| `super_admin` | **System** (cross-school) | `users.global_role_id` |
| `school_admin` | **School** (per-tenant) | `school_users.role_id` |
| `teacher` | **Classroom** (per-class) | `enrolments.role_id` |
| `learner` | **Classroom** (per-class) | `enrolments.role_id` |

### Key invariant: teacher/learner are PER CLASSROOM

A user can simultaneously hold `teacher` role in one classroom and
`learner` role in another. Concretely:

- A senior student who tutors juniors in vocab → `learner` in their own
  classes + `teacher` in the tutoring class
- A teacher enrolled in a colleague's professional development class →
  `teacher` in their own + `learner` in the PD class

This is why the role isn't on `school_users` but on `enrolments`.
School-level membership (`school_users`) only carries `role_id` when the
user is a `school_admin`; otherwise the column is NULL ("ordinary
school member").

### What each role can do (the capability matrix)

| Capability | super_admin | school_admin | teacher (in this school) | learner |
|---|---|---|---|---|
| Access /admin/{slug} | ✓ | ✓ | ✓ | ✗ |
| View all student data | ✓ | ✓ | ✓ | own only |
| View all classrooms | ✓ | ✓ | ✓ | enrolled only |
| Create classrooms | ✓ | ✓ | ✓ | ✗ |
| Edit own classroom | ✓ | ✓ | ✓ | ✗ |
| Edit another teacher's classroom | ✓ | ✓ | ✗ | ✗ |
| Add / remove students from school | ✓ | ✓ | ✗ | ✗ |
| Edit words / questions | ✓ | ✓ | view-only | ✗ |
| Edit configs (branding, SMTP) | ✓ | ✓ | ✗ | ✗ |
| View reports / export data | ✓ | ✓ | ✓ | ✗ |
| Take tests (Flutter app) | ✓ | ✓ | ✓ | ✓ |

Implemented as Laravel Gates in
`app/Providers/TenancyAuthServiceProvider.php`. Filament Resources
auto-apply policies (e.g. `app/Policies/ClassroomPolicy.php`).

---

## 2. Schema

### `classrooms`

The teaching group within a school.

| Column | Purpose |
|---|---|
| `id` | PK |
| `school_id` | FK schools (cascade delete) |
| `name` | "7A", "Mrs Lim Y7 English", "Reading Buddies" |
| `grade_level_code` | Optional Vocabile band hint (K, G7, …) — drives analytics norm overlay |
| `academic_year` | e.g. 2026; year-rollover creates next year's class with same students |
| `subject` | Optional ("English", "Vocabulary"). NULL = homeroom-style |
| `join_code` | UNIQUE per school. Format: `XXX-XXXX` (3 alpha + 4 alphanumeric, no confusing chars 0/1/I/L/O) |
| `join_code_enabled` | bool — teacher can pause joins once roster is locked |
| `join_code_expires_at` | optional time-limited codes |
| `status` | active / archived |
| `meta` | JSON catch-all |

Soft-deletes enabled so an archived classroom can be restored.

### `enrolments`

Pivot between user and classroom, with a **per-classroom role**.

| Column | Purpose |
|---|---|
| `school_id` | denormalised from classroom for fast tenancy scoping |
| `classroom_id` | FK |
| `user_id` | FK |
| `role_id` | FK roles — `teacher` or `learner` |
| `status` | pending / active / inactive / withdrawn |
| `invited_by`, `enrolled_at`, `withdrawn_at` | onboarding audit |

UNIQUE on `(classroom_id, user_id)` — one role per (user, classroom)
pair. To switch a user from learner to teacher in the same class,
update the existing enrolment row.

### `roles`

A simple catalog of 4 rows: super_admin, school_admin, teacher,
learner. `scope` column (`system | school | classroom`) is
documentation only — the same `teacher` row is referenced from both
`enrolments.role_id` AND (in principle) school-level memberships.

---

## 3. The QR-driven join flow

### 3.1 What the teacher does

1. **Sign in** to `/admin/{school-slug}` as school_admin or teacher
2. **Create** a classroom (User Management → Classrooms → New classroom)
3. **"Show join QR"** action — opens a modal with the QR + the plain-text code + the join URL
4. **"Print poster"** action — opens an A4-printable poster (the school's logo + class name + QR + code + instructions) ready for Ctrl-P
5. Display the QR / poster in the classroom; share the URL via email if remote

### 3.2 What the student does

1. **Scan** the QR with their phone camera → opens `https://{school}.agsvocab.com/join/{CODE}`
2. **Or** type the code manually at `https://{school}.agsvocab.com/join`
3. **Enter email or phone** → server sends a 6-digit OTP
4. **Enter the code** → user is created (if new) and enrolment row is written
5. Land on a "🎉 You're in!" page; from there, open the AGS Vocab app to start practising

### 3.3 What the server does (the four flows)

| Scenario | What happens |
|---|---|
| **Flow A — new student** | Subdomain resolves to school (e.g. Bayview) → /join/{code} resolves to a Classroom in Bayview → OTP → user created with school_id=Bayview → school_users row added → enrolment row added |
| **Flow B — existing student, same school** | User already in school_users; just create enrolment row |
| **Flow C — existing student, different school** | User has school_users at Acme; scanning Bayview's QR adds them to Bayview's school_users + enrolment. They now belong to both. Their Acme history stays intact. |
| **Flow D — no camera (manual entry)** | Goes to /join → types code → identical flow from step 3 of Flow A |

### 3.4 Code lifecycle

- **Generation**: auto on classroom creation. Format `XXX-XXXX`, no confusing characters. ~ 2 billion combinations per school — unguessable.
- **Regeneration**: one-click action in the classroom row. Invalidates the old code; existing enrolments stay.
- **Pause**: toggle `join_code_enabled` to stop new joins without changing the code.
- **Expiry**: optional `join_code_expires_at` for one-period-only codes.

### 3.5 Failure modes

| Failure | Status | What the learner sees |
|---|---|---|
| Code doesn't exist (or belongs to another school) | 404 | "That class code doesn't exist or belongs to a different school." |
| Code is disabled | 410 | "This class code is no longer accepting new students. Ask your teacher for a new code." |
| Code has expired | 410 | Same as above |
| Classroom archived | 404 | Same as #1 |
| School is suspended | 403 | "This school account is currently inactive." (from middleware) |

---

## 4. Teacher visibility (the per-classroom permission story)

Reflecting the user's design decision: **a user is granted teacher-level
admin capabilities for a school the moment they have ANY active
teacher enrolment in any classroom of that school**. So:

```
Mrs Lim is a teacher of 7A in Bayview.
→ She has enrolments.role = teacher for classroom 7A.
→ User::isTeacherIn(Bayview) returns true.
→ Gate "view-all-students" passes when current_school = Bayview.
→ Filament admin shows her ALL students at Bayview, not just 7A.
```

The cross-classroom edit restriction (Mrs Lim can edit 7A but not 7B
unless she's also a teacher there) is enforced via the
`edit-classroom` gate which takes the Classroom as an argument.

---

## 5. Implementation pointers

| Component | File |
|---|---|
| Models | `app/Models/Classroom.php`, `app/Models/Enrolment.php`, `app/Models/Role.php` |
| Migrations | `2026_05_25_000002…_create_roles_table.php`, `2026_05_25_000003…_add_role_ids_and_backfill.php`, `2026_05_25_000004…_create_classrooms_and_enrolments_tables.php` |
| QR generation | `app/Services/Classrooms/QrCodeService.php` (uses `endroid/qr-code` v6) |
| Filament resource | `app/Filament/Admin/Resources/Classrooms/` |
| Public join controller | `app/Http/Controllers/PublicJoinController.php` |
| Join blade views | `resources/views/public/join-*.blade.php` (manual, landing, verify, welcome) |
| Gates | `app/Providers/TenancyAuthServiceProvider.php` |
| Classroom policy | `app/Policies/ClassroomPolicy.php` |
| Admin-edit-only trait | `app/Filament/Concerns/AdminEditOnly.php` (apply to Word, Question, Config resources) |
| Tests | `tests/Feature/ClassroomEnrolmentTest.php` (10 tests, 32 assertions) |

---

## 6. What's NOT here yet (Phase 2 and beyond)

- **CSV roster import** — teachers manually scan students in via QR today. Bulk import (drop a CSV from the SIS) lands in Phase 2.
- **Assignments** — "Mrs Lim assigns 7A a Diagnostic by Friday" requires the `classroom_assignments` table sketched in Phase 1 planning. Not built.
- **Teacher-side analytics views** — per-class roster + averages + word-struggle aggregates land in Phase 2 (tasks #56-65).
- **Email/SMS invites by the teacher** — currently the teacher shares the QR. Invite-by-email would need an `invitations` table + transactional mail templates.
- **Year rollover** — copying a classroom roster forward into next academic year is currently a manual operation. A "promote class" admin action could automate it.
- **Class join code customisation** — codes are auto-generated. Vanity codes ("YEAR7A-ENGLISH") could be supported with a uniqueness rule.
- **Subject-aware curriculum binding** — currently `subject` is a free-text field. A real subjects taxonomy would let cross-product analytics (vocab + math) tie together.

---

## 7. Useful queries

For developers writing analytics or admin tooling against the schema:

```sql
-- All teachers in school X
SELECT DISTINCT u.*
FROM users u
JOIN enrolments e ON e.user_id = u.id
JOIN classrooms c ON c.id = e.classroom_id
JOIN roles r ON r.id = e.role_id
WHERE c.school_id = ? AND r.code = 'teacher' AND e.status = 'active';

-- All students enrolled in classroom Y
SELECT u.*
FROM users u
JOIN enrolments e ON e.user_id = u.id
JOIN roles r ON r.id = e.role_id
WHERE e.classroom_id = ? AND r.code = 'learner' AND e.status = 'active';

-- "Show me Mrs Lim's classes" (where she is the teacher)
SELECT c.*
FROM classrooms c
JOIN enrolments e ON e.classroom_id = c.id
JOIN roles r ON r.id = e.role_id
WHERE e.user_id = ? AND r.code = 'teacher' AND e.status = 'active' AND c.status = 'active';
```
