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.
A user is born two rows at a time and dies two rows at a time. This page follows the create flow step by step — entry point, transaction, access code, profile — then covers suspend, soft-delete, seeding, and the gotchas that all trace back to "the profile is lazy" and "the platform admin is school-less."
Step 1: Pick the entry point
You almost never call the repositories directly to create a user. Per-type services do it: StaffService.create, StudentService.create, and so on each build a UserEntity payload and hand it to UserService.createLocalUser (or createMicrosoftUser for SSO accounts). Here's the staff path — it hashes a generated default password, then delegates.
async create(dto: CreateStaffDto): Promise<StaffProfileEntity & { defaultPassword: string }> {
const defaultPassword = randomBytes(6).toString('base64url');
const passwordHash = await hash(defaultPassword, saltRounds);
const user = await this.userService.createLocalUser({
email: dto.email, passwordHash, type: UserType.STAFF,
firstName: dto.firstName, lastName: dto.lastName,
otherNames: dto.otherNames, staffId: dto.staffId, campusId: dto.campusId,
});
// ... fetch the freshly-created profile, flip needsPasswordChange = true
return { ...updated, defaultPassword };
}The first thing createLocalUser does is resolve the owning school. It bypasses BaseRepository's automatic stamping (because it writes through a raw transaction manager), so it resolves schoolId explicitly — from the caller's value, or the active tenant context, or undefined.
const existing = await this.findByEmail(data.email);
if (existing) throw new ConflictException('Email already in use');
const schoolId = data.schoolId ?? getTenantContext()?.schoolId ?? undefined;For a PLATFORM_ADMIN no tenant context exists, so schoolId lands as undefined (null in the DB) — exactly what we want for a school-less operator.
Step 2: Open the transaction
Everything from here runs inside this.dataSource.transaction(...) so the user row and its profile row succeed or fail together — no orphaned profiles, ever. First the identity row:
return this.dataSource.transaction(async (manager) => {
const userRepo = manager.getRepository(UserEntity);
const user = userRepo.create({
email: data.email, passwordHash: data.passwordHash, type: data.type,
authProvider: UserAuthProvider.LOCAL,
isActive: true, emailVerified: false, schoolId,
});
const savedUser = await userRepo.save(user);
// ... access code + profile follow
});Using manager.getRepository(...) rather than the injected repositories is what keeps both writes on the same transaction. If the profile insert throws, the user insert rolls back with it.
Step 3: Generate the access code
With the user saved, generate the typed access code. The slugConfig lookup maps the user's type to its prefix and the entity to check uniqueness against, so the same code path serves all five types:
const config = slugConfig[data.type]; // { prefix: SlugPrefix.STAFF, entity: StaffProfileEntity }, etc.
const { slug: accessCode } = await generateSlug({
prefix: config.prefix,
checker: (slug) =>
manager.getRepository(config.entity).exists({ where: { accessCode: slug } }),
});The checker queries inside the transaction manager, so collision checks see the rows being written in this very transaction. (The access-code format itself is covered on Links & Access Codes.)
Step 4: Insert the profile
Now the discriminator does its job: a per-type branch inserts the right profile, carrying the shared spine plus type-specific fields and the resolved schoolId. The student branch is the most involved — it generates a second slug for studentId:
} else if (data.type === UserType.STUDENT) {
const profileRepo = manager.getRepository(StudentProfileEntity);
const { slug: studentId } = await generateSlug({
prefix: SlugPrefix.STUDENT,
checker: (slug) =>
manager.getRepository(StudentProfileEntity).exists({ where: { studentId: slug } }),
});
const profile = profileRepo.create({
userId: savedUser.id, firstName: data.firstName, lastName: data.lastName,
accessCode: accessCode!, studentId: studentId!, campusId: data.campusId, schoolId,
});
await profileRepo.save(profile);
}
// ... GUARDIAN, GUEST, STAFF branches; PLATFORM_ADMIN omits schoolId entirelyThe PLATFORM_ADMIN branch is conspicuously shorter — it never sets schoolId. Once the matching branch saves, the transaction commits and createLocalUser returns the saved UserEntity: two rows, one atomic operation. The per-type service then re-reads the profile (e.g. to flip needsPasswordChange = true) and returns it.
Microsoft users follow the same shape
createMicrosoftUser is the SSO twin of createLocalUser: same transaction, same access-code generation, but it sets authProvider: MICROSOFT_365, stores a microsoftSub instead of a password hash, and emailVerified: true. It only ever creates STAFF or STUDENT. See Microsoft Sync for how directory sync drives it.
Suspend and unsuspend
Suspend is a profile-level flag, not a user-level one. Each per-type service has suspend/unsuspend that flip isActive and stamp the suspend trio (suspendedAt, suspendedBy, suspensionReason). Both actions are audit-logged via the @Audit decorator on the controller.
async suspend(id: string, suspendedBy: string, reason?: string) {
// ... assertCanAccess(UPDATE, STAFF, profile)
return this.staffProfileRepository.update(id, {
isActive: false, suspendedAt: new Date(), suspendedBy, suspensionReason: reason,
});
}Soft-delete cascade
Soft-delete is a cascade across both rows. softDeleteUser checks permissions, runs the super-admin protection guard (you can't delete the super admin), resolves the profile entity from the type, and soft-deletes both rows in one transaction:
async softDeleteUser(userId: string): Promise<boolean> {
// ... findById, assertCanAccess(DELETE, USER), superAdminProtection.assertCanDeleteUser
const profileEntity = profileEntityForType(user.type); // ← throws for PLATFORM_ADMIN
return this.dataSource.transaction(async (manager) => {
const userResult = await manager.getRepository(UserEntity).softDelete({ id: userId });
await manager.getRepository(profileEntity).softDelete({ userId });
return userResult.affected ? userResult.affected > 0 : false;
});
}restoreUser is the exact reverse — restore on both rows in a transaction. Because softDeleteUser routes through profileEntityForType, it will throw for a platform admin: operator deletion is handled by the platform module, not this generic path.
Seeding
There's no per-user seed running on boot for real data. Two seeds exist:
- Demo school —
SchoolSeedService(development only) ensures the bootstrap demo tenant and its config/branding rows exist so roles and seeded users have a real school to belong to. It does not seed users or campuses. - Bulk users — the manual
pnpm seed:stressscript bulk-inserts a realistic dataset against the demo tenant: 1,500 staff (1 super-admin sentinel + 1,499 school-level staff), 5,000 students, and 3,000 guardians, each guardian linked to 1–3 students. All sit at the school level with no campus attachment (campusId = null,campusIds = []), and all share one hashed password. Boot the app once first so the demo school and its cloned roles exist.
Run the stress seed from the repo root:
pnpm seed:stressPlatform operators are seeded separately again: PlatformAdminSeedService calls createLocalUser with type: PLATFORM_ADMIN on bootstrap, but only if the seed email's domain is in PLATFORM_ADMIN_EMAIL_DOMAINS.
Gotchas
A handful of sharp edges, all of which come back to "the profile is lazy" and "the platform admin is school-less."
Lazy relations are Promises — you must await them
user.staffProfile is typed Promise<StaffProfileEntity>, not the entity. Reading it without await gives you a promise object that's truthy but useless — user.staffProfile.firstName is undefined, no error thrown. Either await user.staffProfile, or go through resolveProfile, which awaits for you. And because the relation is lazy, fetching a UserEntity touches no profile table — most auth paths want exactly that, so don't add an eager join unless you truly need profile data.
profileEntityForType throws for PLATFORM_ADMIN
Don't call it in a generic "for every user" loop without guarding on user.type first. It deliberately throws for operators because their lifecycle lives in the platform module. The same trap hides inside softDeleteUser and restoreUser — both will throw if handed a platform admin.
Student enrollment is not on the profile
StudentProfileEntity.enrolledAt is just a date stamp. Actual class / stream / grade membership lives in a separate EnrollmentEntity. Don't reach for the profile to answer "what class is this student in" — it isn't there.
Where to go next
The Five Profiles
The profile entities this flow creates, and the lazy relations behind the gotchas.
Auditing
How suspend, create, and delete actions on users are recorded via @Audit.
Add a Resource Module
Build a new tenant-scoped module the same way these profiles are built.
Microsoft Sync
How directory sync drives createMicrosoftUser for staff and students.
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.
RBAC & Scopes
The CASL permission engine — two layers (action gate plus row filter) and why conditions are always derived, never stored.