feat: add decision requirement and diary upsert APIs
This commit is contained in:
@@ -6,6 +6,7 @@ type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string
|
||||
type JsonRecord = Record<string, JsonValue>;
|
||||
|
||||
type DecisionRecordType = "meeting" | "decision" | "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";
|
||||
|
||||
@@ -109,6 +110,7 @@ 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 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();
|
||||
@@ -246,6 +248,13 @@ function asString(value: unknown): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function asText(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
|
||||
return "";
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown, field: string): string[] {
|
||||
if (value === null || value === undefined || value === "") return [];
|
||||
if (typeof value === "string") {
|
||||
@@ -294,6 +303,14 @@ function parseRecordType(value: unknown, fallback: DecisionRecordType): Decision
|
||||
return raw as DecisionRecordType;
|
||||
}
|
||||
|
||||
function parseRequirementRecordType(value: unknown, fallback: RequirementRecordType): RequirementRecordType {
|
||||
const raw = asString(value) || fallback;
|
||||
if (!requirementRecordTypes.has(raw as RequirementRecordType)) {
|
||||
throw new HttpError(400, "unsupported requirement record type", { value: raw, allowed: [...requirementRecordTypes] });
|
||||
}
|
||||
return raw as RequirementRecordType;
|
||||
}
|
||||
|
||||
function parseLevel(value: unknown, fallback: DecisionRecordLevel): DecisionRecordLevel {
|
||||
const raw = asString(value) || fallback;
|
||||
if (!recordLevels.has(raw as DecisionRecordLevel)) throw new HttpError(400, "unsupported record level", { value: raw, allowed: [...recordLevels] });
|
||||
@@ -313,6 +330,12 @@ function titleFromMarkdown(markdown: string, fallback: string): string {
|
||||
return (firstLine || fallback).slice(0, 220);
|
||||
}
|
||||
|
||||
function validateRecordDraft(title: string, body: string): void {
|
||||
if (!title) throw new HttpError(400, "title is required");
|
||||
if (title.length > 240) throw new HttpError(400, "title must be at most 240 characters");
|
||||
if (body.length > 300_000) throw new HttpError(400, "body must be at most 300000 characters");
|
||||
}
|
||||
|
||||
async function readJsonBody(req: Request): Promise<Record<string, unknown>> {
|
||||
const text = await req.text();
|
||||
if (text.length > 5_000_000) throw new HttpError(413, "request body is too large", { maxBytes: 5_000_000 });
|
||||
@@ -460,11 +483,9 @@ function health(): JsonRecord {
|
||||
}
|
||||
|
||||
async function createRecord(input: Record<string, unknown>): Promise<DecisionRecord> {
|
||||
const body = asString(input.body ?? input.summary ?? input.markdown);
|
||||
const body = asText(input.body ?? input.summary ?? input.markdown);
|
||||
const title = asString(input.title) || titleFromMarkdown(body, "Untitled decision record");
|
||||
if (!title) throw new HttpError(400, "title is required");
|
||||
if (title.length > 240) throw new HttpError(400, "title must be at most 240 characters");
|
||||
if (body.length > 300_000) throw new HttpError(400, "body must be at most 300000 characters");
|
||||
validateRecordDraft(title, body);
|
||||
const id = asString(input.id) || `dc_${randomUUID()}`;
|
||||
const rows = await withDatabaseRecovery("create_record", () => sql<DecisionRecordRow[]>`
|
||||
INSERT INTO decision_center_records (
|
||||
@@ -491,14 +512,17 @@ async function createRecord(input: Record<string, unknown>): Promise<DecisionRec
|
||||
|
||||
async function updateRecord(id: string, input: Record<string, unknown>): Promise<DecisionRecord> {
|
||||
const existing = await getRecord(id);
|
||||
const title = "title" in input ? asString(input.title) : existing.title;
|
||||
const body = "body" in input || "summary" in input || "markdown" in input ? asText(input.body ?? input.summary ?? input.markdown) : existing.body;
|
||||
validateRecordDraft(title, body);
|
||||
const rows = await withDatabaseRecovery("update_record", () => sql<DecisionRecordRow[]>`
|
||||
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},
|
||||
status = ${"status" in input ? parseStatus(input.status, existing.status) : existing.status},
|
||||
title = ${"title" in input ? asString(input.title) : existing.title},
|
||||
body = ${"body" in input || "summary" in input || "markdown" in input ? asString(input.body ?? input.summary ?? input.markdown) : existing.body},
|
||||
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)},
|
||||
@@ -512,28 +536,64 @@ async function updateRecord(id: string, input: Record<string, unknown>): Promise
|
||||
return recordFromRow(rows[0]!);
|
||||
}
|
||||
|
||||
async function upsertRequirementRecord(input: Record<string, unknown>): Promise<JsonRecord> {
|
||||
const id = asString(input.id);
|
||||
if (id) {
|
||||
try {
|
||||
const existing = await getRecord(id);
|
||||
if (!requirementRecordTypes.has(existing.type as RequirementRecordType)) {
|
||||
throw new HttpError(409, "existing record is not a requirement-management record", { id, type: existing.type });
|
||||
}
|
||||
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 { ok: true, action: "updated", record };
|
||||
} catch (error) {
|
||||
if (!(error instanceof HttpError) || error.status !== 404) throw error;
|
||||
}
|
||||
}
|
||||
const record = await createRecord({
|
||||
...input,
|
||||
id: id || undefined,
|
||||
type: parseRequirementRecordType(input.type, "goal"),
|
||||
});
|
||||
log("info", "requirement_created", { id: record.id, type: record.type, level: record.level, status: record.status });
|
||||
return { ok: true, action: "created", record };
|
||||
}
|
||||
|
||||
async function getRecord(id: string): Promise<DecisionRecord> {
|
||||
const rows = await withDatabaseRecovery("get_record", () => sql<DecisionRecordRow[]>`SELECT * FROM decision_center_records WHERE id = ${id}`, { retryRead: true });
|
||||
if (rows.length === 0) throw new HttpError(404, "decision record not found", { id });
|
||||
return recordFromRow(rows[0]!);
|
||||
}
|
||||
|
||||
async function listRecords(url: URL): Promise<DecisionRecord[]> {
|
||||
async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}): Promise<DecisionRecord[]> {
|
||||
const type = asString(url.searchParams.get("type"));
|
||||
const status = asString(url.searchParams.get("status"));
|
||||
const level = asString(url.searchParams.get("level"));
|
||||
const linkedGoalId = asString(url.searchParams.get("linkedGoalId"));
|
||||
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";
|
||||
const limit = Math.max(1, Math.min(500, Number(url.searchParams.get("limit") || 200) || 200));
|
||||
if (type && !recordTypes.has(type as DecisionRecordType)) throw new HttpError(400, "unsupported type filter", { type });
|
||||
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 (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 (${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 (${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
|
||||
CASE level
|
||||
WHEN 'G0' THEN 0
|
||||
@@ -709,6 +769,24 @@ function validMonthFilter(value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function markdownPathForDate(date: string): string {
|
||||
const month = date.slice(0, 7);
|
||||
return `${month}/${date}.md`;
|
||||
}
|
||||
|
||||
function validateDiarySourceFile(value: string): string {
|
||||
if (value.length > 1000) throw new HttpError(400, "sourceFile must be at most 1000 characters");
|
||||
if (/[\u0000-\u001f\u007f]/u.test(value)) throw new HttpError(400, "sourceFile contains control characters");
|
||||
return value;
|
||||
}
|
||||
|
||||
function diaryTitleFor(date: string, body: string, rawTitle: unknown): string {
|
||||
const title = asString(rawTitle) || titleFromMarkdown(body, `${date} 工作日志`);
|
||||
if (!title) throw new HttpError(400, "title is required");
|
||||
if (title.length > 240) throw new HttpError(400, "title must be at most 240 characters");
|
||||
return title;
|
||||
}
|
||||
|
||||
async function upsertDiaryEntry(draft: DiaryDraft, sourceFile: string, tags: string[]): Promise<{ entry: DiaryEntry; action: "created" | "updated" | "unchanged" }> {
|
||||
if (draft.body.length > 300_000) throw new HttpError(400, "diary day body must be at most 300000 characters", { date: draft.date, length: draft.body.length });
|
||||
const hash = contentHash(draft.body);
|
||||
@@ -852,6 +930,83 @@ async function getDiaryEntry(key: string): Promise<DiaryEntry> {
|
||||
return diaryEntryFromRow(rows[0]!);
|
||||
}
|
||||
|
||||
async function upsertDiaryEntryByKey(key: string, input: Record<string, unknown>): Promise<JsonRecord> {
|
||||
const keyDate = /^\d{4}-\d{2}-\d{2}$/u.test(key) ? validDateFilter(key, "date") : "";
|
||||
const bodyProvided = "body" in input || "markdown" in input;
|
||||
const titleProvided = "title" in input;
|
||||
const tagsProvided = "tags" in input;
|
||||
const sourceFileProvided = "sourceFile" in input || "sourcePath" in input || "source" in input;
|
||||
const sourceFile = sourceFileProvided ? validateDiarySourceFile(asString(input.sourceFile ?? input.sourcePath ?? input.source)) : "";
|
||||
|
||||
const existingRows = await withDatabaseRecovery("get_diary_entry_for_upsert", () => sql<DiaryEntryRow[]>`
|
||||
SELECT *
|
||||
FROM decision_center_diary_entries
|
||||
WHERE (${keyDate || null}::date IS NOT NULL AND entry_date = ${keyDate || null}::date)
|
||||
OR (${keyDate || null}::date IS NULL AND id = ${key})
|
||||
ORDER BY imported_at DESC
|
||||
LIMIT 1
|
||||
`, { retryRead: true });
|
||||
|
||||
const existing = existingRows[0];
|
||||
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 ?? "";
|
||||
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) : [];
|
||||
const finalSourceFile = sourceFileProvided ? sourceFile : validateDiarySourceFile(existing?.source_file ?? "manual");
|
||||
const month = date.slice(0, 7);
|
||||
const markdownPath = markdownPathForDate(date);
|
||||
const hash = contentHash(body);
|
||||
|
||||
const rows = existing === undefined
|
||||
? await withDatabaseRecovery("create_diary_entry_by_date", () => sql<DiaryEntryRow[]>`
|
||||
INSERT INTO decision_center_diary_entries (
|
||||
id, entry_date, month, title, body, source_file, markdown_path, tags, content_hash, imported_at, updated_at
|
||||
) VALUES (
|
||||
${`diary_${randomUUID()}`},
|
||||
${date}::date,
|
||||
${month},
|
||||
${title},
|
||||
${body},
|
||||
${finalSourceFile},
|
||||
${markdownPath},
|
||||
${sql.json(tags)},
|
||||
${hash},
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
RETURNING *
|
||||
`)
|
||||
: await withDatabaseRecovery("update_diary_entry", () => sql<DiaryEntryRow[]>`
|
||||
UPDATE decision_center_diary_entries
|
||||
SET
|
||||
title = ${title},
|
||||
body = ${body},
|
||||
source_file = ${finalSourceFile},
|
||||
markdown_path = ${markdownPath},
|
||||
month = ${month},
|
||||
tags = ${sql.json(tags)},
|
||||
content_hash = ${hash},
|
||||
updated_at = CASE
|
||||
WHEN content_hash IS DISTINCT FROM ${hash}
|
||||
OR title IS DISTINCT FROM ${title}
|
||||
OR source_file IS DISTINCT FROM ${finalSourceFile}
|
||||
OR tags IS DISTINCT FROM ${sql.json(tags)}::jsonb
|
||||
THEN now()
|
||||
ELSE updated_at
|
||||
END
|
||||
WHERE id = ${existing.id}
|
||||
RETURNING *
|
||||
`);
|
||||
const row = rows[0];
|
||||
if (row === undefined) throw new HttpError(500, "diary edit returned no row", { key });
|
||||
const action = existing === undefined ? "created" : "updated";
|
||||
log("info", "diary_entry_upserted", { id: row.id, date, action, sourceFile: row.source_file });
|
||||
return { ok: true, action, entry: diaryEntryFromRow(row) };
|
||||
}
|
||||
|
||||
async function route(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const method = req.method.toUpperCase();
|
||||
@@ -865,6 +1020,11 @@ async function route(req: Request): Promise<Response> {
|
||||
if (url.pathname === "/logs" && method === "GET") return jsonResponse({ ok: true, logs: recentLogs.slice(-200) });
|
||||
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")) {
|
||||
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/entries" && method === "GET") return jsonResponse({ ok: true, entries: await listDiaryEntries(url) });
|
||||
@@ -874,7 +1034,11 @@ async function route(req: Request): Promise<Response> {
|
||||
const key = decodeURIComponent(diaryMatch[1] ?? "");
|
||||
if (!key) throw new HttpError(400, "diary entry id or date is required");
|
||||
if (method === "GET") return jsonResponse({ ok: true, entry: await getDiaryEntry(key) });
|
||||
throw new HttpError(405, "diary entry route supports GET", { method });
|
||||
if (method === "PUT") {
|
||||
const result = await upsertDiaryEntryByKey(key, await readJsonBody(req));
|
||||
return jsonResponse(result, result.action === "created" ? 201 : 200);
|
||||
}
|
||||
throw new HttpError(405, "diary entry route supports GET and PUT", { method });
|
||||
}
|
||||
const recordMatch = url.pathname.match(/^\/api\/records\/([^/]+)$/u);
|
||||
if (recordMatch !== null) {
|
||||
|
||||
Reference in New Issue
Block a user