User & Participant
User (Recruiter)
Slimmed down from current model — org membership now lives on Organization.members.
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.
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.participantonCandidateFirstLogin() Logic
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:
- A
Userrecord is created withroles: ['candidate'] - A
Participantrecord is created (or linked) withParticipant.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)
// 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 routesCandidate Auth (src/middleware/candidateAuthMiddleware.ts)
// 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)