careermate 0.1.0

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 (124) hide show
  1. package/README.md +256 -0
  2. package/THIRD_PARTY_NOTICES.md +40 -0
  3. package/apps/mcp/src/index.ts +66 -0
  4. package/apps/web/DESIGN_GUIDE.md +105 -0
  5. package/apps/web/UI_CONTRACT.md +44 -0
  6. package/apps/web/public/app.js +118 -0
  7. package/apps/web/public/fonts/PretendardVariable.woff2 +0 -0
  8. package/apps/web/public/index.html +41 -0
  9. package/apps/web/public/lib.js +282 -0
  10. package/apps/web/public/pages/applications.js +98 -0
  11. package/apps/web/public/pages/documents.js +446 -0
  12. package/apps/web/public/pages/home.js +263 -0
  13. package/apps/web/public/pages/interview.js +230 -0
  14. package/apps/web/public/pages/jobs.js +494 -0
  15. package/apps/web/public/pages/profile.js +576 -0
  16. package/apps/web/public/pages/settings.js +233 -0
  17. package/apps/web/public/styles.css +426 -0
  18. package/apps/web/src/exports.ts +68 -0
  19. package/apps/web/src/http.ts +180 -0
  20. package/apps/web/src/index.ts +49 -0
  21. package/apps/web/src/info.ts +50 -0
  22. package/apps/web/src/routes.ts +350 -0
  23. package/apps/web/src/security.ts +102 -0
  24. package/apps/web/src/server.ts +141 -0
  25. package/apps/web/src/settings.ts +88 -0
  26. package/bin/careermate.mjs +74 -0
  27. package/dist/careermate.mcpb +0 -0
  28. package/dist/install-page/index.html +474 -0
  29. package/dist/install-page/style.css +391 -0
  30. package/dist/install-page/vercel.json +20 -0
  31. package/dist/mcp-smoke.err +3 -0
  32. package/dist/mcp.mjs +23704 -0
  33. package/dist/mcpb-stage/README.md +219 -0
  34. package/dist/mcpb-stage/dist/install-page/index.html +434 -0
  35. package/dist/mcpb-stage/dist/install-page/style.css +407 -0
  36. package/dist/mcpb-stage/dist/install-page/vercel.json +20 -0
  37. package/dist/mcpb-stage/dist/mcp.mjs +23704 -0
  38. package/dist/mcpb-stage/dist/public/app.js +118 -0
  39. package/dist/mcpb-stage/dist/public/fonts/PretendardVariable.woff2 +0 -0
  40. package/dist/mcpb-stage/dist/public/index.html +41 -0
  41. package/dist/mcpb-stage/dist/public/lib.js +282 -0
  42. package/dist/mcpb-stage/dist/public/pages/applications.js +98 -0
  43. package/dist/mcpb-stage/dist/public/pages/documents.js +446 -0
  44. package/dist/mcpb-stage/dist/public/pages/home.js +263 -0
  45. package/dist/mcpb-stage/dist/public/pages/interview.js +230 -0
  46. package/dist/mcpb-stage/dist/public/pages/jobs.js +494 -0
  47. package/dist/mcpb-stage/dist/public/pages/profile.js +576 -0
  48. package/dist/mcpb-stage/dist/public/pages/settings.js +233 -0
  49. package/dist/mcpb-stage/dist/public/styles.css +420 -0
  50. package/dist/mcpb-stage/dist/web.mjs +7240 -0
  51. package/dist/mcpb-stage/manifest.json +40 -0
  52. package/dist/public/app.js +118 -0
  53. package/dist/public/fonts/PretendardVariable.woff2 +0 -0
  54. package/dist/public/index.html +41 -0
  55. package/dist/public/lib.js +282 -0
  56. package/dist/public/pages/applications.js +98 -0
  57. package/dist/public/pages/documents.js +446 -0
  58. package/dist/public/pages/home.js +263 -0
  59. package/dist/public/pages/interview.js +230 -0
  60. package/dist/public/pages/jobs.js +494 -0
  61. package/dist/public/pages/profile.js +576 -0
  62. package/dist/public/pages/settings.js +233 -0
  63. package/dist/public/styles.css +426 -0
  64. package/dist/web.mjs +7240 -0
  65. package/docs/ARCHITECTURE.md +208 -0
  66. package/docs/CHANGES_V1.md +103 -0
  67. package/docs/DATA_MODEL.md +460 -0
  68. package/docs/DECISIONS.md +277 -0
  69. package/docs/DEMO.md +242 -0
  70. package/docs/INSTALL.md +148 -0
  71. package/docs/INSTALL_AND_USAGE.md +99 -0
  72. package/docs/MCP_TOOLS.md +233 -0
  73. package/docs/ROADMAP.md +134 -0
  74. package/docs/START_WORKFLOW.md +125 -0
  75. package/docs/SUPPORTED_AI_APPS.md +60 -0
  76. package/docs/TODO.md +57 -0
  77. package/docs/UX_NOTES.md +247 -0
  78. package/docs/WORKFLOWS.md +200 -0
  79. package/install-page/index.html +474 -0
  80. package/install-page/style.css +391 -0
  81. package/install-page/vercel.json +20 -0
  82. package/package.json +68 -0
  83. package/packages/core/src/context.ts +74 -0
  84. package/packages/core/src/index.ts +8 -0
  85. package/packages/core/src/onboarding.ts +81 -0
  86. package/packages/core/src/services.ts +146 -0
  87. package/packages/core/src/summary.ts +104 -0
  88. package/packages/db/src/connection.ts +46 -0
  89. package/packages/db/src/index.ts +22 -0
  90. package/packages/db/src/paths.ts +41 -0
  91. package/packages/db/src/repositories.ts +828 -0
  92. package/packages/db/src/runtime.ts +58 -0
  93. package/packages/db/src/schema.ts +189 -0
  94. package/packages/exporters/src/html.ts +113 -0
  95. package/packages/exporters/src/index.ts +364 -0
  96. package/packages/exporters/src/markdown.ts +178 -0
  97. package/packages/mcp-tools/src/bridge.ts +83 -0
  98. package/packages/mcp-tools/src/index.ts +8 -0
  99. package/packages/mcp-tools/src/result.ts +49 -0
  100. package/packages/mcp-tools/src/tools.ts +455 -0
  101. package/packages/parsers/src/html.ts +86 -0
  102. package/packages/parsers/src/index.ts +228 -0
  103. package/packages/parsers/src/keywords.ts +151 -0
  104. package/packages/prompts/src/humanize.ts +59 -0
  105. package/packages/prompts/src/index.ts +82 -0
  106. package/packages/prompts/src/install.ts +43 -0
  107. package/packages/prompts/src/onboarding.ts +35 -0
  108. package/packages/prompts/src/system.ts +53 -0
  109. package/packages/shared/src/enums.ts +103 -0
  110. package/packages/shared/src/index.ts +18 -0
  111. package/packages/shared/src/schemas.ts +398 -0
  112. package/packages/workflows/src/definitions.ts +107 -0
  113. package/packages/workflows/src/index.ts +39 -0
  114. package/scripts/build-dist.mjs +62 -0
  115. package/scripts/build-mcpb.mjs +70 -0
  116. package/scripts/doctor.ts +81 -0
  117. package/scripts/init.ts +342 -0
  118. package/scripts/mcp-probe.ts +55 -0
  119. package/scripts/migrate.ts +6 -0
  120. package/scripts/run.mjs +33 -0
  121. package/scripts/seed.ts +129 -0
  122. package/scripts/test.ts +117 -0
  123. package/scripts/ui-smoke.ts +73 -0
  124. package/tsconfig.json +29 -0
@@ -0,0 +1,828 @@
1
+ /**
2
+ * Repositories — typed CRUD over the SQLite tables. Each function returns the
3
+ * parsed domain record shape from @careermate/shared (JSON columns decoded,
4
+ * bits turned into booleans). This is the only layer that touches raw SQL.
5
+ */
6
+ import {
7
+ newId,
8
+ now,
9
+ type ProfileInput,
10
+ type ProfileRecord,
11
+ type ExperienceInput,
12
+ type ExperienceRecord,
13
+ type ProjectInput,
14
+ type ProjectRecord,
15
+ type SkillInput,
16
+ type SkillRecord,
17
+ type DocumentInput,
18
+ type DocumentRecord,
19
+ type CoverLetterRecord,
20
+ type CoverLetterVersionRecord,
21
+ type JobInput,
22
+ type JobRecord,
23
+ type FitAnalysisInput,
24
+ type FitAnalysisRecord,
25
+ type ApplicationInput,
26
+ type ApplicationRecord,
27
+ type ApplicationStatus,
28
+ type InterviewPrepInput,
29
+ type InterviewPrepRecord,
30
+ type ActivityRecord,
31
+ type ActivityType,
32
+ type EntityType,
33
+ type ContentSource,
34
+ type DocumentKind,
35
+ } from '@careermate/shared';
36
+ import { getDb, toJson, fromJson, toBit, fromBit } from './connection.ts';
37
+
38
+ const PROFILE_ID = 'profile-singleton';
39
+
40
+ /* ----------------------------------------------------------------- Profile */
41
+
42
+ function mapProfile(r: any): ProfileRecord {
43
+ return {
44
+ id: r.id,
45
+ name: r.name,
46
+ email: r.email,
47
+ phone: r.phone,
48
+ location: r.location,
49
+ headline: r.headline,
50
+ summary: r.summary,
51
+ desired_roles: fromJson(r.desired_roles, []),
52
+ desired_conditions: r.desired_conditions,
53
+ preferred_tone: r.preferred_tone,
54
+ emphasis_points: fromJson(r.emphasis_points, []),
55
+ links: fromJson(r.links, []),
56
+ created_at: r.created_at,
57
+ updated_at: r.updated_at,
58
+ };
59
+ }
60
+
61
+ export const profileRepo = {
62
+ get(): ProfileRecord | null {
63
+ const r = getDb().prepare(`SELECT * FROM profile WHERE id = ?`).get(PROFILE_ID);
64
+ return r ? mapProfile(r) : null;
65
+ },
66
+ /** Upsert + merge: only provided fields overwrite; arrays replace when given. */
67
+ save(input: ProfileInput): ProfileRecord {
68
+ const db = getDb();
69
+ const existing = this.get();
70
+ const ts = now();
71
+ const merged: ProfileRecord = {
72
+ id: PROFILE_ID,
73
+ name: input.name ?? existing?.name ?? null,
74
+ email: input.email ?? existing?.email ?? null,
75
+ phone: input.phone ?? existing?.phone ?? null,
76
+ location: input.location ?? existing?.location ?? null,
77
+ headline: input.headline ?? existing?.headline ?? null,
78
+ summary: input.summary ?? existing?.summary ?? null,
79
+ desired_roles: input.desired_roles ?? existing?.desired_roles ?? [],
80
+ desired_conditions: input.desired_conditions ?? existing?.desired_conditions ?? null,
81
+ preferred_tone: input.preferred_tone ?? existing?.preferred_tone ?? null,
82
+ emphasis_points: input.emphasis_points ?? existing?.emphasis_points ?? [],
83
+ links: input.links ?? existing?.links ?? [],
84
+ created_at: existing?.created_at ?? ts,
85
+ updated_at: ts,
86
+ };
87
+ db.prepare(
88
+ `INSERT INTO profile (id,name,email,phone,location,headline,summary,desired_roles,desired_conditions,preferred_tone,emphasis_points,links,created_at,updated_at)
89
+ VALUES (@id,@name,@email,@phone,@location,@headline,@summary,@desired_roles,@desired_conditions,@preferred_tone,@emphasis_points,@links,@created_at,@updated_at)
90
+ ON CONFLICT(id) DO UPDATE SET
91
+ name=@name,email=@email,phone=@phone,location=@location,headline=@headline,summary=@summary,
92
+ desired_roles=@desired_roles,desired_conditions=@desired_conditions,preferred_tone=@preferred_tone,
93
+ emphasis_points=@emphasis_points,links=@links,updated_at=@updated_at`,
94
+ ).run({
95
+ ...merged,
96
+ desired_roles: toJson(merged.desired_roles),
97
+ emphasis_points: toJson(merged.emphasis_points),
98
+ links: toJson(merged.links),
99
+ });
100
+ return merged;
101
+ },
102
+ };
103
+
104
+ /* -------------------------------------------------------------- Experiences */
105
+
106
+ function mapExperience(r: any): ExperienceRecord {
107
+ return {
108
+ id: r.id,
109
+ company: r.company,
110
+ role: r.role,
111
+ employment_type: r.employment_type,
112
+ start_date: r.start_date,
113
+ end_date: r.end_date,
114
+ is_current: fromBit(r.is_current),
115
+ description: r.description,
116
+ achievements: fromJson(r.achievements, []),
117
+ tech: fromJson(r.tech, []),
118
+ order_index: r.order_index,
119
+ created_at: r.created_at,
120
+ updated_at: r.updated_at,
121
+ };
122
+ }
123
+
124
+ export const experienceRepo = {
125
+ list(): ExperienceRecord[] {
126
+ return (
127
+ getDb()
128
+ .prepare(`SELECT * FROM experiences ORDER BY order_index ASC, start_date DESC`)
129
+ .all() as any[]
130
+ ).map(mapExperience);
131
+ },
132
+ get(id: string): ExperienceRecord | null {
133
+ const r = getDb().prepare(`SELECT * FROM experiences WHERE id = ?`).get(id);
134
+ return r ? mapExperience(r) : null;
135
+ },
136
+ add(input: ExperienceInput): ExperienceRecord {
137
+ const ts = now();
138
+ const id = newId('exp_');
139
+ getDb()
140
+ .prepare(
141
+ `INSERT INTO experiences (id,company,role,employment_type,start_date,end_date,is_current,description,achievements,tech,order_index,created_at,updated_at)
142
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
143
+ )
144
+ .run(
145
+ id,
146
+ input.company,
147
+ input.role ?? null,
148
+ input.employment_type ?? null,
149
+ input.start_date ?? null,
150
+ input.end_date ?? null,
151
+ toBit(input.is_current),
152
+ input.description ?? null,
153
+ toJson(input.achievements ?? []),
154
+ toJson(input.tech ?? []),
155
+ input.order_index ?? 0,
156
+ ts,
157
+ ts,
158
+ );
159
+ return this.get(id)!;
160
+ },
161
+ update(id: string, input: Partial<ExperienceInput>): ExperienceRecord | null {
162
+ const cur = this.get(id);
163
+ if (!cur) return null;
164
+ const m = { ...cur, ...input } as ExperienceRecord;
165
+ getDb()
166
+ .prepare(
167
+ `UPDATE experiences SET company=?,role=?,employment_type=?,start_date=?,end_date=?,is_current=?,description=?,achievements=?,tech=?,order_index=?,updated_at=? WHERE id=?`,
168
+ )
169
+ .run(
170
+ m.company,
171
+ m.role,
172
+ m.employment_type,
173
+ m.start_date,
174
+ m.end_date,
175
+ toBit(m.is_current),
176
+ m.description,
177
+ toJson(m.achievements),
178
+ toJson(m.tech),
179
+ m.order_index,
180
+ now(),
181
+ id,
182
+ );
183
+ return this.get(id);
184
+ },
185
+ remove(id: string): boolean {
186
+ return getDb().prepare(`DELETE FROM experiences WHERE id=?`).run(id).changes > 0;
187
+ },
188
+ };
189
+
190
+ /* ----------------------------------------------------------------- Projects */
191
+
192
+ function mapProject(r: any): ProjectRecord {
193
+ return {
194
+ id: r.id,
195
+ name: r.name,
196
+ role: r.role,
197
+ description: r.description,
198
+ highlights: fromJson(r.highlights, []),
199
+ tech: fromJson(r.tech, []),
200
+ url: r.url,
201
+ start_date: r.start_date,
202
+ end_date: r.end_date,
203
+ order_index: r.order_index,
204
+ created_at: r.created_at,
205
+ updated_at: r.updated_at,
206
+ };
207
+ }
208
+
209
+ export const projectRepo = {
210
+ list(): ProjectRecord[] {
211
+ return (
212
+ getDb().prepare(`SELECT * FROM projects ORDER BY order_index ASC, start_date DESC`).all() as any[]
213
+ ).map(mapProject);
214
+ },
215
+ get(id: string): ProjectRecord | null {
216
+ const r = getDb().prepare(`SELECT * FROM projects WHERE id = ?`).get(id);
217
+ return r ? mapProject(r) : null;
218
+ },
219
+ add(input: ProjectInput): ProjectRecord {
220
+ const ts = now();
221
+ const id = newId('prj_');
222
+ getDb()
223
+ .prepare(
224
+ `INSERT INTO projects (id,name,role,description,highlights,tech,url,start_date,end_date,order_index,created_at,updated_at)
225
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
226
+ )
227
+ .run(
228
+ id,
229
+ input.name,
230
+ input.role ?? null,
231
+ input.description ?? null,
232
+ toJson(input.highlights ?? []),
233
+ toJson(input.tech ?? []),
234
+ input.url ?? null,
235
+ input.start_date ?? null,
236
+ input.end_date ?? null,
237
+ input.order_index ?? 0,
238
+ ts,
239
+ ts,
240
+ );
241
+ return this.get(id)!;
242
+ },
243
+ update(id: string, input: Partial<ProjectInput>): ProjectRecord | null {
244
+ const cur = this.get(id);
245
+ if (!cur) return null;
246
+ const m = { ...cur, ...input } as ProjectRecord;
247
+ getDb()
248
+ .prepare(
249
+ `UPDATE projects SET name=?,role=?,description=?,highlights=?,tech=?,url=?,start_date=?,end_date=?,order_index=?,updated_at=? WHERE id=?`,
250
+ )
251
+ .run(
252
+ m.name,
253
+ m.role,
254
+ m.description,
255
+ toJson(m.highlights),
256
+ toJson(m.tech),
257
+ m.url,
258
+ m.start_date,
259
+ m.end_date,
260
+ m.order_index,
261
+ now(),
262
+ id,
263
+ );
264
+ return this.get(id);
265
+ },
266
+ remove(id: string): boolean {
267
+ return getDb().prepare(`DELETE FROM projects WHERE id=?`).run(id).changes > 0;
268
+ },
269
+ };
270
+
271
+ /* ------------------------------------------------------------------- Skills */
272
+
273
+ function mapSkill(r: any): SkillRecord {
274
+ return {
275
+ id: r.id,
276
+ name: r.name,
277
+ category: r.category,
278
+ level: r.level,
279
+ years: r.years,
280
+ order_index: r.order_index,
281
+ created_at: r.created_at,
282
+ updated_at: r.updated_at,
283
+ };
284
+ }
285
+
286
+ export const skillRepo = {
287
+ list(): SkillRecord[] {
288
+ return (
289
+ getDb().prepare(`SELECT * FROM skills ORDER BY order_index ASC, name ASC`).all() as any[]
290
+ ).map(mapSkill);
291
+ },
292
+ get(id: string): SkillRecord | null {
293
+ const r = getDb().prepare(`SELECT * FROM skills WHERE id = ?`).get(id);
294
+ return r ? mapSkill(r) : null;
295
+ },
296
+ add(input: SkillInput): SkillRecord {
297
+ const ts = now();
298
+ const id = newId('skl_');
299
+ getDb()
300
+ .prepare(
301
+ `INSERT INTO skills (id,name,category,level,years,order_index,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)`,
302
+ )
303
+ .run(
304
+ id,
305
+ input.name,
306
+ input.category ?? null,
307
+ input.level ?? null,
308
+ input.years ?? null,
309
+ input.order_index ?? 0,
310
+ ts,
311
+ ts,
312
+ );
313
+ return this.get(id)!;
314
+ },
315
+ update(id: string, input: Partial<SkillInput>): SkillRecord | null {
316
+ const cur = this.get(id);
317
+ if (!cur) return null;
318
+ const m = { ...cur, ...input } as SkillRecord;
319
+ getDb()
320
+ .prepare(`UPDATE skills SET name=?,category=?,level=?,years=?,order_index=?,updated_at=? WHERE id=?`)
321
+ .run(m.name, m.category, m.level, m.years, m.order_index, now(), id);
322
+ return this.get(id);
323
+ },
324
+ remove(id: string): boolean {
325
+ return getDb().prepare(`DELETE FROM skills WHERE id=?`).run(id).changes > 0;
326
+ },
327
+ };
328
+
329
+ /* ---------------------------------------------------------------- Documents */
330
+
331
+ function mapDocument(r: any): DocumentRecord {
332
+ return {
333
+ id: r.id,
334
+ kind: r.kind,
335
+ title: r.title,
336
+ content: r.content,
337
+ source: r.source,
338
+ is_primary: fromBit(r.is_primary),
339
+ tags: fromJson(r.tags, []),
340
+ created_at: r.created_at,
341
+ updated_at: r.updated_at,
342
+ };
343
+ }
344
+
345
+ export const documentRepo = {
346
+ list(kind?: DocumentKind): DocumentRecord[] {
347
+ const db = getDb();
348
+ const rows = kind
349
+ ? db.prepare(`SELECT * FROM documents WHERE kind=? ORDER BY is_primary DESC, updated_at DESC`).all(kind)
350
+ : db.prepare(`SELECT * FROM documents ORDER BY is_primary DESC, updated_at DESC`).all();
351
+ return (rows as any[]).map(mapDocument);
352
+ },
353
+ get(id: string): DocumentRecord | null {
354
+ const r = getDb().prepare(`SELECT * FROM documents WHERE id=?`).get(id);
355
+ return r ? mapDocument(r) : null;
356
+ },
357
+ primary(kind: DocumentKind): DocumentRecord | null {
358
+ const r = getDb()
359
+ .prepare(`SELECT * FROM documents WHERE kind=? ORDER BY is_primary DESC, updated_at DESC LIMIT 1`)
360
+ .get(kind);
361
+ return r ? mapDocument(r) : null;
362
+ },
363
+ add(input: DocumentInput): DocumentRecord {
364
+ const ts = now();
365
+ const id = newId('doc_');
366
+ const db = getDb();
367
+ if (input.is_primary) {
368
+ db.prepare(`UPDATE documents SET is_primary=0 WHERE kind=?`).run(input.kind);
369
+ }
370
+ db.prepare(
371
+ `INSERT INTO documents (id,kind,title,content,source,is_primary,tags,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)`,
372
+ ).run(
373
+ id,
374
+ input.kind,
375
+ input.title,
376
+ input.content ?? '',
377
+ (input.source ?? 'manual') as ContentSource,
378
+ toBit(input.is_primary),
379
+ toJson(input.tags ?? []),
380
+ ts,
381
+ ts,
382
+ );
383
+ return this.get(id)!;
384
+ },
385
+ update(id: string, input: Partial<DocumentInput>): DocumentRecord | null {
386
+ const cur = this.get(id);
387
+ if (!cur) return null;
388
+ const db = getDb();
389
+ const m = { ...cur, ...input } as DocumentRecord;
390
+ if (input.is_primary) db.prepare(`UPDATE documents SET is_primary=0 WHERE kind=?`).run(m.kind);
391
+ db.prepare(
392
+ `UPDATE documents SET kind=?,title=?,content=?,source=?,is_primary=?,tags=?,updated_at=? WHERE id=?`,
393
+ ).run(m.kind, m.title, m.content, m.source, toBit(m.is_primary), toJson(m.tags), now(), id);
394
+ return this.get(id);
395
+ },
396
+ remove(id: string): boolean {
397
+ return getDb().prepare(`DELETE FROM documents WHERE id=?`).run(id).changes > 0;
398
+ },
399
+ };
400
+
401
+ /* ------------------------------------------------------------ Cover letters */
402
+
403
+ function mapVersion(r: any): CoverLetterVersionRecord {
404
+ return {
405
+ id: r.id,
406
+ cover_letter_id: r.cover_letter_id,
407
+ version_no: r.version_no,
408
+ content: r.content,
409
+ note: r.note,
410
+ source: r.source,
411
+ created_at: r.created_at,
412
+ };
413
+ }
414
+
415
+ function mapCoverLetter(r: any, withVersions = false): CoverLetterRecord {
416
+ const db = getDb();
417
+ const versions = (
418
+ db
419
+ .prepare(`SELECT * FROM cover_letter_versions WHERE cover_letter_id=? ORDER BY version_no DESC`)
420
+ .all(r.id) as any[]
421
+ ).map(mapVersion);
422
+ const current = versions.find((v) => v.id === r.current_version_id) ?? versions[0] ?? null;
423
+ return {
424
+ id: r.id,
425
+ title: r.title,
426
+ job_id: r.job_id,
427
+ is_primary: fromBit(r.is_primary),
428
+ current_version_id: r.current_version_id,
429
+ version_count: versions.length,
430
+ current_content: current ? current.content : null,
431
+ versions: withVersions ? versions : undefined,
432
+ created_at: r.created_at,
433
+ updated_at: r.updated_at,
434
+ };
435
+ }
436
+
437
+ export const coverLetterRepo = {
438
+ list(): CoverLetterRecord[] {
439
+ return (
440
+ getDb().prepare(`SELECT * FROM cover_letters ORDER BY is_primary DESC, updated_at DESC`).all() as any[]
441
+ ).map((r) => mapCoverLetter(r, false));
442
+ },
443
+ get(id: string, withVersions = true): CoverLetterRecord | null {
444
+ const r = getDb().prepare(`SELECT * FROM cover_letters WHERE id=?`).get(id);
445
+ return r ? mapCoverLetter(r, withVersions) : null;
446
+ },
447
+ listByJob(jobId: string): CoverLetterRecord[] {
448
+ return (
449
+ getDb().prepare(`SELECT * FROM cover_letters WHERE job_id=? ORDER BY updated_at DESC`).all(jobId) as any[]
450
+ ).map((r) => mapCoverLetter(r, false));
451
+ },
452
+ create(opts: { title: string; job_id?: string | null; is_primary?: boolean }): CoverLetterRecord {
453
+ const ts = now();
454
+ const id = newId('cl_');
455
+ const db = getDb();
456
+ if (opts.is_primary) db.prepare(`UPDATE cover_letters SET is_primary=0`).run();
457
+ db.prepare(
458
+ `INSERT INTO cover_letters (id,title,job_id,is_primary,current_version_id,created_at,updated_at) VALUES (?,?,?,?,?,?,?)`,
459
+ ).run(id, opts.title, opts.job_id ?? null, toBit(opts.is_primary), null, ts, ts);
460
+ return this.get(id)!;
461
+ },
462
+ /** Append a new version. Creates the cover letter if cover_letter_id is omitted. */
463
+ addVersion(opts: {
464
+ cover_letter_id?: string;
465
+ title?: string;
466
+ job_id?: string | null;
467
+ content: string;
468
+ note?: string;
469
+ source?: ContentSource;
470
+ set_current?: boolean;
471
+ }): { coverLetter: CoverLetterRecord; version: CoverLetterVersionRecord } {
472
+ const db = getDb();
473
+ let clId = opts.cover_letter_id;
474
+ if (!clId) {
475
+ const cl = this.create({ title: opts.title ?? '자기소개서', job_id: opts.job_id ?? null });
476
+ clId = cl.id;
477
+ }
478
+ const maxRow = db
479
+ .prepare(`SELECT MAX(version_no) AS m FROM cover_letter_versions WHERE cover_letter_id=?`)
480
+ .get(clId) as { m: number | null };
481
+ const versionNo = (maxRow.m ?? 0) + 1;
482
+ const vid = newId('clv_');
483
+ const ts = now();
484
+ db.prepare(
485
+ `INSERT INTO cover_letter_versions (id,cover_letter_id,version_no,content,note,source,created_at) VALUES (?,?,?,?,?,?,?)`,
486
+ ).run(vid, clId, versionNo, opts.content, opts.note ?? null, opts.source ?? 'ai', ts);
487
+
488
+ const setCurrent = opts.set_current !== false;
489
+ if (setCurrent) {
490
+ db.prepare(`UPDATE cover_letters SET current_version_id=?, updated_at=? WHERE id=?`).run(vid, ts, clId);
491
+ } else {
492
+ db.prepare(`UPDATE cover_letters SET updated_at=? WHERE id=?`).run(ts, clId);
493
+ }
494
+ if (opts.title) db.prepare(`UPDATE cover_letters SET title=? WHERE id=?`).run(opts.title, clId);
495
+ if (opts.job_id !== undefined)
496
+ db.prepare(`UPDATE cover_letters SET job_id=? WHERE id=?`).run(opts.job_id, clId);
497
+
498
+ return { coverLetter: this.get(clId)!, version: mapVersion(db.prepare(`SELECT * FROM cover_letter_versions WHERE id=?`).get(vid)) };
499
+ },
500
+ setCurrentVersion(coverLetterId: string, versionId: string): CoverLetterRecord | null {
501
+ getDb()
502
+ .prepare(`UPDATE cover_letters SET current_version_id=?, updated_at=? WHERE id=?`)
503
+ .run(versionId, now(), coverLetterId);
504
+ return this.get(coverLetterId);
505
+ },
506
+ setPrimary(id: string): CoverLetterRecord | null {
507
+ const db = getDb();
508
+ db.prepare(`UPDATE cover_letters SET is_primary=0`).run();
509
+ db.prepare(`UPDATE cover_letters SET is_primary=1, updated_at=? WHERE id=?`).run(now(), id);
510
+ return this.get(id);
511
+ },
512
+ remove(id: string): boolean {
513
+ const db = getDb();
514
+ db.prepare(`DELETE FROM cover_letter_versions WHERE cover_letter_id=?`).run(id);
515
+ return db.prepare(`DELETE FROM cover_letters WHERE id=?`).run(id).changes > 0;
516
+ },
517
+ };
518
+
519
+ /* --------------------------------------------------------------------- Jobs */
520
+
521
+ function mapJob(r: any): JobRecord {
522
+ return {
523
+ id: r.id,
524
+ company: r.company,
525
+ position: r.position,
526
+ url: r.url,
527
+ location: r.location,
528
+ employment_type: r.employment_type,
529
+ description: r.description,
530
+ requirements: fromJson(r.requirements, []),
531
+ keywords: fromJson(r.keywords, []),
532
+ deadline: r.deadline,
533
+ source: r.source,
534
+ created_at: r.created_at,
535
+ updated_at: r.updated_at,
536
+ };
537
+ }
538
+
539
+ export const jobRepo = {
540
+ list(): JobRecord[] {
541
+ return (getDb().prepare(`SELECT * FROM jobs ORDER BY updated_at DESC`).all() as any[]).map(mapJob);
542
+ },
543
+ get(id: string): JobRecord | null {
544
+ const r = getDb().prepare(`SELECT * FROM jobs WHERE id=?`).get(id);
545
+ return r ? mapJob(r) : null;
546
+ },
547
+ findByUrl(url: string): JobRecord | null {
548
+ const r = getDb().prepare(`SELECT * FROM jobs WHERE url=? LIMIT 1`).get(url);
549
+ return r ? mapJob(r) : null;
550
+ },
551
+ relatedTo(company: string, position: string, excludeId?: string): JobRecord[] {
552
+ const rows = getDb()
553
+ .prepare(
554
+ `SELECT * FROM jobs WHERE (company=? OR position LIKE ?) AND id != ? ORDER BY updated_at DESC LIMIT 10`,
555
+ )
556
+ .all(company, `%${position}%`, excludeId ?? '') as any[];
557
+ return rows.map(mapJob);
558
+ },
559
+ upsert(input: JobInput, id?: string): JobRecord {
560
+ const db = getDb();
561
+ const existing = id ? this.get(id) : input.url ? this.findByUrl(input.url) : null;
562
+ const ts = now();
563
+ if (existing) {
564
+ const m = { ...existing, ...input } as JobRecord;
565
+ db.prepare(
566
+ `UPDATE jobs SET company=?,position=?,url=?,location=?,employment_type=?,description=?,requirements=?,keywords=?,deadline=?,source=?,updated_at=? WHERE id=?`,
567
+ ).run(
568
+ m.company,
569
+ m.position,
570
+ m.url ?? null,
571
+ m.location ?? null,
572
+ m.employment_type ?? null,
573
+ m.description ?? null,
574
+ toJson(m.requirements ?? []),
575
+ toJson(m.keywords ?? []),
576
+ m.deadline ?? null,
577
+ m.source ?? null,
578
+ ts,
579
+ existing.id,
580
+ );
581
+ return this.get(existing.id)!;
582
+ }
583
+ const newJobId = id ?? newId('job_');
584
+ db.prepare(
585
+ `INSERT INTO jobs (id,company,position,url,location,employment_type,description,requirements,keywords,deadline,source,created_at,updated_at)
586
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
587
+ ).run(
588
+ newJobId,
589
+ input.company,
590
+ input.position,
591
+ input.url ?? null,
592
+ input.location ?? null,
593
+ input.employment_type ?? null,
594
+ input.description ?? null,
595
+ toJson(input.requirements ?? []),
596
+ toJson(input.keywords ?? []),
597
+ input.deadline ?? null,
598
+ input.source ?? null,
599
+ ts,
600
+ ts,
601
+ );
602
+ return this.get(newJobId)!;
603
+ },
604
+ remove(id: string): boolean {
605
+ const db = getDb();
606
+ db.prepare(`DELETE FROM fit_analyses WHERE job_id=?`).run(id);
607
+ db.prepare(`DELETE FROM applications WHERE job_id=?`).run(id);
608
+ db.prepare(`DELETE FROM interview_preps WHERE job_id=?`).run(id);
609
+ return db.prepare(`DELETE FROM jobs WHERE id=?`).run(id).changes > 0;
610
+ },
611
+ };
612
+
613
+ /* ------------------------------------------------------------ Fit analyses */
614
+
615
+ function mapFit(r: any): FitAnalysisRecord {
616
+ return {
617
+ id: r.id,
618
+ job_id: r.job_id,
619
+ score: r.score,
620
+ summary: r.summary,
621
+ strengths: fromJson(r.strengths, []),
622
+ gaps: fromJson(r.gaps, []),
623
+ matched_keywords: fromJson(r.matched_keywords, []),
624
+ missing_keywords: fromJson(r.missing_keywords, []),
625
+ recommendations: fromJson(r.recommendations, []),
626
+ created_at: r.created_at,
627
+ updated_at: r.updated_at,
628
+ };
629
+ }
630
+
631
+ export const fitRepo = {
632
+ getByJob(jobId: string): FitAnalysisRecord | null {
633
+ const r = getDb()
634
+ .prepare(`SELECT * FROM fit_analyses WHERE job_id=? ORDER BY updated_at DESC LIMIT 1`)
635
+ .get(jobId);
636
+ return r ? mapFit(r) : null;
637
+ },
638
+ save(input: FitAnalysisInput): FitAnalysisRecord {
639
+ const db = getDb();
640
+ const existing = this.getByJob(input.job_id);
641
+ const ts = now();
642
+ if (existing) {
643
+ const m = { ...existing, ...input } as FitAnalysisRecord;
644
+ db.prepare(
645
+ `UPDATE fit_analyses SET score=?,summary=?,strengths=?,gaps=?,matched_keywords=?,missing_keywords=?,recommendations=?,updated_at=? WHERE id=?`,
646
+ ).run(
647
+ m.score ?? null,
648
+ m.summary ?? null,
649
+ toJson(m.strengths ?? []),
650
+ toJson(m.gaps ?? []),
651
+ toJson(m.matched_keywords ?? []),
652
+ toJson(m.missing_keywords ?? []),
653
+ toJson(m.recommendations ?? []),
654
+ ts,
655
+ existing.id,
656
+ );
657
+ return this.getByJob(input.job_id)!;
658
+ }
659
+ const id = newId('fit_');
660
+ db.prepare(
661
+ `INSERT INTO fit_analyses (id,job_id,score,summary,strengths,gaps,matched_keywords,missing_keywords,recommendations,created_at,updated_at)
662
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
663
+ ).run(
664
+ id,
665
+ input.job_id,
666
+ input.score ?? null,
667
+ input.summary ?? null,
668
+ toJson(input.strengths ?? []),
669
+ toJson(input.gaps ?? []),
670
+ toJson(input.matched_keywords ?? []),
671
+ toJson(input.missing_keywords ?? []),
672
+ toJson(input.recommendations ?? []),
673
+ ts,
674
+ ts,
675
+ );
676
+ return this.getByJob(input.job_id)!;
677
+ },
678
+ };
679
+
680
+ /* ------------------------------------------------------------ Applications */
681
+
682
+ function mapApplication(r: any): ApplicationRecord {
683
+ return {
684
+ id: r.id,
685
+ job_id: r.job_id,
686
+ status: r.status,
687
+ resume_id: r.resume_id,
688
+ cover_letter_id: r.cover_letter_id,
689
+ applied_at: r.applied_at,
690
+ notes: r.notes,
691
+ created_at: r.created_at,
692
+ updated_at: r.updated_at,
693
+ };
694
+ }
695
+
696
+ export const applicationRepo = {
697
+ list(): ApplicationRecord[] {
698
+ return (getDb().prepare(`SELECT * FROM applications ORDER BY updated_at DESC`).all() as any[]).map(
699
+ mapApplication,
700
+ );
701
+ },
702
+ get(id: string): ApplicationRecord | null {
703
+ const r = getDb().prepare(`SELECT * FROM applications WHERE id=?`).get(id);
704
+ return r ? mapApplication(r) : null;
705
+ },
706
+ getByJob(jobId: string): ApplicationRecord | null {
707
+ const r = getDb().prepare(`SELECT * FROM applications WHERE job_id=?`).get(jobId);
708
+ return r ? mapApplication(r) : null;
709
+ },
710
+ recent(limit = 10): ApplicationRecord[] {
711
+ return (
712
+ getDb().prepare(`SELECT * FROM applications ORDER BY updated_at DESC LIMIT ?`).all(limit) as any[]
713
+ ).map(mapApplication);
714
+ },
715
+ /** Ensure a (single) application row exists for a job. */
716
+ ensure(jobId: string, status: ApplicationStatus = 'draft'): ApplicationRecord {
717
+ const existing = this.getByJob(jobId);
718
+ if (existing) return existing;
719
+ const ts = now();
720
+ const id = newId('app_');
721
+ getDb()
722
+ .prepare(
723
+ `INSERT INTO applications (id,job_id,status,resume_id,cover_letter_id,applied_at,notes,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)`,
724
+ )
725
+ .run(id, jobId, status, null, null, null, null, ts, ts);
726
+ return this.get(id)!;
727
+ },
728
+ upsert(input: ApplicationInput): ApplicationRecord {
729
+ const app = this.ensure(input.job_id, input.status ?? 'draft');
730
+ const m = { ...app, ...input } as ApplicationRecord;
731
+ getDb()
732
+ .prepare(
733
+ `UPDATE applications SET status=?,resume_id=?,cover_letter_id=?,applied_at=?,notes=?,updated_at=? WHERE id=?`,
734
+ )
735
+ .run(m.status, m.resume_id, m.cover_letter_id, m.applied_at, m.notes, now(), app.id);
736
+ return this.get(app.id)!;
737
+ },
738
+ setStatus(jobId: string, status: ApplicationStatus, note?: string): ApplicationRecord {
739
+ const app = this.ensure(jobId);
740
+ const appliedAt =
741
+ status === 'applied' && !app.applied_at ? now() : app.applied_at;
742
+ const notes = note ? [app.notes, note].filter(Boolean).join('\n') : app.notes;
743
+ getDb()
744
+ .prepare(`UPDATE applications SET status=?, applied_at=?, notes=?, updated_at=? WHERE id=?`)
745
+ .run(status, appliedAt, notes, now(), app.id);
746
+ return this.get(app.id)!;
747
+ },
748
+ };
749
+
750
+ /* --------------------------------------------------------- Interview preps */
751
+
752
+ function mapInterviewPrep(r: any): InterviewPrepRecord {
753
+ return {
754
+ id: r.id,
755
+ job_id: r.job_id,
756
+ questions: fromJson(r.questions, []),
757
+ star_guides: fromJson(r.star_guides, []),
758
+ self_introduction: r.self_introduction,
759
+ notes: r.notes,
760
+ created_at: r.created_at,
761
+ updated_at: r.updated_at,
762
+ };
763
+ }
764
+
765
+ export const interviewRepo = {
766
+ getByJob(jobId: string): InterviewPrepRecord | null {
767
+ const r = getDb().prepare(`SELECT * FROM interview_preps WHERE job_id=?`).get(jobId);
768
+ return r ? mapInterviewPrep(r) : null;
769
+ },
770
+ list(): InterviewPrepRecord[] {
771
+ return (getDb().prepare(`SELECT * FROM interview_preps ORDER BY updated_at DESC`).all() as any[]).map(
772
+ mapInterviewPrep,
773
+ );
774
+ },
775
+ save(input: InterviewPrepInput): InterviewPrepRecord {
776
+ const db = getDb();
777
+ const existing = this.getByJob(input.job_id);
778
+ const ts = now();
779
+ if (existing) {
780
+ const m = { ...existing, ...input } as InterviewPrepRecord;
781
+ db.prepare(
782
+ `UPDATE interview_preps SET questions=?,star_guides=?,self_introduction=?,notes=?,updated_at=? WHERE id=?`,
783
+ ).run(
784
+ toJson(m.questions ?? []),
785
+ toJson(m.star_guides ?? []),
786
+ m.self_introduction ?? null,
787
+ m.notes ?? null,
788
+ ts,
789
+ existing.id,
790
+ );
791
+ return this.getByJob(input.job_id)!;
792
+ }
793
+ const id = newId('itv_');
794
+ db.prepare(
795
+ `INSERT INTO interview_preps (id,job_id,questions,star_guides,self_introduction,notes,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)`,
796
+ ).run(
797
+ id,
798
+ input.job_id,
799
+ toJson(input.questions ?? []),
800
+ toJson(input.star_guides ?? []),
801
+ input.self_introduction ?? null,
802
+ input.notes ?? null,
803
+ ts,
804
+ ts,
805
+ );
806
+ return this.getByJob(input.job_id)!;
807
+ },
808
+ };
809
+
810
+ /* --------------------------------------------------------------- Activities */
811
+
812
+ export const activityRepo = {
813
+ log(type: ActivityType, summary: string, entity_type?: EntityType, entity_id?: string): ActivityRecord {
814
+ const ts = now();
815
+ const id = newId('act_');
816
+ getDb()
817
+ .prepare(
818
+ `INSERT INTO activities (id,type,entity_type,entity_id,summary,created_at) VALUES (?,?,?,?,?,?)`,
819
+ )
820
+ .run(id, type, entity_type ?? null, entity_id ?? null, summary, ts);
821
+ return { id, type, entity_type: entity_type ?? null, entity_id: entity_id ?? null, summary, created_at: ts };
822
+ },
823
+ recent(limit = 20): ActivityRecord[] {
824
+ return getDb()
825
+ .prepare(`SELECT * FROM activities ORDER BY created_at DESC LIMIT ?`)
826
+ .all(limit) as ActivityRecord[];
827
+ },
828
+ };