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.
@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.
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
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.
User Lifecycle
The transaction that creates a user and its profile together, plus suspend, soft-delete cascade, seeding, and the sharp edges to watch for.