Naalya Handbook
Users & Profiles

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.

apps/server/src/app/user/entities/staff-profile.entity.ts
@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:

ProfileOwn fields beyond the spineNotable
student_profilestudentId, enrollmentNo, campusId, enrolledAt, admissionApplicationIdstudentId is a second generated slug; enrolledAt is just a date stamp
guardian_profilephone, campusIds: string[]the only multi-campus type — a Postgres UUID array, not a single FK
guest_profilephone (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.

apps/server/src/app/user/entities/platform-admin-profile.entity.ts
/**
 * 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.

apps/server/src/app/user/dto/user/user-profile.types.ts
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.

apps/server/src/app/user/user.service.ts
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

On this page