careermate 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/AGENTS.md +2 -2
  2. package/LICENSE +1 -1
  3. package/README.md +108 -91
  4. package/THIRD_PARTY_NOTICES.md +2 -1
  5. package/bin/careermate.mjs +11 -2
  6. package/dist/career-os/eop/cover-letter.md +13 -6
  7. package/dist/career-os/eop/interview-prep.md +1 -0
  8. package/dist/career-os/knowledge/cover-letter.md +7 -3
  9. package/dist/career-os/knowledge/fit-matching.md +2 -0
  10. package/dist/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  11. package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
  12. package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -0
  13. package/dist/doctor.mjs +19760 -1018
  14. package/dist/mcp.mjs +19783 -1035
  15. package/dist/migrate.mjs +289 -20
  16. package/dist/public/i18n/en/applications.js +19 -0
  17. package/dist/public/i18n/en/common.js +10 -0
  18. package/dist/public/i18n/en/documents.js +0 -10
  19. package/dist/public/i18n/en/home.js +5 -0
  20. package/dist/public/i18n/en/jobs.js +18 -1
  21. package/dist/public/i18n/en/profile.js +0 -5
  22. package/dist/public/i18n/en/settings.js +5 -2
  23. package/dist/public/i18n/ko/applications.js +19 -0
  24. package/dist/public/i18n/ko/common.js +9 -0
  25. package/dist/public/i18n/ko/documents.js +0 -10
  26. package/dist/public/i18n/ko/home.js +5 -0
  27. package/dist/public/i18n/ko/jobs.js +18 -1
  28. package/dist/public/i18n/ko/profile.js +0 -5
  29. package/dist/public/i18n/ko/settings.js +5 -2
  30. package/dist/public/lib.js +101 -0
  31. package/dist/public/pages/applications.js +95 -15
  32. package/dist/public/pages/documents.js +20 -14
  33. package/dist/public/pages/home.js +38 -6
  34. package/dist/public/pages/interview.js +11 -4
  35. package/dist/public/pages/jobs.js +71 -1
  36. package/dist/public/pages/profile.js +2 -3
  37. package/dist/public/pages/settings.js +2 -2
  38. package/dist/public/styles.css +52 -4
  39. package/dist/web.mjs +19001 -758
  40. package/docs/FAQ.md +13 -3
  41. package/docs/MCP_TOOLS.md +32 -12
  42. package/docs/career-os/eop/cover-letter.md +13 -6
  43. package/docs/career-os/eop/interview-prep.md +1 -0
  44. package/docs/career-os/knowledge/cover-letter.md +7 -3
  45. package/docs/career-os/knowledge/fit-matching.md +2 -0
  46. package/docs/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  47. package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
  48. package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -0
  49. package/package.json +5 -2
  50. package/packages/core/src/index.ts +2 -0
  51. package/packages/core/src/jobsearch.ts +190 -0
  52. package/packages/core/src/offers.ts +41 -0
  53. package/packages/core/src/services.ts +29 -3
  54. package/packages/core/src/summary.ts +431 -9
  55. package/packages/core/src/verify/charcount.ts +87 -0
  56. package/packages/core/src/verify/index.ts +1 -0
  57. package/packages/core/src/verify/lint.ts +5 -0
  58. package/packages/db/src/connection.ts +70 -2
  59. package/packages/db/src/index.ts +1 -0
  60. package/packages/db/src/repositories.ts +179 -13
  61. package/packages/db/src/schema.ts +169 -8
  62. package/packages/exporters/src/docx.ts +165 -0
  63. package/packages/exporters/src/index.ts +142 -9
  64. package/packages/exporters/src/markdown.ts +16 -11
  65. package/packages/knowledge/src/index.ts +8 -3
  66. package/packages/mcp-tools/src/result.ts +9 -0
  67. package/packages/mcp-tools/src/tools.ts +303 -60
  68. package/packages/parsers/src/documents.ts +7 -1
  69. package/packages/prompts/src/humanize.ts +4 -0
  70. package/packages/shared/src/schemas.ts +198 -0
  71. package/scripts/init.ts +7 -0
@@ -4,18 +4,86 @@
4
4
  * Both the web server and the MCP server import this. SQLite with WAL handles
5
5
  * the two-process concurrency (dashboard editing + AI writing) cleanly.
6
6
  */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
7
9
  import { DatabaseSync } from 'node:sqlite';
8
- import { getDbPath } from './paths.ts';
9
- import { migrate } from './schema.ts';
10
+ import { getBackupsDir, getDbPath } from './paths.ts';
11
+ import { migrate, MIGRATIONS } from './schema.ts';
10
12
 
11
13
  let _db: DatabaseSync | null = null;
12
14
 
15
+ /** How many automatic pre-migration snapshots to keep (newest first). */
16
+ const PRE_MIGRATE_KEEP = 5;
17
+
18
+ /**
19
+ * `VACUUM INTO` a destination path, with the guard both backup callers need. The
20
+ * data dir can be set via env, so `destPath` isn't fully trusted: reject NUL/newline
21
+ * (can't be embedded safely in the SQL string literal) and single-quote-escape the
22
+ * rest. Throws on an unsafe path so the caller can fall back to fs.copyFileSync.
23
+ */
24
+ export function vacuumInto(db: DatabaseSync, destPath: string): void {
25
+ if (/[\0\n\r]/.test(destPath)) throw new Error('backup path not safe for SQL literal');
26
+ db.exec(`VACUUM INTO '${destPath.replace(/'/g, "''")}'`);
27
+ }
28
+
29
+ /**
30
+ * Snapshot the DB into backups/ right before a schema upgrade runs. The DB is
31
+ * the user's only copy, so even though each migration is transactional, a bad
32
+ * migration shipped in an update must never be able to destroy data without a
33
+ * restore point. Best-effort: a failed backup logs to stderr but does not block
34
+ * boot (blocking would take both the dashboard and the MCP server down).
35
+ */
36
+ function preMigrationBackup(db: DatabaseSync): void {
37
+ try {
38
+ const hasMeta = db
39
+ .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='_meta'`)
40
+ .get();
41
+ if (!hasMeta) return; // fresh file — nothing to protect yet
42
+ const row = db.prepare(`SELECT value FROM _meta WHERE key = 'schema_version'`).get() as
43
+ | { value: string }
44
+ | undefined;
45
+ const from = row ? Number(row.value) : 0;
46
+ if (from === 0 || from >= MIGRATIONS.length) return; // fresh, current, or newer — no upgrade will run
47
+
48
+ const dir = getBackupsDir();
49
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
50
+ const dest = path.join(dir, `pre-migrate-v${from}-${stamp}.sqlite`);
51
+ try {
52
+ vacuumInto(db, dest);
53
+ } catch {
54
+ fs.copyFileSync(getDbPath(), dest); // WAL is checkpointed on close; copy is a coarse fallback
55
+ }
56
+
57
+ // Prune old snapshots (ISO stamp in the name → lexicographic sort == time sort).
58
+ // fs.unlinkSync only — rmSync hard-crashes on Node 25/Windows.
59
+ const stale = fs
60
+ .readdirSync(dir)
61
+ .filter((f) => f.startsWith('pre-migrate-') && f.endsWith('.sqlite'))
62
+ .sort()
63
+ .reverse()
64
+ .slice(PRE_MIGRATE_KEEP);
65
+ for (const f of stale) {
66
+ try {
67
+ fs.unlinkSync(path.join(dir, f));
68
+ } catch {
69
+ /* best-effort */
70
+ }
71
+ }
72
+ } catch (err) {
73
+ // stderr only — stdout is the MCP transport channel.
74
+ console.error(
75
+ `[careermate] 업그레이드 전 자동 백업 실패(마이그레이션은 계속 진행): ${err instanceof Error ? err.message : String(err)}`,
76
+ );
77
+ }
78
+ }
79
+
13
80
  export function getDb(): DatabaseSync {
14
81
  if (_db) return _db;
15
82
  const db = new DatabaseSync(getDbPath());
16
83
  db.exec('PRAGMA journal_mode = WAL;');
17
84
  db.exec('PRAGMA foreign_keys = ON;');
18
85
  db.exec('PRAGMA busy_timeout = 5000;');
86
+ preMigrationBackup(db);
19
87
  migrate(db);
20
88
  _db = db;
21
89
  return db;
@@ -19,6 +19,7 @@ export {
19
19
  fitRepo,
20
20
  applicationRepo,
21
21
  interviewRepo,
22
+ offerRepo,
22
23
  activityRepo,
23
24
  timelineRepo,
24
25
  getEntityCounts,
@@ -21,11 +21,14 @@ import {
21
21
  type CoverLetterVersionRecord,
22
22
  type JobInput,
23
23
  type JobRecord,
24
+ type OfferInput,
25
+ type OfferRecord,
24
26
  type FitAnalysisInput,
25
27
  type FitAnalysisRecord,
26
28
  type ApplicationInput,
27
29
  type ApplicationRecord,
28
30
  type ApplicationStatus,
31
+ type RejectedStage,
29
32
  type InterviewPrepInput,
30
33
  type InterviewPrepRecord,
31
34
  type ActivityRecord,
@@ -64,6 +67,10 @@ function mapProfile(r: any): ProfileRecord {
64
67
  preferred_tone: r.preferred_tone,
65
68
  emphasis_points: fromJson(r.emphasis_points, []),
66
69
  links: fromJson(r.links, []),
70
+ education: fromJson(r.education, []),
71
+ certifications: fromJson(r.certifications, []),
72
+ language_scores: fromJson(r.language_scores, []),
73
+ awards: fromJson(r.awards, []),
67
74
  created_at: r.created_at,
68
75
  updated_at: r.updated_at,
69
76
  };
@@ -92,21 +99,31 @@ export const profileRepo = {
92
99
  preferred_tone: input.preferred_tone ?? existing?.preferred_tone ?? null,
93
100
  emphasis_points: input.emphasis_points ?? existing?.emphasis_points ?? [],
94
101
  links: input.links ?? existing?.links ?? [],
102
+ education: input.education ?? existing?.education ?? [],
103
+ certifications: input.certifications ?? existing?.certifications ?? [],
104
+ language_scores: input.language_scores ?? existing?.language_scores ?? [],
105
+ awards: input.awards ?? existing?.awards ?? [],
95
106
  created_at: existing?.created_at ?? ts,
96
107
  updated_at: ts,
97
108
  };
98
109
  db.prepare(
99
- `INSERT INTO profile (id,name,email,phone,location,headline,summary,desired_roles,desired_conditions,preferred_tone,emphasis_points,links,created_at,updated_at)
100
- VALUES (@id,@name,@email,@phone,@location,@headline,@summary,@desired_roles,@desired_conditions,@preferred_tone,@emphasis_points,@links,@created_at,@updated_at)
110
+ `INSERT INTO profile (id,name,email,phone,location,headline,summary,desired_roles,desired_conditions,preferred_tone,emphasis_points,links,education,certifications,language_scores,awards,created_at,updated_at)
111
+ VALUES (@id,@name,@email,@phone,@location,@headline,@summary,@desired_roles,@desired_conditions,@preferred_tone,@emphasis_points,@links,@education,@certifications,@language_scores,@awards,@created_at,@updated_at)
101
112
  ON CONFLICT(id) DO UPDATE SET
102
113
  name=@name,email=@email,phone=@phone,location=@location,headline=@headline,summary=@summary,
103
114
  desired_roles=@desired_roles,desired_conditions=@desired_conditions,preferred_tone=@preferred_tone,
104
- emphasis_points=@emphasis_points,links=@links,updated_at=@updated_at`,
115
+ emphasis_points=@emphasis_points,links=@links,
116
+ education=@education,certifications=@certifications,language_scores=@language_scores,awards=@awards,
117
+ updated_at=@updated_at`,
105
118
  ).run({
106
119
  ...merged,
107
120
  desired_roles: toJson(merged.desired_roles),
108
121
  emphasis_points: toJson(merged.emphasis_points),
109
122
  links: toJson(merged.links),
123
+ education: toJson(merged.education),
124
+ certifications: toJson(merged.certifications),
125
+ language_scores: toJson(merged.language_scores),
126
+ awards: toJson(merged.awards),
110
127
  });
111
128
  return merged;
112
129
  },
@@ -642,6 +659,12 @@ function mapJob(r: any): JobRecord {
642
659
  keywords: fromJson(r.keywords, []),
643
660
  deadline: r.deadline,
644
661
  source: r.source,
662
+ company_overview: r.company_overview ?? null,
663
+ talent_profile: r.talent_profile ?? null,
664
+ core_values: fromJson(r.core_values, []),
665
+ reputation: r.reputation ?? null,
666
+ questions: fromJson(r.questions, []),
667
+ schedule: fromJson(r.schedule, []),
645
668
  created_at: r.created_at,
646
669
  updated_at: r.updated_at,
647
670
  };
@@ -674,7 +697,7 @@ export const jobRepo = {
674
697
  if (existing) {
675
698
  const m = { ...existing, ...input } as JobRecord;
676
699
  db.prepare(
677
- `UPDATE jobs SET company=?,position=?,url=?,location=?,employment_type=?,description=?,requirements=?,keywords=?,deadline=?,source=?,updated_at=? WHERE id=?`,
700
+ `UPDATE jobs SET company=?,position=?,url=?,location=?,employment_type=?,description=?,requirements=?,keywords=?,deadline=?,source=?,company_overview=?,talent_profile=?,core_values=?,reputation=?,questions=?,schedule=?,updated_at=? WHERE id=?`,
678
701
  ).run(
679
702
  m.company,
680
703
  m.position,
@@ -686,6 +709,15 @@ export const jobRepo = {
686
709
  toJson(m.keywords ?? []),
687
710
  m.deadline ?? null,
688
711
  m.source ?? null,
712
+ m.company_overview ?? null,
713
+ m.talent_profile ?? null,
714
+ toJson(m.core_values ?? []),
715
+ m.reputation ?? null,
716
+ // questions omitted from input → m keeps existing.questions (preserved);
717
+ // provided → replaced. Same merge semantics as the other JSON fields.
718
+ toJson(m.questions ?? []),
719
+ // schedule follows the same preserve-on-omit merge (v11 전형 일정).
720
+ toJson(m.schedule ?? []),
689
721
  ts,
690
722
  existing.id,
691
723
  );
@@ -693,8 +725,8 @@ export const jobRepo = {
693
725
  }
694
726
  const newJobId = id ?? newId('job_');
695
727
  db.prepare(
696
- `INSERT INTO jobs (id,company,position,url,location,employment_type,description,requirements,keywords,deadline,source,created_at,updated_at)
697
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
728
+ `INSERT INTO jobs (id,company,position,url,location,employment_type,description,requirements,keywords,deadline,source,company_overview,talent_profile,core_values,reputation,questions,schedule,created_at,updated_at)
729
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
698
730
  ).run(
699
731
  newJobId,
700
732
  input.company,
@@ -707,6 +739,12 @@ export const jobRepo = {
707
739
  toJson(input.keywords ?? []),
708
740
  input.deadline ?? null,
709
741
  input.source ?? null,
742
+ input.company_overview ?? null,
743
+ input.talent_profile ?? null,
744
+ toJson(input.core_values ?? []),
745
+ input.reputation ?? null,
746
+ toJson(input.questions ?? []),
747
+ toJson(input.schedule ?? []),
710
748
  ts,
711
749
  ts,
712
750
  );
@@ -721,6 +759,9 @@ export const jobRepo = {
721
759
  db.prepare(`DELETE FROM applications WHERE job_id=?`).run(id);
722
760
  db.prepare(`DELETE FROM interview_preps WHERE job_id=?`).run(id);
723
761
  db.prepare(`DELETE FROM application_timeline_events WHERE job_id=?`).run(id);
762
+ // offers has ON DELETE CASCADE, but delete explicitly like the siblings above
763
+ // (consistent pattern + safe even if PRAGMA foreign_keys is ever off).
764
+ db.prepare(`DELETE FROM offers WHERE job_id=?`).run(id);
724
765
  return db.prepare(`DELETE FROM jobs WHERE id=?`).run(id).changes > 0;
725
766
  });
726
767
  },
@@ -804,6 +845,8 @@ function mapApplication(r: any): ApplicationRecord {
804
845
  cover_letter_id: r.cover_letter_id,
805
846
  applied_at: r.applied_at,
806
847
  notes: r.notes,
848
+ // 탈락 회고: 탈락이 아니거나 미기록이면 NULL(v10 이전 DB는 컬럼이 없어 undefined → null 정규화).
849
+ rejected_stage: r.rejected_stage ?? null,
807
850
  created_at: r.created_at,
808
851
  updated_at: r.updated_at,
809
852
  };
@@ -846,25 +889,39 @@ export const applicationRepo = {
846
889
  // A status change through upsert must respect the same lifecycle gate as setStatus.
847
890
  if (input.status && input.status !== app.status) assertStatusTransition(app.status, input.status);
848
891
  const m = { ...app, ...input } as ApplicationRecord;
892
+ // rejected_stage is only meaningful while status is 'rejected' (same invariant as
893
+ // setStatus): clear it on any transition back out, preserve it when staying
894
+ // rejected. ApplicationInput carries no rejected_stage, so m keeps the stored value.
895
+ const rejectedStageValue = m.status === 'rejected' ? m.rejected_stage ?? null : null;
849
896
  getDb()
850
897
  .prepare(
851
- `UPDATE applications SET status=?,resume_id=?,cover_letter_id=?,applied_at=?,notes=?,updated_at=? WHERE id=?`,
898
+ `UPDATE applications SET status=?,resume_id=?,cover_letter_id=?,applied_at=?,notes=?,rejected_stage=?,updated_at=? WHERE id=?`,
852
899
  )
853
900
  // Coerce undefined → null: node:sqlite rejects undefined bindings, and a
854
901
  // caller that spreads explicit `undefined` fields (e.g. updateApplicationStatus
855
902
  // with a doc-less submission) would otherwise crash here mid-write.
856
- .run(m.status, m.resume_id ?? null, m.cover_letter_id ?? null, m.applied_at ?? null, m.notes ?? null, now(), app.id);
903
+ .run(m.status, m.resume_id ?? null, m.cover_letter_id ?? null, m.applied_at ?? null, m.notes ?? null, rejectedStageValue, now(), app.id);
857
904
  return this.get(app.id)!;
858
905
  },
859
- setStatus(jobId: string, status: ApplicationStatus, note?: string): ApplicationRecord {
906
+ setStatus(
907
+ jobId: string,
908
+ status: ApplicationStatus,
909
+ note?: string,
910
+ rejectedStage?: RejectedStage,
911
+ ): ApplicationRecord {
860
912
  const app = this.ensure(jobId);
861
913
  assertStatusTransition(app.status, status);
862
914
  const appliedAt =
863
915
  status === 'applied' && !app.applied_at ? now() : app.applied_at;
864
916
  const notes = note ? [app.notes, note].filter(Boolean).join('\n') : app.notes;
917
+ // 탈락 회고: 'rejected'일 때만 단계를 저장한다. 명시 값이 오면 갱신하고, 없으면 기존 값을
918
+ // 보존해(같은 rejected에 메모만 추가하는 등) 이미 남긴 회고를 지우지 않는다. rejected에서 다른
919
+ // 상태로 되살아나면 NULL로 초기화한다(정합성 — 탈락이 아닌데 단계가 남아 있으면 안 됨).
920
+ const rejectedStageValue =
921
+ status === 'rejected' ? rejectedStage ?? app.rejected_stage ?? null : null;
865
922
  getDb()
866
- .prepare(`UPDATE applications SET status=?, applied_at=?, notes=?, updated_at=? WHERE id=?`)
867
- .run(status, appliedAt, notes, now(), app.id);
923
+ .prepare(`UPDATE applications SET status=?, applied_at=?, notes=?, rejected_stage=?, updated_at=? WHERE id=?`)
924
+ .run(status, appliedAt, notes, rejectedStageValue, now(), app.id);
868
925
  return this.get(app.id)!;
869
926
  },
870
927
  };
@@ -872,6 +929,9 @@ export const applicationRepo = {
872
929
  /* --------------------------------------------------------- Interview preps */
873
930
 
874
931
  function mapInterviewPrep(r: any): InterviewPrepRecord {
932
+ // debrief defaults to '{}' at the DB level; surface an empty debrief as null so
933
+ // consumers can treat "no debrief yet" uniformly.
934
+ const debrief = fromJson(r.debrief, null);
875
935
  return {
876
936
  id: r.id,
877
937
  job_id: r.job_id,
@@ -879,6 +939,7 @@ function mapInterviewPrep(r: any): InterviewPrepRecord {
879
939
  star_guides: fromJson(r.star_guides, []),
880
940
  self_introduction: r.self_introduction,
881
941
  notes: r.notes,
942
+ debrief: debrief && Object.keys(debrief).length ? debrief : null,
882
943
  created_at: r.created_at,
883
944
  updated_at: r.updated_at,
884
945
  };
@@ -900,13 +961,19 @@ export const interviewRepo = {
900
961
  const ts = now();
901
962
  if (existing) {
902
963
  const m = { ...existing, ...input } as InterviewPrepRecord;
964
+ // Debrief is shallow-merged (not replaced) so a later partial debrief save
965
+ // doesn't wipe earlier fields; prep (pre) and debrief (post) coexist per job.
966
+ const mergedDebrief = input.debrief
967
+ ? { ...(existing.debrief ?? {}), ...input.debrief }
968
+ : existing.debrief;
903
969
  db.prepare(
904
- `UPDATE interview_preps SET questions=?,star_guides=?,self_introduction=?,notes=?,updated_at=? WHERE id=?`,
970
+ `UPDATE interview_preps SET questions=?,star_guides=?,self_introduction=?,notes=?,debrief=?,updated_at=? WHERE id=?`,
905
971
  ).run(
906
972
  toJson(m.questions ?? []),
907
973
  toJson(m.star_guides ?? []),
908
974
  m.self_introduction ?? null,
909
975
  m.notes ?? null,
976
+ toJson(mergedDebrief ?? {}),
910
977
  ts,
911
978
  existing.id,
912
979
  );
@@ -914,7 +981,7 @@ export const interviewRepo = {
914
981
  }
915
982
  const id = newId('itv_');
916
983
  db.prepare(
917
- `INSERT INTO interview_preps (id,job_id,questions,star_guides,self_introduction,notes,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)`,
984
+ `INSERT INTO interview_preps (id,job_id,questions,star_guides,self_introduction,notes,debrief,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)`,
918
985
  ).run(
919
986
  id,
920
987
  input.job_id,
@@ -922,6 +989,7 @@ export const interviewRepo = {
922
989
  toJson(input.star_guides ?? []),
923
990
  input.self_introduction ?? null,
924
991
  input.notes ?? null,
992
+ toJson(input.debrief ?? {}),
925
993
  ts,
926
994
  ts,
927
995
  );
@@ -1001,6 +1069,102 @@ export const timelineRepo = {
1001
1069
  },
1002
1070
  };
1003
1071
 
1072
+ /* -------------------------------------------------------------------- Offers */
1073
+
1074
+ function mapOffer(r: any): OfferRecord {
1075
+ const base = r.base_salary ?? null;
1076
+ const bonus = r.bonus_amount ?? null;
1077
+ const welfare = r.welfare_amount ?? null;
1078
+ // Annual cash estimate = base + bonus + welfare (signing is one-time → excluded).
1079
+ // null when no numeric cash piece was provided (can't estimate).
1080
+ const hasAny = base != null || bonus != null || welfare != null;
1081
+ const total = hasAny ? (base ?? 0) + (bonus ?? 0) + (welfare ?? 0) : null;
1082
+ return {
1083
+ id: r.id,
1084
+ job_id: r.job_id,
1085
+ offer_status: r.offer_status ?? 'open',
1086
+ base_salary: base,
1087
+ bonus_amount: bonus,
1088
+ welfare_amount: welfare,
1089
+ signing_amount: r.signing_amount ?? null,
1090
+ equity_note: r.equity_note ?? null,
1091
+ comp_note: r.comp_note ?? null,
1092
+ work_arrangement: r.work_arrangement ?? null,
1093
+ contract_type: r.contract_type ?? null,
1094
+ accept_deadline: r.accept_deadline ?? null,
1095
+ ai_score: r.ai_score ?? null,
1096
+ verdict: r.verdict ?? null,
1097
+ notes: r.notes ?? null,
1098
+ total_comp_annual_est: total,
1099
+ created_at: r.created_at,
1100
+ updated_at: r.updated_at,
1101
+ };
1102
+ }
1103
+
1104
+ export const offerRepo = {
1105
+ getByJob(jobId: string): OfferRecord | null {
1106
+ const r = getDb().prepare(`SELECT * FROM offers WHERE job_id=?`).get(jobId);
1107
+ return r ? mapOffer(r) : null;
1108
+ },
1109
+ list(): OfferRecord[] {
1110
+ return (getDb().prepare(`SELECT * FROM offers ORDER BY ai_score IS NULL, ai_score DESC, updated_at DESC`).all() as any[]).map(mapOffer);
1111
+ },
1112
+ /** Upsert by job_id (one offer per job). Missing fields are preserved on update (partial-safe). */
1113
+ save(input: OfferInput): OfferRecord {
1114
+ const db = getDb();
1115
+ const existing = this.getByJob(input.job_id);
1116
+ const ts = now();
1117
+ const n = (v: number | undefined, prev: number | null | undefined) => (v ?? prev ?? null);
1118
+ const s = (v: string | undefined, prev: string | null | undefined) => (v ?? prev ?? null);
1119
+ if (existing) {
1120
+ db.prepare(
1121
+ `UPDATE offers SET offer_status=?,base_salary=?,bonus_amount=?,welfare_amount=?,signing_amount=?,equity_note=?,comp_note=?,work_arrangement=?,contract_type=?,accept_deadline=?,ai_score=?,verdict=?,notes=?,updated_at=? WHERE id=?`,
1122
+ ).run(
1123
+ input.offer_status ?? existing.offer_status,
1124
+ n(input.base_salary, existing.base_salary),
1125
+ n(input.bonus_amount, existing.bonus_amount),
1126
+ n(input.welfare_amount, existing.welfare_amount),
1127
+ n(input.signing_amount, existing.signing_amount),
1128
+ s(input.equity_note, existing.equity_note),
1129
+ s(input.comp_note, existing.comp_note),
1130
+ s(input.work_arrangement, existing.work_arrangement),
1131
+ s(input.contract_type, existing.contract_type),
1132
+ s(input.accept_deadline, existing.accept_deadline),
1133
+ n(input.ai_score, existing.ai_score),
1134
+ s(input.verdict, existing.verdict),
1135
+ s(input.notes, existing.notes),
1136
+ ts,
1137
+ existing.id,
1138
+ );
1139
+ return this.getByJob(input.job_id)!;
1140
+ }
1141
+ const id = newId('ofr_');
1142
+ db.prepare(
1143
+ `INSERT INTO offers (id,job_id,offer_status,base_salary,bonus_amount,welfare_amount,signing_amount,equity_note,comp_note,work_arrangement,contract_type,accept_deadline,ai_score,verdict,notes,created_at,updated_at)
1144
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
1145
+ ).run(
1146
+ id,
1147
+ input.job_id,
1148
+ input.offer_status ?? 'open',
1149
+ input.base_salary ?? null,
1150
+ input.bonus_amount ?? null,
1151
+ input.welfare_amount ?? null,
1152
+ input.signing_amount ?? null,
1153
+ input.equity_note ?? null,
1154
+ input.comp_note ?? null,
1155
+ input.work_arrangement ?? null,
1156
+ input.contract_type ?? null,
1157
+ input.accept_deadline ?? null,
1158
+ input.ai_score ?? null,
1159
+ input.verdict ?? null,
1160
+ input.notes ?? null,
1161
+ ts,
1162
+ ts,
1163
+ );
1164
+ return this.getByJob(input.job_id)!;
1165
+ },
1166
+ };
1167
+
1004
1168
  /* ------------------------------------------------------------------ Counts */
1005
1169
 
1006
1170
  /**
@@ -1024,6 +1188,7 @@ export function getEntityCounts(): Record<string, number> {
1024
1188
  'applications',
1025
1189
  'interview_preps',
1026
1190
  'application_timeline_events',
1191
+ 'offers',
1027
1192
  ]);
1028
1193
  const c = (t: string): number => {
1029
1194
  if (!ALLOWED_TABLES.has(t)) throw new Error(`getEntityCounts: 허용되지 않은 테이블 이름: ${t}`);
@@ -1040,5 +1205,6 @@ export function getEntityCounts(): Record<string, number> {
1040
1205
  applications: c('applications'),
1041
1206
  interview_preps: c('interview_preps'),
1042
1207
  application_timeline_events: c('application_timeline_events'),
1208
+ offers: c('offers'),
1043
1209
  };
1044
1210
  }
@@ -5,6 +5,16 @@
5
5
  * in `_meta`. Applying is idempotent and safe to run on every process start, so
6
6
  * both the web server and the MCP server can call `migrate()` on boot and share
7
7
  * the same database file without coordination.
8
+ *
9
+ * Crash safety (this DB is the user's only copy):
10
+ * - Each migration runs in its own transaction and `schema_version` is stamped
11
+ * inside that same transaction, so a step either fully applies (including its
12
+ * version bump) or fully rolls back — a process death mid-upgrade can never
13
+ * leave "applied but unrecorded" work that re-throws on the next boot.
14
+ * - `ALTER TABLE … ADD COLUMN` statements are skipped when the column already
15
+ * exists (SQLite has no IF NOT EXISTS for columns), so a DB damaged by the
16
+ * pre-hardening runner self-heals instead of crash-looping on boot.
17
+ * - A DB stamped by a NEWER CareerMate is left untouched (never stamped down).
8
18
  */
9
19
  import type { DatabaseSync } from 'node:sqlite';
10
20
 
@@ -300,6 +310,96 @@ export const MIGRATIONS: string[] = [
300
310
 
301
311
  CREATE INDEX IF NOT EXISTS idx_timeline_job ON application_timeline_events(job_id, occurred_at ASC);
302
312
  `,
313
+
314
+ // v4 — structured quantitative credentials on the profile (학력·자격증·어학점수·수상).
315
+ // Stored as JSON arrays in TEXT columns (same pattern as profile.links), so the AI
316
+ // can cite exact values (GPA, TOEIC 920) without re-asking or hallucinating, and
317
+ // they feed the verify corpus. ALTER ADD COLUMN is non-destructive and idempotent
318
+ // across the ordered migration runner.
319
+ `
320
+ ALTER TABLE profile ADD COLUMN education TEXT NOT NULL DEFAULT '[]';
321
+ ALTER TABLE profile ADD COLUMN certifications TEXT NOT NULL DEFAULT '[]';
322
+ ALTER TABLE profile ADD COLUMN language_scores TEXT NOT NULL DEFAULT '[]';
323
+ ALTER TABLE profile ADD COLUMN awards TEXT NOT NULL DEFAULT '[]';
324
+ `,
325
+
326
+ // v5 — company research on the job posting (사업개요·인재상·핵심가치). Korea-centric: cover
327
+ // letters and interview prep align to a company's 인재상/핵심가치, so store it where the AI
328
+ // already reads it (the job context) rather than a separate entity. Additive ALTER, idempotent.
329
+ `
330
+ ALTER TABLE jobs ADD COLUMN company_overview TEXT;
331
+ ALTER TABLE jobs ADD COLUMN talent_profile TEXT;
332
+ ALTER TABLE jobs ADD COLUMN core_values TEXT NOT NULL DEFAULT '[]';
333
+ `,
334
+
335
+ // v6 — post-interview debrief on interview_preps (받은 질문·잘된 점·개선점·다음 라운드 포커스·
336
+ // 체감 분위기). Closes the loop: prep is pre-interview, debrief is post-interview, but both belong
337
+ // to the same per-job entity, so store as one JSON column rather than a new table. Additive ALTER,
338
+ // idempotent. Feeds next-round prep and consistency (lessons carry into later interviews).
339
+ `
340
+ ALTER TABLE interview_preps ADD COLUMN debrief TEXT NOT NULL DEFAULT '{}';
341
+ `,
342
+
343
+ // v7 — offer comparison (한국 총보상 필드) + company reputation research field on jobs.
344
+ // offers: one per job (UNIQUE job_id, mirrors interview_preps). Cash pieces are numeric (만원) so
345
+ // the server can deterministically sum an annual estimate; equity/other stay free-text (comp is
346
+ // messy). ai_score/verdict are written by the connected AI at save time (no LLM here) and rendered
347
+ // by the dashboard, so it never needs to call an LLM. jobs.reputation: AI-researched company
348
+ // reputation summary (same pattern as company_overview) — feeds offer verdicts + offer-evaluation.
349
+ `
350
+ CREATE TABLE IF NOT EXISTS offers (
351
+ id TEXT PRIMARY KEY,
352
+ job_id TEXT NOT NULL UNIQUE REFERENCES jobs(id) ON DELETE CASCADE,
353
+ base_salary INTEGER,
354
+ bonus_amount INTEGER,
355
+ welfare_amount INTEGER,
356
+ signing_amount INTEGER,
357
+ equity_note TEXT,
358
+ comp_note TEXT,
359
+ work_arrangement TEXT,
360
+ contract_type TEXT,
361
+ accept_deadline TEXT,
362
+ ai_score INTEGER,
363
+ verdict TEXT,
364
+ notes TEXT,
365
+ created_at TEXT NOT NULL,
366
+ updated_at TEXT NOT NULL
367
+ );
368
+ CREATE INDEX IF NOT EXISTS idx_offers_job ON offers(job_id);
369
+ ALTER TABLE jobs ADD COLUMN reputation TEXT;
370
+ `,
371
+
372
+ // v8 — offer lifecycle status. 지원 상태(전형 깔때기 = 회사의 프로세스)와 직교하는
373
+ // "내 응답" 축: open(진행)/accepted(수락)/declined(거절). declined여도 행을 지우지
374
+ // 않는다 — 조건(연봉·처우) 히스토리는 다음 협상의 자산이라, 대시보드가 접힌
375
+ // '지난 오퍼'로 강등해 보여줄 뿐이다. Additive ALTER (runner가 멱등 처리).
376
+ `
377
+ ALTER TABLE offers ADD COLUMN offer_status TEXT NOT NULL DEFAULT 'open';
378
+ `,
379
+
380
+ // v9 — 한국 문항형 자소서: 회사가 지정한 문항·글자수를 공고에 구조화 보관. 한국 자소서는
381
+ // 회사별 문항형(항목·글자수 제한)이 지배적이라, AI가 문항별로 답변을 쓰고 글자수를 점검할 때
382
+ // 참조할 원본을 공고 옆에 둔다(별도 엔티티가 아니라 job 컨텍스트에 함께 실린다). JSON 배열을
383
+ // TEXT에 저장(다른 JSON 필드와 동일). Additive ALTER (runner가 멱등 처리).
384
+ `
385
+ ALTER TABLE jobs ADD COLUMN questions TEXT NOT NULL DEFAULT '[]';
386
+ `,
387
+
388
+ // v10 — 탈락 회고: 어느 전형 단계에서 떨어졌는지 자가 기록. 탈락 사유를 고지하는 기업은 6%뿐이고
389
+ // 지원자 83%가 알고 싶어하므로, 이 자가 기록이 "서류는 붙는데 면접에서 떨어진다" 같은 패턴을 로컬
390
+ // 데이터로 드러내는 유일한 신호다. 탈락이 아니거나 미기록이면 NULL. Additive ALTER (runner가 멱등 처리).
391
+ `
392
+ ALTER TABLE applications ADD COLUMN rejected_stage TEXT;
393
+ `,
394
+
395
+ // v11 — 전형 일정: 서류마감→인적성·AI역검→1·2차 면접→발표로 이어지는 단계별 날짜를 공고에
396
+ // 구조화 보관. 일정을 jobs에 두는 이유: 서류마감(deadline)이 이미 jobs에 있고, save_job_posting
397
+ // 업서트를 그대로 재사용해 도구 신설 없이 갱신할 수 있다. 면접초대 메일·발표 공지를 사용자가
398
+ // 붙여넣으면 AI가 날짜를 추출해 저장하고, 서버가 D-day·같은 날짜 충돌을 결정론으로 계산한다.
399
+ // JSON 배열을 TEXT에 저장(다른 JSON 필드와 동일). Additive ALTER (runner가 멱등 처리).
400
+ `
401
+ ALTER TABLE jobs ADD COLUMN schedule TEXT NOT NULL DEFAULT '[]';
402
+ `,
303
403
  ];
304
404
 
305
405
  export function migrate(db: DatabaseSync): { from: number; to: number } {
@@ -309,16 +409,77 @@ export function migrate(db: DatabaseSync): { from: number; to: number } {
309
409
  | undefined;
310
410
  const from = row ? Number(row.value) : 0;
311
411
 
412
+ // DB stamped by a newer CareerMate than this code: leave it alone. Stamping it
413
+ // back down would make the newer app re-run migrations it already applied.
414
+ if (from >= MIGRATIONS.length) return { from, to: from };
415
+
312
416
  for (let v = from; v < MIGRATIONS.length; v++) {
313
- db.exec(MIGRATIONS[v]!);
417
+ applyMigration(db, v);
418
+ }
419
+ return { from, to: MIGRATIONS.length };
420
+ }
421
+
422
+ function stampVersion(db: DatabaseSync, version: number): void {
423
+ db.prepare(
424
+ `INSERT INTO _meta (key, value) VALUES ('schema_version', ?)
425
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
426
+ ).run(String(version));
427
+ }
428
+
429
+ function columnExists(db: DatabaseSync, table: string, column: string): boolean {
430
+ const rows = db
431
+ .prepare(`PRAGMA table_info("${table.replaceAll('"', '""')}")`)
432
+ .all() as Array<{ name: string }>;
433
+ return rows.some((r) => r.name === column);
434
+ }
435
+
436
+ // Migrations that manage their own BEGIN/COMMIT, by zero-based index into MIGRATIONS.
437
+ // Only v2 (index 1) does: its FK rebuild toggles PRAGMA foreign_keys, a no-op inside
438
+ // an open transaction, so the runner must NOT wrap it. Register any future
439
+ // self-transactional migration here (a plain `sql.includes('BEGIN;')` sniff would
440
+ // misfire if a normal migration ever contained the substring, e.g. in a comment).
441
+ const SELF_TRANSACTIONAL_MIGRATIONS = new Set<number>([1]);
442
+
443
+ // Style contract for column-adding migrations: every `ALTER TABLE <t> ADD COLUMN <c>
444
+ // …;` MUST be a single statement on its own line so this regex can find it and the
445
+ // idempotent-runner can drop it when the column already exists. A multi-column or
446
+ // inline-with-other-DDL ALTER would slip past skipExistingColumns and crash a
447
+ // partial-migration re-run. scripts/test-migrate.ts guards this with a count check
448
+ // (loose ALTER…ADD COLUMN scan === strict per-line matches). Keep new ALTERs one-per-line.
449
+ const ALTER_ADD_COLUMN_RE = /^[ \t]*ALTER TABLE\s+(\w+)\s+ADD COLUMN\s+(\w+)[^;]*;[ \t]*$/gm;
450
+ export { ALTER_ADD_COLUMN_RE };
451
+
452
+ /** Drop ADD COLUMN statements whose column already exists (idempotent re-run). */
453
+ function skipExistingColumns(db: DatabaseSync, sql: string): string {
454
+ return sql.replace(ALTER_ADD_COLUMN_RE, (stmt, table: string, column: string) =>
455
+ columnExists(db, table, column) ? '' : stmt,
456
+ );
457
+ }
458
+
459
+ function applyMigration(db: DatabaseSync, v: number): void {
460
+ const sql = MIGRATIONS[v]!;
461
+
462
+ if (SELF_TRANSACTIONAL_MIGRATIONS.has(v)) {
463
+ // Self-transactional migration (v2 toggles PRAGMA foreign_keys, which is a
464
+ // no-op inside an open transaction, so it must manage BEGIN/COMMIT itself).
465
+ // Stamp right after; if the process dies in the tiny window between the two
466
+ // statements, re-running the rebuild is safe (it copies the data again).
467
+ db.exec(sql);
468
+ stampVersion(db, v + 1);
469
+ return;
314
470
  }
315
471
 
316
- const to = MIGRATIONS.length;
317
- if (to !== from) {
318
- db.prepare(
319
- `INSERT INTO _meta (key, value) VALUES ('schema_version', ?)
320
- ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
321
- ).run(String(to));
472
+ db.exec('BEGIN');
473
+ try {
474
+ db.exec(skipExistingColumns(db, sql));
475
+ stampVersion(db, v + 1);
476
+ db.exec('COMMIT');
477
+ } catch (err) {
478
+ try {
479
+ db.exec('ROLLBACK');
480
+ } catch {
481
+ /* surface the original error */
482
+ }
483
+ throw err;
322
484
  }
323
- return { from, to };
324
485
  }