1import { AttachmentService } from "./chunk-ICBWSMPO.js"; 2import { EmployeeService } from "./chunk-5TGS33NL.js"; 3import { StorageService } from "./chunk-4XLWURBE.js"; 4import { apiTranslations } from "./chunk-RVCT4EDL.js"; 5import { DateTime } from "./chunk-XYSFEBQZ.js"; 6import { AuthenticationService, Firestore, Timestamp, collection2 as collection, doc, docSnapshots, endBefore, environment, getCountFromServer, getDoc, getDocs2 as getDocs, limit2 as limit, limitToLast, orderBy, query2 as query, startAfter, updateDoc, where2 as where, writeBatch } from "./chunk-2TSZT3SZ.js"; 7import { HttpClient } from "./chunk-HE5ARQDU.js"; 8import { Injectable, __async, __spreadProps, __spreadValues, catchError, from, lastValueFrom, map, of, setClassMetadata, shareReplay, switchMap, take, tap, throwError, ɵɵdefineInjectable, ɵɵinject } from "./chunk-ZGC4HH7K.js"; 9
10// src/app/_shared/timestamp.util.ts 11function toMillisSafe(value) { 12 if (value == null) return null; 13 const candidate = value; 14 if (typeof candidate.toMillis === "function") return candidate.toMillis(); 15 if (value instanceof Date) return value.getTime(); 16 if (typeof value === "number") return value; 17 if (typeof candidate.seconds === "number") return candidate.seconds * 1e3 + Math.floor((candidate.nanoseconds ?? 0) / 1e6); 18 if (typeof candidate._seconds === "number") return candidate._seconds * 1e3 + Math.floor((candidate._nanoseconds ?? 0) / 1e6); 19 if (typeof value === "string") { 20 const dateTime = DateTime.fromISO(value); 21 return dateTime.isValid ? dateTime.toMillis() : null; 22 } 23 return null; 24} 25
26// src/app/_shared/applicant-talent-pool.util.ts 27function isPermissionGranted(permission) { 28 return permission?.status === "granted"; 29} 30function isPermissionExpired(permission, nowMs = Date.now()) { 31 if (!permission) return false; 32 if (permission.status === "expired") return true; 33 const expiresMs = toMillisSafe(permission.expiresAt); 34 return permission.status === "granted" && expiresMs !== null && expiresMs <= nowMs; 35} 36function isApplicantTalentPoolEligible(applicant, now = Timestamp.now()) { 37 if (applicant.talentPoolEnrollment !== "enrolled") return false; 38 if (applicant.privacyPermission?.status !== "granted") return false; 39 if (applicant.retentionStatus !== "active") return false; 40 if (applicant.applicantStatus === "hired" || applicant.applicantStatus === "pending_deletion" || applicant.applicantStatus === "deleted") { 41 return false; 42 } 43 const expiresAtMs = validTimestampMillis(applicant.privacyPermission.expiresAt); 44 const nowMs = validTimestampMillis(now); 45 return expiresAtMs !== null && nowMs !== null && expiresAtMs > nowMs; 46} 47function validTimestampMillis(value) { 48 if (!(value instanceof Timestamp)) return null; 49 try { 50 const millis = value.toMillis(); 51 return Number.isFinite(millis) ? millis : null; 52 } catch (e) { 53 return null; 54 } 55} 56function daysUntil(timestamp, nowMs = Date.now()) { 57 const ms = toMillisSafe(timestamp); 58 if (ms == null) return null; 59 return Math.ceil((ms - nowMs) / (24 * 60 * 60 * 1e3)); 60} 61function isDeletionApproaching(retentionDeleteAt, withinDays = 14, nowMs = Date.now()) { 62 const days = daysUntil(retentionDeleteAt, nowMs); 63 return days !== null && days <= withinDays; 64} 65
66// src/app/_shared/applicant-search-tokens.util.ts 67function normalize(value) { 68 return (value ?? "").normalize("NFD").replace(/[Ì-ͯ]/g, "").toLowerCase().trim().replace(/\s+/g, " "); 69} 70function normalizedName(firstName, lastName) { 71 return normalize(`${firstName ?? ""} ${lastName ?? ""}`); 72} 73function normalizedEmail(email) { 74 return normalize(email); 75} 76function phoneDigits(phone) { 77 return (phone ?? "").replace(/\D/g, ""); 78} 79function tokenizeText(text) { 80 return normalize(text).split(/[^a-z0-9]+/).filter(token => token.length >= 2); 81} 82function buildSearchTokens(input) { 83 const tokens = /* @__PURE__ */new Set(); 84 tokenizeText(input.firstName).forEach(token => tokens.add(token)); 85 tokenizeText(input.lastName).forEach(token => tokens.add(token)); 86 const email = normalizedEmail(input.email); 87 if (email) { 88 tokens.add(email); 89 const local = email.split("@")[0]; 90 if (local.length >= 2) tokens.add(local); 91 } 92 const digits = phoneDigits(input.phoneNumber); 93 if (digits.length >= 4) tokens.add(digits); 94 (input.functionNames ?? []).forEach(name => tokenizeText(name).forEach(token => tokens.add(token))); 95 (input.tagLabels ?? []).forEach(tag => tokenizeText(tag).forEach(token => tokens.add(token))); 96 (input.profileTagLabels ?? []).forEach(tag => tokenizeText(tag).forEach(token => tokens.add(token))); 97 return Array.from(tokens); 98} 99function getApplicantSearchInputAfterProfileUpdate(currentApplicant, profilePatch) { 100 const currentProfileTagKeys = new Set((currentApplicant.profileTagLabels ?? []).map(tag => normalize(tag)).filter(Boolean)); 101 const jobDerivedTagLabels = (currentApplicant.tagLabels ?? []).filter(tag => !currentProfileTagKeys.has(normalize(tag))); 102 return { 103 firstName: profilePatch.firstName ?? currentApplicant.firstName, 104 lastName: profilePatch.lastName ?? currentApplicant.lastName, 105 email: profilePatch.email ?? currentApplicant.email, 106 phoneNumber: profilePatch.phoneNumber ?? currentApplicant.phoneNumber, 107 functionNames: [...(currentApplicant.appliedFunctionNames ?? [])], 108 tagLabels: [...jobDerivedTagLabels], 109 profileTagLabels: [...(profilePatch.profileTagLabels ?? currentApplicant.profileTagLabels ?? [])] 110 }; 111} 112function buildApplicantSearchFields(input) { 113 const appliedFunctionNames = Array.from(new Set((input.functionNames ?? []).map(name => name.trim()).filter(Boolean))); 114 const profileTagLabels = Array.from(new Set((input.profileTagLabels ?? []).map(tag => tag.trim()).filter(Boolean))); 115 const providedTags = (input.tagLabels ?? []).map(tag => tag.trim()).filter(Boolean); 116 const tagLabels = Array.from(/* @__PURE__ */new Set([...profileTagLabels, ...providedTags])); 117 const appliedFunctionNameTokens = Array.from(new Set(appliedFunctionNames.flatMap(name => tokenizeText(name)))); 118 const profileTagTokens = Array.from(new Set(profileTagLabels.flatMap(tag => tokenizeText(tag)))); 119 const tagTokens = Array.from(new Set(tagLabels.flatMap(tag => tokenizeText(tag)))); 120 return { 121 normalizedName: normalizedName(input.firstName, input.lastName), 122 normalizedEmail: normalizedEmail(input.email), 123 phoneDigits: phoneDigits(input.phoneNumber), 124 searchTokens: buildSearchTokens(input), 125 appliedFunctionNames, 126 appliedFunctionNameTokens, 127 tagLabels, 128 tagTokens, 129 profileTagLabels, 130 profileTagTokens 131 }; 132} 133
134// src/app/_shared/firestore-payload.util.ts 135function removeUndefinedValues(value) { 136 return Object.fromEntries(Object.entries(value).filter(([, entryValue]) => entryValue !== void 0)); 137} 138
139// src/app/_services/applicant.service.ts 140var ApplicantService = class _ApplicantService { 141 constructor(firestore, attachmentService, storageService, httpClient, employeeService, authenticationService) { 142 this.firestore = firestore; 143 this.attachmentService = attachmentService; 144 this.storageService = storageService; 145 this.httpClient = httpClient; 146 this.employeeService = employeeService; 147 this.authenticationService = authenticationService; 148 } 149 // ------------------------- 150 // READ METHODS 151 // ------------------------- 152 // Per-status applicant tallies for a single job â used by the open-jobs dashboard widget so 153 // each row can show the funnel (received / reviewed / denied / interviewing) instead of a 154 // single total. One Firestore query per job (filtered by `jobId` at the DB level); statuses 155 // are tallied client-side because Firestore can't return grouped counts. `hired` is 156 // intentionally excluded â once a candidate is hired the job moves out of "open". 157 getApplicantStatusCountsByJobId(jobId) { 158 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(({ 159 orgId 160 }) => { 161 const jobApplicantsCol = collection(this.firestore, "organizations", orgId, "jobApplicants"); 162 return from(getDocs(query(jobApplicantsCol, where("jobId", "==", jobId)))).pipe(map(jaSnap => this.tallyStatusCounts(jaSnap.docs.map(docSnap => docSnap.data().status)))); 163 })); 164 } 165 tallyStatusCounts(statuses) { 166 const counts = { 167 received: 0, 168 reviewed: 0, 169 denied: 0, 170 interviewing: 0 171 }; 172 for (const status of statuses) { 173 if (status === "received" || status === "reviewed" || status === "denied" || status === "interviewing") { 174 counts[status]++; 175 } 176 } 177 return counts; 178 } 179 // Active applicants (received/reviewed/interviewing) for a job, derived from JobApplicants. 180 getActiveApplicantsByJobId(jobId) { 181 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(({ 182 orgId 183 }) => { 184 const jobApplicantsCol = collection(this.firestore, "organizations", orgId, "jobApplicants"); 185 return from(getDocs(query(jobApplicantsCol, where("jobId", "==", jobId)))); 186 }), switchMap(jaSnap => { 187 const active = jaSnap.docs.map(docSnap => docSnap.data()).filter(jobApplicant => ["received", "reviewed", "interviewing"].includes(jobApplicant.status)); 188 if (!active.length) return of([]); 189 return this.getApplicantsForSearch().pipe(map(allApplicants => { 190 const byId = new Map(allApplicants.map(applicant => [applicant.id, applicant]));
191 return active.map(jobApplicant => { 192 const person = byId.get(jobApplicant.applicantId); 193 if (!person) return null; 194 return __spreadProps(__spreadValues({}, person), { 195 status: jobApplicant.status, 196 overallRating: jobApplicant.overallRating, 197 receivedAt: jobApplicant.receivedAt 198 }); 199 }).filter(applicant => !!applicant); 200 })); 201 })); 202 } 203 getApplicantsForSearch() { 204 if (!this.applicantsForSearch$) { 205 this.applicantsForSearch$ = this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(({ 206 orgId 207 }) => from(getDocs(query(collection(this.firestore, "organizations", orgId, "applicants"))))), map(snapshot => snapshot.docs.map(docSnap => __spreadProps(__spreadValues({}, docSnap.data()), { 208 id: docSnap.id, 209 exists: docSnap.exists() 210 }))), shareReplay({ 211 bufferSize: 1, 212 refCount: false 213 })); 214 } 215 return this.applicantsForSearch$; 216 } 217 /** 218 * Drop the cached applicants-for-search snapshot so the next read re-fetches. Called after writes 219 * (create / update) â otherwise the cached list is served forever and new/edited applicants only 220 * appear after a full page reload. 221 */ 222 invalidateApplicantsForSearch() { 223 this.applicantsForSearch$ = void 0; 224 } 225 // ** ============================== ** // 226 // Applicants overview pagination 227 // ** ============================== ** // 228 getApplicantsCount(status) { 229 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(({ 230 orgId 231 }) => { 232 const colRef = collection(this.firestore, "organizations", orgId, "applicants"); 233 let q = query(colRef); 234 if (status && status !== "all") { 235 q = query(q, where("applicantStatus", "==", status)); 236 } 237 return from(getCountFromServer(q)).pipe(map(snapshot => snapshot.data().count)); 238 })); 239 } 240 getApplicantsFirstPage(perPage, orderByField = "createdAt", sortDirection = "desc", status) { 241 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(({ 242 orgId 243 }) => { 244 const colRef = collection(this.firestore, "organizations", orgId, "applicants"); 245 let q = query(colRef, orderBy(orderByField, sortDirection), limit(perPage)); 246 if (status && status !== "all") { 247 q = query(q, where("applicantStatus", "==", status)); 248 } 249 return getDocs(q); 250 }), map(snapshot => this.toApplicantPage(snapshot))); 251 } 252 getApplicantsNextPage(fieldValue, perPage, orderByField = "createdAt", sortDirection = "desc", status) { 253 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(({ 254 orgId 255 }) => { 256 const colRef = collection(this.firestore, "organizations", orgId, "applicants"); 257 let q = query(colRef, orderBy(orderByField, sortDirection), startAfter(fieldValue), limit(perPage)); 258 if (status && status !== "all") { 259 q = query(q, where("applicantStatus", "==", status)); 260 } 261 return getDocs(q); 262 }), map(snapshot => this.toApplicantPage(snapshot))); 263 } 264 getApplicantsPrevPage(fieldValue, perPage, orderByField = "createdAt", sortDirection = "desc", status) { 265 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(({ 266 orgId 267 }) => { 268 const colRef = collection(this.firestore, "organizations", orgId, "applicants"); 269 let q = query(colRef, orderBy(orderByField, sortDirection), endBefore(fieldValue), limitToLast(perPage)); 270 if (status && status !== "all") { 271 q = query(q, where("applicantStatus", "==", status)); 272 } 273 return getDocs(q); 274 }), map(snapshot => this.toApplicantPage(snapshot))); 275 } 276 toApplicantPage(snapshot) { 277 const result = snapshot.docs.map(docSnap => __spreadProps(__spreadValues({}, docSnap.data()), { 278 id: docSnap.id, 279 exists: docSnap.exists() 280 })); 281 return { 282 result, 283 lastVisible: snapshot.docs[snapshot.docs.length - 1], 284 firstVisible: snapshot.docs[0] 285 }; 286 } 287 getApplicantById(applicantId) { 288 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(({ 289 orgId 290 }) => { 291 const docRef = doc(this.firestore, "organizations", orgId, "applicants", applicantId); 292 return docSnapshots(docRef); 293 }), map(docSnap => { 294 const data = docSnap.data(); 295 return __spreadProps(__spreadValues({}, data), { 296 id: docSnap.id, 297 exists: docSnap.exists() 298 }); 299 })); 300 } 301 // ------------------------- 302 // PERSON-MODEL: search / talent pool / retention 303 // ------------------------- 304 /** 305 * Token search via array-contains-any. Retention filtering is applied on the limited result set 306 * (Firestore can't combine array-contains-any with another disjunctive `in` clause). 307 */ 308 searchApplicants(searchTerm, limitTo = 50) {
309 const tokens = tokenizeText(searchTerm).slice(0, 30); 310 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(({ 311 orgId 312 }) => { 313 if (!tokens.length) return of([]); 314 const colRef = collection(this.firestore, "organizations", orgId, "applicants"); 315 const q = query(colRef, where("searchTokens", "array-contains-any", tokens), limit(limitTo)); 316 return from(getDocs(q)).pipe(map(snap => snap.docs.map(docSnap => __spreadProps(__spreadValues({}, docSnap.data()), { 317 id: docSnap.id, 318 exists: docSnap.exists() 319 })).filter(applicant => applicant.retentionStatus !== "pending_deletion" && applicant.retentionStatus !== "deleted"))); 320 })); 321 } 322 /** First applicant whose normalizedEmail matches the given email (or null). DB-filtered lookup. */ 323 findApplicantByEmail(email) { 324 const normalized = normalizedEmail(email); 325 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(({ 326 orgId 327 }) => { 328 if (!normalized) return of(null); 329 const colRef = collection(this.firestore, "organizations", orgId, "applicants"); 330 return from(getDocs(query(colRef, where("normalizedEmail", "==", normalized), limit(1)))).pipe(map(snap => snap.docs.length ? __spreadProps(__spreadValues({}, snap.docs[0].data()), { 331 id: snap.docs[0].id, 332 exists: snap.docs[0].exists() 333 }) : null)); 334 })); 335 } 336 /** Talent pool: permission granted, not expired, still retained. */ 337 getTalentPoolApplicants() { 338 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(({ 339 orgId 340 }) => { 341 const colRef = collection(this.firestore, "organizations", orgId, "applicants"); 342 return from(getDocs(query(colRef, where("privacyPermission.status", "==", "granted")))); 343 }), map(snap => { 344 const now = Timestamp.now(); 345 return snap.docs.map(docSnap => __spreadProps(__spreadValues({}, docSnap.data()), { 346 id: docSnap.id, 347 exists: docSnap.exists() 348 })).filter(applicant => isApplicantTalentPoolEligible(applicant, now)); 349 })); 350 } 351 /** Applicants whose permission expires within the next `days` (default 30). */ 352 getApplicantsWithExpiringPermission(days = 30) { 353 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(({ 354 orgId 355 }) => { 356 const colRef = collection(this.firestore, "organizations", orgId, "applicants"); 357 return from(getDocs(query(colRef, where("privacyPermission.status", "==", "granted")))); 358 }), map(snap => { 359 const threshold = Timestamp.now().toMillis() + days * 24 * 60 * 60 * 1e3; 360 return snap.docs.map(docSnap => __spreadProps(__spreadValues({}, docSnap.data()), { 361 id: docSnap.id, 362 exists: docSnap.exists() 363 })).filter(applicant => { 364 const expires = applicant.privacyPermission?.expiresAt?.toMillis(); 365 return expires !== void 0 && expires <= threshold; 366 }); 367 })); 368 } 369 /** Flag an applicant for deletion (retention flow / manual action). */ 370 markApplicantPendingDeletion(applicantId) { 371 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(session => { 372 const docRef = doc(this.firestore, "organizations", session.orgId, "applicants", applicantId); 373 const update = {
374 retentionStatus: "pending_deletion", 375 lastUpdatedAt: Timestamp.now(), 376 lastUpdatedBy: session.fullName, 377 lastUpdatedById: session.employeeId 378 }; 379 return updateDoc(docRef, update); 380 })); 381 } 382 /** 383 * Add an existing applicant (person) to a job: create the JobApplicant, then copy the job's 384 * function name + tags into the applicant search context. Enforces the AVG/retention block rule 385 * (pending deletion always blocks; an invalid permission blocks unless the applicant still has an 386 * open application elsewhere) and prevents linking the same person to the same job twice. 387 */ 388 addExistingApplicantToJob(applicantId, jobId, details) { 389 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(session => from(this.addExistingApplicantToJobRequest(session, applicantId, jobId, details)))); 390 } 391 /** 392 * Apply a batch of job-assignment changes for an applicant atomically: link the given jobs 393 * (creating JobApplicants, enforcing the AVG/dedupe rules + snapshots) and delete the given 394 * JobApplicants, then rebuild the applicant search tokens once. 395 */ 396 manageApplicantJobs(applicantId, addJobIds, removeJobApplicantIds) { 397 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(session => from(this.manageApplicantJobsRequest(session, applicantId, addJobIds, removeJobApplicantIds)))); 398 } 399 // AVG/retention guard shared by the single-add and batch-manage flows: pending deletion always 400 // blocks; an invalid permission blocks unless the applicant still has an open application. 401 assertApplicantAssignable(applicant, existing) { 402 const hasOpenApplication = existing.some(jobApplicant => jobApplicant.processClosedAt === null && jobApplicant.status !== "denied" && jobApplicant.status !== "hired"); 403 const permissionInvalid = ["expired", "withdrawn", "declined"].includes(applicant.privacyPermission?.status ?? "");
404 if (applicant.retentionStatus === "pending_deletion" || permissionInvalid && !hasOpenApplication) { 405 throw new Error("Deze kandidaat kan niet worden toegevoegd vanwege regels voor gegevensbewaring of privacytoestemming"); 406 } 407 } 408 manageApplicantJobsRequest(session, applicantId, addJobIds, removeJobApplicantIds) { 409 return __async(this, null, function* () { 410 const orgId = session.orgId; 411 if (!addJobIds.length && !removeJobApplicantIds.length) { 412 return; 413 } 414 const applicantRef = doc(this.firestore, "organizations", orgId, "applicants", applicantId); 415 const applicant = (yield getDoc(applicantRef)).data(); 416 if (!applicant) { 417 throw new Error("Kandidaat niet gevonden"); 418 } 419 const jobApplicantsCol = collection(this.firestore, "organizations", orgId, "jobApplicants"); 420 const existingSnap = yield getDocs(query(jobApplicantsCol, where("applicantId", "==", applicantId))); 421 const existing = existingSnap.docs.map(docSnap => docSnap.data()); 422 const existingById = new Map(existingSnap.docs.map(docSnap => [docSnap.id, docSnap.data()])); 423 const existingJobIds = new Set(existing.map(jobApplicant => jobApplicant.jobId)); 424 const candidateName = `${applicant.firstName ?? ""} ${applicant.lastName ?? ""}`.trim(); 425 if (addJobIds.length) { 426 this.assertApplicantAssignable(applicant, existing); 427 } 428 const batch = writeBatch(this.firestore); 429 for (const jobApplicantId of removeJobApplicantIds) { 430 batch.delete(doc(this.firestore, "organizations", orgId, "jobApplicants", jobApplicantId)); 431 const removed = existingById.get(jobApplicantId); 432 if (removed) { 433 this.addJobAssignmentTimeline(batch, session, applicantId, removed.jobId, candidateName, removed.functionNameSnapshot ?? "", false); 434 } 435 } 436 for (const jobId of addJobIds) { 437 if (existingJobIds.has(jobId)) { 438 continue; 439 } 440 const job = (yield getDoc(doc(this.firestore, "organizations", orgId, "jobs", jobId))).data(); 441 const jobApplicantRef = doc(jobApplicantsCol); 442 batch.set(jobApplicantRef, this.newJobApplicant(session, applicantId, jobId, job)); 443 this.addJobAssignmentTimeline(batch, session, applicantId, jobId, candidateName, job?.title ?? "", true); 444 yield this.addReviewTaskToBatch(batch, session, orgId, job, jobId, applicantId, jobApplicantRef.id, candidateName); 445 } 446 yield batch.commit(); 447 yield this.rebuildSearchTokens(session, applicantId); 448 }); 449 } 450 /** 451 * Assign multiple applicants to one job in a single atomic batch: create a JobApplicant for each 452 * (enforcing the AVG/dedupe rules + snapshot), then rebuild each linked applicant's search tokens. 453 */ 454 assignApplicantsToJob(jobId, applicantIds) { 455 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(session => from(this.assignApplicantsToJobRequest(session, jobId, applicantIds)))); 456 } 457 assignApplicantsToJobRequest(session, jobId, applicantIds) { 458 return __async(this, null, function* () { 459 const orgId = session.orgId; 460 const ids = Array.from(new Set(applicantIds.filter(Boolean))); 461 if (!ids.length) { 462 return; 463 } 464 const job = (yield getDoc(doc(this.firestore, "organizations", orgId, "jobs", jobId))).data(); 465 const jobApplicantsCol = collection(this.firestore, "organizations", orgId, "jobApplicants"); 466 const jobSnap = yield getDocs(query(jobApplicantsCol, where("jobId", "==", jobId))); 467 const alreadyLinked = new Set(jobSnap.docs.map(docSnap => docSnap.data().applicantId)); 468 const batch = writeBatch(this.firestore); 469 const linked = []; 470 for (const applicantId of ids) { 471 if (alreadyLinked.has(applicantId)) { 472 continue; 473 } 474 const applicant = (yield getDoc(doc(this.firestore, "organizations", orgId, "applicants", applicantId))).data(); 475 if (!applicant) { 476 continue; 477 } 478 const appSnap = yield getDocs(query(jobApplicantsCol, where("applicantId", "==", applicantId))); 479 this.assertApplicantAssignable(applicant, appSnap.docs.map(docSnap => docSnap.data())); 480 const jobApplicantRef = doc(jobApplicantsCol); 481 batch.set(jobApplicantRef, this.newJobApplicant(session, applicantId, jobId, job)); 482 const candidateName = `${applicant.firstName ?? ""} ${applicant.lastName ?? ""}`.trim(); 483 this.addJobAssignmentTimeline(batch, session, applicantId, jobId, candidateName, job?.title ?? "", true); 484 yield this.addReviewTaskToBatch(batch, session, orgId, job, jobId, applicantId, jobApplicantRef.id, candidateName); 485 linked.push(applicantId); 486 } 487 yield batch.commit(); 488 for (const applicantId of linked) { 489 yield this.rebuildSearchTokens(session, applicantId); 490 } 491 }); 492 } 493 // Build a new JobApplicant document with defaults + the job snapshot (single source of truth). 494 newJobApplicant(session, applicantId, jobId, job, details) { 495 const now = Timestamp.now(); 496 return { 497 applicantId, 498 jobId, 499 status: details?.status ?? "received", 500 functionNameSnapshot: job?.title ?? "", 501 tagLabelsSnapshot: job?.tagLabels ?? [], 502 feedback: details?.feedback ?? null, 503 resumeFeedback: details?.resumeFeedback ?? null, 504 overallRating: details?.overallRating ?? null, 505 resumeRating: details?.resumeRating ?? null, 506 receivedAt: now, 507 deniedAt: null, 508 hiredAt: null, 509 processClosedAt: null, 510 lastUpdatedBy: session.fullName, 511 lastUpdatedById: session.employeeId, 512 lastUpdatedAt: now, 513 createdBy: session.fullName, 514 createdById: session.employeeId, 515 createdAt: now 516 }; 517 } 518 static { 519 this.DEFAULT_REVIEW_DAYS = 7; 520 } 521 /** 522 * Queues a "review this application" task for the job's reviewer (now + applicationResponseTime, 523 * clamped to the job's hiring window). No-op when the job has no reviewer. Added to the caller's 524 * batch so it commits atomically with the JobApplicant. 525 */ 526 addReviewTaskToBatch(batch, session, orgId, job, jobId, applicantId, jobApplicantId, candidateName) { 527 return __async(this, null, function* () { 528 const reviewerId = job?.reviewerId; 529 if (!reviewerId) { 530 return; 531 } 532 const reviewerName = yield lastValueFrom(this.employeeService.getEmployeeName$(reviewerId).pipe(take(1))); 533 const responseDays = job?.applicationResponseTime && job.applicationResponseTime >
533 0 ? job.applicationResponseTime : _ApplicantService.DEFAULT_REVIEW_DAYS; 534 const dueDate = this.clampReviewDueDate(DateTime.now().plus({ 535 days: responseDays 536 }), job); 537 const now = Timestamp.now(); 538 const apiLng = environment.dbLanguage; 539 const apiText = apiTranslations[apiLng]; 540 const task = { 541 title: apiText.taskReviewApplicantTitle.replace("{name}", candidateName), 542 description: apiText.taskReviewApplicantDescription.replace("{name}", candidateName).replace("{days}", String(responseDays)).replace("{fullName}", session.fullName), 543 associatedId: jobId, 544 associatedLabel: job?.title ?? candidateName, 545 associatedType: "JOB", 546 attachments: [], 547 comments: [], 548 completedById: null, 549 ownerRead: null, 550 seenBy: null, 551 completedAt: null, 552 createdAt: now, 553 documentIds: [reviewerId, applicantId], 554 dueDate, 555 isCompleted: false, 556 lastUpdatedAt: now, 557 ownerId: reviewerId, 558 ownerName: reviewerName, 559 ownerType: "EMPLOYER" 560 }; 561 batch.set(doc(collection(this.firestore, "organizations", orgId, "tasks")), task); 562 }); 563 } 564 // Keep a task's due date within the job's hiring window: never after dueDate, and never in the past. 565 clampReviewDueDate(candidate, job) { 566 let due = candidate; 567 const jobDue = job?.dueDate ? DateTime.fromMillis(job.dueDate.toMillis()) : null; 568 if (jobDue && due > jobDue) { 569 due = jobDue; 570 } 571 const min = DateTime.now(); 572 if (due < min) { 573 due = min; 574 } 575 return Timestamp.fromDate(due.toJSDate()); 576 } 577 // Adds an assign/unassign job-applicant timeline entry to the batch, tied to the candidate + 578 // vacancy (documentIds: [applicantId, jobId]). Subject/content come from the EN/NL apiText 579 // templates; the placeholders are filled here. 580 addJobAssignmentTimeline(batch, session, applicantId, jobId, candidateName, jobTitle, assigned) { 581 const apiText = apiTranslations[environment.language]; 582 const subjectTpl = assigned ? apiText.timelineJobApplicantAssignedSubject : apiText.timelineJobApplicantUnassignedSubject; 583 const contentTpl = assigned ? apiText.timelineJobApplicantAssignedContent : apiText.timelineJobApplicantUnassignedContent; 584 const fill = tpl => tpl.replace("{name}", candidateName).replace("{jobName}", jobTitle).replace("{timeNow}", DateTime.now().toFormat("dd/MM/yyyy HH:mm")).replace("{fullName}", session.fullName); 585 const timelineRef = doc(collection(this.firestore, "organizations", session.orgId, "timeline")); 586 const timeline = { 587 subject: fill(subjectTpl), 588 content: fill(contentTpl), 589 category: assigned ? "APPLICANT_ASSIGNED" : "APPLICANT_UNASSIGNED", 590 createdById: session.employeeId, 591 documentIds: [applicantId, jobId], 592 createdAt: Timestamp.now() 593 }; 594 batch.set(timelineRef, timeline); 595 } 596 addExistingApplicantToJobRequest(session, applicantId, jobId, details) { 597 return __async(this, null, function* () { 598 const orgId = session.orgId; 599 const applicantRef = doc(this.firestore, "organizations", orgId, "applicants", applicantId); 600 const applicant = (yield getDoc(applicantRef)).data(); 601 if (!applicant) { 602 throw new Error("Kandidaat niet gevonden"); 603 } 604 const jobApplicantsCol = collection(this.firestore, "organizations", orgId, "jobApplicants"); 605 const existingSnap = yield getDocs(query(jobApplicantsCol, where("applicantId", "==", applicantId))); 606 const existing = existingSnap.docs.map(docSnap => docSnap.data()); 607 if (existing.some(jobApplicant => jobApplicant.jobId === jobId)) { 608 throw new Error("Deze kandidaat is al aan deze vacature gekoppeld"); 609 } 610 this.assertApplicantAssignable(applicant, existing); 611 const job = (yield getDoc(doc(this.firestore, "organizations", orgId, "jobs", jobId))).data(); 612 const jobApplicantRef = doc(collection(this.firestore, "organizations", orgId, "jobApplicants")); 613 const batch = writeBatch(this.firestore); 614 batch.set(jobApplicantRef, this.newJobApplicant(session, applicantId, jobId, job, details)); 615 const candidateName = `${applicant.firstName ?? ""} ${applicant.lastName ?? ""}`.trim(); 616 this.addJobAssignmentTimeline(batch, session, applicantId, jobId, candidateName, job?.title ?? "", true); 617 yield this.addReviewTaskToBatch(batch, session, orgId, job, jobId, applicantId, jobApplicantRef.id, candidateName); 618 yield batch.commit(); 619 yield this.rebuildSearchTokens(session, applicantId); 620 return jobApplicantRef.id; 621 }); 622 } 623 static { 624 this.HIRING_STAGE_RANK = { 625 denied: 0, 626 withdrawn: 1, 627 suggested: 2, 628 received: 3, 629 reviewed: 4, 630 interviewing: 5, 631 offer: 6, 632 hired: 7 633 }; 634 } 635 /** 636 * Per-applicant job-application summary for the visible page: how many jobs they're linked to and 637 * their furthest hiring stage. Filters at the DB level with `in` (max 30 ids per query) over the 638 * visible page, never the whole collection. 639 */ 640 getJobApplicantSummaries(applicantIds) { 641 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(session => from(this.jobApplicantSummariesRequest(session, applicantIds)))); 642 } 643 jobApplicantSummariesRequest(session, applicantIds) { 644 return __async(this, null, function* () { 645 const summaries = {};
646 const ids = Array.from(new Set(applicantIds.filter(Boolean))); 647 if (!ids.length) return summaries; 648 const col = collection(this.firestore, "organizations", session.orgId, "jobApplicants"); 649 for (let i = 0; i < ids.length; i += 30) { 650 const chunk = ids.slice(i, i + 30); 651 const snap = yield getDocs(query(col, where("applicantId", "in", chunk))); 652 snap.docs.forEach(docSnap => { 653 const data = docSnap.data(); 654 const summary = summaries[data.applicantId] ?? { 655 count: 0, 656 stage: null 657 }; 658 summary.count += 1; 659 summary.stage = this.furthestStage(summary.stage, data.status); 660 summaries[data.applicantId] = summary; 661 }); 662 } 663 return summaries; 664 }); 665 } 666 furthestStage(current, candidate) { 667 if (!current) return candidate; 668 const rank = _ApplicantService.HIRING_STAGE_RANK; 669 return rank[candidate] >= rank[current] ? candidate : current; 670 } 671 /** 672 * Update only the recruiter-managed profile tags on a person, then rebuild search tokens. 673 * Used by the job-applicant review so Skills edited there persist on the Applicant. 674 */ 675 updateApplicantProfileTags(applicantId, profileTagLabels) { 676 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(session => __async(this, null, function* () { 677 const ref = doc(this.firestore, "organizations", session.orgId, "applicants", applicantId); 678 yield updateDoc(ref, { 679 profileTagLabels, 680 lastUpdatedAt: Timestamp.now(), 681 lastUpdatedBy: session.fullName, 682 lastUpdatedById: session.employeeId 683 }); 684 yield this.rebuildSearchTokens(session, applicantId); 685 }))); 686 } 687 rebuildSearchTokens(session, applicantId) { 688 return __async(this, null, function* () { 689 const orgId = session.orgId; 690 const applicantRef = doc(this.firestore, "organizations", orgId, "applicants", applicantId); 691 const applicant = (yield getDoc(applicantRef)).data(); 692 if (!applicant) return; 693 const jobApplicantsCol = collection(this.firestore, "organizations", orgId, "jobApplicants"); 694 const jobApplicantSnap = yield getDocs(query(jobApplicantsCol, where("applicantId", "==", applicantId))); 695 const jobIds = Array.from(new Set(jobApplicantSnap.docs.map(docSnap => docSnap.data().jobId))); 696 const functionNames = []; 697 const profileTagLabels = applicant.profileTagLabels ?? []; 698 const jobTags = []; 699 for (const jobId of jobIds) { 700 const job = (yield getDoc(doc(this.firestore, "organizations", orgId, "jobs", jobId))).data(); 701 if (job?.title) functionNames.push(job.title); 702 if (job?.tagLabels?.length) jobTags.push(...job.tagLabels); 703 } 704 const update = __spreadProps(__spreadValues({}, buildApplicantSearchFields({ 705 firstName: applicant.firstName, 706 lastName: applicant.lastName, 707 email: applicant.email, 708 phoneNumber: applicant.phoneNumber, 709 functionNames, 710 tagLabels: jobTags, 711 profileTagLabels 712 })), { 713 lastUpdatedAt: Timestamp.now(), 714 lastUpdatedBy: session.fullName, 715 lastUpdatedById: session.employeeId 716 }); 717 yield updateDoc(applicantRef, update); 718 }); 719 } 720 // ------------------------- 721 // ONE-OFF MIGRATION / BACKFILL 722 // ------------------------- 723 /** 724 * One-off migration for pre-existing applicants. Idempotent and safe to re-run: 725 * Step 2 â for every applicant that still carries a legacy `jobId`, create a JobApplicant from 726 * its job-tied fields (jobId/status/feedback/ratings/receivedAt) unless one already 727 * exists for that applicant+job. 728 * Step 3 â (re)build normalizedName/normalizedEmail/searchTokens/tagLabels from the person plus 729 * every job they are linked to (legacy + existing jobApplicants), and set the retention 730 * defaults (retentionStatus 'active', retentionDeleteAt null) when missing. 731 * Not wired to a route â trigger manually (e.g. from the browser console) on each org once. 732 */ 733 backfillApplicants() { 734 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(session => from(this.backfillApplicantsRequest(session)))); 735 } 736 backfillApplicantsRequest(session) { 737 return __async(this, null, function* () { 738 const orgId = session.orgId; 739 const applicantsSnap = yield getDocs(query(collection(this.firestore, "organizations", orgId, "applicants"))); 740 const jobApplicantsCol = collection(this.firestore, "organizations", orgId, "jobApplicants"); 741 const existingJobApplicantsSnap = yield getDocs(query(jobAp
741plicantsCol)); 742 const existingKeys = /* @__PURE__ */new Set(); 743 const jobIdsByApplicant = /* @__PURE__ */new Map(); 744 for (const docSnap of existingJobApplicantsSnap.docs) { 745 const data = docSnap.data(); 746 existingKeys.add(`${data.applicantId}__${data.jobId}`); 747 if (!jobIdsByApplicant.has(data.applicantId)) jobIdsByApplicant.set(data.applicantId, /* @__PURE__ */new Set()); 748 jobIdsByApplicant.get(data.applicantId).add(data.jobId); 749 } 750 const jobCache = /* @__PURE__ */new Map(); 751 const getJob = jobId => __async(this, null, function* () { 752 if (!jobCache.has(jobId)) { 753 jobCache.set(jobId, (yield getDoc(doc(this.firestore, "organizations", orgId, "jobs", jobId))).data()); 754 } 755 return jobCache.get(jobId); 756 }); 757 const now = Timestamp.now(); 758 let processed = 0; 759 let jobApplicantsCreated = 0; 760 let batch = writeBatch(this.firestore); 761 let writes = 0; 762 const flush = () => __async(this, null, function* () { 763 if (writes > 0) { 764 yield batch.commit(); 765 batch = writeBatch(this.firestore); 766 writes = 0; 767 } 768 }); 769 for (const applicantSnap of applicantsSnap.docs) { 770 const applicantId = applicantSnap.id; 771 const applicant = applicantSnap.data(); 772 const legacy = applicantSnap.data(); 773 const legacyJobId = legacy["jobId"]; 774 if (legacyJobId && !existingKeys.has(`${applicantId}__${legacyJobId}`)) { 775 const legacyJob = yield getJob(legacyJobId); 776 const jobApplicantRef = doc(jobApplicantsCol); 777 const jobApplicantDoc = { 778 applicantId, 779 jobId: legacyJobId, 780 status: legacy["status"] ?? "received", 781 functionNameSnapshot: legacyJob?.title ?? "", 782 tagLabelsSnapshot: legacyJob?.tagLabels ?? [], 783 feedback: legacy["feedback"] ?? null, 784 resumeFeedback: legacy["resumeFeedback"] ?? null, 785 overallRating: legacy["overallRating"] ?? null, 786 resumeRating: legacy["resumeRating"] ?? null, 787 receivedAt: legacy["receivedAt"] ?? applicant.createdAt ?? now, 788 deniedAt: null, 789 hiredAt: null, 790 processClosedAt: null, 791 lastUpdatedBy: session.fullName, 792 lastUpdatedById: session.employeeId, 793 lastUpdatedAt: now, 794 createdBy: applicant.createdBy ?? session.fullName, 795 createdById: applicant.createdById ?? session.employeeId, 796 createdAt: applicant.createdAt ?? now 797 }; 798 batch.set(jobApplicantRef, jobApplicantDoc); 799 existingKeys.add(`${applicantId}__${legacyJobId}`); 800 jobApplicantsCreated++; 801 writes++; 802 } 803 const jobIds = new Set(jobIdsByApplicant.get(applicantId) ?? []); 804 if (legacyJobId) jobIds.add(legacyJobId); 805 const functionNames = []; 806 const tagLabels = [...(applicant.tagLabels ?? [])]; 807 for (const jobId of jobIds) { 808 const job = yield getJob(jobId); 809 if (job?.title) functionNames.push(job.title); 810 if (job?.tagLabels?.length) tagLabels.push(...job.tagLabels); 811 } 812 const update = __spreadProps(__spreadValues({}, buildApplicantSearchFields({ 813 firstName: applicant.firstName, 814 lastName: applicant.lastName, 815 email: applicant.email, 816 phoneNumber: applicant.phoneNumber, 817 functionNames, 818 tagLabels 819 })), { 820 retentionStatus: applicant.retentionStatus ?? "active", 821 retentionDeleteAt: applicant.retentionDeleteAt ?? null, 822 lastUpdatedAt: now, 823 lastUpdatedBy: session.fullName, 824 lastUpdatedById: session.employeeId 825 }); 826 batch.update(applicantSnap.ref, update); 827 processed++; 828 writes++; 829 if (writes >= 400) { 830 yield flush(); 831 } 832 } 833 yield flush(); 834 return { 835 processed, 836 jobApplicantsCreated 837 }; 838 }); 839 } 840 // ------------------------- 841 // CREATE (refactored) 842 // ------------------------- 843 createApplicant(applicant, jobApplicant, file) { 844 let session; 845 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(currentSession => { 846 session = currentSession;
847 if (!jobApplicant) { 848 return of(void 0); 849 } 850 const jobRef = doc(this.firestore, "organizations", session.orgId, "jobs", jobApplicant.jobId); 851 return from(getDoc(jobRef)).pipe(map(jobSnap => jobSnap.data())); 852 }), switchMap(job => { 853 const functionNames = job?.title ? [job.title] : []; 854 const profileTagLabels = applicant.tagLabels ?? []; 855 const jobTags = job?.tagLabels ?? []; 856 const applicantRef = doc(collection(this.firestore, "organizations", session.orgId, "applicants")); 857 const now = Timestamp.now(); 858 const searchFields = buildApplicantSearchFields({ 859 firstName: applicant.firstName, 860 lastName: applicant.lastName, 861 email: applicant.email, 862 phoneNumber: applicant.phoneNumber, 863 functionNames, 864 tagLabels: jobTags, 865 profileTagLabels 866 }); 867 const buildApplicant = (resumeStoragePath, resumeFileName, resumeUploadedAt) => __spreadProps(__spreadValues({ 868 firstName: applicant.firstName, 869 lastName: applicant.lastName, 870 pronouns: applicant.pronouns, 871 phoneNumber: applicant.phoneNumber, 872 email: applicant.email, 873 dateOfBirth: applicant.dateOfBirth, 874 city: applicant.city, 875 resumeStoragePath, 876 resumeFileName, 877 resumeUploadedAt, 878 linkedInUrl: applicant.linkedInUrl ?? null 879 }, searchFields), { 880 source: applicant.source ?? null, 881 applicantStatus: "active", 882 privacyPermission: applicant.privacyPermission ?? null, 883 retentionStatus: "active", 884 retentionDeleteAt: null, 885 lastUpdatedBy: session.fullName, 886 lastUpdatedById: session.employeeId, 887 lastUpdatedAt: now, 888 createdBy: session.fullName, 889 createdById: session.employeeId, 890 createdAt: now 891 }); 892 const commit = (resumeStoragePath, resumeFileName, resumeUploadedAt) => __async(this, null, function* () { 893 const batch = writeBatch(this.firestore); 894 batch.set(applicantRef, buildApplicant(resumeStoragePath, resumeFileName, resumeUploadedAt)); 895 if (jobApplicant) { 896 const jobApplicantRef = doc(collection(this.firestore, "organizations", session.orgId, "jobApplicants")); 897 batch.set(jobApplicantRef, this.newJobApplicant(session, applicantRef.id, jobApplicant.jobId, job, jobApplicant)); 898 const candidateName = `${applicant.firstName ?? ""} ${applicant.lastName ?? ""}`.trim(); 899 yield this.addReviewTaskToBatch(batch, session, session.orgId, job, jobApplicant.jobId, applicantRef.id, jobApplicantRef.id, candidateName); 900 } 901 yield batch.commit(); 902 return applicantRef.id; 903 }); 904 if (!file) { 905 return commit(null, null, null); 906 } 907 const resumeLabel = "CV"; 908 const description = `${resumeLabel} ${applicant.firstName} ${applicant.lastName}`; 909 return this.attachmentService.uploadApplicantFileReturnAttachment(file, description, applicantRef.id, jobApplicant?.jobId ?? "").pipe(switchMap(persistedAttachment => commit(persistedAttachment.storagePath, file.name, Timestamp.now()))); 910 }), 911 // New applicant person written â drop the search cache so overviews/lookups see it without a reload. 912 tap(() => this.invalidateApplicantsForSearch()), catchError(err => { 913 console.error("Error creating applicant:", err); 914 return throwError(() => err); 915 })); 916 } 917 updateApplicant(applicantBody, file, applicantId, attachmentChanged, oldResumeStoragePath, jobId) { 918 let session; 919 return this.authenticationService.getCurrentSession$().pipe(take(1), switchMap(currentSession => { 920 session = currentSession; 921 if (attachmentChanged && file) { 922 const description = `${file.name} - ${DateTime.now().toFormat("dd.MM.yyyy")}`; 923 return this.attachmentService.uploadApplicantFileReturnAttachment(file, description, applicantId, jobId).pipe(map(result => ({ 924 uploaded: result 925 }))); 926 } 927 return of({ 928 uploaded: null 929 }); 930 }), switchMap(({ 931 uploaded 932 }) => from(this.updateApplicantRequest(session, applicantBody, applicantId, uploaded, oldResumeStoragePath, file ?? null))), 933 // Applicant person changed â drop the search cache so overviews/lookups reflect the edit. 934 tap(() => this.invalidateApplicantsForSearch()), catchError(err => { 935 console.error("Error updating applicant:", err); 936 return throwError(() => err); 937 })); 938 } 939 updateApplicantRequest(session, applicantBody, applicantId, uploaded, oldResumeStoragePath, uploadedFile) { 940 return __async(this, null, function* () { 941 const applicantRef = doc(this.firestore, "organizations", session.orgId, "applicants", applicantId); 942 const applicantSnapshot = yield getDoc(applicantRef); 943 if (!applicantSnapshot.exists()) { 944 throw new Error("De kandidaat bestaat niet meer"); 945 } 946 const currentApplicant = applicantSnapshot.data(); 947 const profilePatch = removeUndefinedValues(__spreadValues({}, applicantBody)); 948 const searchFields = buildApplicantSearchFields(getApplicantSearchInputAfterProfileUpdate(currentApplicant, profilePatch)); 949 const batch = writeBatch(this.firestore); 950 const resumeRemoved = !uploaded && profilePatch.resumeStoragePath === null; 951 const resumePatch = uploaded ? {
952 resumeStoragePath: uploaded.storagePath, 953 resumeFileName: uploadedFile?.name ?? null, 954 resumeUploadedAt: Timestamp.now() 955 } : resumeRemoved ? { 956 resumeStoragePath: null, 957 resumeFileName: null, 958 resumeUploadedAt: null 959 } : {}; 960 const mustCleanupOld = !!oldResumeStoragePath && (uploaded || resumeRemoved); 961 if (mustCleanupOld && oldResumeStoragePath) { 962 try { 963 yield this.storageService.deleteObjectFromStorage(oldResumeStoragePath, session); 964 } catch (e) { 965 console.error("Failed to delete old resume from storage:", e); 966 } 967 } 968 const applicantPatch = __spreadProps(__spreadValues(__spreadValues(__spreadValues({}, profilePatch), resumePatch), searchFields), { 969 lastUpdatedAt: Timestamp.now(), 970 lastUpdatedBy: session.fullName, 971 lastUpdatedById: session.employeeId 972 }); 973 batch.update(applicantRef, applicantPatch); 974 if (uploaded || resumeRemoved) { 975 yield this.syncResumeSnapshotsToJobApplicants(batch, session, applicantId, { 976 resumeStoragePathSnapshot: resumePatch.resumeStoragePath ?? null, 977 resumeFileNameSnapshot: resumePatch.resumeFileName ?? null, 978 resumeSubmittedAt: resumePatch.resumeUploadedAt ?? null 979 }); 980 } 981 const apiLng = environment.dbLanguage; 982 const apiText = apiTranslations[apiLng]; 983 const timelineRef = doc(collection(this.firestore, "organizations", session.orgId, "timeline")); 984 const firstName = (profilePatch.firstName ?? currentApplicant.firstName).toString(); 985 const lastName = (profilePatch.lastName ?? currentApplicant.lastName).toString(); 986 const fullName = `${firstName} ${lastName}`.trim(); 987 const timeline = { 988 documentIds: [applicantId], 989 subject: apiText.timelineApplicantSubject.replace("{fullName}", fullName), 990 content: apiText.timelineApplicantContent.replace("{fullName}", fullName).replace("{timeNow}", DateTime.now().toFormat("dd/MM/yyyy HH:mm")), 991 category: "APPLICANT_UPDATED", 992 createdById: session.employeeId, 993 createdAt: Timestamp.now() 994 }; 995 batch.set(timelineRef, timeline); 996 yield batch.commit(); 997 }); 998 } 999 /** 1000 * Mirror a person's resume change onto every JobApplicant linking them to a vacancy. Editing the 1001 * applicant in one place must keep the per-application resume snapshots in sync (the CV the 1002 * recruiter sees on each application), so this updates all of the applicant's job-applications. 1003 */ 1004 syncResumeSnapshotsToJobApplicants(batch, session, applicantId, snapshot) { 1005 return __async(this, null, function* () { 1006 const jobApplicantsCol = collection(this.firestore, "organizations", session.orgId, "jobApplicants"); 1007 const jobApplicantsSnap = yield getDocs(query(jobApplicantsCol, where("applicantId", "==", applicantId))); 1008 const now = Timestamp.now(); 1009 for (const jobApplicantDoc of jobApplicantsSnap.docs) { 1010 batch.update(jobApplicantDoc.ref, __spreadProps(__spreadValues({}, snapshot), { 1011 lastUpdatedBy: session.fullName, 1012 lastUpdatedById: session.employeeId, 1013 lastUpdatedAt: now 1014 })); 1015 } 1016 }); 1017 } 1018 // ------------------------- 1019 // REST METHODS (unchanged) 1020 // ------------------------- 1021 /** Deny a job application (no candidate email). Existing endpoint. */ 1022 denyApplicant(jobApplicantId, feedback) { 1023 const url = environment.apiConfig.denyApplicant; 1024 return this.httpClient.post(url, this.buildDenyPayload(jobApplicantId, feedback)).pipe(catchError(err => this.handleHttpError(err))); 1025 } 1026 /** Deny a job application AND send the candidate a rejection email (the ngx-editor HTML). */ 1027 denyApplicantWithEmail(jobApplicantId, feedback) { 1028 const url = environment.apiConfig.denyApplicantWithEmail; 1029 return this.httpClient.post(url, this.buildDenyPayload(jobApplicantId, feedback)).pipe(catchError(err => this.handleHttpError(err))); 1030 } 1031 /** 1032 * Deny a job application for this vacancy but keep the candidate in the talent pool. 1033 * `responseDeadline` is the localized date the candidate has to answer the consent email by 1034 * (it's injected straight into the talent-pool Brevo template) â not the rejection feedback. 1035 */ 1036 denyApplicantAndKeepInTalentPool(jobApplicantId, responseDeadline) { 1037 const url = environment.apiConfig.denyApplicantAndKeepInTalentPool; 1038 return this.httpClient.post(url, { 1039 jobApplicantId, 1040 responseDeadline 1041 }).pipe(catchError(err => this.handleHttpError(err))); 1042 } 1043 // Shared deny request body â only the job-applicant id and the feedback (the editor value).
1044 buildDenyPayload(jobApplicantId, feedback) { 1045 return { 1046 jobApplicantId, 1047 feedback 1048 }; 1049 } 1050 hireApplicant(applicant, jobId, email, emailContent, talentPoolJobApplicantIds = [], removeFromTalentPoolJobApplicantIds = []) { 1051 const url = environment.apiConfig.hireApplicant; 1052 const body = { 1053 applicantId: applicant.id, 1054 jobId, 1055 email, 1056 content: emailContent, 1057 talentPoolJobApplicantIds, 1058 removeFromTalentPoolJobApplicantIds, 1059 language: environment.language 1060 }; 1061 return this.httpClient.post(url, body).pipe(catchError(err => this.handleHttpError(err))); 1062 } 1063 handleHttpError(err) { 1064 if (err?.error) return throwError(() => err.error); 1065 if (err?.message) return throwError(() => err.message); 1066 return throwError(() => err); 1067 } 1068 static { 1069 this.ɵfac = function ApplicantService_Factory(__ngFactoryType__) { 1070 return new (__ngFactoryType__ || _ApplicantService)(ɵɵinject(Firestore), ɵɵinject(AttachmentService), ɵɵinject(StorageService), ɵɵinject(HttpClient), ɵɵinject(EmployeeService), ɵɵinject(AuthenticationService)); 1071 }; 1072 } 1073 static { 1074 this.ɵprov = /* @__PURE__ */ɵɵdefineInjectable({ 1075 token: _ApplicantService, 1076 factory: _ApplicantService.ɵfac, 1077 providedIn: "root" 1078 }); 1079 } 1080}; 1081(() => { 1082 (typeof ngDevMode === "undefined" || ngDevMode) && setClassMetadata(ApplicantService, [{ 1083 type: Injectable, 1084 args: [{ 1085 providedIn: "root" 1086 }] 1087 }], () => [{ 1088 type: Firestore 1089 }, { 1090 type: AttachmentService 1091 }, { 1092 type: StorageService 1093 }, { 1094 type: HttpClient 1095 }, { 1096 type: EmployeeService 1097 }, { 1098 type: AuthenticationService 1099 }], null); 1100})(); 1101export { toMillisSafe, isPermissionGranted, isPermissionExpired, isApplicantTalentPoolEligible, isDeletionApproaching, ApplicantService }; 1102/**i18n:55a58011b6fcf12b70aba021cbe233ce2fe179902ce3502a5c6547ccaba0ed7f*/ 1103//# sourceMappingURL=chunk-523OW2E2.js.map
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.