feat: productize decision center backend APIs
This commit is contained in:
@@ -5,7 +5,7 @@ import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../../s
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
||||
type JsonRecord = Record<string, JsonValue>;
|
||||
|
||||
type DecisionRecordType = "meeting" | "decision" | "goal" | "blocker" | "debt" | "experiment";
|
||||
type DecisionRecordType = "meeting" | "decision" | "goal" | "external_goal" | "internal_goal" | "blocker" | "debt" | "experiment";
|
||||
type RequirementRecordType = Exclude<DecisionRecordType, "meeting">;
|
||||
type DecisionRecordLevel = "G0" | "G1" | "G2" | "G3" | "P0" | "P1" | "P2" | "P3" | "none";
|
||||
type DecisionRecordStatus = "active" | "blocked" | "parked" | "done";
|
||||
@@ -28,7 +28,9 @@ interface DecisionRecordRow {
|
||||
linked_goal_id: string | null;
|
||||
tags: JsonValue;
|
||||
evidence_links: JsonValue;
|
||||
source: string;
|
||||
source_session: string;
|
||||
issue_id: string;
|
||||
task_id: string;
|
||||
commit_id: string;
|
||||
created_at: Date | string;
|
||||
@@ -43,10 +45,13 @@ interface DecisionRecord extends JsonRecord {
|
||||
title: string;
|
||||
summary: string;
|
||||
body: string;
|
||||
priority: DecisionRecordLevel;
|
||||
linkedGoalId: string | null;
|
||||
tags: string[];
|
||||
evidenceLinks: string[];
|
||||
source: string;
|
||||
sourceSession: string;
|
||||
issueId: string;
|
||||
taskId: string;
|
||||
commitId: string;
|
||||
createdAt: string;
|
||||
@@ -109,8 +114,8 @@ class HttpError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const recordTypes = new Set<DecisionRecordType>(["meeting", "decision", "goal", "blocker", "debt", "experiment"]);
|
||||
const requirementRecordTypes = new Set<RequirementRecordType>(["decision", "goal", "blocker", "debt", "experiment"]);
|
||||
const recordTypes = new Set<DecisionRecordType>(["meeting", "decision", "goal", "external_goal", "internal_goal", "blocker", "debt", "experiment"]);
|
||||
const requirementRecordTypes = new Set<RequirementRecordType>(["decision", "goal", "external_goal", "internal_goal", "blocker", "debt", "experiment"]);
|
||||
const recordLevels = new Set<DecisionRecordLevel>(["G0", "G1", "G2", "G3", "P0", "P1", "P2", "P3", "none"]);
|
||||
const recordStatuses = new Set<DecisionRecordStatus>(["active", "blocked", "parked", "done"]);
|
||||
const serviceStartedAt = new Date().toISOString();
|
||||
@@ -286,10 +291,13 @@ function recordFromRow(row: DecisionRecordRow): DecisionRecord {
|
||||
title: row.title,
|
||||
summary: summaryFromBody(body),
|
||||
body,
|
||||
priority: row.level,
|
||||
linkedGoalId: row.linked_goal_id,
|
||||
tags: Array.isArray(row.tags) ? row.tags.map(String) : [],
|
||||
evidenceLinks: Array.isArray(row.evidence_links) ? row.evidence_links.map(String) : [],
|
||||
source: row.source,
|
||||
sourceSession: row.source_session,
|
||||
issueId: row.issue_id,
|
||||
taskId: row.task_id,
|
||||
commitId: row.commit_id,
|
||||
createdAt: iso(row.created_at),
|
||||
@@ -317,6 +325,10 @@ function parseLevel(value: unknown, fallback: DecisionRecordLevel): DecisionReco
|
||||
return raw as DecisionRecordLevel;
|
||||
}
|
||||
|
||||
function parsePriority(input: Record<string, unknown>, fallback: DecisionRecordLevel): DecisionRecordLevel {
|
||||
return parseLevel(input.priority ?? input.level, fallback);
|
||||
}
|
||||
|
||||
function parseStatus(value: unknown, fallback: DecisionRecordStatus): DecisionRecordStatus {
|
||||
const raw = asString(value) || fallback;
|
||||
if (!recordStatuses.has(raw as DecisionRecordStatus)) throw new HttpError(400, "unsupported record status", { value: raw, allowed: [...recordStatuses] });
|
||||
@@ -364,19 +376,30 @@ async function ensureSchema(): Promise<void> {
|
||||
linked_goal_id TEXT,
|
||||
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
evidence_links JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
source_session TEXT NOT NULL DEFAULT '',
|
||||
issue_id TEXT NOT NULL DEFAULT '',
|
||||
task_id TEXT NOT NULL DEFAULT '',
|
||||
commit_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT decision_center_records_type_check CHECK (type IN ('meeting', 'decision', 'goal', 'blocker', 'debt', 'experiment')),
|
||||
CONSTRAINT decision_center_records_type_check CHECK (type IN ('meeting', 'decision', 'goal', 'external_goal', 'internal_goal', 'blocker', 'debt', 'experiment')),
|
||||
CONSTRAINT decision_center_records_level_check CHECK (level IN ('G0', 'G1', 'G2', 'G3', 'P0', 'P1', 'P2', 'P3', 'none')),
|
||||
CONSTRAINT decision_center_records_status_check CHECK (status IN ('active', 'blocked', 'parked', 'done'))
|
||||
)
|
||||
`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT ''`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS issue_id TEXT NOT NULL DEFAULT ''`;
|
||||
await sql`ALTER TABLE decision_center_records DROP CONSTRAINT IF EXISTS decision_center_records_type_check`;
|
||||
await sql`
|
||||
ALTER TABLE decision_center_records
|
||||
ADD CONSTRAINT decision_center_records_type_check
|
||||
CHECK (type IN ('meeting', 'decision', 'goal', 'external_goal', 'internal_goal', 'blocker', 'debt', 'experiment'))
|
||||
`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_records_type_status_level ON decision_center_records(type, status, level)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_records_linked_goal ON decision_center_records(linked_goal_id)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_records_updated ON decision_center_records(updated_at DESC)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_records_issue_id ON decision_center_records(issue_id)`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS decision_center_diary_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -489,18 +512,20 @@ async function createRecord(input: Record<string, unknown>): Promise<DecisionRec
|
||||
const id = asString(input.id) || `dc_${randomUUID()}`;
|
||||
const rows = await withDatabaseRecovery("create_record", () => sql<DecisionRecordRow[]>`
|
||||
INSERT INTO decision_center_records (
|
||||
id, type, level, status, title, body, linked_goal_id, tags, evidence_links, source_session, task_id, commit_id
|
||||
id, type, level, status, title, body, linked_goal_id, tags, evidence_links, source, source_session, issue_id, task_id, commit_id
|
||||
) VALUES (
|
||||
${id},
|
||||
${parseRecordType(input.type, "meeting")},
|
||||
${parseLevel(input.level, "none")},
|
||||
${parsePriority(input, "none")},
|
||||
${parseStatus(input.status, "active")},
|
||||
${title},
|
||||
${body},
|
||||
${asString(input.linkedGoalId) || null},
|
||||
${sql.json(asStringArray(input.tags, "tags"))},
|
||||
${sql.json(asStringArray(input.evidenceLinks ?? input.evidence, "evidenceLinks"))},
|
||||
${asString(input.source)},
|
||||
${asString(input.sourceSession)},
|
||||
${asString(input.issueId ?? input.issue ?? input.linkedIssue ?? input.linkedTask ?? input.taskId)},
|
||||
${asString(input.taskId)},
|
||||
${asString(input.commitId)}
|
||||
)
|
||||
@@ -519,14 +544,16 @@ async function updateRecord(id: string, input: Record<string, unknown>): Promise
|
||||
UPDATE decision_center_records
|
||||
SET
|
||||
type = ${"type" in input ? parseRecordType(input.type, existing.type) : existing.type},
|
||||
level = ${"level" in input ? parseLevel(input.level, existing.level) : existing.level},
|
||||
level = ${"level" in input || "priority" in input ? parsePriority(input, existing.level) : existing.level},
|
||||
status = ${"status" in input ? parseStatus(input.status, existing.status) : existing.status},
|
||||
title = ${title},
|
||||
body = ${body},
|
||||
linked_goal_id = ${"linkedGoalId" in input ? asString(input.linkedGoalId) || null : existing.linkedGoalId},
|
||||
tags = ${"tags" in input ? sql.json(asStringArray(input.tags, "tags")) : sql.json(existing.tags)},
|
||||
evidence_links = ${"evidenceLinks" in input || "evidence" in input ? sql.json(asStringArray(input.evidenceLinks ?? input.evidence, "evidenceLinks")) : sql.json(existing.evidenceLinks)},
|
||||
source = ${"source" in input ? asString(input.source) : existing.source},
|
||||
source_session = ${"sourceSession" in input ? asString(input.sourceSession) : existing.sourceSession},
|
||||
issue_id = ${"issueId" in input || "issue" in input || "linkedIssue" in input || "linkedTask" in input ? asString(input.issueId ?? input.issue ?? input.linkedIssue ?? input.linkedTask) : existing.issueId},
|
||||
task_id = ${"taskId" in input ? asString(input.taskId) : existing.taskId},
|
||||
commit_id = ${"commitId" in input ? asString(input.commitId) : existing.commitId},
|
||||
updated_at = now()
|
||||
@@ -557,7 +584,7 @@ async function upsertRequirementRecord(input: Record<string, unknown>): Promise<
|
||||
const record = await createRecord({
|
||||
...input,
|
||||
id: id || undefined,
|
||||
type: parseRequirementRecordType(input.type, "goal"),
|
||||
type: parseRequirementRecordType(input.type, "external_goal"),
|
||||
});
|
||||
log("info", "requirement_created", { id: record.id, type: record.type, level: record.level, status: record.status });
|
||||
return { ok: true, action: "created", record };
|
||||
@@ -574,6 +601,8 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
const status = asString(url.searchParams.get("status"));
|
||||
const level = asString(url.searchParams.get("level"));
|
||||
const linkedGoalId = asString(url.searchParams.get("linkedGoalId"));
|
||||
const source = asString(url.searchParams.get("source"));
|
||||
const issueId = asString(url.searchParams.get("issueId") ?? url.searchParams.get("issue"));
|
||||
const tag = asString(url.searchParams.get("tag"));
|
||||
const query = asString(url.searchParams.get("q") ?? url.searchParams.get("query"));
|
||||
const requirementOnly = options.requirementOnly === true || url.searchParams.get("requirementOnly") === "true";
|
||||
@@ -582,16 +611,20 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
if (requirementOnly && type && !requirementRecordTypes.has(type as RequirementRecordType)) throw new HttpError(400, "unsupported requirement type filter", { type });
|
||||
if (status && !recordStatuses.has(status as DecisionRecordStatus)) throw new HttpError(400, "unsupported status filter", { status });
|
||||
if (level && !recordLevels.has(level as DecisionRecordLevel)) throw new HttpError(400, "unsupported level filter", { level });
|
||||
if (source.length > 240) throw new HttpError(400, "source filter must be at most 240 characters");
|
||||
if (issueId.length > 240) throw new HttpError(400, "issueId filter must be at most 240 characters");
|
||||
if (tag.length > 120) throw new HttpError(400, "tag filter must be at most 120 characters");
|
||||
if (query.length > 240) throw new HttpError(400, "query filter must be at most 240 characters");
|
||||
const rows = await withDatabaseRecovery("list_records", () => sql<DecisionRecordRow[]>`
|
||||
SELECT *
|
||||
FROM decision_center_records
|
||||
WHERE (${type || null}::text IS NULL OR type = ${type || null})
|
||||
AND (${requirementOnly}::boolean IS FALSE OR type IN ('decision', 'goal', 'blocker', 'debt', 'experiment'))
|
||||
AND (${requirementOnly}::boolean IS FALSE OR type IN ('decision', 'goal', 'external_goal', 'internal_goal', 'blocker', 'debt', 'experiment'))
|
||||
AND (${status || null}::text IS NULL OR status = ${status || null})
|
||||
AND (${level || null}::text IS NULL OR level = ${level || null})
|
||||
AND (${linkedGoalId || null}::text IS NULL OR linked_goal_id = ${linkedGoalId || null})
|
||||
AND (${source || null}::text IS NULL OR source = ${source || null})
|
||||
AND (${issueId || null}::text IS NULL OR issue_id = ${issueId || null})
|
||||
AND (${tag || null}::text IS NULL OR tags ? ${tag || ""})
|
||||
AND (${query || null}::text IS NULL OR title ILIKE ${query ? `%${query}%` : ""} OR body ILIKE ${query ? `%${query}%` : ""})
|
||||
ORDER BY
|
||||
@@ -612,6 +645,33 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
return rows.map(recordFromRow);
|
||||
}
|
||||
|
||||
async function getRequirementRecord(id: string): Promise<DecisionRecord> {
|
||||
const record = await getRecord(id);
|
||||
if (!requirementRecordTypes.has(record.type as RequirementRecordType)) {
|
||||
throw new HttpError(404, "requirement record not found", { id, type: record.type });
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async function createRequirementRecord(input: Record<string, unknown>): Promise<DecisionRecord> {
|
||||
const record = await createRecord({
|
||||
...input,
|
||||
type: parseRequirementRecordType(input.type, "external_goal"),
|
||||
});
|
||||
log("info", "requirement_created", { id: record.id, type: record.type, level: record.level, status: record.status });
|
||||
return record;
|
||||
}
|
||||
|
||||
async function updateRequirementRecord(id: string, input: Record<string, unknown>): Promise<DecisionRecord> {
|
||||
const existing = await getRequirementRecord(id);
|
||||
const record = await updateRecord(id, {
|
||||
...input,
|
||||
type: "type" in input ? parseRequirementRecordType(input.type, existing.type as RequirementRecordType) : existing.type,
|
||||
});
|
||||
log("info", "requirement_updated", { id: record.id, type: record.type, level: record.level, status: record.status });
|
||||
return record;
|
||||
}
|
||||
|
||||
function normalizeDecisionDrafts(value: unknown): Array<Record<string, unknown>> {
|
||||
if (value === undefined || value === null) return [];
|
||||
if (!Array.isArray(value)) throw new HttpError(400, "decisions must be an array");
|
||||
@@ -627,14 +687,16 @@ async function importMeeting(input: Record<string, unknown>): Promise<JsonRecord
|
||||
if (!markdown) throw new HttpError(400, "markdown is required");
|
||||
const base: Record<string, unknown> = {
|
||||
type: "meeting",
|
||||
level: parseLevel(input.level, "none"),
|
||||
level: parsePriority(input, "none"),
|
||||
status: parseStatus(input.status, "active"),
|
||||
title: asString(input.title) || titleFromMarkdown(markdown, "Imported meeting"),
|
||||
body: markdown,
|
||||
linkedGoalId: asString(input.linkedGoalId) || null,
|
||||
tags: asStringArray(input.tags, "tags"),
|
||||
evidenceLinks: asStringArray(input.evidenceLinks ?? input.evidence, "evidenceLinks"),
|
||||
source: asString(input.source),
|
||||
sourceSession: asString(input.sourceSession),
|
||||
issueId: asString(input.issueId ?? input.issue ?? input.linkedIssue ?? input.linkedTask ?? input.taskId),
|
||||
taskId: asString(input.taskId),
|
||||
commitId: asString(input.commitId),
|
||||
};
|
||||
@@ -769,6 +831,15 @@ function validMonthFilter(value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function realTodayUtc(): string {
|
||||
const now = new Date();
|
||||
return `${now.getUTCFullYear()}-${pad2(now.getUTCMonth() + 1)}-${pad2(now.getUTCDate())}`;
|
||||
}
|
||||
|
||||
function defaultDiaryBody(date: string): string {
|
||||
return `# ${date}\n\n`;
|
||||
}
|
||||
|
||||
function markdownPathForDate(date: string): string {
|
||||
const month = date.slice(0, 7);
|
||||
return `${month}/${date}.md`;
|
||||
@@ -951,7 +1022,7 @@ async function upsertDiaryEntryByKey(key: string, input: Record<string, unknown>
|
||||
if (existing === undefined && !keyDate) throw new HttpError(404, "diary entry not found", { key });
|
||||
|
||||
const date = existing === undefined ? keyDate : dateOnly(existing.entry_date);
|
||||
const body = bodyProvided ? asText(input.body ?? input.markdown) : existing?.body ?? "";
|
||||
const body = bodyProvided ? asText(input.body ?? input.markdown) : existing?.body ?? defaultDiaryBody(date);
|
||||
if (body.length > 300_000) throw new HttpError(400, "diary day body must be at most 300000 characters", { date, length: body.length });
|
||||
const title = titleProvided || existing === undefined ? diaryTitleFor(date, body, input.title) : existing.title;
|
||||
const tags = tagsProvided ? asStringArray(input.tags, "tags") : Array.isArray(existing?.tags) ? existing.tags.map(String) : [];
|
||||
@@ -1007,6 +1078,24 @@ async function upsertDiaryEntryByKey(key: string, input: Record<string, unknown>
|
||||
return { ok: true, action, entry: diaryEntryFromRow(row) };
|
||||
}
|
||||
|
||||
async function getOrCreateTodayDiaryEntry(input: Record<string, unknown> = {}): Promise<JsonRecord> {
|
||||
const today = realTodayUtc();
|
||||
try {
|
||||
const entry = await getDiaryEntry(today);
|
||||
return { ok: true, action: "existing", today, entry };
|
||||
} catch (error) {
|
||||
if (!(error instanceof HttpError) || error.status !== 404) throw error;
|
||||
}
|
||||
const body = "body" in input || "markdown" in input ? asText(input.body ?? input.markdown) : defaultDiaryBody(today);
|
||||
const result = await upsertDiaryEntryByKey(today, {
|
||||
...input,
|
||||
body,
|
||||
sourceFile: input.sourceFile ?? input.sourcePath ?? input.source ?? "today",
|
||||
});
|
||||
const entry = asRecord(result.entry) as DiaryEntry;
|
||||
return { ok: true, action: "created", today, entry };
|
||||
}
|
||||
|
||||
async function route(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const method = req.method.toUpperCase();
|
||||
@@ -1021,14 +1110,39 @@ async function route(req: Request): Promise<Response> {
|
||||
if (url.pathname === "/api/records" && method === "GET") return jsonResponse({ ok: true, records: await listRecords(url) });
|
||||
if (url.pathname === "/api/records" && method === "POST") return jsonResponse({ ok: true, record: await createRecord(await readJsonBody(req)) }, 201);
|
||||
if (url.pathname === "/api/requirements" && method === "GET") return jsonResponse({ ok: true, requirements: await listRecords(url, { requirementOnly: true }) });
|
||||
if (url.pathname === "/api/requirements" && (method === "POST" || method === "PUT")) {
|
||||
if (url.pathname === "/api/requirements" && method === "POST") {
|
||||
const input = await readJsonBody(req);
|
||||
if (asString(input.id)) {
|
||||
const result = await upsertRequirementRecord(input);
|
||||
return jsonResponse(result, result.action === "created" ? 201 : 200);
|
||||
}
|
||||
return jsonResponse({ ok: true, record: await createRequirementRecord(input) }, 201);
|
||||
}
|
||||
if (url.pathname === "/api/requirements" && method === "PUT") {
|
||||
const result = await upsertRequirementRecord(await readJsonBody(req));
|
||||
return jsonResponse(result, result.action === "created" ? 201 : 200);
|
||||
}
|
||||
if (url.pathname === "/api/meetings/import" && method === "POST") return jsonResponse(await importMeeting(await readJsonBody(req)), 201);
|
||||
if (url.pathname === "/api/diary/months" && method === "GET") return jsonResponse({ ok: true, months: await listDiaryMonths() });
|
||||
if (url.pathname === "/api/diary/history" && method === "GET") return jsonResponse({ ok: true, entries: await listDiaryEntries(url) });
|
||||
if (url.pathname === "/api/diary/today") {
|
||||
if (method === "GET") return jsonResponse(await getOrCreateTodayDiaryEntry());
|
||||
if (method === "PUT" || method === "POST") {
|
||||
const result = await upsertDiaryEntryByKey(realTodayUtc(), await readJsonBody(req));
|
||||
return jsonResponse(result, result.action === "created" ? 201 : 200);
|
||||
}
|
||||
throw new HttpError(405, "diary today route supports GET, POST, and PUT", { method });
|
||||
}
|
||||
if (url.pathname === "/api/diary/entries" && method === "GET") return jsonResponse({ ok: true, entries: await listDiaryEntries(url) });
|
||||
if (url.pathname === "/api/diary/import" && method === "POST") return jsonResponse(await importDiary(await readJsonBody(req)), 201);
|
||||
const requirementMatch = url.pathname.match(/^\/api\/requirements\/([^/]+)$/u);
|
||||
if (requirementMatch !== null) {
|
||||
const id = decodeURIComponent(requirementMatch[1] ?? "");
|
||||
if (!id) throw new HttpError(400, "requirement id is required");
|
||||
if (method === "GET") return jsonResponse({ ok: true, record: await getRequirementRecord(id) });
|
||||
if (method === "PUT") return jsonResponse({ ok: true, record: await updateRequirementRecord(id, await readJsonBody(req)) });
|
||||
throw new HttpError(405, "requirement route supports GET and PUT", { method });
|
||||
}
|
||||
const diaryMatch = url.pathname.match(/^\/api\/diary\/entries\/([^/]+)$/u);
|
||||
if (diaryMatch !== null) {
|
||||
const key = decodeURIComponent(diaryMatch[1] ?? "");
|
||||
|
||||
Reference in New Issue
Block a user