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.
Each UserEntity.type points to exactly one profile entity, and each profile is its own table. The four school-bound profiles share a spine by convention; the fifth — the platform admin — breaks every rule on purpose. This page walks the shapes and what makes each one different.
The shared spine
The four school-bound profiles — staff, student, guardian, guest — share a set of columns by convention, not by a base class: firstName, lastName, otherNames, accessCode, userId, schoolId, needsPasswordChange, isActive, and the suspend trio suspendedAt / suspendedBy / suspensionReason. Here's the staff profile, the richest of them, trimmed to that spine plus its own columns.
@Entity('staff_profile')
@TenantScoped()
@WithTimestamps()
export class StaffProfileEntity extends DatabaseEntity implements StaffProfile {
@Column({ name: 'school_id', type: 'uuid', nullable: true }) schoolId?: string;
@Column({ name: 'first_name' }) firstName: string;
@Column({ name: 'last_name' }) lastName: string;
@Column({ name: 'access_code', unique: true }) accessCode: string;
@Column({ name: 'user_id', type: 'uuid', unique: true }) userId: string;
@Column({ name: 'staff_id', nullable: true, unique: true }) staffId?: string;
@Column({ name: 'campus_id', type: 'uuid', nullable: true }) campusId?: string;
// ... department, address, profileImage, suspend fields
@OneToOne(() => UserEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: UserEntity; // ← the back-reference to identity
}The user_id column is unique, which enforces the one-to-one at the database level: a user has at most one staff profile. The onDelete: 'CASCADE' on the relation means if the user row is hard-deleted, the profile goes with it — though in practice the app soft-deletes, which it handles in an explicit transaction (see User Lifecycle).
Student, guardian, guest
The other three school-bound profiles share the spine but vary the body. The differences worth remembering:
| Profile | Own fields beyond the spine | Notable |
|---|---|---|
student_profile | studentId, enrollmentNo, campusId, enrolledAt, admissionApplicationId | studentId is a second generated slug; enrolledAt is just a date stamp |
guardian_profile | phone, campusIds: string[] | the only multi-campus type — a Postgres UUID array, not a single FK |
guest_profile | phone (optional) | the thinnest school-bound profile; walk-in applicants |
Guardians carry an array of campuses, not one
Every other type has a single campusId. Guardians have campusIds: string[] — a Postgres UUID array — because one parent can have children at different campuses. Code that generically reads profile.campusId returns undefined for a guardian. Treat guardians as the special case in any campus-scoping logic; RBAC & Scopes handles this with an array-overlap operator, not FK equality.
The platform admin
The fifth profile is the exception that proves the rule. PlatformAdminProfileEntity is not @TenantScoped(), has no schoolId column, and skips the suspend trio entirely. Operators sit above tenancy, so a tenant scope would only get in the way.
/**
* Profile for a cross-tenant platform operator. NOT `@TenantScoped` — platform
* admins are school-less and must be readable without a tenant context.
*/
@Entity('platform_admin_profile')
@WithTimestamps()
export class PlatformAdminProfileEntity extends DatabaseEntity implements PlatformAdminProfile {
@Column({ name: 'first_name' }) firstName: string;
@Column({ name: 'last_name' }) lastName: string;
@Column({ name: 'access_code', unique: true }) accessCode: string;
@Column({ name: 'user_id', type: 'uuid', unique: true }) userId: string;
// ... user back-reference
}Resolving a profile
The whole point of the discriminator is that you rarely want to hand-write a switch on user.type in your own code. The module gives you UserService.resolveProfile(userId), which loads the user, reads the type, and returns a discriminated union — a UserProfile whose .type you can narrow on to get a fully-typed .profile.
export type UserProfile =
| { type: UserType.STUDENT; profile: StudentProfileEntity }
| { type: UserType.GUEST; profile: GuestProfileEntity }
| { type: UserType.GUARDIAN; profile: GuardianProfileEntity }
| { type: UserType.STAFF; profile: StaffProfileEntity }
| { type: UserType.PLATFORM_ADMIN; profile: PlatformAdminProfileEntity };Internally, resolveProfile looks the user up, then dispatches through a profileLoaders lookup keyed by every UserType. Each loader queries the right profile repository, throwing NotFoundException if the profile is somehow missing.
const profileLoaders: Record<UserType, (uid: string) => Promise<UserProfile>> = {
[UserType.STUDENT]: async (uid) => {
const profile = await this.studentProfileRepository.findOneWhere({ userId: uid });
if (!profile) throw new NotFoundException('Student profile not found');
return { type: UserType.STUDENT, profile };
},
// ... GUEST, GUARDIAN, STAFF follow the same shape
[UserType.PLATFORM_ADMIN]: async () => {
// school-less: resolved off the user's lazy relation, not a tenant-scoped repo
const profile = await user.platformAdminProfile;
if (!profile) throw new NotFoundException('Platform admin profile not found');
return { type: UserType.PLATFORM_ADMIN, profile };
},
};
return profileLoaders[user.type](user.id);Notice the platform-admin loader is different: it reaches through the lazy user.platformAdminProfile promise rather than a tenant-scoped repository, because there is no tenant-scoped repo for operators. This is the same lookup the UserProfilePipe runs behind the @CurrentUserProfile() decorator to turn a request's JWT into a typed UserProfile before your handler runs.
A second resolver maps types to entity classes
There's a narrower, private profileEntityForType function that maps a UserType to its profile entity class (not an instance). Soft-delete and restore use it to know which table to cascade into. It covers the four school-bound types and throws for PLATFORM_ADMIN, because operator lifecycle lives in the platform module. The lifecycle page shows where that trap bites.
Where to go next
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.
Links & Access Codes
The two cross-type join tables — guardian-to-student and staff-to-department — plus the prefixed, look-alike-safe access codes for every profile.