convert pdf to md, add OVERVIEW and docs

This commit is contained in:
2026-08-17 22:30:18 +00:00
parent 95d5787868
commit 4df62d2609
44 changed files with 3307 additions and 1 deletions

View File

@@ -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`