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