convert pdf to md, add OVERVIEW and docs
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
---
|
||||
type: object
|
||||
cluster: identity
|
||||
universe: live
|
||||
status: verified
|
||||
entity: app/models/student.rb
|
||||
---
|
||||
|
||||
# Student
|
||||
|
||||
A `User` with `type: "Student"` — the only user kind that owns a portfolio and can trade.
|
||||
|
||||
Verified 2026-08-16 against commit `63732df`.
|
||||
|
||||
## Why this shape
|
||||
|
||||
Every money path downstream assumes a portfolio exists, so `Student` guarantees one on
|
||||
create rather than letting callers remember (`app/models/student.rb:9,78-80`). Nothing in
|
||||
the trading code null-checks for a missing portfolio because of this hook.
|
||||
|
||||
The class also carries the **roster bridge**. A student's classroom is reachable two ways
|
||||
and `Student` is where they meet: `primary_classroom` prefers the enrollment record and
|
||||
falls back to the legacy `classroom_id` column (`:37-43`). On create it writes both — but
|
||||
`create_initial_enrollment` fires **only if `classroom_id` is present** (`:10,82-86`), so
|
||||
a student created without it has no enrollment either. Read the roster trap in
|
||||
`../../CONTEXT.md` before touching this.
|
||||
|
||||
## Shape
|
||||
|
||||
- STI subclass of [user](user.md); no table of its own
|
||||
- `has_many :classroom_enrollments`, `has_many :classrooms, through:` (`:4-5`)
|
||||
- Callbacks: `set_default_email` forces blank → `nil` (`:8,74-76`);
|
||||
`ensure_portfolio` (`:9,78-80`); `create_initial_enrollment` (`:10,82-86`)
|
||||
- Reads: `current_enrollments` (`:17-19`), `current_classrooms` (`:24-28`),
|
||||
`primary_enrollment` (`:33-35`), `primary_classroom` (`:41-43`)
|
||||
- Writes: `enroll_in!` (`:51-59`), `unenroll_from!` (`:66-70`)
|
||||
|
||||
`enroll_in!` always creates the row with `primary: false` and then promotes it via
|
||||
`make_primary!` (`:52-57`) — the promotion is what enforces one-primary, not the insert.
|
||||
|
||||
## Connected to
|
||||
|
||||
- **owns:** [portfolio](../money/portfolio.md) (guaranteed on create),
|
||||
[order](../trading/order.md)
|
||||
- **owned-by:** [classroom](../org/classroom.md) — twice over, see Why
|
||||
- **joins:** [classroom-enrollment](../org/classroom-enrollment.md)
|
||||
- **looks-like-but-is-not:** `student.classrooms` (through enrollments) is **not**
|
||||
`student.classroom` (the `classroom_id` column). They can disagree.
|
||||
|
||||
## If you change this
|
||||
|
||||
- **Hits:** [classroom-enrollment](../org/classroom-enrollment.md) and
|
||||
[classroom](../org/classroom.md) — both rosters; [portfolio](../money/portfolio.md) if
|
||||
you touch `ensure_portfolio`; `Admin::StudentsController` and `StudentsController`;
|
||||
the [import-students](../../processes/import-students.md) movement, which creates
|
||||
students by this exact path.
|
||||
- **Does not hit:** [teacher](teacher.md). Same table, but no shared callbacks — `Teacher`
|
||||
runs `sync_username_from_email` instead and shares none of the hooks above.
|
||||
|
||||
## Surfaces
|
||||
|
||||
| Surface | Role |
|
||||
|---|---|
|
||||
| `StudentsController` (nested under classroom) | teacher creates/edits, resets passwords |
|
||||
| `Admin::StudentsController` | admin CRUD, CSV import, restore, manual transactions |
|
||||
| `ImportStudentService` | writes |
|
||||
| student's own portfolio + orders pages | reads |
|
||||
|
||||
## See
|
||||
|
||||
- Source: `app/models/student.rb`
|
||||
- Base class: [user](user.md)
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
type: object
|
||||
cluster: identity
|
||||
universe: live
|
||||
status: verified
|
||||
entity: app/models/teacher.rb
|
||||
---
|
||||
|
||||
# Teacher
|
||||
|
||||
A `User` with `type: "Teacher"` — runs classrooms and gradebooks. Owns no portfolio and
|
||||
cannot trade.
|
||||
|
||||
Verified 2026-08-16 against commit `63732df`.
|
||||
|
||||
## Why this shape
|
||||
|
||||
Login is by `username` app-wide, but teachers think in email addresses. Rather than
|
||||
splitting the auth key, `Teacher` **copies email into username** on every validation
|
||||
(`app/models/teacher.rb:9,17-19`). So a teacher's username is their email, kept in sync
|
||||
automatically — change the email and the login changes with it. This is the exact inverse
|
||||
of [student](student.md), whose username is assigned and whose email is usually `nil`.
|
||||
|
||||
`attr_accessor :school_id` (`:4`) is a form-only field. It is **not a column and not
|
||||
persisted** — a teacher reaches a school only through classrooms.
|
||||
|
||||
## Shape
|
||||
|
||||
- STI subclass of [user](user.md); no table of its own
|
||||
- `has_many :teacher_classrooms`, `has_many :classrooms, through:` (`:6-7`)
|
||||
- `before_validation :sync_username_from_email` (`:9`)
|
||||
- `display_name` prefers `name`, then the email local-part (`:11-13`)
|
||||
- Join table `teacher_classrooms` — unique on `[teacher_id, classroom_id]`
|
||||
(`db/schema.rb:368`). No behaviour of its own, so it has no card.
|
||||
|
||||
## Connected to
|
||||
|
||||
- **owns:** [classroom](../org/classroom.md) (through `teacher_classrooms`)
|
||||
- **owned-by:** —
|
||||
- **joins:** `teacher_classrooms`
|
||||
- **looks-like-but-is-not:** a teacher is not an admin. Admin is a boolean on `users`;
|
||||
`teacher_or_admin?` (`app/models/user.rb:53-55`) exists precisely because the two are
|
||||
independent and often both true.
|
||||
|
||||
## If you change this
|
||||
|
||||
- **Hits:** sign-in for every teacher if you touch `sync_username_from_email` — it
|
||||
rewrites `username`, the auth key; `Order.for_teacher`
|
||||
(`app/models/order.rb:40-42`), which scopes orders through
|
||||
`users.classroom_id`, **not** through `teacher_classrooms`;
|
||||
`Admin::Teachers::DeactivationsController` / `ReactivationsController`.
|
||||
- **Does not hit:** [portfolio](../money/portfolio.md). `Portfolio` validates that its
|
||||
user is a student (`app/models/portfolio.rb:102-104`), so no teacher change can create
|
||||
or affect one.
|
||||
|
||||
## Surfaces
|
||||
|
||||
| Surface | Role |
|
||||
|---|---|
|
||||
| `Admin::TeachersController` | admin CRUD |
|
||||
| `Admin::Teachers::DeactivationsController` / `ReactivationsController` | discard / restore |
|
||||
| `ClassroomsController`, `GradeBooksController` | authorizes as teacher |
|
||||
|
||||
## See
|
||||
|
||||
- Source: `app/models/teacher.rb`
|
||||
- Base class: [user](user.md)
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
type: object
|
||||
cluster: identity
|
||||
universe: live
|
||||
status: verified
|
||||
entity: app/models/user.rb
|
||||
---
|
||||
|
||||
# User
|
||||
|
||||
Every human in the app. STI base class for `Student` and `Teacher` — but **admin is a
|
||||
boolean column on this table, not a subclass**.
|
||||
|
||||
Verified 2026-08-16 against commit `63732df`.
|
||||
|
||||
## Why this shape
|
||||
|
||||
The users are middle-school students, so **email cannot be the login**. Devise is
|
||||
reconfigured to authenticate on `username` (`config/initializers/devise.rb:49`), email is
|
||||
optional, and its uniqueness index is partial — it applies only where email is non-null
|
||||
and non-empty (`db/schema.rb:388`), so any number of students can have no email at all.
|
||||
`Student` actively forces blank email back to `nil` to stay inside that index
|
||||
(`app/models/student.rb:74-76`).
|
||||
|
||||
Hard deletes are blocked because a user owns a financial ledger. `destroy` and `destroy!`
|
||||
are overridden to `discard`, and outside production they *raise* rather than silently
|
||||
soft-delete (`app/models/user.rb:6-14,75-82`). `really_destroy!` is the deliberate escape
|
||||
hatch (`:16-18`).
|
||||
|
||||
## Shape
|
||||
|
||||
- Table `users`, `db/schema.rb:372-391`
|
||||
- `type` — `"User" | "Student" | "Teacher"`, validated at `app/models/user.rb:39`
|
||||
- `admin` — boolean, default false (`db/schema.rb:373`); scope at `:43`
|
||||
- `username` — `null: false`, unique index, the login key (`db/schema.rb:385,390`)
|
||||
- `email` — nullable, partial unique index (`db/schema.rb:388`); required only for
|
||||
teachers and admins (`app/models/user.rb:61-63`)
|
||||
- `discarded_at` — soft delete via `Discard::Model` (`app/models/user.rb:4`)
|
||||
- `classroom_id` — direct membership. See the roster trap in `../../CONTEXT.md`
|
||||
|
||||
`email_changed?` is hard-coded to `false` (`app/models/user.rb:65-67`), which suppresses
|
||||
Devise's reconfirmation path. The code wins over the method name — it is not a real
|
||||
dirty-check.
|
||||
|
||||
## Connected to
|
||||
|
||||
- **owns:** [portfolio](../money/portfolio.md) (`has_one`, students only),
|
||||
[order](../trading/order.md) (`has_many`)
|
||||
- **owned-by:** [classroom](../org/classroom.md) (`belongs_to`, optional)
|
||||
- **joins:** [classroom-enrollment](../org/classroom-enrollment.md) as `Student`,
|
||||
`teacher_classrooms` as `Teacher`
|
||||
- **looks-like-but-is-not:** `admin` is not an STI type — there is no `Admin` class.
|
||||
A `Teacher` with `admin: true` is one row, not two.
|
||||
|
||||
## If you change this
|
||||
|
||||
- **Hits:** [student](student.md) and [teacher](teacher.md) (same table);
|
||||
[portfolio](../money/portfolio.md) — `Portfolio` validates its user is a student
|
||||
(`app/models/portfolio.rb:102-104`); every Pundit policy, which branches on
|
||||
`user.admin?` / `user.student?` (`app/policies/application_policy.rb:39-53`);
|
||||
Devise sign-in if you touch `username` or `email` nullability.
|
||||
- **Does not hit:** [portfolio-transaction](../money/portfolio-transaction.md). It hangs
|
||||
off `Portfolio`, not `User` — discarding a user leaves the ledger fully intact and
|
||||
still summable. That is deliberate, not an oversight.
|
||||
|
||||
## Surfaces
|
||||
|
||||
| Surface | Role |
|
||||
|---|---|
|
||||
| Devise controllers | reads (sign-in by username) |
|
||||
| `Admin::UsersController`, `Admin::StudentsController`, `Admin::TeachersController` | read/write |
|
||||
| `StudentsController` (nested under classrooms, teacher-facing) | read/write |
|
||||
| `ApplicationController#authenticate_user!` | reads every request |
|
||||
|
||||
## See
|
||||
|
||||
- Source: `app/models/user.rb`, `db/schema.rb:372-391`
|
||||
- Login config: `config/initializers/devise.rb:49`
|
||||
Reference in New Issue
Block a user