Skip to content

User & Participant


User (Recruiter)

Slimmed down from current model — org membership now lives on Organization.members.

typescript
interface IUser {
  _id: ObjectId;
  authId: string;               // Firebase UID
  email: string;
  name: string;
  avatar?: string;
  roles: ('recruiter' | 'candidate' | 'admin')[];
  organizationId: ObjectId;     // ref → Organization

  // Notification
  fcmTokens: string[];

  // Preferences
  preferences: {
    timezone: string;           // e.g. "America/New_York"
    emailNotifications: boolean;
    pushNotifications: boolean;
  };

  isDeleted: boolean;
  createdAt: Date;
  updatedAt: Date;
}

Indexes:

  • { authId: 1 } — unique (auth middleware lookup)
  • { email: 1 } — unique
  • { organizationId: 1 }

Change from current model: organizationName: string is removed. Replaced with organizationId: ObjectId ref to Organization.


Participant (Candidate Identity)

A Participant is a candidate's identity record. It is created automatically the first time a recruiter schedules any interview for a given email — before the candidate has any account. When the candidate later logs in via Firebase, their authId is written to this record, linking all existing pipeline history to their account.

typescript
interface IParticipant {
  _id: ObjectId;
  email: string;            // normalized to lowercase — canonical identity key

  // Populated on first invite; may be updated by the candidate
  name?: string;

  // Populated when the candidate registers/logs in via Firebase for the first time.
  // Before this is set, the record is an "unclaimed" participant.
  authId?: string;          // Firebase UID — sparse unique

  // Populated on login if the User collection also has a record for this email
  userId?: ObjectId;        // ref → User (candidate role) — sparse unique

  preferences: {
    timezone?: string;
    emailNotifications: boolean;
  };

  // Aggregate stats — denormalized, updated via $inc
  stats: {
    totalPipelines: number;    // job pipelines enrolled in
    totalInterviews: number;   // total interview sessions across all pipelines
    noShowCount: number;       // missed scheduled live interviews
  };

  isDeleted: boolean;
  createdAt: Date;
  updatedAt: Date;
}

Indexes:

  • { email: 1 } — unique — primary lookup when creating/finding by recruiter invite
  • { authId: 1 } — unique sparse — candidate dashboard auth lookup after Firebase login
  • { userId: 1 } — unique sparse — link to User record if candidate has a platform account

The Candidate Identity Model

The fundamental design contract: recruiter creates the record, candidate claims it.

1. Recruiter schedules interview for "alice@example.com"
   → Participant.findOneAndUpsert({ email: 'alice@example.com' }, { name: 'Alice' })
   → Participant created with no authId (unclaimed)
   → CandidatePipeline created with this Participant._id

2. Alice receives email invitation; clicks the screening link (no auth needed for the session)
   → screeningToken on Interview is the only credential for that one session

3. Alice decides to check her dashboard; goes to /login as a candidate
   → Firebase email/password or magic-link login

4. On successful Firebase auth → backend onCandidateLogin hook:
   → Participant.findOne({ email: alice@example.com })
   → If found and authId is null → set Participant.authId = alice's Firebase UID
   → If found and authId already set (same person re-logging) → no-op
   → Now all her CandidatePipeline records (by participantId) are accessible via Firebase auth

5. Future API calls to /v1/candidate/* go through candidateAuthMiddleware:
   → Verify Firebase token → get authId
   → Participant.findOne({ authId }) → attach req.participant

onCandidateFirstLogin() Logic

typescript
async function onCandidateFirstLogin(uid: string, email: string): Promise<IParticipant> {
  const existing = await Participant.findOne({ email: email.toLowerCase() });
  if (existing && !existing.authId) {
    // Pre-created by recruiter — now claiming it
    await Participant.findByIdAndUpdate(existing._id, { authId: uid });
    return existing;
  }
  if (!existing) {
    // Candidate registered before any recruiter invited them
    return await Participant.create({
      email,
      authId: uid,
      preferences: { emailNotifications: true },
      stats: { totalPipelines: 0, totalInterviews: 0, noShowCount: 0 }
    });
  }
  return existing; // Already claimed (re-login)
}

User ↔ Participant Duality

The current onboarding allows users to register as both recruiters and candidates. When a candidate registers:

  1. A User record is created with roles: ['candidate']
  2. A Participant record is created (or linked) with Participant.userId = user._id

Participant remains the authoritative record for all pipeline and interview history. User is the record used for platform-level features (notifications, preferences, org membership for candidates who also recruit).


Auth Middleware

Recruiter Auth (src/middleware/authMiddleware.ts)

typescript
// Applied at router level: router.use(authMiddleware)
// 1. Read Authorization: Bearer <idToken>
// 2. admin.auth().verifyIdToken(idToken) → { uid }
// 3. User.findOne({ authId: uid }) → attach req.user
// 4. Organization.findOne({ 'members.userId': req.user._id }) → attach req.organizationId
// req.user and req.organizationId available in all protected routes

Candidate Auth (src/middleware/candidateAuthMiddleware.ts)

typescript
// 1. Read Authorization: Bearer <idToken>
// 2. admin.auth().verifyIdToken(idToken) → { uid, email }
// 3. Participant.findOne({ authId: uid }) → attach req.participant
// 4. If not found → onCandidateFirstLogin(uid, email)