StackLogHr (stack_log_hr колекція)

#draft Модель: StackLogHrModel.ts.

Універсальний audit-log усіх подій навколо HR-хайрингу: створення/редагування оператора, зміна статусу картки (StackStatusCard), назначення/зняття HR ↔ Operator, HR ↔ Supervisor, HR ↔ Recruiter, Recruiter ↔ Operator.

Поля

ПолеТипОбов’язкове (schema)Призначення
idstringніmongo _id (transform plugin)
timestampnumberні (default: Date.now)коли сталась подія
initiatorStackIdstringніхто ініціював дію (актор — HR/Recruiter/Director/…). Часто відсутній для системних подій (referral, telegram-bot, besocial.studio form)
hrStackIdstringні (хоч тип у IStackLogHr не позначений ?)HR, з яким пов’язана дія. Подвійна семантика — див. нижче
recruiterStackIdstring?ніRecruiter, з яким пов’язана дія
roleStackRolesніфактично ніде не заповнюється при .create() (перевірено grep-ом по всій кодовій базі) — не покладатись
userStackIdstringтаксуб’єкт логу: юзер (оператор/supervisor/recruiter), якого стосується подія
eventStackHrLogEventType | StackStatusCardтакподвійне призначення — див. нижче
familyFamilies?нізаведене в схемі, але жоден .create() виклик його не встановлює — мертве поле
sourceCardSource?нізвідки прийшов оператор, ставиться лише разом з create_operator

event: два незалежні “словники” в одному полі

StackHrLogEventTypeCombined = StackStatusCard | StackHrLogEventType — тобто в одному стовпці мішаються:

  1. Структурні переходи (StackHrLogEventType): set_stack_operator/unset_stack_operator, set_stack_supervisor/unset_stack_supervisor, set_stack_recruiter/unset_stack_recruiter, create_operator, edit_operator.
  2. Бізнес-статуси картки (StackStatusCard): New, In progress, Approved, Hired, Company declined, Applicant declined, No response, Duplicate, …

Немає discriminator-поля — кожен reader змушений або .includes() по одному з enum-ів, або switch з подвійним as-кастом (log.event as StackStatusCard | StackHrLogEventType — так і зроблено в JobseekerService.convertLogs).

Де пишеться (StackLogHrModel.create)

  • JobseekerService.ts
    • manualCreateOperatorcreate_operator (+ New якщо немає hr)
    • createOperatorBesocialStudioFormNew + create_operator (source: besocialStudioForm)
    • referralCreateOperatorcreate_operator (source: referral)
    • telegramBot2CreateCardForCandidateOperatorcreate_operator (source: telegramBot)
    • manualEditOperatoredit_operator
    • saveLogAndUpdateStatusStack (викликається з acceptOperatorByInitiator / changeStatusStack) — будь-який StackStatusCard, опційно hrStackId/recruiterStackId якщо оператор має hr/recruiter
  • StackHrRepository.ts
    • setStackHrToOperator/unsetStackHrFromOperatorset_stack_operator/unset_stack_operator з hrStackId (+ каскадний In progress/New лог)
    • setStackHrToSupervisor/unsetStackHrFromSupervisorset_stack_supervisor/unset_stack_supervisor з hrStackId (супервайзор технічно “прив’язується до HR”, хоч у домені він не підпорядкований HR напряму)
  • recruiter.service.ts
    • setHrToRecruiterunset_stack_recruiter (старий hr) потім set_stack_recruiter (новий hr), обидва з hrStackId
    • setRecruiterToOperatorset_stack_operator з recruiterStackId (без hrStackId); якщо в оператора ще немає HR — попередньо каскадно викликає stackHrRepository.setStackHrToOperator (окремий лог із hrStackId)
    • unsetRecruiterFromOperatorunset_stack_operator з recruiterStackId (без hrStackId)

Де читається

  • StackHrRepository.getHrAllSupervisors / findLogsByStackHrId — фільтр по hrStackId
  • StackHrService.getHrRecruiterPeriodscalculateTimestampPeriodsForRecruiters — будує періоди hr↔recruiter з set_stack_recruiter/unset_stack_recruiter
  • StackStatisticsService (isUserOnHrLogs, isUserUnsetFromHrLogs, isOperatorHaveChangeHrByOperatorLogs, usersOnHrByTimeByLogs, getHrOperatorStatistics) — основний споживач для щоденної статистики відвідуваності HR; трактує послідовність set_stack_operator/unset_stack_operator (по hrStackId) як таймлайн періодів “оператор був на HR”
  • RecruiterGenerateDashboardService (recruiter-generate-dashboard.service.ts) — dashboard/decision-logs, групує по userStackId
  • JobseekerService.getCard — шукає operatorLogAccepted (Hired/Approved) для визначення, хто найняв (hrStackId/recruiterStackId на цьому логу); convertLogs (WIP) формує людський список подій картки

Нюанси / gotchas

  1. event без discriminator — легко пропустити кейс при додаванні нового статусу/типу, бо перевірка робиться вручну в кожному місці (switch, .includes()).
  2. role фактично мертве поле — типізовано як обов’язкове (role: StackRoles, без ?), але жоден .create() його не заповнює. Будь-яка майбутня логіка, що читає log.role, отримає undefined.
  3. Неузгоджена опційність у TS-типі: hrStackId: string (обов’язкове за типом) vs recruiterStackId?: string (опційне), хоча в mongoose-схемі обидва не required, і на практиці обидва часто відсутні одночасно (напр. create_operator без hr).
  4. Асиметрія HR/Recruiter-шляхів для однієї логічної дії “оператора призначили”: шлях через StackHrRepository.setStackHrToOperator пише hrStackId, шлях через recruiter.service.setRecruiterToOperator пише лише recruiterStackId (і лиш каскадно створює окремий hr-лог, якщо hr ще не було). Статистика (getHrOperatorStatistics) фільтрує виключно по hrStackId — отже коректність спирається на те, що каскад завжди відпрацює при першому призначенні. Це неявний контракт між двома сервісами.
  5. hrStackId для supervisor-подій (set_stack_supervisor/unset_stack_supervisor) семантично означає “HR, під яким супервайзор”, а не “HR виконав дію” — те саме поле перевикористовується з різним змістом залежно від event.
  6. family не заповнюється ніде — попри наявність у схемі й інтерфейсі, тому “family-scoped” статистика (StackStatisticsService) змушена окремо резолвити family через findOperatorFamiliesBy... замість прямого фільтру по логах.
  7. Один лог = максимум один hr і один recruiter. Немає місця для довільної кількості “сторін” події (напр. одночасно старий+новий hr, або team-lead окремо від hr).

Ідеї для більшої гнучкості hrStackId / recruiterStackId

Наведені нижче — варіанти рефакторингу, не застосовані (потребують окремого рішення, бо чіпають кілька сервісів і читальні запити):

  1. Generic “relations” map замість окремих колонок:

    relations?: Partial<Record<'hr' | 'recruiter' | 'supervisor', string>>;

    Додавання нового типу зв’язку (напр. teamlead, clientManager) не вимагає нової колонки/індексу — просто новий ключ. Запити переписуються на relations.hr замість hrStackId.

  2. Масив “learties” (counterparts) — розв’язує проблему “1 hr + 1 recruiter максимум” і прибирає асиметрію (одна подія set_stack_operator могла б одразу нести і hr, і recruiter, без каскадного другого запису):

    counterparts: {
    	role: StackRoles;
    	userStackId: string;
    }
    [];

    Дає змогу писати один лог на дію замість двох (HR-каскад + Recruiter-лог), і статистика фільтрує по counterparts через $elemMatch.

  3. Discriminator для event:

    kind: 'transition' | 'status';
    event: StackHrLogEventType | StackStatusCard;

    Прибирає потребу вручну перевіряти належність значення до одного з двох enum-ів у кожному читальному місці.

  4. Зробити role значущим або прибрати. Якщо задум був “роль ініціатора” — перейменувати на initiatorRole і почати заповнювати; якщо не потрібне — видалити з інтерфейсу/схеми, щоб не вводити в оману.

  5. Заповнювати family при записі (благо поле вже є) — дає змогу фільтрувати статистику по family напряму в MongoDB-запиті, без додаткового join через userRepository.findOperatorFamiliesBy....

  6. Явний контракт для “cascade” HR-логу — задокументувати/винести в один спільний хелпер (ensureHrLogForOperator), яким користуються і StackHrRepository, і RecruiterService, щоб асиметрія з п.4 не залежала від того, що обидва сервіси “пам’ятають” викликати одне одного.

Будь-яка з цих змін — це міграція існуючих записів + узгоджена зміна усіх read-сайтів вище. Раджу впроваджувати поступово (напр. спочатку discriminator для event, він найдешевший і найменш ризикований), а не одним великим рефакторингом.

ТЗ: Логи в картці оператора / кандидата (Audit Log)

Формалізовані бізнес-правила для читабельного відображення історії змін картки (ICardResult.logs, формується в JobseekerService.convertLogs).

1. Формат лога

[Date Time] — [User Name] ([Role]) — [Event]

або

[Date Time] — System — [Event]
  • User Name — користувач, що виконав дію.
  • Role — Recruiter, HR, TeamLead, CEO.
  • System — використовувати лише якщо дія відбулась повністю автоматично, без участі користувача.
  • Якщо дію ініціював користувач — навіть якщо саму зміну виконала система автоматично — в лозі відображається саме цей користувач (не System).

2. Події

2.1 Створення оператора (Operator creation)

  • Operator created (Manual)
  • Operator created (Telegram Bot)
  • Operator created (Referral)
  • Operator created (Website Form)

2.2 Поля картки (Card fields)

При зміні будь-якого поля картки показувати назву поля, старе і нове значення:

{Field Name} updated: {Old Value} → {New Value}

Приклади: First name updated: Ivan → John, Last name updated: Petrenko → Smith, Email updated: old@email.com → new@email.com, Phone updated: +34600000000 → +34611111111, Telegram updated: @old → @new, Country updated: Ukraine → Spain, Language updated: English → English, Spanish.

  • Якщо за одне збереження змінено кілька полів — кожна зміна пишеться окремим записом в історії.
  • Лог створюється тільки при фактичній зміні даних (немає діффа → немає логу).

2.3 Призначення (Assignments)

HR:

  • HR assigned: {Name} (HR)
  • HR changed: {Old Name} (HR) → {New Name} (HR)
  • HR unassigned: {Name} (HR)

Recruiter:

  • Recruiter assigned: {Name} (Recruiter)
  • Recruiter changed: {Old Name} (Recruiter) → {New Name} (Recruiter)
  • Recruiter unassigned: {Name} (Recruiter)

2.4 Family

  • Family assigned: {New Family}
  • Family TeamLead assigned: {New Name} (TeamLead)
  • Family TeamLead changed: {Old Name} (TeamLead) → {New Name} (TeamLead)
  • Family TeamLead unassigned: {New Name} (TeamLead)
  • Family changed: {Old Family} → {New Family}
  • Family TeamLead changed: {Old Name} (TeamLead) → {New Name} (TeamLead)
  • Family changed: {Old Family} → {New Family}

Правило парних lifecycle-логів: послідовність delete_familycreate_family або delete_familyrecovery_delete_family означає переміщення й відображається одним записом Family changed: {Old Family} → {New Family}. Обидва технічні записи окремо в картці не дублюються. Кожна lifecycle-подія, яка не входить у таку пару, відображається окремо.

2.5 Статус (Status)

Status changed: {Old Status} → {New Status}

Значення: New, In Progress, Approved, Hired, Applicant Declined, Company Declined, No Response, Duplicate.

2.6 Доступ (Access)

  • Blocked
  • Unblocked

3. Приклади логів

15 Jul 2026 14:20 — System — Operator created (Telegram Bot)
15 Jul 2026 14:23 — Alina (Recruiter) — Operator created (Manual)
15 Jul 2026 14:25 — Alina (Recruiter) — First name updated: Ivan → John
15 Jul 2026 14:26 — Alina (Recruiter) — Email updated: old@email.com → new@email.com
15 Jul 2026 14:31 — Alina (Recruiter) — Recruiter assigned: Alina (Recruiter)
15 Jul 2026 14:35 — Diana (HR) — HR assigned: Diana (HR)
15 Jul 2026 14:40 — Stanislav (CEO) — TeamLead assigned: Kateryna KitKat (TeamLead)
15 Jul 2026 14:42 — Stanislav (CEO) — Recruiter changed: Alina (Recruiter) → Kate (Recruiter)
15 Jul 2026 14:45 — Stanislav (CEO) — HR changed: Diana (HR) → Mike (HR)
15 Jul 2026 14:47 — Stanislav (CEO) — Status changed: In Progress → Approved
15 Jul 2026 14:49 — Stanislav (CEO) — Status changed: Approved → Hired
15 Jul 2026 14:52 — Alina (Recruiter) — Family changed: Silver Family → Golden Family
15 Jul 2026 14:54 — Alina (Recruiter) — Family TeamLead changed: Kateryna KitKat (TeamLead) → Olena (TeamLead)
15 Jul 2026 14:56 — Diana (HR) — Blocked

4. Правила

  1. Кожен лог містить дату й час, користувача (або System), роль користувача і опис події.
  2. System використовувати лише для повністю автоматичних дій.
  3. Якщо дію ініціював користувач — завжди відображати цього користувача (навіть якщо виконання автоматичне).
  4. Для змін полів картки, Family, статусів і призначень відображати старе і нове значення, якщо старе значення існувало.
  5. Для всіх призначень відображати користувача, якого призначили, зняли або замінили.
  6. Якщо змінено кілька полів картки за раз — для кожного поля створювати окремий запис в історії.
  7. Не створювати лог при відкритті картки, перегляді картки або збереженні без фактичних змін.

Примітка: це ТЗ підготував менеджер продукту. Бізнес-логіка та послідовність подій тут коректні, і більшу частину має реалізувати розробник (convertLogs / IStackLogResult). Але конкретні назви (значення source — Manual/Telegram Bot/Referral/Website Form, назви статусів — New/In Progress/Approved/Hired/… тощо) можуть не збігатися 1:1 з реальними enum-ами системи (CardSource, StackStatusCard, StackHrLogEventType) — при імплементації брати канонічні значення з @it-monkeys/stack-commons і StackLogHrModel.ts, а не з тексту ТЗ дослівно. #Entity

stack-log-hr

StackLogHr

src/stack/model/StackLogHrModel.ts — колекція stack_log_hr: аудит-лог кадрових подій оператора (створення картки, зміни статусу, призначення/зняття HR, Recruiter-а, тімліда).

Для менеджера: які події фіксуємо і як показуємо логи

Які дії фіксуємо:

  • Створення анкети оператора/кандидата — вручну, по реферальному посиланню, через Telegram-бота або через форму на сайті besocial.studio.
  • Редагування картки оператора.
  • Призначення та зняття HR, Recruiter-а, тімліда.
  • Переведення Recruiter-а від одного HR до іншого.
  • Будь-яку зміну статусу картки: New, In progress, Approved, Hired, Applicant declined, Company declined, No response, Duplicate.

Як зараз виглядають логи:

Логи показуються в картці оператора як історія дій, українською мовою, у хронологічному порядку. Для більшості подій видно лише саму дію (“Створення оператора”, “Назначення оператора на HR” тощо, без деталей). Для двох ключових статусів — Approved і Hired — додатково показується, хто прийняв рішення: Recruiter, HR чи директор. Ім’я конкретної людини, яка ініціювала дію, на екрані зараз не показується.

Приклади повідомлень — вже реалізовано

Так виглядає текст логу в картці оператора зараз (беремо реальний текст з коду, JobseekerService.convertLogs):

Що сталосьЩо бачить менеджер
Створив реферал (система)“Створення оператора Ref системою”
Створив Telegram-бот”Створення оператора Telegram ботом”
Заповнив форму на besocial.studio”Створення оператора: besocial.studio/form”
Оператора вручну створив Recruiter”Створення оператора Recruiter-ом”
Оператора вручну створив HR”Створення оператора HR-ом”
Оператора створили без HR/Recruiter-а”Створення оператора”
Відредагували картку”Редагування оператора”
Призначили оператора на HR”Назначення оператора на HR”
Призначили оператора на Recruiter-а”Назначення оператора на Recruiter-а”
Зняли оператора з HR”Зняття оператора з HR”
Зняли оператора з Recruiter-а”Зняття оператора з Recruiter-а”
Призначили тімліда”Назначення тімліда на HR”
Зняли тімліда”Зняття тімліда з HR”
HR погодив картку”Approved HR-ом”
Recruiter найняв оператора”Hired Recruiter-ом”
Директор погодив/найняв напряму (без HR і Recruiter-а)“Approved директором” / “Hired директором”
Статус змінили на New / In progress / Applicant declined / Company declined / No response / Duplicateпоказується без перекладу, так як записано в базі: “New” / “In progress” / “Applicant declined” / “Company declined” / “No response” / “Duplicate”

Нюанс: для статусів “Approved” і “Hired” слово залишається англійською, а виконавець дописується українською (“Approved HR-ом”) — переклад лише частковий, це не помилка документації, а поточна поведінка коду.

Пропозиції щодо покращення відображення логів

Ідеї для доопрацювання, ще не реалізовано. Нижче — як виглядає зараз і як могло б виглядати.

  • Логи блокування/розблокування оператора. Зараз таких рядків в історії немає взагалі. Могло б бути: “Заблоковано директором” / “Розблоковано HR-ом”, а для сімейних подій — “Заблоковано (сім’я Chathouse)” / “Розблоковано (сім’я Golden)“.
  • Ім’я, Нікнейм і Роль ініціатора лога. Зараз: “Назначення оператора на HR” (без імені того, хто призначив). Могло б бути: “Назначення оператора на HR — Олена Коваль (нікнейм: elena_k, роль: HR)” — за наявності initiatorStackId, аналогічно тому, як вже підтягується автор до коментарів у getCard.
  • Логи зміни family. Зараз таких рядків в історії немає. Могло б бути: “Створено сім’ю Golden”, “Заблоковано сім’ю Chathouse”, “Переведено з Prime на Udate” — на основі поля family, яке вже є у схемі, але зараз ніде не заповнюється.

Поля

  • timestamp — час події (мс), за замовчуванням момент запису.
  • initiatorStackId — хто ініціював дію (директор / HR / recruiter). Відсутнє для системних подій (телеграм-бот, реферальне створення, besocial.studio/form).
  • hrStackId — HR, повʼязаний з подією: або кому призначили/у кого зняли оператора чи тімліда, або HR оператора на момент запису статусу картки.
  • recruiterStackId — Recruiter, повʼязаний з подією (аналогічно hrStackId, але для Recruiter-а).
  • userStackId — оператор/кандидат/recruiter/тімлід, якого стосується подія (обовʼязкове).
  • event — тип події. Значення беруться з двох різних enum одразу в одне поле: StackHrLogEventType (службові дії) або StackStatusCard (статус картки, пишеться напряму як рядок, без окремої обгортки).
  • source — джерело створення оператора (CardSource), заповнюється лише для create_operator.
  • family — присутнє у схемі, але жоден з записів у сервісі stack його не заповнює.

Типи подій (event)

Службові (StackHrLogEventType)

ПодіяКоли пишетьсяДе в коді
create_operatorПри будь-якому створенні оператора/кандидата (ручне, реферал, telegram-бот, besocial.studio/form)JobseekerService.manualCreateOperator / createOperatorBesocialStudioForm / referralCreateOperator / telegramBot2CreateCardForCandidateOperator
edit_operatorПри кожному ручному редагуванні картки оператораJobseekerService.manualEditOperator
set_stack_operatorПризначення оператора на HR (hrStackId) або на Recruiter-а (recruiterStackId) — те саме значення event, розрізняється лише заповненим полемStackHrRepository.setStackHrToOperator (HR) / recruiter.service.setRecruiterToOperator (Recruiter)
unset_stack_operatorЗняття оператора з HR або з Recruiter-а (аналогічно, за тим самим принципом)StackHrRepository.unsetStackHrFromOperator (HR) / recruiter.service.unsetRecruiterFromOperator (Recruiter)
set_stack_supervisor / unset_stack_supervisorПризначення/зняття тімліда (Supervisor) з HRStackHrRepository.setStackHrToSupervisor / unsetStackHrFromSupervisor
set_stack_recruiter / unset_stack_recruiterПереназначення Recruiter-а на іншого HR — пишуться обидві події одним викликом (спочатку unset, потім set)recruiter.service.setHrToRecruiter

Статуси картки (StackStatusCard, пишуться напряму як event)

Будь-яка зміна статусу картки (New, In progress, Approved, Hired, Applicant declined, Company declined, No response, Duplicate) записується як окремий лог з event, рівним значенню статусу:

  • Центральна точка запису — JobseekerService.saveLogAndUpdateStatusStack (викликається з changeStatusStack і acceptOperatorByInitiator).
  • manualCreateOperator і createOperatorBesocialStudioForm пишуть New напряму одразу при створенні оператора без HR.
  • StackHrRepository.setStackHrToOperator / unsetStackHrFromOperator пишуть побічний лог зміни статусу (NewIn progress при призначенні HR і навпаки при знятті) — це автоматичний ефект призначення/зняття HR, а не окрема дія користувача.

Відображення (JobseekerService.convertLogs)

Логи оператора повертаються в картці (getCardICardResult.logs) як історія дій. Перед видачею фронту convertLogs перетворює технічне значення event на людський текст українською:

  • Для create_operator текст залежить від source (реферал / telegram-бот / besocial.studio/form) або, якщо джерело не системне, від того, чи заповнені hrStackId/recruiterStackId (створено HR-ом / Recruiter-ом / просто “Створення оператора”).
  • Для set_stack_operator / unset_stack_operator текст залежить від того, яке поле заповнене — recruiterStackId (дія на Recruiter-і) чи hrStackId (дія на HR).
  • Для статусів Approved і Hired до тексту статусу додається виконавець: “Recruiter-ом”, “HR-ом” або “директором” (за наявністю hrStackId/recruiterStackId).
  • Статуси In progress, New, Applicant declined, Company declined та будь-яка інша подія повертаються без перекладу (as is) — default у switch теж повертає лог без змін.

Де використовується (тільки сервіс stack)

МісцеЩо робить з логами
JobseekerService.getCardТягне всі логи оператора (find({ userStackId })), прогонює через convertLogs, повертає як ICardResult.logs — історія картки на фронті
JobseekerService.getCard / hiringInManualEditOperator / cooperationInManualEditOperatorШукають лог Hired/Approved, щоб визначити, чи оператора вже наймали — після цього HR/Recruiter не можна поміняти напряму
StackStatisticsServiceВибирає set_stack_operator/unset_stack_operator по hrStackId за період — рахує всіх операторів, які коли-небудь були на HR
StackHrService.getHrRecruiterPeriodsВибирає set_stack_recruiter/unset_stack_recruiter — рахує часові періоди співпраці Recruiter-а з конкретним HR
StackHrService (celebrations)Шукає лог Hired по операторам — дата логу = дата прийняття, звідти рахується річниця співпраці для нагадувань
RecruiterGenerateDashboardServiceВибирає широкий діапазон подій (усі StackStatusCard + create_operator + set_stack_operator + set_stack_recruiter) за день, групує по оператору, рахує “останній вирішальний лог” для щоденної статистики
StackHrRepository.getHrAllSupervisorsВибирає set_stack_supervisor/unset_stack_supervisor — знаходить усіх тімлідів, які колись були призначені HR (навіть уже знятих)
StackHrRepository.findLogsByStackHrIdТягне логи конкретного оператора під конкретним HR до заданої дати (сирі дані, без трансформації)
StackSupervisorService2Бере всі логи по hrStackId, для кожного тімліда шукає останній лог до заданої дати — визначає, чи був він активний тімлідом на конкретний момент
UserRepository.findOperatorCardsForRecruiter / findCardsByString / customizeRecruitersШукають Hired/Approved логи по recruiterStackId — показують картки, що колись пройшли через recruiter-а (навіть якщо recruiterId картки вже змінився), і рахують заблокованих/активних
HiringWorkspaceServiceПри повному видаленні hire з системи видаляє всі його логи (deleteMany({ userStackId })) разом з іншими повʼязаними колекціями

Нюанси

  • event в одному й тому ж полі може бути як StackHrLogEventType, так і StackStatusCard — при читанні завжди потрібен switch/includes по обох enum одразу (як у convertLogs).
  • set_stack_operator/unset_stack_operator — одна й та сама подія для двох різних дій (HR і Recruiter); розрізняти можна лише по тому, яке з полів hrStackId/recruiterStackId заповнене.
  • Призначення/зняття HR через StackHrRepository автоматично тригерить ще один лог зміни статусу картки (NewIn progress) — це побічний ефект, не окрема дія користувача.
  • Колекція не має TTL — записи не видаляються, окрім явного видалення при повному видаленні hire (HiringWorkspaceService).