careermate 0.0.9 → 0.0.12
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 +3 -2
- package/INSTALL.md +6 -6
- package/LICENSE +1 -1
- package/README.md +18 -16
- package/dist/career-os/README.md +2 -2
- package/dist/doctor.mjs +17857 -6595
- package/dist/mcp.mjs +14545 -3839
- package/dist/migrate.mjs +14258 -3709
- package/dist/pdf.worker.min.mjs +4 -4
- package/dist/public/app.js +3 -18
- 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 +2 -0
- package/dist/public/i18n/en/nav.js +1 -0
- package/dist/public/i18n/en/settings.js +1 -6
- 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 +2 -0
- package/dist/public/i18n/ko/nav.js +1 -0
- package/dist/public/i18n/ko/settings.js +1 -6
- package/dist/public/index.html +0 -1
- package/dist/public/lib.js +13 -4
- package/dist/public/pages/documents.js +27 -10
- package/dist/public/pages/home.js +15 -5
- package/dist/public/pages/insights.js +156 -0
- package/dist/public/pages/jobs.js +21 -3
- package/dist/public/pages/settings.js +13 -12
- package/dist/public/styles.css +33 -0
- package/dist/web.mjs +16890 -5492
- package/docs/FAQ.md +3 -4
- package/docs/LOCAL_FIRST.md +32 -0
- package/docs/MCP_TOOLS.md +6 -3
- package/docs/REVIEW_IMPLEMENTATION_PLAN.md +22 -0
- package/docs/career-os/README.md +2 -2
- package/package.json +7 -12
- 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/job-categories.data.ts +140 -0
- package/packages/core/src/job-categories.ts +106 -0
- package/packages/core/src/jobsearch.ts +374 -66
- package/packages/core/src/reuse-insights.ts +126 -0
- package/packages/db/src/connection.ts +46 -26
- package/packages/db/src/index.ts +3 -0
- package/packages/db/src/interview-retrospectives.ts +145 -0
- package/packages/db/src/job-search.ts +100 -0
- package/packages/db/src/provenance.ts +125 -0
- package/packages/db/src/repositories.ts +1 -0
- package/packages/mcp-tools/src/tools.ts +115 -11
- package/packages/prompts/src/system.ts +21 -64
- package/scripts/init.ts +2 -0
- package/tsconfig.json +9 -10
|
@@ -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
|
+
}
|
|
@@ -19,7 +19,8 @@ const PRE_MIGRATE_KEEP = 5;
|
|
|
19
19
|
* `VACUUM INTO` a destination path, with the guard both backup callers need. The
|
|
20
20
|
* data dir can be set via env, so `destPath` isn't fully trusted: reject NUL/newline
|
|
21
21
|
* (can't be embedded safely in the SQL string literal) and single-quote-escape the
|
|
22
|
-
* rest.
|
|
22
|
+
* rest. A failure is intentionally surfaced: copying only the live main SQLite
|
|
23
|
+
* file while WAL is active can produce a stale or incomplete snapshot.
|
|
23
24
|
*/
|
|
24
25
|
export function vacuumInto(db: DatabaseSync, destPath: string): void {
|
|
25
26
|
if (/[\0\n\r]/.test(destPath)) throw new Error('backup path not safe for SQL literal');
|
|
@@ -28,10 +29,9 @@ export function vacuumInto(db: DatabaseSync, destPath: string): void {
|
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Snapshot the DB into backups/ right before a schema upgrade runs. The DB is
|
|
31
|
-
* the user's only copy, so
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* boot (blocking would take both the dashboard and the MCP server down).
|
|
32
|
+
* the user's only copy, so a schema upgrade must not proceed without a restore
|
|
33
|
+
* point. Operators may explicitly opt out for emergency recovery with
|
|
34
|
+
* CAREERMATE_ALLOW_UNBACKED_MIGRATION=1.
|
|
35
35
|
*/
|
|
36
36
|
function preMigrationBackup(db: DatabaseSync): void {
|
|
37
37
|
try {
|
|
@@ -48,11 +48,9 @@ function preMigrationBackup(db: DatabaseSync): void {
|
|
|
48
48
|
const dir = getBackupsDir();
|
|
49
49
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
50
50
|
const dest = path.join(dir, `pre-migrate-v${from}-${stamp}.sqlite`);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
fs.copyFileSync(getDbPath(), dest); // WAL is checkpointed on close; copy is a coarse fallback
|
|
55
|
-
}
|
|
51
|
+
// Never fall back to copying only the main file while WAL is active. If a
|
|
52
|
+
// consistent snapshot cannot be created, the migration must not start.
|
|
53
|
+
vacuumInto(db, dest);
|
|
56
54
|
|
|
57
55
|
// Prune old snapshots (ISO stamp in the name → lexicographic sort == time sort).
|
|
58
56
|
// fs.unlinkSync only — rmSync hard-crashes on Node 25/Windows.
|
|
@@ -70,9 +68,17 @@ function preMigrationBackup(db: DatabaseSync): void {
|
|
|
70
68
|
}
|
|
71
69
|
}
|
|
72
70
|
} catch (err) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
72
|
+
if (process.env.CAREERMATE_ALLOW_UNBACKED_MIGRATION === '1') {
|
|
73
|
+
console.error(
|
|
74
|
+
`[careermate] 업그레이드 전 자동 백업 실패 — 명시적 우회로 마이그레이션을 계속합니다: ${reason}`,
|
|
75
|
+
);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
throw new Error(
|
|
79
|
+
`데이터베이스 업그레이드 전 백업을 만들지 못해 시작을 중단했습니다. ` +
|
|
80
|
+
`데이터 위치를 확인하고 다시 시도하세요. 긴급 복구 시에만 ` +
|
|
81
|
+
`CAREERMATE_ALLOW_UNBACKED_MIGRATION=1을 설정할 수 있습니다. 원인: ${reason}`,
|
|
76
82
|
);
|
|
77
83
|
}
|
|
78
84
|
}
|
|
@@ -80,13 +86,22 @@ function preMigrationBackup(db: DatabaseSync): void {
|
|
|
80
86
|
export function getDb(): DatabaseSync {
|
|
81
87
|
if (_db) return _db;
|
|
82
88
|
const db = new DatabaseSync(getDbPath());
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
try {
|
|
90
|
+
db.exec('PRAGMA journal_mode = WAL;');
|
|
91
|
+
db.exec('PRAGMA foreign_keys = ON;');
|
|
92
|
+
db.exec('PRAGMA busy_timeout = 5000;');
|
|
93
|
+
preMigrationBackup(db);
|
|
94
|
+
migrate(db);
|
|
95
|
+
_db = db;
|
|
96
|
+
return db;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
try {
|
|
99
|
+
db.close();
|
|
100
|
+
} catch {
|
|
101
|
+
/* best-effort */
|
|
102
|
+
}
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
90
105
|
}
|
|
91
106
|
|
|
92
107
|
export function closeDb(): void {
|
|
@@ -99,19 +114,24 @@ export function closeDb(): void {
|
|
|
99
114
|
let _inTx = false;
|
|
100
115
|
|
|
101
116
|
/**
|
|
102
|
-
* Run `fn` inside a single transaction so multi-statement writes are
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* directly. Re-entrant: a nested tx() call joins the outer transaction (SQLite
|
|
106
|
-
* has no nested transactions), so only the outermost call commits or rolls back.
|
|
117
|
+
* Run `fn` inside a single synchronous transaction so multi-statement writes are
|
|
118
|
+
* atomic. Promise-returning callbacks are rejected because committing before an
|
|
119
|
+
* async callback settles would silently break transaction guarantees.
|
|
107
120
|
*/
|
|
108
121
|
export function tx<T>(fn: () => T): T {
|
|
109
|
-
if (_inTx)
|
|
122
|
+
if (_inTx) {
|
|
123
|
+
const nested = fn();
|
|
124
|
+
if (nested instanceof Promise) throw new TypeError('tx() does not accept async callbacks');
|
|
125
|
+
return nested;
|
|
126
|
+
}
|
|
110
127
|
const db = getDb();
|
|
111
128
|
db.exec('BEGIN');
|
|
112
129
|
_inTx = true;
|
|
113
130
|
try {
|
|
114
131
|
const result = fn();
|
|
132
|
+
if (result instanceof Promise) {
|
|
133
|
+
throw new TypeError('tx() does not accept async callbacks');
|
|
134
|
+
}
|
|
115
135
|
db.exec('COMMIT');
|
|
116
136
|
return result;
|
|
117
137
|
} catch (err) {
|
package/packages/db/src/index.ts
CHANGED
|
@@ -7,6 +7,9 @@ export * from './paths.ts';
|
|
|
7
7
|
export * from './runtime.ts';
|
|
8
8
|
export * from './connection.ts';
|
|
9
9
|
export * from './settings.ts';
|
|
10
|
+
export * from './provenance.ts';
|
|
11
|
+
export * from './interview-retrospectives.ts';
|
|
12
|
+
export * from './job-search.ts';
|
|
10
13
|
export { migrate, MIGRATIONS } from './schema.ts';
|
|
11
14
|
export {
|
|
12
15
|
profileRepo,
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { getDb } from './connection.ts';
|
|
2
|
+
|
|
3
|
+
export interface InterviewRetrospective {
|
|
4
|
+
id: string;
|
|
5
|
+
job_id: string;
|
|
6
|
+
interview_round: string | null;
|
|
7
|
+
occurred_at: string | null;
|
|
8
|
+
questions: string[];
|
|
9
|
+
strong_answers: string[];
|
|
10
|
+
weak_answers: string[];
|
|
11
|
+
follow_up_actions: string[];
|
|
12
|
+
notes: string | null;
|
|
13
|
+
created_at: string;
|
|
14
|
+
updated_at: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ensureTable(): void {
|
|
18
|
+
getDb().exec(`
|
|
19
|
+
CREATE TABLE IF NOT EXISTS interview_retrospectives (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
|
22
|
+
interview_round TEXT,
|
|
23
|
+
occurred_at TEXT,
|
|
24
|
+
questions TEXT NOT NULL DEFAULT '[]',
|
|
25
|
+
strong_answers TEXT NOT NULL DEFAULT '[]',
|
|
26
|
+
weak_answers TEXT NOT NULL DEFAULT '[]',
|
|
27
|
+
follow_up_actions TEXT NOT NULL DEFAULT '[]',
|
|
28
|
+
notes TEXT,
|
|
29
|
+
created_at TEXT NOT NULL,
|
|
30
|
+
updated_at TEXT NOT NULL
|
|
31
|
+
);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_interview_retrospectives_job
|
|
33
|
+
ON interview_retrospectives(job_id, occurred_at DESC, updated_at DESC);
|
|
34
|
+
DROP TRIGGER IF EXISTS trg_interview_retrospectives_delete_job;
|
|
35
|
+
`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseList(value: unknown): string[] {
|
|
39
|
+
if (typeof value !== 'string') return [];
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(value);
|
|
42
|
+
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : [];
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function map(row: any): InterviewRetrospective {
|
|
49
|
+
return {
|
|
50
|
+
id: row.id,
|
|
51
|
+
job_id: row.job_id,
|
|
52
|
+
interview_round: row.interview_round,
|
|
53
|
+
occurred_at: row.occurred_at,
|
|
54
|
+
questions: parseList(row.questions),
|
|
55
|
+
strong_answers: parseList(row.strong_answers),
|
|
56
|
+
weak_answers: parseList(row.weak_answers),
|
|
57
|
+
follow_up_actions: parseList(row.follow_up_actions),
|
|
58
|
+
notes: row.notes,
|
|
59
|
+
created_at: row.created_at,
|
|
60
|
+
updated_at: row.updated_at,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function id(): string {
|
|
65
|
+
return `retro_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const interviewRetrospectiveRepo = {
|
|
69
|
+
listAll(limit = 100): InterviewRetrospective[] {
|
|
70
|
+
ensureTable();
|
|
71
|
+
const safeLimit = Math.max(1, Math.min(500, Math.floor(limit)));
|
|
72
|
+
return (
|
|
73
|
+
getDb()
|
|
74
|
+
.prepare(`SELECT * FROM interview_retrospectives ORDER BY occurred_at DESC, updated_at DESC LIMIT ?`)
|
|
75
|
+
.all(safeLimit) as any[]
|
|
76
|
+
).map(map);
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
listByJob(jobId: string): InterviewRetrospective[] {
|
|
80
|
+
ensureTable();
|
|
81
|
+
return (
|
|
82
|
+
getDb()
|
|
83
|
+
.prepare(`SELECT * FROM interview_retrospectives WHERE job_id = ? ORDER BY occurred_at DESC, updated_at DESC`)
|
|
84
|
+
.all(jobId) as any[]
|
|
85
|
+
).map(map);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
save(input: {
|
|
89
|
+
id?: string;
|
|
90
|
+
job_id: string;
|
|
91
|
+
interview_round?: string | null;
|
|
92
|
+
occurred_at?: string | null;
|
|
93
|
+
questions?: string[];
|
|
94
|
+
strong_answers?: string[];
|
|
95
|
+
weak_answers?: string[];
|
|
96
|
+
follow_up_actions?: string[];
|
|
97
|
+
notes?: string | null;
|
|
98
|
+
}): InterviewRetrospective {
|
|
99
|
+
ensureTable();
|
|
100
|
+
const now = new Date().toISOString();
|
|
101
|
+
const recordId = input.id ?? id();
|
|
102
|
+
const existing = getDb().prepare(`SELECT * FROM interview_retrospectives WHERE id = ?`).get(recordId) as any;
|
|
103
|
+
getDb()
|
|
104
|
+
.prepare(`
|
|
105
|
+
INSERT INTO interview_retrospectives (
|
|
106
|
+
id, job_id, interview_round, occurred_at, questions, strong_answers,
|
|
107
|
+
weak_answers, follow_up_actions, notes, created_at, updated_at
|
|
108
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
109
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
110
|
+
job_id = excluded.job_id,
|
|
111
|
+
interview_round = excluded.interview_round,
|
|
112
|
+
occurred_at = excluded.occurred_at,
|
|
113
|
+
questions = excluded.questions,
|
|
114
|
+
strong_answers = excluded.strong_answers,
|
|
115
|
+
weak_answers = excluded.weak_answers,
|
|
116
|
+
follow_up_actions = excluded.follow_up_actions,
|
|
117
|
+
notes = excluded.notes,
|
|
118
|
+
updated_at = excluded.updated_at
|
|
119
|
+
`)
|
|
120
|
+
.run(
|
|
121
|
+
recordId,
|
|
122
|
+
input.job_id,
|
|
123
|
+
input.interview_round ?? existing?.interview_round ?? null,
|
|
124
|
+
input.occurred_at ?? existing?.occurred_at ?? null,
|
|
125
|
+
JSON.stringify(input.questions ?? parseList(existing?.questions)),
|
|
126
|
+
JSON.stringify(input.strong_answers ?? parseList(existing?.strong_answers)),
|
|
127
|
+
JSON.stringify(input.weak_answers ?? parseList(existing?.weak_answers)),
|
|
128
|
+
JSON.stringify(input.follow_up_actions ?? parseList(existing?.follow_up_actions)),
|
|
129
|
+
input.notes ?? existing?.notes ?? null,
|
|
130
|
+
existing?.created_at ?? now,
|
|
131
|
+
now,
|
|
132
|
+
);
|
|
133
|
+
return map(getDb().prepare(`SELECT * FROM interview_retrospectives WHERE id = ?`).get(recordId));
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
remove(retrospectiveId: string): boolean {
|
|
137
|
+
ensureTable();
|
|
138
|
+
return getDb().prepare(`DELETE FROM interview_retrospectives WHERE id = ?`).run(retrospectiveId).changes > 0;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Keep the table available before settings import/export enumerates sqlite_master.
|
|
143
|
+
// This also installs the job-deletion cleanup trigger for databases created before
|
|
144
|
+
// interview retrospectives were introduced.
|
|
145
|
+
ensureTable();
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { getDb, tx } from './connection.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Job-search state, in real tables (not `_meta`) so backup, export and reset cover it:
|
|
5
|
+
* - job_search_seen: first/last sighting of each posting search_jobs returned, keyed by
|
|
6
|
+
* (source, external_id). Wanted/Jumpit lists carry no posting date, so "posted since"
|
|
7
|
+
* for them falls back to first_seen_at. Only board ids and dates, never content.
|
|
8
|
+
* - job_search_preferences: the saved conditions search_jobs uses when called without
|
|
9
|
+
* its own keyword/category (single row).
|
|
10
|
+
*/
|
|
11
|
+
function ensureTables(): void {
|
|
12
|
+
getDb().exec(`
|
|
13
|
+
CREATE TABLE IF NOT EXISTS job_search_seen (
|
|
14
|
+
source TEXT NOT NULL,
|
|
15
|
+
external_id TEXT NOT NULL,
|
|
16
|
+
first_seen_at TEXT NOT NULL,
|
|
17
|
+
last_seen_at TEXT NOT NULL,
|
|
18
|
+
PRIMARY KEY (source, external_id)
|
|
19
|
+
);
|
|
20
|
+
CREATE TABLE IF NOT EXISTS job_search_preferences (
|
|
21
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
22
|
+
keyword TEXT NOT NULL DEFAULT '',
|
|
23
|
+
remember_categories TEXT NOT NULL DEFAULT '[]',
|
|
24
|
+
saramin_categories TEXT NOT NULL DEFAULT '[]',
|
|
25
|
+
updated_at TEXT NOT NULL
|
|
26
|
+
);
|
|
27
|
+
`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A posting not returned by any search for this long is forgotten (and counts as new if it reappears). */
|
|
31
|
+
const FORGET_AFTER_DAYS = 120;
|
|
32
|
+
|
|
33
|
+
/** Record the given postings as seen today and return each one's first-seen date (YYYY-MM-DD), keyed "source:external_id". */
|
|
34
|
+
export function markJobsSeen(items: { source: string; external_id: string }[], today: string): Map<string, string> {
|
|
35
|
+
const seen = new Map<string, string>();
|
|
36
|
+
if (!items.length) return seen;
|
|
37
|
+
const db = getDb();
|
|
38
|
+
const upsert = db.prepare(
|
|
39
|
+
`INSERT INTO job_search_seen (source, external_id, first_seen_at, last_seen_at) VALUES (?, ?, ?, ?)
|
|
40
|
+
ON CONFLICT(source, external_id) DO UPDATE SET last_seen_at = excluded.last_seen_at
|
|
41
|
+
RETURNING first_seen_at`,
|
|
42
|
+
);
|
|
43
|
+
tx(() => {
|
|
44
|
+
for (const it of items) {
|
|
45
|
+
const row = upsert.get(it.source, it.external_id, today, today) as { first_seen_at: string } | undefined;
|
|
46
|
+
seen.set(`${it.source}:${it.external_id}`, row?.first_seen_at ?? today);
|
|
47
|
+
}
|
|
48
|
+
db.prepare(`DELETE FROM job_search_seen WHERE last_seen_at < date(?, ?)`).run(today, `-${FORGET_AFTER_DAYS} days`);
|
|
49
|
+
});
|
|
50
|
+
return seen;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface JobSearchPreferences {
|
|
54
|
+
keyword: string;
|
|
55
|
+
remember_categories: string[];
|
|
56
|
+
saramin_categories: string[];
|
|
57
|
+
updated_at: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseList(value: unknown): string[] {
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(String(value));
|
|
63
|
+
return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getJobSearchPreferences(): JobSearchPreferences {
|
|
70
|
+
const row = getDb().prepare(`SELECT * FROM job_search_preferences WHERE id = 1`).get() as
|
|
71
|
+
| Record<string, unknown>
|
|
72
|
+
| undefined;
|
|
73
|
+
return {
|
|
74
|
+
keyword: typeof row?.keyword === 'string' ? row.keyword : '',
|
|
75
|
+
remember_categories: parseList(row?.remember_categories ?? '[]'),
|
|
76
|
+
saramin_categories: parseList(row?.saramin_categories ?? '[]'),
|
|
77
|
+
updated_at: typeof row?.updated_at === 'string' ? row.updated_at : null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Only the given fields change; an empty string/array clears that field. */
|
|
82
|
+
export function setJobSearchPreferences(
|
|
83
|
+
prefs: Partial<Omit<JobSearchPreferences, 'updated_at'>>,
|
|
84
|
+
): JobSearchPreferences {
|
|
85
|
+
const next = { ...getJobSearchPreferences(), ...prefs, updated_at: new Date().toISOString() };
|
|
86
|
+
getDb()
|
|
87
|
+
.prepare(
|
|
88
|
+
`INSERT INTO job_search_preferences (id, keyword, remember_categories, saramin_categories, updated_at)
|
|
89
|
+
VALUES (1, ?, ?, ?, ?)
|
|
90
|
+
ON CONFLICT(id) DO UPDATE SET keyword = excluded.keyword, remember_categories = excluded.remember_categories,
|
|
91
|
+
saramin_categories = excluded.saramin_categories, updated_at = excluded.updated_at`,
|
|
92
|
+
)
|
|
93
|
+
.run(next.keyword, JSON.stringify(next.remember_categories), JSON.stringify(next.saramin_categories), next.updated_at);
|
|
94
|
+
return next;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Backup import accepts only tables that already exist, so create these when @careermate/db
|
|
98
|
+
// loads (same as provenance.ts): a backup from a machine that has searched must restore on a
|
|
99
|
+
// fresh install that never has.
|
|
100
|
+
ensureTables();
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { getDb } from './connection.ts';
|
|
2
|
+
|
|
3
|
+
export type ProvenanceSource = 'human' | 'ai' | 'import' | 'document';
|
|
4
|
+
|
|
5
|
+
export interface EntityProvenance {
|
|
6
|
+
entity_type: string;
|
|
7
|
+
entity_id: string;
|
|
8
|
+
source: ProvenanceSource;
|
|
9
|
+
source_document_id: string | null;
|
|
10
|
+
source_excerpt: string | null;
|
|
11
|
+
verified_by_human: boolean;
|
|
12
|
+
verified_at: string | null;
|
|
13
|
+
created_at: string;
|
|
14
|
+
updated_at: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ensureTable(): void {
|
|
18
|
+
getDb().exec(`
|
|
19
|
+
CREATE TABLE IF NOT EXISTS entity_provenance (
|
|
20
|
+
entity_type TEXT NOT NULL,
|
|
21
|
+
entity_id TEXT NOT NULL,
|
|
22
|
+
source TEXT NOT NULL CHECK(source IN ('human','ai','import','document')),
|
|
23
|
+
source_document_id TEXT,
|
|
24
|
+
source_excerpt TEXT,
|
|
25
|
+
verified_by_human INTEGER NOT NULL DEFAULT 0,
|
|
26
|
+
verified_at TEXT,
|
|
27
|
+
created_at TEXT NOT NULL,
|
|
28
|
+
updated_at TEXT NOT NULL,
|
|
29
|
+
PRIMARY KEY(entity_type, entity_id)
|
|
30
|
+
);
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_entity_provenance_verified
|
|
32
|
+
ON entity_provenance(verified_by_human, updated_at DESC);
|
|
33
|
+
`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function map(row: any): EntityProvenance {
|
|
37
|
+
return {
|
|
38
|
+
entity_type: row.entity_type,
|
|
39
|
+
entity_id: row.entity_id,
|
|
40
|
+
source: row.source,
|
|
41
|
+
source_document_id: row.source_document_id,
|
|
42
|
+
source_excerpt: row.source_excerpt,
|
|
43
|
+
verified_by_human: Number(row.verified_by_human) === 1,
|
|
44
|
+
verified_at: row.verified_at,
|
|
45
|
+
created_at: row.created_at,
|
|
46
|
+
updated_at: row.updated_at,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const provenanceRepo = {
|
|
51
|
+
get(entityType: string, entityId: string): EntityProvenance | null {
|
|
52
|
+
ensureTable();
|
|
53
|
+
const row = getDb()
|
|
54
|
+
.prepare(`SELECT * FROM entity_provenance WHERE entity_type = ? AND entity_id = ?`)
|
|
55
|
+
.get(entityType, entityId);
|
|
56
|
+
return row ? map(row) : null;
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
listUnverified(): EntityProvenance[] {
|
|
60
|
+
ensureTable();
|
|
61
|
+
return (
|
|
62
|
+
getDb()
|
|
63
|
+
.prepare(`SELECT * FROM entity_provenance WHERE verified_by_human = 0 ORDER BY updated_at DESC`)
|
|
64
|
+
.all() as any[]
|
|
65
|
+
).map(map);
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
save(input: {
|
|
69
|
+
entity_type: string;
|
|
70
|
+
entity_id: string;
|
|
71
|
+
source: ProvenanceSource;
|
|
72
|
+
source_document_id?: string | null;
|
|
73
|
+
source_excerpt?: string | null;
|
|
74
|
+
verified_by_human?: boolean;
|
|
75
|
+
}): EntityProvenance {
|
|
76
|
+
ensureTable();
|
|
77
|
+
const existing = this.get(input.entity_type, input.entity_id);
|
|
78
|
+
const now = new Date().toISOString();
|
|
79
|
+
const verified = input.verified_by_human ?? existing?.verified_by_human ?? input.source === 'human';
|
|
80
|
+
const verifiedAt = verified ? existing?.verified_at ?? now : null;
|
|
81
|
+
getDb()
|
|
82
|
+
.prepare(`
|
|
83
|
+
INSERT INTO entity_provenance (
|
|
84
|
+
entity_type, entity_id, source, source_document_id, source_excerpt,
|
|
85
|
+
verified_by_human, verified_at, created_at, updated_at
|
|
86
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
87
|
+
ON CONFLICT(entity_type, entity_id) DO UPDATE SET
|
|
88
|
+
source = excluded.source,
|
|
89
|
+
source_document_id = excluded.source_document_id,
|
|
90
|
+
source_excerpt = excluded.source_excerpt,
|
|
91
|
+
verified_by_human = excluded.verified_by_human,
|
|
92
|
+
verified_at = excluded.verified_at,
|
|
93
|
+
updated_at = excluded.updated_at
|
|
94
|
+
`)
|
|
95
|
+
.run(
|
|
96
|
+
input.entity_type,
|
|
97
|
+
input.entity_id,
|
|
98
|
+
input.source,
|
|
99
|
+
input.source_document_id ?? existing?.source_document_id ?? null,
|
|
100
|
+
input.source_excerpt ?? existing?.source_excerpt ?? null,
|
|
101
|
+
verified ? 1 : 0,
|
|
102
|
+
verifiedAt,
|
|
103
|
+
existing?.created_at ?? now,
|
|
104
|
+
now,
|
|
105
|
+
);
|
|
106
|
+
return this.get(input.entity_type, input.entity_id)!;
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
setHumanVerified(entityType: string, entityId: string, verified: boolean): EntityProvenance {
|
|
110
|
+
const existing = this.get(entityType, entityId);
|
|
111
|
+
return this.save({
|
|
112
|
+
entity_type: entityType,
|
|
113
|
+
entity_id: entityId,
|
|
114
|
+
source: existing?.source ?? 'human',
|
|
115
|
+
source_document_id: existing?.source_document_id,
|
|
116
|
+
source_excerpt: existing?.source_excerpt,
|
|
117
|
+
verified_by_human: verified,
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Backup import discovers allowed tables from sqlite_master before any provenance
|
|
123
|
+
// endpoint is called. Create this sidecar table when @careermate/db loads so a
|
|
124
|
+
// backup made on another machine is accepted on a completely fresh installation.
|
|
125
|
+
ensureTable();
|
|
@@ -758,6 +758,7 @@ export const jobRepo = {
|
|
|
758
758
|
db.prepare(`DELETE FROM fit_analyses WHERE job_id=?`).run(id);
|
|
759
759
|
db.prepare(`DELETE FROM applications WHERE job_id=?`).run(id);
|
|
760
760
|
db.prepare(`DELETE FROM interview_preps WHERE job_id=?`).run(id);
|
|
761
|
+
db.prepare(`DELETE FROM interview_retrospectives WHERE job_id=?`).run(id);
|
|
761
762
|
db.prepare(`DELETE FROM application_timeline_events WHERE job_id=?`).run(id);
|
|
762
763
|
// offers has ON DELETE CASCADE, but delete explicitly like the siblings above
|
|
763
764
|
// (consistent pattern + safe even if PRAGMA foreign_keys is ever off).
|