@gafj/gafj 0.1.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.
Files changed (121) hide show
  1. package/README.md +99 -0
  2. package/bin/cli.js +628 -0
  3. package/bridge/byo_key.js +50 -0
  4. package/bridge/credits.js +72 -0
  5. package/bridge/paste.js +44 -0
  6. package/bridge/providers/anthropic.js +16 -0
  7. package/bridge/providers/gemini.js +15 -0
  8. package/bridge/providers/index.js +39 -0
  9. package/bridge/providers/openai_compatible.js +17 -0
  10. package/core/extract.js +55 -0
  11. package/core/generators/excerpt.js +64 -0
  12. package/core/generators/packet.js +197 -0
  13. package/core/generators/serialize.js +56 -0
  14. package/core/generators/slice.js +61 -0
  15. package/core/house_rules.js +49 -0
  16. package/core/kb_import.js +257 -0
  17. package/core/migrations/002-profile.sql +3 -0
  18. package/core/migrations/003-discover.sql +49 -0
  19. package/core/parsers.js +79 -0
  20. package/core/practice.js +75 -0
  21. package/core/provenance.js +90 -0
  22. package/core/qr.js +185 -0
  23. package/core/rules/categories.md +5 -0
  24. package/core/rules/deep_answers.md +7 -0
  25. package/core/rules/discover.md +30 -0
  26. package/core/rules/extract.md +31 -0
  27. package/core/rules/followup.md +9 -0
  28. package/core/rules/index.js +32 -0
  29. package/core/rules/onepager.md +15 -0
  30. package/core/rules/practice.md +10 -0
  31. package/core/rules/prep.md +47 -0
  32. package/core/rules/resume.md +49 -0
  33. package/core/rules/rubric.md +120 -0
  34. package/core/schema.sql +342 -0
  35. package/core/schemas/categories.js +16 -0
  36. package/core/schemas/cover.js +10 -0
  37. package/core/schemas/deep_answers.js +16 -0
  38. package/core/schemas/discover.js +19 -0
  39. package/core/schemas/extract.js +32 -0
  40. package/core/schemas/followup.js +10 -0
  41. package/core/schemas/index.js +32 -0
  42. package/core/schemas/lang.js +111 -0
  43. package/core/schemas/onepager.js +26 -0
  44. package/core/schemas/practice.js +18 -0
  45. package/core/schemas/prep.js +15 -0
  46. package/core/schemas/resume.js +25 -0
  47. package/core/scoring.js +586 -0
  48. package/core/text.js +98 -0
  49. package/core/tracker_map.js +94 -0
  50. package/core/ulid.js +38 -0
  51. package/core/validators/highlight_body_match.js +49 -0
  52. package/core/validators/index.js +43 -0
  53. package/core/validators/metric_pairing.js +52 -0
  54. package/core/validators/no_confabulated_history.js +63 -0
  55. package/core/validators/no_dashes.js +31 -0
  56. package/core/validators/no_derived_tenure.js +60 -0
  57. package/core/validators/style_config.js +18 -0
  58. package/core/validators/verb_ladder.js +51 -0
  59. package/core/verbs.js +43 -0
  60. package/http/api.js +260 -0
  61. package/http/guard.js +106 -0
  62. package/http/headers.js +17 -0
  63. package/http/hosted.js +273 -0
  64. package/http/identity.js +70 -0
  65. package/http/server.js +136 -0
  66. package/http/sse.js +41 -0
  67. package/mcp/server.js +25 -0
  68. package/mcp/tools.js +237 -0
  69. package/package.json +59 -0
  70. package/store/backup.js +63 -0
  71. package/store/backup_dir.js +60 -0
  72. package/store/config.js +32 -0
  73. package/store/context.js +74 -0
  74. package/store/db.js +37 -0
  75. package/store/discover.js +120 -0
  76. package/store/events.js +28 -0
  77. package/store/export_xlsx.js +62 -0
  78. package/store/home.js +36 -0
  79. package/store/import_kb.js +88 -0
  80. package/store/import_tracker.js +195 -0
  81. package/store/ingest.js +140 -0
  82. package/store/kb.js +202 -0
  83. package/store/log.js +88 -0
  84. package/store/migrate.js +55 -0
  85. package/store/onboarding.js +267 -0
  86. package/store/packets.js +110 -0
  87. package/store/practice.js +52 -0
  88. package/store/proposals.js +119 -0
  89. package/store/reads.js +110 -0
  90. package/store/render.js +111 -0
  91. package/store/reply.js +19 -0
  92. package/store/review.js +64 -0
  93. package/store/scoring.js +134 -0
  94. package/store/settings.js +142 -0
  95. package/store/snapshot.js +88 -0
  96. package/store/transitions.js +391 -0
  97. package/store/tx.js +71 -0
  98. package/store/tz.js +24 -0
  99. package/ui/app.css +137 -0
  100. package/ui/app.js +75 -0
  101. package/ui/fonts/plexmono-400.woff2 +0 -0
  102. package/ui/fonts/plexmono-500.woff2 +0 -0
  103. package/ui/fonts/plexmono-600.woff2 +0 -0
  104. package/ui/fonts/spacegrotesk-var.woff2 +0 -0
  105. package/ui/hosted/signin.css +8 -0
  106. package/ui/hosted/signin.html +30 -0
  107. package/ui/hosted/signin.js +28 -0
  108. package/ui/icon-192.png +0 -0
  109. package/ui/icon-512.png +0 -0
  110. package/ui/index.html +20 -0
  111. package/ui/lib.js +92 -0
  112. package/ui/manifest.webmanifest +18 -0
  113. package/ui/screens/application.js +73 -0
  114. package/ui/screens/dashboard.js +37 -0
  115. package/ui/screens/document.js +64 -0
  116. package/ui/screens/interview.js +85 -0
  117. package/ui/screens/kb.js +142 -0
  118. package/ui/screens/packet.js +122 -0
  119. package/ui/screens/postings.js +92 -0
  120. package/ui/screens/settings.js +115 -0
  121. package/ui/vendor/preact.mjs +1 -0
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * campaign_snapshot: the dashboard as a query. now is a parameter; nothing
5
+ * here reads the clock. Thresholds come from config (plan section O).
6
+ */
7
+
8
+ const DEFAULT_THRESHOLDS = {
9
+ thankyou_due_h: 12, thankyou_overdue_h: 36, default_interview_min: 45,
10
+ register_switch_window_h: 3, invite_alert_h: 24, aging_days: 7,
11
+ };
12
+
13
+ const H = 3600 * 1000;
14
+
15
+ function snapshot(db, { candidate_id, now, thresholds = {} }) {
16
+ const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
17
+ const nowMs = Date.parse(now);
18
+ const apps = db.prepare(`
19
+ SELECT a.id, a.status, a.applied_at, a.decision_expected_at, a.notes, p.company, p.title, p.taken_down_at, s.fit_score
20
+ FROM application a JOIN posting p ON p.id = a.posting_id LEFT JOIN scoring s ON s.id = a.scoring_id
21
+ WHERE p.candidate_id = ? ORDER BY a.applied_at DESC`).all(candidate_id);
22
+ const interviews = db.prepare(`
23
+ SELECT i.*, p.company, p.title, a.status AS app_status
24
+ FROM interview i JOIN application a ON a.id = i.application_id JOIN posting p ON p.id = a.posting_id
25
+ WHERE p.candidate_id = ? AND i.status IN ('scheduled', 'completed') ORDER BY i.scheduled_at`).all(candidate_id);
26
+ const people = db.prepare(`
27
+ SELECT ip.interview_id, pe.name FROM interview_person ip JOIN person pe ON pe.id = ip.person_id`).all();
28
+ const peopleByInterview = new Map();
29
+ for (const p of people) peopleByInterview.set(p.interview_id, [...(peopleByInterview.get(p.interview_id) || []), p.name]);
30
+ const passedPrep = new Set(db.prepare("SELECT interview_id FROM document WHERE kind = 'prep' AND status = 'passed'").all().map((r) => r.interview_id));
31
+ const thanked = new Set(db.prepare("SELECT interview_id FROM communication WHERE kind = 'thankyou' AND interview_id IS NOT NULL").all().map((r) => r.interview_id));
32
+
33
+ const needs_you = [];
34
+ const upcoming = [];
35
+ for (const iv of interviews) {
36
+ const at = Date.parse(iv.scheduled_at);
37
+ const label = `${iv.company} ${iv.round_label}`;
38
+ if (iv.status === "scheduled" && at >= nowMs) {
39
+ upcoming.push({ interview_id: iv.id, company: iv.company, title: iv.title, round: iv.round_label, scheduled_at: iv.scheduled_at, tz: iv.tz,
40
+ format: iv.format, people: peopleByInterview.get(iv.id) || [] });
41
+ if (!passedPrep.has(iv.id)) needs_you.push({ kind: "prep_not_built", interview_id: iv.id, text: `Prep not built: ${label}` });
42
+ if (!iv.invite_received_at && at - nowMs <= t.invite_alert_h * H) needs_you.push({ kind: "invite_missing", interview_id: iv.id, text: `Invite not received: ${label}` });
43
+ }
44
+ if (iv.status === "completed" && !thanked.has(iv.id)) {
45
+ const end = Date.parse(iv.completed_at || iv.scheduled_at) + (iv.completed_at ? 0 : t.default_interview_min * 60000);
46
+ const hours = (nowMs - end) / H;
47
+ if (hours >= t.thankyou_overdue_h) needs_you.push({ kind: "thankyou_overdue", interview_id: iv.id, text: `Thank-you overdue: ${label} (${Math.floor(hours)}h)` });
48
+ else if (hours >= t.thankyou_due_h) needs_you.push({ kind: "thankyou_due", interview_id: iv.id, text: `Thank-you due: ${label}` });
49
+ }
50
+ }
51
+ // register switch: two scheduled interviews within the window, different companies or formats
52
+ const sched = interviews.filter((i) => i.status === "scheduled" && Date.parse(i.scheduled_at) >= nowMs);
53
+ for (let i = 0; i < sched.length; i++) {
54
+ for (let j = i + 1; j < sched.length; j++) {
55
+ const a = sched[i], b = sched[j];
56
+ const gap = Math.abs(Date.parse(a.scheduled_at) - Date.parse(b.scheduled_at));
57
+ if (gap <= t.register_switch_window_h * H && (a.company !== b.company || a.format !== b.format)) {
58
+ needs_you.push({ kind: "register_switch", interview_ids: [a.id, b.id], text: `Register switch: ${a.company} ${a.round_label} then ${b.company} ${b.round_label}` });
59
+ }
60
+ }
61
+ }
62
+ const review = db.prepare("SELECT metrics_json, verbs_json, verbatim_claims_json FROM accomplishment WHERE candidate_id = ?").all(candidate_id)
63
+ .reduce((n, r) => n + [...JSON.parse(r.metrics_json), ...JSON.parse(r.verbs_json), ...JSON.parse(r.verbatim_claims_json)]
64
+ .filter((x) => x.review_state !== "confirmed").length, 0);
65
+ if (review) needs_you.push({ kind: "review", count: review, text: `Imported dimensions to review: ${review}` });
66
+
67
+ const active = [], closed = [], aging = [];
68
+ for (const a of apps) {
69
+ const item = { application_id: a.id, company: a.company, title: a.title, status: a.status, fit: a.fit_score, applied_at: a.applied_at,
70
+ decision_expected_at: a.decision_expected_at, taken_down: !!a.taken_down_at };
71
+ if (["to_apply", "applied", "offer"].includes(a.status)) {
72
+ active.push(item);
73
+ if (a.decision_expected_at && Date.parse(a.decision_expected_at) > nowMs) {
74
+ aging.push({ ...item, text: `${a.company}: decision expected ${a.decision_expected_at.slice(0, 10)}, do not nudge` });
75
+ } else if (a.status === "applied" && a.applied_at && (nowMs - Date.parse(a.applied_at)) / (24 * H) >= t.aging_days
76
+ && !interviews.some((i) => i.application_id === a.id)) {
77
+ aging.push({ ...item, text: `${a.company}: applied ${Math.floor((nowMs - Date.parse(a.applied_at)) / (24 * H))}d, no reply, nudge?` });
78
+ } else if (a.status === "to_apply") {
79
+ aging.push({ ...item, text: `${a.company} ${a.title}: to apply, held` });
80
+ }
81
+ } else {
82
+ closed.push(item);
83
+ }
84
+ }
85
+ return { now, needs_you, upcoming, active, closed, aging, review_count: review };
86
+ }
87
+
88
+ module.exports = { snapshot, DEFAULT_THRESHOLDS };
@@ -0,0 +1,391 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * UI-only writes (plan sections G and M): the state machines for postings,
5
+ * applications, interviews, people, and communications. Every unlisted
6
+ * transition throws a TransitionError carrying the allowed set, which the
7
+ * API turns into a 409. Every write is one transaction with its event.
8
+ * The actor is always ui or cli; no model route reaches this file.
9
+ */
10
+
11
+ const { ulid } = require("../core/ulid");
12
+ const { assertIanaZone } = require("./tz");
13
+ const { withTx } = require("./tx");
14
+ const { appendEvent } = require("./events");
15
+
16
+ class TransitionError extends Error {
17
+ constructor(message, allowed) { super(message); this.name = "TransitionError"; this.allowed = allowed || []; this.status = 409; }
18
+ }
19
+ class NotFound extends Error {
20
+ constructor(what) { super(`unknown ${what}`); this.name = "NotFound"; this.status = 404; }
21
+ }
22
+
23
+ const APP_NEXT = {
24
+ to_apply: ["applied", "pass"],
25
+ applied: ["rejected", "closed", "offer", "pass"],
26
+ offer: ["closed", "rejected"],
27
+ rejected: [], closed: [], pass: [],
28
+ };
29
+ const CORRECTIONS = ["applied", "to_apply", "offer"];
30
+ const INTERVIEW_FORMATS = ["screen", "hm", "panel", "technical", "onsite"];
31
+ const OUTCOMES = ["advanced", "rejected", "ghosted"];
32
+
33
+ function candidateOfApplication(db, id) {
34
+ const r = db.prepare("SELECT a.*, p.candidate_id, p.company, p.title FROM application a JOIN posting p ON p.id = a.posting_id WHERE a.id = ?").get(id);
35
+ if (!r) throw new NotFound("application");
36
+ return r;
37
+ }
38
+ function candidateOfInterview(db, id) {
39
+ const r = db.prepare("SELECT i.*, p.candidate_id, p.company FROM interview i JOIN application a ON a.id = i.application_id JOIN posting p ON p.id = a.posting_id WHERE i.id = ?").get(id);
40
+ if (!r) throw new NotFound("interview");
41
+ return r;
42
+ }
43
+ const iso = (s, name) => {
44
+ if (typeof s !== "string" || !isFinite(Date.parse(s))) throw new TransitionError(`${name} must be an ISO timestamp`);
45
+ return new Date(Date.parse(s)).toISOString();
46
+ };
47
+
48
+ // ----- postings -----
49
+
50
+ function acceptPosting(db, { posting_id, now, actor }) {
51
+ return withTx(db, (d) => {
52
+ const p = d.prepare("SELECT id, candidate_id, status FROM posting WHERE id = ?").get(posting_id);
53
+ if (!p) throw new NotFound("posting");
54
+ if (p.status !== "proposed") throw new TransitionError(`posting is ${p.status}; only a proposed posting is accepted`, ["proposed"]);
55
+ d.prepare("UPDATE posting SET status = 'accepted' WHERE id = ?").run(p.id);
56
+ appendEvent(d, { candidate_id: p.candidate_id, entity: "posting", entity_id: p.id, event: "accepted", actor_type: actor.type, actor_ref: actor.ref, prior: { status: p.status }, at: now });
57
+ return { posting_id: p.id, status: "accepted" };
58
+ });
59
+ }
60
+
61
+ function archivePosting(db, { posting_id, now, actor }) {
62
+ return withTx(db, (d) => {
63
+ const p = d.prepare("SELECT id, candidate_id, status FROM posting WHERE id = ?").get(posting_id);
64
+ if (!p) throw new NotFound("posting");
65
+ if (p.status === "archived") return { posting_id: p.id, status: "archived" };
66
+ if (d.prepare("SELECT 1 FROM application WHERE posting_id = ?").get(p.id)) throw new TransitionError("a posting with an application is closed through the application, not archived");
67
+ d.prepare("UPDATE posting SET status = 'archived' WHERE id = ?").run(p.id);
68
+ appendEvent(d, { candidate_id: p.candidate_id, entity: "posting", entity_id: p.id, event: "archived", actor_type: actor.type, actor_ref: actor.ref, prior: { status: p.status }, at: now });
69
+ return { posting_id: p.id, status: "archived" };
70
+ });
71
+ }
72
+
73
+ function postingTakenDown(db, { posting_id, now, actor }) {
74
+ return withTx(db, (d) => {
75
+ const p = d.prepare("SELECT id, candidate_id, taken_down_at FROM posting WHERE id = ?").get(posting_id);
76
+ if (!p) throw new NotFound("posting");
77
+ if (p.taken_down_at) return { posting_id: p.id, taken_down_at: p.taken_down_at };
78
+ d.prepare("UPDATE posting SET taken_down_at = ? WHERE id = ?").run(now, p.id);
79
+ appendEvent(d, { candidate_id: p.candidate_id, entity: "posting", entity_id: p.id, event: "taken_down", actor_type: actor.type, actor_ref: actor.ref, at: now });
80
+ return { posting_id: p.id, taken_down_at: now };
81
+ });
82
+ }
83
+
84
+ // ----- applications -----
85
+
86
+ /** Log an application for an accepted posting with frozen scoring. */
87
+ function logApplication(db, { posting_id, status = "to_apply", now, actor }) {
88
+ return withTx(db, (d) => {
89
+ const p = d.prepare("SELECT id, candidate_id, status FROM posting WHERE id = ?").get(posting_id);
90
+ if (!p) throw new NotFound("posting");
91
+ if (p.status !== "accepted") throw new TransitionError("applications require an accepted posting", ["accepted"]);
92
+ if (d.prepare("SELECT id FROM application WHERE posting_id = ?").get(p.id)) throw new TransitionError("this posting already has an application");
93
+ const scoring = d.prepare("SELECT id FROM scoring WHERE posting_id = ? AND status = 'frozen' ORDER BY scored_at DESC, rowid DESC LIMIT 1").get(p.id);
94
+ if (!scoring) throw new TransitionError("freeze the scoring before logging an application");
95
+ if (!["to_apply", "applied"].includes(status)) throw new TransitionError("a new application is to_apply or applied", ["to_apply", "applied"]);
96
+ const id = ulid(Date.parse(now));
97
+ d.prepare("INSERT INTO application (id, posting_id, status, applied_at, scoring_id) VALUES (?, ?, ?, ?, ?)").run(id, p.id, status, status === "applied" ? now : null, scoring.id);
98
+ appendEvent(d, { candidate_id: p.candidate_id, entity: "application", entity_id: id, event: "logged", actor_type: actor.type, actor_ref: actor.ref, payload: { status }, at: now });
99
+ return { application_id: id, status };
100
+ });
101
+ }
102
+
103
+ /** Forward transitions per section M. applied requires a passed resume for this posting. */
104
+ function updateStatus(db, { application_id, status, resume_doc_id, cover_doc_id, now, actor }) {
105
+ return withTx(db, (d) => {
106
+ const a = candidateOfApplication(d, application_id);
107
+ const allowed = APP_NEXT[a.status] || [];
108
+ if (!allowed.includes(status)) throw new TransitionError(`${a.status} cannot move to ${status}`, allowed);
109
+ if (status === "applied") {
110
+ const doc = resume_doc_id || a.resume_doc_id;
111
+ if (!doc) throw new TransitionError("applied needs a passed resume (resume_doc_id) for this posting");
112
+ const ok = d.prepare("SELECT 1 FROM document WHERE id = ? AND kind = 'resume' AND status = 'passed' AND posting_id = ?").get(doc, a.posting_id);
113
+ if (!ok) throw new TransitionError("resume_doc_id must be a passed resume for this posting");
114
+ const scoring = d.prepare("SELECT id FROM scoring WHERE posting_id = ? AND status = 'frozen' ORDER BY scored_at DESC, rowid DESC LIMIT 1").get(a.posting_id);
115
+ d.prepare("UPDATE application SET status = 'applied', applied_at = ?, resume_doc_id = ?, cover_doc_id = coalesce(?, cover_doc_id), scoring_id = ? WHERE id = ?")
116
+ .run(now, doc, cover_doc_id || null, scoring ? scoring.id : a.scoring_id, a.id);
117
+ } else {
118
+ d.prepare("UPDATE application SET status = ? WHERE id = ?").run(status, a.id);
119
+ if (status === "closed" || status === "rejected" || status === "pass") {
120
+ for (const iv of d.prepare("SELECT id FROM interview WHERE application_id = ? AND status = 'scheduled'").all(a.id)) {
121
+ d.prepare("UPDATE interview SET status = 'cancelled' WHERE id = ?").run(iv.id);
122
+ appendEvent(d, { candidate_id: a.candidate_id, entity: "interview", entity_id: iv.id, event: "cancelled", actor_type: actor.type, actor_ref: actor.ref, payload: { reason: "application " + status }, at: now });
123
+ }
124
+ }
125
+ }
126
+ appendEvent(d, { candidate_id: a.candidate_id, entity: "application", entity_id: a.id, event: "status", actor_type: actor.type, actor_ref: actor.ref, payload: { status }, prior: { status: a.status }, at: now });
127
+ return { application_id: a.id, status };
128
+ });
129
+ }
130
+
131
+ /** A named correction with a reason; the prior state is on the event. */
132
+ function correctStatus(db, { application_id, status, reason, now, actor }) {
133
+ if (!reason || !String(reason).trim()) throw new TransitionError("a correction needs a reason");
134
+ if (!CORRECTIONS.includes(status)) throw new TransitionError(`a correction moves to one of ${CORRECTIONS.join(", ")}`, CORRECTIONS);
135
+ return withTx(db, (d) => {
136
+ const a = candidateOfApplication(d, application_id);
137
+ d.prepare("UPDATE application SET status = ? WHERE id = ?").run(status, a.id);
138
+ appendEvent(d, { candidate_id: a.candidate_id, entity: "application", entity_id: a.id, event: "corrected", actor_type: actor.type, actor_ref: actor.ref, payload: { status, reason }, prior: { status: a.status }, at: now });
139
+ return { application_id: a.id, status };
140
+ });
141
+ }
142
+
143
+ function setDecisionExpected(db, { application_id, at, now, actor }) {
144
+ return withTx(db, (d) => {
145
+ const a = candidateOfApplication(d, application_id);
146
+ const when = at ? iso(at, "decision_expected_at") : null;
147
+ d.prepare("UPDATE application SET decision_expected_at = ? WHERE id = ?").run(when, a.id);
148
+ appendEvent(d, { candidate_id: a.candidate_id, entity: "application", entity_id: a.id, event: "decision_expected", actor_type: actor.type, actor_ref: actor.ref, payload: { at: when }, prior: { decision_expected_at: a.decision_expected_at }, at: now });
149
+ return { application_id: a.id, decision_expected_at: when };
150
+ });
151
+ }
152
+
153
+ function setNotes(db, { application_id, notes, now, actor }) {
154
+ return withTx(db, (d) => {
155
+ const a = candidateOfApplication(d, application_id);
156
+ d.prepare("UPDATE application SET notes = ? WHERE id = ?").run(String(notes || "").slice(0, 4000), a.id);
157
+ appendEvent(d, { candidate_id: a.candidate_id, entity: "application", entity_id: a.id, event: "notes", actor_type: actor.type, actor_ref: actor.ref, prior: { notes: a.notes }, at: now });
158
+ return { application_id: a.id };
159
+ });
160
+ }
161
+
162
+ // ----- interviews -----
163
+
164
+ function scheduleInterview(db, { application_id, round_label, scheduled_at, tz, format, invite_received_at, people = [], now, actor }) {
165
+ if (!round_label || !String(round_label).trim()) throw new TransitionError("round_label is required");
166
+ if (!INTERVIEW_FORMATS.includes(format)) throw new TransitionError(`format must be one of ${INTERVIEW_FORMATS.join(", ")}`, INTERVIEW_FORMATS);
167
+ assertIanaZone(tz);
168
+ const at = iso(scheduled_at, "scheduled_at");
169
+ return withTx(db, (d) => {
170
+ const a = candidateOfApplication(d, application_id);
171
+ if (!["to_apply", "applied", "offer"].includes(a.status)) throw new TransitionError(`no interviews on a ${a.status} application`);
172
+ if (d.prepare("SELECT 1 FROM interview WHERE application_id = ? AND round_label = ? AND status = 'scheduled'").get(a.id, round_label)) {
173
+ throw new TransitionError(`${round_label} is already scheduled; reschedule it instead`);
174
+ }
175
+ const id = ulid(Date.parse(now));
176
+ d.prepare("INSERT INTO interview (id, application_id, round_label, scheduled_at, tz, format, status, invite_received_at) VALUES (?, ?, ?, ?, ?, ?, 'scheduled', ?)")
177
+ .run(id, a.id, String(round_label).trim(), at, tz, format, invite_received_at ? iso(invite_received_at, "invite_received_at") : null);
178
+ appendEvent(d, { candidate_id: a.candidate_id, entity: "interview", entity_id: id, event: "scheduled", actor_type: actor.type, actor_ref: actor.ref, payload: { round_label, scheduled_at: at, tz, format }, at: now });
179
+ for (const p of people) attachPersonToInterview(d, { interview_id: id, person: p, now, actor });
180
+ return { interview_id: id };
181
+ });
182
+ }
183
+
184
+ /** Reschedule inserts a superseding row; the old row becomes superseded and keeps its history. */
185
+ function rescheduleInterview(db, { interview_id, scheduled_at, tz, format, now, actor }) {
186
+ const at = iso(scheduled_at, "scheduled_at");
187
+ if (tz) assertIanaZone(tz);
188
+ return withTx(db, (d) => {
189
+ const i = candidateOfInterview(d, interview_id);
190
+ if (i.status !== "scheduled") throw new TransitionError(`only a scheduled interview reschedules; this one is ${i.status}`, ["scheduled"]);
191
+ d.prepare("UPDATE interview SET status = 'superseded' WHERE id = ?").run(i.id);
192
+ const id = ulid(Date.parse(now));
193
+ d.prepare(`INSERT INTO interview (id, application_id, round_label, scheduled_at, tz, format, status, invite_received_at, supersedes_id, pinned_ids_json)
194
+ VALUES (?, ?, ?, ?, ?, ?, 'scheduled', ?, ?, ?)`).run(id, i.application_id, i.round_label, at, tz || i.tz, format || i.format, i.invite_received_at, i.id, i.pinned_ids_json);
195
+ for (const p of d.prepare("SELECT person_id FROM interview_person WHERE interview_id = ?").all(i.id)) d.prepare("INSERT INTO interview_person (interview_id, person_id) VALUES (?, ?)").run(id, p.person_id);
196
+ appendEvent(d, { candidate_id: i.candidate_id, entity: "interview", entity_id: id, event: "rescheduled", actor_type: actor.type, actor_ref: actor.ref, payload: { supersedes: i.id, scheduled_at: at, tz: tz || i.tz }, prior: { scheduled_at: i.scheduled_at, tz: i.tz }, at: now });
197
+ return { interview_id: id, supersedes: i.id };
198
+ });
199
+ }
200
+
201
+ function completeInterview(db, { interview_id, completed_at, outcome, now, actor }) {
202
+ return withTx(db, (d) => {
203
+ const i = candidateOfInterview(d, interview_id);
204
+ if (i.status !== "scheduled") throw new TransitionError(`only a scheduled interview completes; this one is ${i.status}`, ["scheduled"]);
205
+ const at = completed_at ? iso(completed_at, "completed_at") : now;
206
+ if (outcome && !OUTCOMES.includes(outcome)) throw new TransitionError(`outcome must be one of ${OUTCOMES.join(", ")}`, OUTCOMES);
207
+ d.prepare("UPDATE interview SET status = 'completed', completed_at = ?, outcome = coalesce(?, outcome) WHERE id = ?").run(at, outcome || null, i.id);
208
+ appendEvent(d, { candidate_id: i.candidate_id, entity: "interview", entity_id: i.id, event: "completed", actor_type: actor.type, actor_ref: actor.ref, payload: { completed_at: at, outcome: outcome || null }, at: now });
209
+ return { interview_id: i.id, status: "completed", completed_at: at };
210
+ });
211
+ }
212
+
213
+ function setOutcome(db, { interview_id, outcome, now, actor }) {
214
+ if (!OUTCOMES.includes(outcome)) throw new TransitionError(`outcome must be one of ${OUTCOMES.join(", ")}`, OUTCOMES);
215
+ return withTx(db, (d) => {
216
+ const i = candidateOfInterview(d, interview_id);
217
+ if (i.status !== "completed") throw new TransitionError("an outcome is set only after the interview is completed", ["completed"]);
218
+ d.prepare("UPDATE interview SET outcome = ? WHERE id = ?").run(outcome, i.id);
219
+ appendEvent(d, { candidate_id: i.candidate_id, entity: "interview", entity_id: i.id, event: "outcome", actor_type: actor.type, actor_ref: actor.ref, payload: { outcome }, prior: { outcome: i.outcome }, at: now });
220
+ return { interview_id: i.id, outcome };
221
+ });
222
+ }
223
+
224
+ function cancelInterview(db, { interview_id, now, actor }) {
225
+ return withTx(db, (d) => {
226
+ const i = candidateOfInterview(d, interview_id);
227
+ if (i.status !== "scheduled") throw new TransitionError(`only a scheduled interview cancels; this one is ${i.status}`, ["scheduled"]);
228
+ d.prepare("UPDATE interview SET status = 'cancelled' WHERE id = ?").run(i.id);
229
+ appendEvent(d, { candidate_id: i.candidate_id, entity: "interview", entity_id: i.id, event: "cancelled", actor_type: actor.type, actor_ref: actor.ref, at: now });
230
+ return { interview_id: i.id, status: "cancelled" };
231
+ });
232
+ }
233
+
234
+ function inviteReceived(db, { interview_id, at, now, actor }) {
235
+ return withTx(db, (d) => {
236
+ const i = candidateOfInterview(d, interview_id);
237
+ const when = at ? iso(at, "invite_received_at") : now;
238
+ d.prepare("UPDATE interview SET invite_received_at = ? WHERE id = ?").run(when, i.id);
239
+ appendEvent(d, { candidate_id: i.candidate_id, entity: "interview", entity_id: i.id, event: "invite_received", actor_type: actor.type, actor_ref: actor.ref, payload: { at: when }, at: now });
240
+ return { interview_id: i.id, invite_received_at: when };
241
+ });
242
+ }
243
+
244
+ /** Debrief text is ground truth for later packets; capped at 64 KB by the store. */
245
+ function captureDebrief(db, { interview_id, text, now, actor }) {
246
+ const t = String(text || "").trim();
247
+ if (!t) throw new TransitionError("debrief text is required");
248
+ if (t.length > 65536) throw new TransitionError("debrief is longer than 64 KB");
249
+ return withTx(db, (d) => {
250
+ const i = candidateOfInterview(d, interview_id);
251
+ if (i.status !== "completed") throw new TransitionError("a debrief is captured after the interview is marked completed", ["completed"]);
252
+ d.prepare("UPDATE interview SET debrief_text = ?, debrief_captured_at = ? WHERE id = ?").run(t, now, i.id);
253
+ appendEvent(d, { candidate_id: i.candidate_id, entity: "interview", entity_id: i.id, event: "debrief", actor_type: actor.type, actor_ref: actor.ref, payload: { chars: t.length }, prior: { debrief_text: i.debrief_text }, at: now });
254
+ return { interview_id: i.id, debrief_captured_at: now };
255
+ });
256
+ }
257
+
258
+ function pinStories(db, { interview_id, ids, now, actor }) {
259
+ if (!Array.isArray(ids)) throw new TransitionError("ids must be an array of record ids");
260
+ return withTx(db, (d) => {
261
+ const i = candidateOfInterview(d, interview_id);
262
+ const known = new Set(d.prepare("SELECT id FROM accomplishment WHERE candidate_id = ?").all(i.candidate_id).map((r) => r.id));
263
+ const bad = ids.filter((x) => !known.has(x));
264
+ if (bad.length) throw new TransitionError(`unknown record ids: ${bad.join(", ")}`);
265
+ const clean = [...new Set(ids)].slice(0, 12);
266
+ d.prepare("UPDATE interview SET pinned_ids_json = ? WHERE id = ?").run(JSON.stringify(clean), i.id);
267
+ appendEvent(d, { candidate_id: i.candidate_id, entity: "interview", entity_id: i.id, event: "pinned", actor_type: actor.type, actor_ref: actor.ref, payload: { ids: clean }, prior: { pinned_ids_json: i.pinned_ids_json }, at: now });
268
+ return { interview_id: i.id, pinned_ids: clean };
269
+ });
270
+ }
271
+
272
+ // ----- people -----
273
+
274
+ function canonicalPerson(d, id) {
275
+ let p = d.prepare("SELECT * FROM person WHERE id = ?").get(id);
276
+ let hops = 0;
277
+ while (p && p.merged_into_id && hops++ < 10) p = d.prepare("SELECT * FROM person WHERE id = ?").get(p.merged_into_id);
278
+ return p;
279
+ }
280
+
281
+ /** Partial identity is allowed: a first name alone creates a row; merge later when the surname lands. */
282
+ function addPerson(db, { candidate_id, name, title, org, intel_text, links, now, actor }) {
283
+ const n = String(name || "").trim();
284
+ if (!n) throw new TransitionError("name is required");
285
+ return withTx(db, (d) => {
286
+ const id = ulid(Date.parse(now));
287
+ d.prepare("INSERT INTO person (id, candidate_id, name, title, org, intel_text, links_json, first_seen_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
288
+ .run(id, candidate_id, n.slice(0, 200), title ? String(title).slice(0, 200) : null, org ? String(org).slice(0, 200) : null, intel_text ? String(intel_text).slice(0, 8000) : null, JSON.stringify(Array.isArray(links) ? links.slice(0, 10) : []), now);
289
+ appendEvent(d, { candidate_id, entity: "person", entity_id: id, event: "added", actor_type: actor.type, actor_ref: actor.ref, payload: { name: n }, at: now });
290
+ return { person_id: id };
291
+ });
292
+ }
293
+
294
+ function updatePerson(db, { person_id, title, org, intel_text, now, actor }) {
295
+ return withTx(db, (d) => {
296
+ const p = canonicalPerson(d, person_id);
297
+ if (!p) throw new NotFound("person");
298
+ d.prepare("UPDATE person SET title = coalesce(?, title), org = coalesce(?, org), intel_text = coalesce(?, intel_text) WHERE id = ?")
299
+ .run(title != null ? String(title).slice(0, 200) : null, org != null ? String(org).slice(0, 200) : null, intel_text != null ? String(intel_text).slice(0, 8000) : null, p.id);
300
+ appendEvent(d, { candidate_id: p.candidate_id, entity: "person", entity_id: p.id, event: "updated", actor_type: actor.type, actor_ref: actor.ref, prior: { title: p.title, org: p.org, intel_text: p.intel_text }, at: now });
301
+ return { person_id: p.id };
302
+ });
303
+ }
304
+
305
+ /** person: { person_id } or { name, title?, org?, intel_text? } (created on the spot). */
306
+ function resolvePerson(d, candidate_id, person, now, actor) {
307
+ if (person && person.person_id) {
308
+ const p = canonicalPerson(d, person.person_id);
309
+ if (!p || p.candidate_id !== candidate_id) throw new NotFound("person");
310
+ return p.id;
311
+ }
312
+ return addPerson(d, { candidate_id, ...person, now, actor }).person_id;
313
+ }
314
+
315
+ function attachPersonToInterview(db, { interview_id, person, now, actor }) {
316
+ return withTx(db, (d) => {
317
+ const i = candidateOfInterview(d, interview_id);
318
+ const pid = resolvePerson(d, i.candidate_id, person, now, actor);
319
+ d.prepare("INSERT OR IGNORE INTO interview_person (interview_id, person_id) VALUES (?, ?)").run(i.id, pid);
320
+ d.prepare("INSERT OR IGNORE INTO application_person (application_id, person_id, role) VALUES (?, ?, 'interviewer')").run(i.application_id, pid);
321
+ appendEvent(d, { candidate_id: i.candidate_id, entity: "interview", entity_id: i.id, event: "person_attached", actor_type: actor.type, actor_ref: actor.ref, payload: { person_id: pid }, at: now });
322
+ return { interview_id: i.id, person_id: pid };
323
+ });
324
+ }
325
+
326
+ function attachPersonToApplication(db, { application_id, person, role = "other", now, actor }) {
327
+ if (!["interviewer", "recruiter", "referral", "other"].includes(role)) throw new TransitionError("role must be interviewer, recruiter, referral, or other");
328
+ return withTx(db, (d) => {
329
+ const a = candidateOfApplication(d, application_id);
330
+ const pid = resolvePerson(d, a.candidate_id, person, now, actor);
331
+ d.prepare("INSERT OR IGNORE INTO application_person (application_id, person_id, role) VALUES (?, ?, ?)").run(a.id, pid, role);
332
+ appendEvent(d, { candidate_id: a.candidate_id, entity: "application", entity_id: a.id, event: "person_attached", actor_type: actor.type, actor_ref: actor.ref, payload: { person_id: pid, role }, at: now });
333
+ return { application_id: a.id, person_id: pid };
334
+ });
335
+ }
336
+
337
+ /** Merge sets merged_into_id and repoints every join in one transaction; old ids keep resolving through canonical_person. */
338
+ function mergePerson(db, { person_id, into_id, now, actor }) {
339
+ if (person_id === into_id) throw new TransitionError("a person cannot merge into itself");
340
+ return withTx(db, (d) => {
341
+ const from = d.prepare("SELECT * FROM person WHERE id = ?").get(person_id);
342
+ const into = canonicalPerson(d, into_id);
343
+ if (!from || !into) throw new NotFound("person");
344
+ if (from.candidate_id !== into.candidate_id) throw new TransitionError("people belong to different candidates");
345
+ if (from.merged_into_id) throw new TransitionError("already merged");
346
+ for (const r of d.prepare("SELECT interview_id FROM interview_person WHERE person_id = ?").all(from.id)) {
347
+ d.prepare("INSERT OR IGNORE INTO interview_person (interview_id, person_id) VALUES (?, ?)").run(r.interview_id, into.id);
348
+ }
349
+ d.prepare("DELETE FROM interview_person WHERE person_id = ?").run(from.id);
350
+ for (const r of d.prepare("SELECT application_id, role FROM application_person WHERE person_id = ?").all(from.id)) {
351
+ d.prepare("INSERT OR IGNORE INTO application_person (application_id, person_id, role) VALUES (?, ?, ?)").run(r.application_id, into.id, r.role);
352
+ }
353
+ d.prepare("DELETE FROM application_person WHERE person_id = ?").run(from.id);
354
+ d.prepare("UPDATE person SET merged_into_id = ?, title = coalesce(title, ?), org = coalesce(org, ?) WHERE id = ?").run(into.id, from.title, from.org, from.id);
355
+ d.prepare("UPDATE person SET title = coalesce(title, ?), org = coalesce(org, ?), intel_text = CASE WHEN intel_text IS NULL THEN ? ELSE intel_text END WHERE id = ?").run(from.title, from.org, from.intel_text, into.id);
356
+ appendEvent(d, { candidate_id: from.candidate_id, entity: "person", entity_id: from.id, event: "merged", actor_type: actor.type, actor_ref: actor.ref, payload: { into: into.id }, prior: { name: from.name }, at: now });
357
+ return { person_id: from.id, into_id: into.id };
358
+ });
359
+ }
360
+
361
+ // ----- communications -----
362
+
363
+ /** "Mark sent": the communication row against a passed draft, body only when it changed. */
364
+ function markSent(db, { application_id, interview_id, kind, document_id, to_addr, sent_body_text, sent_at, now, actor }) {
365
+ if (!["thankyou", "nudge", "negotiation", "other"].includes(kind)) throw new TransitionError("kind must be thankyou, nudge, negotiation, or other");
366
+ return withTx(db, (d) => {
367
+ const a = candidateOfApplication(d, application_id);
368
+ if (interview_id) {
369
+ const i = d.prepare("SELECT application_id FROM interview WHERE id = ?").get(interview_id);
370
+ if (!i || i.application_id !== a.id) throw new TransitionError("the interview is not on this application");
371
+ }
372
+ if (document_id) {
373
+ const doc = d.prepare("SELECT status, kind FROM document WHERE id = ?").get(document_id);
374
+ if (!doc || doc.status !== "passed" || doc.kind !== "email") throw new TransitionError("document_id must be a passed email draft");
375
+ }
376
+ const id = ulid(Date.parse(now));
377
+ d.prepare("INSERT INTO communication (id, application_id, interview_id, kind, document_id, to_addr, sent_at, sent_body_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
378
+ .run(id, a.id, interview_id || null, kind, document_id || null, to_addr ? String(to_addr).slice(0, 300) : null, sent_at ? iso(sent_at, "sent_at") : now, sent_body_text ? String(sent_body_text).slice(0, 20000) : null);
379
+ appendEvent(d, { candidate_id: a.candidate_id, entity: "communication", entity_id: id, event: "sent", actor_type: actor.type, actor_ref: actor.ref, payload: { kind, interview_id: interview_id || null, document_id: document_id || null }, at: now });
380
+ return { communication_id: id };
381
+ });
382
+ }
383
+
384
+ module.exports = {
385
+ TransitionError, NotFound, APP_NEXT, INTERVIEW_FORMATS, OUTCOMES,
386
+ acceptPosting, archivePosting, postingTakenDown,
387
+ logApplication, updateStatus, correctStatus, setDecisionExpected, setNotes,
388
+ scheduleInterview, rescheduleInterview, completeInterview, setOutcome, cancelInterview, inviteReceived, captureDebrief, pinStories,
389
+ addPerson, updatePerson, attachPersonToInterview, attachPersonToApplication, mergePerson, canonicalPerson,
390
+ markSent,
391
+ };
package/store/tx.js ADDED
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * withTx(db, fn): BEGIN IMMEDIATE, fn(db), COMMIT. On SQLITE_BUSY or
5
+ * SQLITE_LOCKED the transaction is rolled back and retried with backoff, up
6
+ * to a cap. A lock failure is never surfaced as a tool error a model could
7
+ * narrate; after the cap it becomes a plain Error naming the cause.
8
+ *
9
+ * DatabaseSync is synchronous, so the backoff is a synchronous wait on a
10
+ * SharedArrayBuffer. That is deliberate: there is nothing else this process
11
+ * could usefully do while it holds no lock.
12
+ */
13
+
14
+ const SQLITE_BUSY = 5;
15
+ const SQLITE_LOCKED = 6;
16
+ const RETRIES = 5;
17
+
18
+ function isLockError(e) {
19
+ const code = e && (e.errcode ?? e.code);
20
+ return code === SQLITE_BUSY || code === SQLITE_LOCKED ||
21
+ /database is locked|database table is locked/i.test(String(e && e.message));
22
+ }
23
+
24
+ function sleepSync(ms) {
25
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
26
+ }
27
+
28
+ const IN_TX = new WeakSet();
29
+
30
+ function withTx(db, fn, opts = {}) {
31
+ if (IN_TX.has(db)) return fn(db);
32
+ IN_TX.add(db);
33
+ try {
34
+ return withTxInner(db, fn, opts);
35
+ } finally { IN_TX.delete(db); }
36
+ }
37
+
38
+ function withTxInner(db, fn, opts = {}) {
39
+ const retries = opts.retries ?? RETRIES;
40
+ const log = opts.log || (() => {});
41
+ let attempt = 0;
42
+ for (;;) {
43
+ try {
44
+ db.exec("BEGIN IMMEDIATE");
45
+ } catch (e) {
46
+ if (isLockError(e) && attempt < retries) {
47
+ attempt++;
48
+ log({ event: "tx_busy", attempt });
49
+ sleepSync(25 * 2 ** attempt);
50
+ continue;
51
+ }
52
+ throw new Error(`store busy after ${attempt} retries: ${e.message}`);
53
+ }
54
+ try {
55
+ const out = fn(db);
56
+ db.exec("COMMIT");
57
+ return out;
58
+ } catch (e) {
59
+ try { db.exec("ROLLBACK"); } catch (_) { /* already rolled back */ }
60
+ if (isLockError(e) && attempt < retries) {
61
+ attempt++;
62
+ log({ event: "tx_busy", attempt });
63
+ sleepSync(25 * 2 ** attempt);
64
+ continue;
65
+ }
66
+ throw e;
67
+ }
68
+ }
69
+ }
70
+
71
+ module.exports = { withTx, isLockError, RETRIES };
package/store/tz.js ADDED
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Interview time zones are IANA names or nothing. "CET", "EST", "CT", "PT"
5
+ * are labels, not zones, and a label entered as a zone is how a 1 PM Eastern
6
+ * invite becomes a missed noon Central call. Display code may derive a label
7
+ * from the zone; the store only stores the zone.
8
+ */
9
+
10
+ const ZONES = new Set(Intl.supportedValuesOf("timeZone"));
11
+ ZONES.add("UTC");
12
+
13
+ function isIanaZone(name) {
14
+ return typeof name === "string" && ZONES.has(name);
15
+ }
16
+
17
+ function assertIanaZone(name) {
18
+ if (!isIanaZone(name)) {
19
+ throw new Error(`tz must be an IANA zone such as America/Chicago, not ${JSON.stringify(name)}`);
20
+ }
21
+ return name;
22
+ }
23
+
24
+ module.exports = { isIanaZone, assertIanaZone };