PageSourceSearch

https://office.workvine.nl/chunk-523OW2E2.js

js workvine.nl collected 2026-10-09 01:39:56 UTC 52,454 bytes, 1,103 lines download raw bytes

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.