careermate 0.0.8 → 0.0.10
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 +2 -2
- package/LICENSE +1 -1
- package/README.md +78 -81
- package/bin/careermate.mjs +11 -2
- package/dist/career-os/eop/cover-letter.md +10 -4
- package/dist/career-os/eop/interview-prep.md +1 -0
- package/dist/career-os/knowledge/cover-letter.md +4 -0
- package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/dist/doctor.mjs +18138 -6866
- package/dist/mcp.mjs +14820 -4124
- package/dist/migrate.mjs +14436 -3712
- package/dist/pdf.worker.min.mjs +4 -4
- package/dist/public/app.js +10 -19
- package/dist/public/i18n/en/applications.js +19 -0
- package/dist/public/i18n/en/common.js +6 -0
- package/dist/public/i18n/en/documents.js +1 -0
- package/dist/public/i18n/en/home.js +8 -1
- package/dist/public/i18n/en/jobs.js +19 -0
- package/dist/public/i18n/en/nav.js +1 -0
- package/dist/public/i18n/en/settings.js +5 -2
- package/dist/public/i18n/ko/applications.js +19 -0
- package/dist/public/i18n/ko/common.js +5 -0
- package/dist/public/i18n/ko/documents.js +1 -0
- package/dist/public/i18n/ko/home.js +8 -1
- package/dist/public/i18n/ko/jobs.js +19 -0
- package/dist/public/i18n/ko/nav.js +1 -0
- package/dist/public/i18n/ko/settings.js +5 -2
- package/dist/public/lib.js +29 -0
- package/dist/public/pages/applications.js +86 -10
- package/dist/public/pages/documents.js +25 -12
- package/dist/public/pages/home.js +24 -4
- package/dist/public/pages/insights.js +156 -0
- package/dist/public/pages/interview.js +11 -4
- package/dist/public/pages/jobs.js +92 -4
- package/dist/public/pages/settings.js +2 -2
- package/dist/public/styles.css +16 -0
- package/dist/web.mjs +17079 -5651
- package/docs/FAQ.md +13 -3
- package/docs/LOCAL_FIRST.md +32 -0
- package/docs/MCP_TOOLS.md +22 -9
- package/docs/REVIEW_IMPLEMENTATION_PLAN.md +22 -0
- package/docs/career-os/eop/cover-letter.md +10 -4
- package/docs/career-os/eop/interview-prep.md +1 -0
- package/docs/career-os/knowledge/cover-letter.md +4 -0
- package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/package.json +9 -5
- package/packages/core/src/data-health.ts +161 -0
- package/packages/core/src/fit-explain.ts +63 -0
- package/packages/core/src/index.ts +4 -0
- package/packages/core/src/jobsearch.ts +99 -54
- package/packages/core/src/offers.ts +41 -0
- package/packages/core/src/reuse-insights.ts +126 -0
- package/packages/core/src/services.ts +5 -1
- package/packages/core/src/summary.ts +245 -19
- package/packages/db/src/connection.ts +102 -14
- package/packages/db/src/index.ts +3 -0
- package/packages/db/src/interview-retrospectives.ts +149 -0
- package/packages/db/src/provenance.ts +125 -0
- package/packages/db/src/repositories.ts +140 -8
- package/packages/db/src/schema.ts +140 -8
- package/packages/mcp-tools/src/result.ts +9 -0
- package/packages/mcp-tools/src/tools.ts +143 -20
- package/packages/parsers/src/documents.ts +7 -1
- package/packages/prompts/src/humanize.ts +4 -0
- package/packages/prompts/src/system.ts +21 -64
- package/packages/shared/src/schemas.ts +134 -0
- package/scripts/init.ts +2 -0
- package/tsconfig.json +9 -10
|
@@ -2,44 +2,36 @@
|
|
|
2
2
|
* Job search import — pull postings from key-free public job-board endpoints by
|
|
3
3
|
* keyword, so the AI can offer real listings the user can save into CareerMate.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - No WAF bypass, no auth keys. These providers' own frontends call these JSON
|
|
10
|
-
* endpoints unauthenticated; a plain fetch suffices. We do NOT bundle a
|
|
11
|
-
* scraper/anti-bot evasion (that's the legally dangerous part — see
|
|
12
|
-
* .claude/work/research/research_0004). Sites requiring keys (Saramin oapi,
|
|
13
|
-
* 고용24) or that forbid it in robots.txt (JobKorea search) are excluded here.
|
|
14
|
-
* - Read-only w.r.t. the DB. This returns candidates; the AI shows them and the
|
|
15
|
-
* user picks which to save via save_job_posting (no auto-dumping into the DB).
|
|
16
|
-
*
|
|
17
|
-
* Providers are unofficial public endpoints — if a response shape changes, only
|
|
18
|
-
* the affected provider's parser needs updating (others keep working).
|
|
5
|
+
* CareerMate is local-first: only the search keyword leaves the machine. Stored
|
|
6
|
+
* profile, résumé, application, and document data are never sent to providers.
|
|
7
|
+
* Provider endpoints are unofficial and therefore validated at runtime; a shape
|
|
8
|
+
* change degrades only that provider instead of silently returning corrupt rows.
|
|
19
9
|
*/
|
|
20
10
|
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import { APP_VERSION } from '@careermate/shared';
|
|
13
|
+
|
|
21
14
|
export interface JobSearchResult {
|
|
22
|
-
/** Provider id, e.g. 'wanted' | 'jumpit'. */
|
|
23
15
|
source: string;
|
|
24
16
|
company: string;
|
|
25
17
|
position: string;
|
|
26
|
-
/** Canonical posting URL the user can open. */
|
|
27
18
|
url: string;
|
|
28
19
|
location?: string;
|
|
29
|
-
/** Required experience, human-readable (e.g. '신입', '5~20년'). */
|
|
30
20
|
experience?: string;
|
|
31
|
-
/** Tech stack / tags when the provider exposes them. */
|
|
32
21
|
keywords?: string[];
|
|
33
|
-
/** Application deadline as YYYY-MM-DD when known. */
|
|
34
22
|
deadline?: string;
|
|
35
|
-
/** Provider-native id (for dedup / detail fetch). */
|
|
36
23
|
external_id: string;
|
|
37
24
|
}
|
|
38
25
|
|
|
26
|
+
export type ProviderStatus = 'healthy' | 'degraded' | 'unavailable';
|
|
27
|
+
|
|
39
28
|
export interface ProviderOutcome {
|
|
40
29
|
source: string;
|
|
30
|
+
label: string;
|
|
41
31
|
ok: boolean;
|
|
32
|
+
status: ProviderStatus;
|
|
42
33
|
count: number;
|
|
34
|
+
checked_at: string;
|
|
43
35
|
error?: string;
|
|
44
36
|
}
|
|
45
37
|
|
|
@@ -50,25 +42,20 @@ export interface JobSearchResponse {
|
|
|
50
42
|
}
|
|
51
43
|
|
|
52
44
|
export interface JobSearchOptions {
|
|
53
|
-
/** Which providers to query. Defaults to all registered. */
|
|
54
45
|
sources?: string[];
|
|
55
|
-
/** Max results per provider (1–50). Default 10. */
|
|
56
46
|
limit?: number;
|
|
57
|
-
/** Per-request timeout in ms. Default 10000. */
|
|
58
47
|
timeoutMs?: number;
|
|
59
48
|
}
|
|
60
49
|
|
|
61
|
-
const UA =
|
|
62
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
|
|
50
|
+
const UA = `careermate/${APP_VERSION} (+https://careermate.life)`;
|
|
63
51
|
|
|
64
|
-
/** Slice an ISO timestamp / date string to YYYY-MM-DD; undefined if not a date. */
|
|
65
52
|
function toYmd(v: unknown): string | undefined {
|
|
66
53
|
if (typeof v !== 'string' || v.length < 10) return undefined;
|
|
67
54
|
const m = v.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
68
55
|
return m ? m[1] : undefined;
|
|
69
56
|
}
|
|
70
57
|
|
|
71
|
-
async function getJson(url: string, timeoutMs: number): Promise<
|
|
58
|
+
async function getJson(url: string, timeoutMs: number): Promise<unknown> {
|
|
72
59
|
const ctrl = new AbortController();
|
|
73
60
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
74
61
|
try {
|
|
@@ -77,6 +64,8 @@ async function getJson(url: string, timeoutMs: number): Promise<any> {
|
|
|
77
64
|
signal: ctrl.signal,
|
|
78
65
|
});
|
|
79
66
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
67
|
+
const type = res.headers.get('content-type') ?? '';
|
|
68
|
+
if (!type.includes('json')) throw new Error(`unexpected content-type: ${type || 'unknown'}`);
|
|
80
69
|
return await res.json();
|
|
81
70
|
} finally {
|
|
82
71
|
clearTimeout(timer);
|
|
@@ -89,7 +78,41 @@ interface Provider {
|
|
|
89
78
|
search(query: string, limit: number, timeoutMs: number): Promise<JobSearchResult[]>;
|
|
90
79
|
}
|
|
91
80
|
|
|
92
|
-
|
|
81
|
+
const wantedSchema = z.object({
|
|
82
|
+
data: z.array(
|
|
83
|
+
z.object({
|
|
84
|
+
id: z.union([z.string(), z.number()]),
|
|
85
|
+
position: z.string().min(1),
|
|
86
|
+
company: z.object({ name: z.string().min(1) }),
|
|
87
|
+
annual_from: z.number().nullable().optional(),
|
|
88
|
+
annual_to: z.number().nullable().optional(),
|
|
89
|
+
due_time: z.string().nullable().optional(),
|
|
90
|
+
address: z
|
|
91
|
+
.object({ location: z.string().optional(), full_location: z.string().optional() })
|
|
92
|
+
.nullable()
|
|
93
|
+
.optional(),
|
|
94
|
+
}).passthrough(),
|
|
95
|
+
),
|
|
96
|
+
}).passthrough();
|
|
97
|
+
|
|
98
|
+
const jumpitSchema = z.object({
|
|
99
|
+
result: z.object({
|
|
100
|
+
positions: z.array(
|
|
101
|
+
z.object({
|
|
102
|
+
id: z.union([z.string(), z.number()]),
|
|
103
|
+
companyName: z.string().min(1),
|
|
104
|
+
title: z.string().min(1),
|
|
105
|
+
locations: z.array(z.string()).optional(),
|
|
106
|
+
newcomer: z.boolean().optional(),
|
|
107
|
+
minCareer: z.number().nullable().optional(),
|
|
108
|
+
maxCareer: z.number().nullable().optional(),
|
|
109
|
+
techStacks: z.array(z.string()).optional(),
|
|
110
|
+
closedAt: z.string().nullable().optional(),
|
|
111
|
+
}).passthrough(),
|
|
112
|
+
),
|
|
113
|
+
}).passthrough(),
|
|
114
|
+
}).passthrough();
|
|
115
|
+
|
|
93
116
|
const wanted: Provider = {
|
|
94
117
|
id: 'wanted',
|
|
95
118
|
label: '원티드',
|
|
@@ -97,81 +120,103 @@ const wanted: Provider = {
|
|
|
97
120
|
const url =
|
|
98
121
|
`https://www.wanted.co.kr/api/v4/jobs?country=kr&locations=all&years=-1` +
|
|
99
122
|
`&job_sort=job.latest_order&limit=${limit}&offset=0&query=${encodeURIComponent(query)}`;
|
|
100
|
-
const j = await getJson(url, timeoutMs);
|
|
101
|
-
|
|
102
|
-
return rows.map((r) => {
|
|
123
|
+
const j = wantedSchema.parse(await getJson(url, timeoutMs));
|
|
124
|
+
return j.data.map((r) => {
|
|
103
125
|
const annual =
|
|
104
126
|
r.annual_from || r.annual_to
|
|
105
127
|
? `${r.annual_from ?? 0}~${r.annual_to ?? '?'}년`
|
|
106
128
|
: undefined;
|
|
107
129
|
return {
|
|
108
130
|
source: 'wanted',
|
|
109
|
-
company: r.company
|
|
110
|
-
position: r.position
|
|
131
|
+
company: r.company.name,
|
|
132
|
+
position: r.position,
|
|
111
133
|
url: `https://www.wanted.co.kr/wd/${r.id}`,
|
|
112
|
-
location: r.address?.location ?? r.address?.full_location
|
|
134
|
+
location: r.address?.location ?? r.address?.full_location,
|
|
113
135
|
experience: annual,
|
|
114
136
|
deadline: toYmd(r.due_time),
|
|
115
137
|
external_id: String(r.id),
|
|
116
|
-
}
|
|
138
|
+
};
|
|
117
139
|
});
|
|
118
140
|
},
|
|
119
141
|
};
|
|
120
142
|
|
|
121
|
-
/** Jumpit (jumpit.co.kr) — public positions JSON, no auth. Dev roles, tech stacks. */
|
|
122
143
|
const jumpit: Provider = {
|
|
123
144
|
id: 'jumpit',
|
|
124
145
|
label: '점핏',
|
|
125
146
|
async search(query, limit, timeoutMs) {
|
|
126
147
|
const url =
|
|
127
148
|
`https://api.jumpit.co.kr/api/positions?sort=rsp_rate&page=1&keyword=${encodeURIComponent(query)}`;
|
|
128
|
-
const j = await getJson(url, timeoutMs);
|
|
129
|
-
|
|
130
|
-
return rows.slice(0, limit).map((p) => ({
|
|
149
|
+
const j = jumpitSchema.parse(await getJson(url, timeoutMs));
|
|
150
|
+
return j.result.positions.slice(0, limit).map((p) => ({
|
|
131
151
|
source: 'jumpit',
|
|
132
|
-
company: p.companyName
|
|
133
|
-
position: p.title
|
|
152
|
+
company: p.companyName,
|
|
153
|
+
position: p.title,
|
|
134
154
|
url: `https://www.jumpit.co.kr/position/${p.id}`,
|
|
135
|
-
location:
|
|
155
|
+
location: p.locations?.[0],
|
|
136
156
|
experience: p.newcomer
|
|
137
157
|
? '신입'
|
|
138
158
|
: p.minCareer != null
|
|
139
159
|
? `${p.minCareer}~${p.maxCareer ?? p.minCareer}년`
|
|
140
160
|
: undefined,
|
|
141
|
-
keywords:
|
|
161
|
+
keywords: p.techStacks,
|
|
142
162
|
deadline: toYmd(p.closedAt),
|
|
143
163
|
external_id: String(p.id),
|
|
144
|
-
}
|
|
164
|
+
}));
|
|
145
165
|
},
|
|
146
166
|
};
|
|
147
167
|
|
|
148
168
|
const PROVIDERS: Provider[] = [wanted, jumpit];
|
|
149
|
-
|
|
150
|
-
/** Registered provider ids (key-free, no WAF bypass). */
|
|
151
169
|
export const JOB_SEARCH_SOURCES = PROVIDERS.map((p) => p.id);
|
|
152
170
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
171
|
+
function providerError(e: unknown): { status: ProviderStatus; error: string } {
|
|
172
|
+
if (e instanceof z.ZodError) {
|
|
173
|
+
const first = e.issues[0];
|
|
174
|
+
return {
|
|
175
|
+
status: 'degraded',
|
|
176
|
+
error: `provider response changed${first ? ` (${first.path.join('.') || 'root'})` : ''}`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
180
|
+
return { status: 'unavailable', error: message };
|
|
181
|
+
}
|
|
182
|
+
|
|
158
183
|
export async function searchJobs(query: string, opts: JobSearchOptions = {}): Promise<JobSearchResponse> {
|
|
159
184
|
const q = (query ?? '').trim();
|
|
160
185
|
if (!q) return { query: '', results: [], providers: [] };
|
|
161
186
|
const limit = Math.min(50, Math.max(1, opts.limit ?? 10));
|
|
162
|
-
const timeoutMs = opts.timeoutMs ??
|
|
187
|
+
const timeoutMs = Math.min(30_000, Math.max(1_000, opts.timeoutMs ?? 10_000));
|
|
163
188
|
const chosen = opts.sources?.length
|
|
164
189
|
? PROVIDERS.filter((p) => opts.sources!.includes(p.id))
|
|
165
190
|
: PROVIDERS;
|
|
166
191
|
|
|
167
192
|
const settled = await Promise.all(
|
|
168
193
|
chosen.map(async (p): Promise<{ outcome: ProviderOutcome; results: JobSearchResult[] }> => {
|
|
194
|
+
const checked_at = new Date().toISOString();
|
|
169
195
|
try {
|
|
170
196
|
const results = await p.search(q, limit, timeoutMs);
|
|
171
|
-
return {
|
|
197
|
+
return {
|
|
198
|
+
outcome: {
|
|
199
|
+
source: p.id,
|
|
200
|
+
label: p.label,
|
|
201
|
+
ok: true,
|
|
202
|
+
status: 'healthy',
|
|
203
|
+
count: results.length,
|
|
204
|
+
checked_at,
|
|
205
|
+
},
|
|
206
|
+
results,
|
|
207
|
+
};
|
|
172
208
|
} catch (e) {
|
|
209
|
+
const failure = providerError(e);
|
|
173
210
|
return {
|
|
174
|
-
outcome: {
|
|
211
|
+
outcome: {
|
|
212
|
+
source: p.id,
|
|
213
|
+
label: p.label,
|
|
214
|
+
ok: false,
|
|
215
|
+
status: failure.status,
|
|
216
|
+
count: 0,
|
|
217
|
+
checked_at,
|
|
218
|
+
error: failure.error,
|
|
219
|
+
},
|
|
175
220
|
results: [],
|
|
176
221
|
};
|
|
177
222
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offer comparison use-cases. CareerMate stores offers and a deterministic annual
|
|
3
|
+
* cash estimate (base+bonus+welfare); the connected AI writes the score/verdict at
|
|
4
|
+
* save time (no LLM here). compareOffers returns all offers joined with their job,
|
|
5
|
+
* pre-sorted by the AI's score — the dashboard renders that, and the head-to-head
|
|
6
|
+
* "which is better" reasoning happens in the AI chat via this same data.
|
|
7
|
+
*/
|
|
8
|
+
import { offerRepo, jobRepo } from '@careermate/db';
|
|
9
|
+
import type { OfferInput, OfferRecord, JobRecord } from '@careermate/shared';
|
|
10
|
+
|
|
11
|
+
export interface OfferWithJob extends OfferRecord {
|
|
12
|
+
company: string;
|
|
13
|
+
position: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function withJob(offer: OfferRecord, job: JobRecord | null): OfferWithJob {
|
|
17
|
+
return { ...offer, company: job?.company ?? '—', position: job?.position ?? '—' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Save/update the offer for a job (one per job). The job must exist first. */
|
|
21
|
+
export function saveOffer(input: OfferInput): OfferWithJob {
|
|
22
|
+
const job = jobRepo.get(input.job_id);
|
|
23
|
+
if (!job) throw new Error('공고를 찾을 수 없습니다. 먼저 save_job_posting으로 공고를 저장하세요.');
|
|
24
|
+
return withJob(offerRepo.save(input), job);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The offer for one job (or null). */
|
|
28
|
+
export function getOffer(jobId: string): OfferWithJob | null {
|
|
29
|
+
const offer = offerRepo.getByJob(jobId);
|
|
30
|
+
if (!offer) return null;
|
|
31
|
+
return withJob(offer, jobRepo.get(jobId));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* All offers, joined with their job, sorted by the AI's score (desc, null last).
|
|
36
|
+
* `total_comp_annual_est` on each is the server's deterministic sum — the AI does
|
|
37
|
+
* the qualitative comparison from this structured data.
|
|
38
|
+
*/
|
|
39
|
+
export function compareOffers(): OfferWithJob[] {
|
|
40
|
+
return offerRepo.list().map((o) => withJob(o, jobRepo.get(o.job_id)));
|
|
41
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { coverLetterRepo, experienceRepo, projectRepo, jobRepo } from '@careermate/db';
|
|
2
|
+
|
|
3
|
+
export interface ReuseReference {
|
|
4
|
+
kind: 'experience' | 'project';
|
|
5
|
+
entity_id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
cover_letters: Array<{ id: string; title: string; job_id: string | null; company: string | null; position: string | null }>;
|
|
8
|
+
reuse_count: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DiffLine {
|
|
12
|
+
type: 'equal' | 'add' | 'remove';
|
|
13
|
+
text: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function containsNormalized(content: string, needle: string): boolean {
|
|
17
|
+
const normalize = (s: string) => s.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
18
|
+
const n = normalize(needle);
|
|
19
|
+
return n.length >= 2 && normalize(content).includes(n);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getExperienceReuseMap(): ReuseReference[] {
|
|
23
|
+
const letters = coverLetterRepo.list().map((letter) => coverLetterRepo.get(letter.id, true)!).filter(Boolean);
|
|
24
|
+
const jobs = new Map(jobRepo.list().map((job) => [job.id, job]));
|
|
25
|
+
const refs: ReuseReference[] = [];
|
|
26
|
+
|
|
27
|
+
for (const exp of experienceRepo.list()) {
|
|
28
|
+
const needles = [exp.company, exp.role].filter((v): v is string => !!v && v.trim().length >= 2);
|
|
29
|
+
const matches = letters.filter((letter) => {
|
|
30
|
+
const body = letter.current_content ?? '';
|
|
31
|
+
return needles.some((needle) => containsNormalized(body, needle));
|
|
32
|
+
});
|
|
33
|
+
refs.push({
|
|
34
|
+
kind: 'experience',
|
|
35
|
+
entity_id: exp.id,
|
|
36
|
+
label: [exp.company, exp.role].filter(Boolean).join(' · '),
|
|
37
|
+
cover_letters: matches.map((letter) => {
|
|
38
|
+
const job = letter.job_id ? jobs.get(letter.job_id) : null;
|
|
39
|
+
return {
|
|
40
|
+
id: letter.id,
|
|
41
|
+
title: letter.title,
|
|
42
|
+
job_id: letter.job_id,
|
|
43
|
+
company: job?.company ?? null,
|
|
44
|
+
position: job?.position ?? null,
|
|
45
|
+
};
|
|
46
|
+
}),
|
|
47
|
+
reuse_count: matches.length,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const project of projectRepo.list()) {
|
|
52
|
+
const matches = letters.filter((letter) => containsNormalized(letter.current_content ?? '', project.name));
|
|
53
|
+
refs.push({
|
|
54
|
+
kind: 'project',
|
|
55
|
+
entity_id: project.id,
|
|
56
|
+
label: project.name,
|
|
57
|
+
cover_letters: matches.map((letter) => {
|
|
58
|
+
const job = letter.job_id ? jobs.get(letter.job_id) : null;
|
|
59
|
+
return {
|
|
60
|
+
id: letter.id,
|
|
61
|
+
title: letter.title,
|
|
62
|
+
job_id: letter.job_id,
|
|
63
|
+
company: job?.company ?? null,
|
|
64
|
+
position: job?.position ?? null,
|
|
65
|
+
};
|
|
66
|
+
}),
|
|
67
|
+
reuse_count: matches.length,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return refs.sort((a, b) => b.reuse_count - a.reuse_count || a.label.localeCompare(b.label));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function diffText(before: string, after: string): DiffLine[] {
|
|
75
|
+
const a = before.replace(/\r\n/g, '\n').split('\n');
|
|
76
|
+
const b = after.replace(/\r\n/g, '\n').split('\n');
|
|
77
|
+
const rows = a.length + 1;
|
|
78
|
+
const cols = b.length + 1;
|
|
79
|
+
const dp = Array.from({ length: rows }, () => new Uint32Array(cols));
|
|
80
|
+
|
|
81
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
82
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
83
|
+
dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const result: DiffLine[] = [];
|
|
88
|
+
let i = 0;
|
|
89
|
+
let j = 0;
|
|
90
|
+
while (i < a.length && j < b.length) {
|
|
91
|
+
if (a[i] === b[j]) {
|
|
92
|
+
result.push({ type: 'equal', text: a[i]! });
|
|
93
|
+
i++;
|
|
94
|
+
j++;
|
|
95
|
+
} else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
|
|
96
|
+
result.push({ type: 'remove', text: a[i]! });
|
|
97
|
+
i++;
|
|
98
|
+
} else {
|
|
99
|
+
result.push({ type: 'add', text: b[j]! });
|
|
100
|
+
j++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
while (i < a.length) result.push({ type: 'remove', text: a[i++]! });
|
|
104
|
+
while (j < b.length) result.push({ type: 'add', text: b[j++]! });
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function diffCoverLetterVersions(coverLetterId: string, beforeVersionId: string, afterVersionId: string) {
|
|
109
|
+
const letter = coverLetterRepo.get(coverLetterId, true);
|
|
110
|
+
if (!letter?.versions) return null;
|
|
111
|
+
const before = letter.versions.find((version) => version.id === beforeVersionId);
|
|
112
|
+
const after = letter.versions.find((version) => version.id === afterVersionId);
|
|
113
|
+
if (!before || !after) return null;
|
|
114
|
+
const lines = diffText(before.content, after.content);
|
|
115
|
+
return {
|
|
116
|
+
cover_letter_id: coverLetterId,
|
|
117
|
+
before: { id: before.id, version_no: before.version_no, source: before.source, created_at: before.created_at },
|
|
118
|
+
after: { id: after.id, version_no: after.version_no, source: after.source, created_at: after.created_at },
|
|
119
|
+
summary: {
|
|
120
|
+
added: lines.filter((line) => line.type === 'add').length,
|
|
121
|
+
removed: lines.filter((line) => line.type === 'remove').length,
|
|
122
|
+
unchanged: lines.filter((line) => line.type === 'equal').length,
|
|
123
|
+
},
|
|
124
|
+
lines,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type CoverLetterVersionInput,
|
|
15
15
|
type ApplicationStatus,
|
|
16
16
|
type ApplicationSubmission,
|
|
17
|
+
type RejectedStage,
|
|
17
18
|
type ApplicationRecord,
|
|
18
19
|
type InterviewPrepInput,
|
|
19
20
|
type InterviewPrepRecord,
|
|
@@ -350,6 +351,7 @@ export function updateApplicationStatus(
|
|
|
350
351
|
status: ApplicationStatus,
|
|
351
352
|
note?: string,
|
|
352
353
|
submission?: ApplicationSubmission,
|
|
354
|
+
rejectedStage?: RejectedStage,
|
|
353
355
|
): StatusChangeResult {
|
|
354
356
|
const job = jobRepo.get(jobId);
|
|
355
357
|
if (!job) throw notFound('공고를 찾을 수 없습니다.');
|
|
@@ -358,7 +360,7 @@ export function updateApplicationStatus(
|
|
|
358
360
|
// together: if any write throws (e.g. a bad bind), roll back so we never leave
|
|
359
361
|
// the status changed but the submission/timeline missing.
|
|
360
362
|
const application = tx(() => {
|
|
361
|
-
let application = applicationRepo.setStatus(jobId, status, note);
|
|
363
|
+
let application = applicationRepo.setStatus(jobId, status, note, rejectedStage);
|
|
362
364
|
const submittedDocs = (submission?.document_ids ?? [])
|
|
363
365
|
.map((docId) => documentRepo.get(docId))
|
|
364
366
|
.filter((doc): doc is DocumentRecord => !!doc);
|
|
@@ -407,6 +409,8 @@ export function updateApplicationStatus(
|
|
|
407
409
|
status_label: APPLICATION_STATUS_LABELS[status],
|
|
408
410
|
note: note ?? null,
|
|
409
411
|
submission: submissionPayload,
|
|
412
|
+
// 탈락 회고: rejected로 넘어갈 때 실제 저장된 단계(setStatus가 정합성 처리한 값)를 타임라인에도 남긴다.
|
|
413
|
+
rejected_stage: application.rejected_stage,
|
|
410
414
|
},
|
|
411
415
|
occurred_at: submittedAtToOccurrence(submittedAt),
|
|
412
416
|
});
|