Naalya Handbook
Users & Profiles

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.

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

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

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

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

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

The 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.

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

apps/server/src/app/user/user.service.ts
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 schoolSchoolSeedService (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:stress script 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:stress

Platform 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

On this page