/** * 채용 파이프라인 트래커. * * 4인 → 5인 이상 확장 시점에 후보자가 여러 명, 여러 역할(개발/기획/디자인) 로 * 들어오기 시작 — 노션·스프레드시트·이메일에 흩어진 정보를 한 명령으로 본다. * * Event-sourced (customersStore 와 동일 패턴) — append-only 이벤트 로그를 * 재생해 후보자별 현재 단계 + 노트 누적 도출. * * 위치: `/.astra/hire.jsonl`. 사람 직접 편집 가능. * 민감 정보(이름, 연봉, 거절 사유) 포함 — 로컬 only. */ import * as fs from 'fs'; import * as path from 'path'; import { createEventStore } from '../_shared/eventSourcedStore'; const STORE_REL_PATH = '.astra/hire.jsonl'; export type HireEventType = 'add' | 'stage' | 'note' | 'offer' | 'reject' | 'decline' | 'hire'; /** * 기본 파이프라인 단계. 사용자가 다른 단계명 지정 가능 — 그냥 문자열로 저장. * 표시 순서·정렬용으로 알려진 단계는 가중치 부여. */ export const KNOWN_STAGES = ['inbox', 'screened', 'interview', 'final', 'offer', 'accepted', 'hired', 'rejected', 'declined'] as const; export type KnownStage = typeof KNOWN_STAGES[number]; export interface HireEvent { id: string; timestamp: string; candidateId: string; candidateName: string; role: string; type: HireEventType; /** stage 전환 시 새 단계. add 시 시작 단계 (기본 'inbox'). */ stage?: string; /** offer 의 연봉 — KRW. */ salary?: number; /** 입사 예정일 / 거절·이탈 사유. */ memo?: string; } export interface CandidateState { candidateId: string; candidateName: string; role: string; stage: string; salary?: number; addedAt: string; lastEventAt: string; eventCount: number; notes: { timestamp: string; type: HireEventType; memo: string }[]; } const _store = createEventStore({ relPath: STORE_REL_PATH, validate: (e): e is HireEvent => !!e && typeof (e as any).id === 'string' && typeof (e as any).candidateId === 'string' && typeof (e as any).type === 'string', }); export const getHireFilePath = _store.getFilePath; export const readHireEvents = _store.read; export const appendHireEvent = _store.append; export function candidateIdFromName(name: string): string { return name.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^\w가-힣-]/g, ''); } export function computeCandidateStates(): Map { const events = readHireEvents().slice().sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || '')); const states = new Map(); for (const e of events) { let s = states.get(e.candidateId); if (!s) { s = { candidateId: e.candidateId, candidateName: e.candidateName, role: e.role || '', stage: e.stage || 'inbox', addedAt: e.timestamp, lastEventAt: e.timestamp, eventCount: 0, notes: [], }; states.set(e.candidateId, s); } s.candidateName = e.candidateName || s.candidateName; s.role = e.role || s.role; s.lastEventAt = e.timestamp; s.eventCount += 1; switch (e.type) { case 'add': s.stage = e.stage || s.stage || 'inbox'; break; case 'stage': if (e.stage) s.stage = e.stage; break; case 'offer': s.stage = 'offer'; if (e.salary !== undefined) s.salary = e.salary; if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'offer', memo: e.memo }); break; case 'hire': s.stage = 'hired'; if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'hire', memo: e.memo }); break; case 'reject': s.stage = 'rejected'; if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'reject', memo: e.memo }); break; case 'decline': s.stage = 'declined'; if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'decline', memo: e.memo }); break; case 'note': if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'note', memo: e.memo }); break; } } return states; }