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