Naalya Handbook
Users & Profiles

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.

Two things tie this module together at the edges: the join tables that span types, and the short access codes that give every profile a human-friendly handle. This page covers both — the relationships that live in their own tables, and the slug machinery that names them.

Guardian and student

Guardian ↔ Student is the big cross-type link, and it lives in its own join table rather than as a column on a profile. A GuardianXStudentEntity row in guardian_x_student links one guardian to one student, carrying the relationship label, an isVerified flag, and an optional inviteCode. The link is what grants a guardian access to a child's data — but only once verified.

apps/server/src/app/user/entities/guardian-x-student.entity.ts
@Entity('guardian_x_student')
@Unique(['guardianId', 'studentId'])   // a pairing is unique
export class GuardianXStudentEntity extends DatabaseEntity implements GuardianXStudent {
  @Column({ name: 'guardian_id', type: 'uuid' }) guardianId: string;
  @Column({ name: 'student_id', type: 'uuid' }) studentId: string;
  @Column({ type: 'varchar', length: 20 }) relationship: string;
  @Column({ name: 'is_verified', default: false }) isVerified: boolean;
  @Column({ name: 'invite_code', nullable: true, unique: true }) inviteCode?: string;
  // ... guardian/student/verifier relations
}

The service enforces two rules on creation: a guardian can't be linked to the same student twice (ConflictException), and a student may have at most 3 guardians (MAX_GUARDIANS_PER_STUDENT). Admin-created links are marked isVerified: true immediately.

An unverified link is, for authorization, no link at all

The verification gate matters downstream. listMyStudents and isVerifiedGuardianOf both filter to isVerified: true, and the CASL ability factory ignores unverified links entirely. A guardian with only an unverified link to a child has no authorization to that child's data — verification is the switch, not the row's existence.

Staff and department

Staff ↔ Department is the other cross-type link. StaffProfileEntity has a @OneToMany of DepartmentXStaffEntity rows (department_x_staff), each carrying a DepartmentRole of member or head. That join table lives in the department module; the staff profile just reads through it for the "departments" view. So a single staff member can sit in several departments, and be the head of some and a plain member of others.

Access codes

Every profile carries a unique accessCode — a short, prefixed, look-alike-safe string like stf_K7m9P. It's how staff OTP login identifies a user without typing a UUID, and how operators eyeball "this is a teacher" at a glance. The prefix encodes the type, drawn from the SlugPrefix enum.

libs/shared/src/utils/slug-generator.ts
export enum SlugPrefix {
  STAFF = 'stf',
  STUDENT = 'stn',
  GUARDIAN = 'gdn',
  GUEST = 'gst',
  PLATFORM_ADMIN = 'plt',
  // ... CAMPUS, REPORT, ENROLLMENT, SCHOOL
}

const SLUG_CHARSET = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';

Look closely at SLUG_CHARSET: no O/0, no I/1/l. Codes get read aloud and typed by humans, so the look-alikes are excluded. generateSlug builds prefix_XXXXX, then — given a checker callback — retries up to 20 times until the code is unique, returning an error object if it can't. The caller passes a checker that queries the relevant profile table, so uniqueness is enforced per type.

Looking up by code

UserService.findByAccessCode parses the prefix and queries only the matching profile repo — gdn_ hits the guardian table, stn_ the student table. It runs runUnscoped because login happens before any tenant context exists.

findByAccessCode returns null for plt_ codes

The helper handles stf_/stn_/gdn_/gst_ and returns null for anything else, including plt_. Platform admins authenticate by email + password, never by access code. Any login or lookup path routed through findByAccessCode will silently get null for an operator — don't treat that as "user not found" without checking the type.

Where to go next

On this page