Users & Profiles
One identity table, five profile types, a type discriminator — and where to read about the profiles, their cross-type links, and the user lifecycle.
Every person who can log in — a teacher, a student, a parent, a walk-in applicant, a platform operator — is, at the database level, two rows: one UserEntity that holds their identity and login secrets, and one profile row that holds everything type-specific. The UserEntity is deliberately thin. It knows your email, your password hash (or Microsoft sub), whether you're active, when you last logged in — and a single type column that says which kind of person you are. That type is the hinge the whole module swings on.
Here's the mental model. The user table is the trunk; the five profile tables are branches. A staff member's pedagogy, department, and campus live on StaffProfileEntity; a guardian's phone and the campuses their children attend live on GuardianProfileEntity. The UserEntity.type enum (student, staff, guardian, guest, platform_admin) is the discriminator that tells you which branch to follow. Almost every operation in this module is some variation of "read the type, pick the right profile."
Why split identity from profile at all?
Auth code (login, token refresh, /auth/me) only ever needs the UserEntity — email, hash, isActive. Keeping that small and uniform means the login path never has to know or care whether you're a student or a teacher. The type-specific bulk only loads when something actually needs it. That separation is also why the two rows are lazy-linked — loading a user does not drag a profile along.
The identity table
Start with the trunk. UserEntity maps to the user table and owns identity and auth — and nothing type-specific. Note the five lazy @OneToOne relations at the bottom: one branch per type.
@Entity('user')
@TenantScoped()
@WithTimestamps()
export class UserEntity extends DatabaseEntity implements User {
@Column({ unique: true }) email: string;
@Column({ type: 'enum', enum: UserType }) type: UserType; // ← the discriminator
/** Owning school (tenant). NULL for platform admins, who sit above all schools. */
@Column({ name: 'school_id', type: 'uuid', nullable: true }) schoolId?: string;
@Column({ name: 'user_auth_provider', type: 'enum', enum: UserAuthProvider }) authProvider: UserAuthProvider;
@Column({ name: 'microsoft_sub', nullable: true, unique: true }) microsoftSub?: string;
@Column({ name: 'password_hash', nullable: true }) passwordHash?: string;
@Column({ name: 'is_active', default: true }) isActive: boolean;
// ... emailVerified, lastLoginAt
@OneToOne('StaffProfileEntity', 'user', { lazy: true }) staffProfile?: Promise<StaffProfileEntity>;
@OneToOne('StudentProfileEntity', 'user', { lazy: true }) studentProfile?: Promise<StudentProfileEntity>;
@OneToOne('GuardianProfileEntity', 'user', { lazy: true }) guardianProfile?: Promise<GuardianProfileEntity>;
@OneToOne('GuestProfileEntity', 'user', { lazy: true }) guestProfile?: Promise<GuestProfileEntity>;
@OneToOne('PlatformAdminProfileEntity', 'user', { lazy: true }) platformAdminProfile?: Promise<PlatformAdminProfileEntity>;
}Two design decisions are doing heavy lifting here. First, @TenantScoped() plus a nullable school_id column opt the table into automatic tenant filtering — but nullable so a platform admin can have school_id = null and the engine simply lets them through. Second, every profile relation is { lazy: true }, which in TypeORM means the property type is a Promise, not the entity. You don't get a profile by reading user.staffProfile — you get a promise you have to await. That single fact drives most of the gotchas on the lifecycle page.
The discriminator is an enum, not a class hierarchy
There's no TypeORM single-table-inheritance discriminator column magic here. The type enum is a plain column and the code branches on it explicitly with switch/lookup objects. That's a deliberate choice: it keeps each profile in its own table with its own columns, indexes, and migrations, instead of one giant sparse table.
Tenant asymmetry
The four school-bound types — staff, student, guardian, guest — each carry a schoolId stamped by the tenant engine, and every query against their tables is auto-filtered to the active school. The fifth type, platform_admin, is the odd one out: it is school-less by design (schoolId = null) and sits above all tenants. That asymmetry is the source of most of the gotchas. Multi-Tenancy covers the stamping engine; RBAC & Scopes covers how each type's abilities are derived.
Where to go next
The Five Profiles
Staff, student, guardian, guest, and the school-less platform admin — their fields and shared spine.
Links & Access Codes
Guardian-student and staff-department join tables, plus the prefixed access codes.
User Lifecycle
The transactional create flow, suspend, soft-delete cascade, seeding, and the gotchas.
RBAC & Scopes
How each user type's abilities are derived — and why platform admins are hardcoded.
Querying & Pagination
How list endpoints read data, extend the query per request, and page results with the cursor-based pagination helper.
The Five Profiles
Staff, student, guardian, guest, and platform admin — each its own table, the shared spine they follow by convention, and the school-less exception.