fix: tighten decision center query paths
This commit is contained in:
@@ -438,6 +438,14 @@ function diaryQuery(filters: AnyRecord): string {
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function diaryEntryLookupPath(entry: any): string {
|
||||
const key = entry?.id || entry?.date;
|
||||
const params = new URLSearchParams();
|
||||
if (entry?.sourceFile) params.set("sourceFile", String(entry.sourceFile));
|
||||
const query = params.toString();
|
||||
return `/api/diary/entries/${encodeURIComponent(key)}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
function DiaryEntryCard({ entry, selected, onSelect, onRaw }: AnyRecord) {
|
||||
return h("article", { className: `diary-entry-card ${selected ? "selected" : ""}`, "data-testid": `diary-entry-${stableTestId(entry.date || entry.id)}` },
|
||||
h("button", { type: "button", className: "diary-entry-main", onClick: () => onSelect(entry) },
|
||||
@@ -702,7 +710,7 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
async function selectDiaryEntry(entry: any): Promise<void> {
|
||||
setDiaryState((prev: any) => ({ ...prev, selected: entry }));
|
||||
try {
|
||||
const response = await requestJson(decisionApi(apiBaseUrl, `/api/diary/entries/${encodeURIComponent(entry.date || entry.id)}`));
|
||||
const response = await requestJson(decisionApi(apiBaseUrl, diaryEntryLookupPath(entry)));
|
||||
const selected = response.entry || entry;
|
||||
setDiaryState((prev: any) => ({ ...prev, selected }));
|
||||
setDiaryForm(diaryFormFromEntry(selected));
|
||||
@@ -740,10 +748,18 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
}));
|
||||
}
|
||||
|
||||
function editRecord(record: any): void {
|
||||
async function editRecord(record: any): Promise<void> {
|
||||
setRecordForm(recordFormFromRecord(record));
|
||||
setRecordSaveState({ saving: false, error: "", message: `正在编辑 ${record?.id || ""}` });
|
||||
setActiveView("records");
|
||||
if (!record?.id || record?.body) return;
|
||||
try {
|
||||
const response = await requestJson(decisionApi(apiBaseUrl, `/api/records/${encodeURIComponent(record.id)}`));
|
||||
const fullRecord = response.record || record;
|
||||
setRecordForm(recordFormFromRecord(fullRecord));
|
||||
} catch (err) {
|
||||
setRecordSaveState({ saving: false, error: errorMessage(err, "记录正文加载失败"), message: "" });
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRecord(event: any): Promise<void> {
|
||||
|
||||
@@ -281,7 +281,8 @@ function summaryFromBody(body: string): string {
|
||||
.slice(0, 280);
|
||||
}
|
||||
|
||||
function recordFromRow(row: DecisionRecordRow): DecisionRecord {
|
||||
function recordFromRow(row: DecisionRecordRow, options: { includeBody?: boolean } = {}): DecisionRecord {
|
||||
const includeBody = options.includeBody !== false;
|
||||
const body = row.body || "";
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -290,7 +291,7 @@ function recordFromRow(row: DecisionRecordRow): DecisionRecord {
|
||||
status: row.status,
|
||||
title: row.title,
|
||||
summary: summaryFromBody(body),
|
||||
body,
|
||||
body: includeBody ? body : "",
|
||||
priority: row.level,
|
||||
linkedGoalId: row.linked_goal_id,
|
||||
tags: Array.isArray(row.tags) ? row.tags.map(String) : [],
|
||||
@@ -606,6 +607,7 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
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 includeBody = url.searchParams.get("includeBody") === "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 });
|
||||
@@ -616,7 +618,23 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
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 *
|
||||
SELECT
|
||||
id,
|
||||
type,
|
||||
level,
|
||||
status,
|
||||
title,
|
||||
CASE WHEN ${includeBody}::boolean THEN body ELSE left(body, 4000) END AS body,
|
||||
linked_goal_id,
|
||||
tags,
|
||||
evidence_links,
|
||||
source,
|
||||
source_session,
|
||||
issue_id,
|
||||
task_id,
|
||||
commit_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM decision_center_records
|
||||
WHERE (${type || null}::text IS NULL OR type = ${type || null})
|
||||
AND (${requirementOnly}::boolean IS FALSE OR type IN ('decision', 'goal', 'external_goal', 'internal_goal', 'blocker', 'debt', 'experiment'))
|
||||
@@ -642,7 +660,7 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
updated_at DESC
|
||||
LIMIT ${limit}
|
||||
`, { retryRead: true });
|
||||
return rows.map(recordFromRow);
|
||||
return rows.map((row) => recordFromRow(row, { includeBody }));
|
||||
}
|
||||
|
||||
async function getRequirementRecord(id: string): Promise<DecisionRecord> {
|
||||
@@ -851,6 +869,11 @@ function validateDiarySourceFile(value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function sourceFileFilterFromUrl(url: URL): string {
|
||||
const sourceFile = asString(url.searchParams.get("sourceFile") ?? url.searchParams.get("sourcePath") ?? url.searchParams.get("source"));
|
||||
return sourceFile ? validateDiarySourceFile(sourceFile) : "";
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -952,14 +975,26 @@ async function listDiaryEntries(url: URL): Promise<DiaryEntry[]> {
|
||||
const month = asString(url.searchParams.get("month"));
|
||||
const from = asString(url.searchParams.get("from"));
|
||||
const to = asString(url.searchParams.get("to"));
|
||||
const sourceFile = asString(url.searchParams.get("sourceFile"));
|
||||
const sourceFile = sourceFileFilterFromUrl(url);
|
||||
const includeBody = url.searchParams.get("includeBody") === "true";
|
||||
const limit = Math.max(1, Math.min(1000, Number(url.searchParams.get("limit") || 240) || 240));
|
||||
if (month) validMonthFilter(month);
|
||||
if (from) validDateFilter(from, "from");
|
||||
if (to) validDateFilter(to, "to");
|
||||
const rows = await withDatabaseRecovery("list_diary_entries", () => sql<DiaryEntryRow[]>`
|
||||
SELECT *
|
||||
SELECT
|
||||
id,
|
||||
entry_date,
|
||||
month,
|
||||
title,
|
||||
CASE WHEN ${includeBody}::boolean THEN body ELSE left(body, 4000) END AS body,
|
||||
source_file,
|
||||
markdown_path,
|
||||
tags,
|
||||
content_hash,
|
||||
created_at,
|
||||
updated_at,
|
||||
imported_at
|
||||
FROM decision_center_diary_entries
|
||||
WHERE (${month || null}::text IS NULL OR month = ${month || null})
|
||||
AND (${from || null}::date IS NULL OR entry_date >= ${from || null}::date)
|
||||
@@ -987,17 +1022,21 @@ async function listDiaryMonths(): Promise<JsonRecord[]> {
|
||||
}));
|
||||
}
|
||||
|
||||
async function getDiaryEntry(key: string): Promise<DiaryEntry> {
|
||||
async function getDiaryEntry(key: string, options: { sourceFile?: string } = {}): Promise<DiaryEntry> {
|
||||
const date = /^\d{4}-\d{2}-\d{2}$/u.test(key) ? validDateFilter(key, "date") : "";
|
||||
const sourceFile = options.sourceFile ? validateDiarySourceFile(options.sourceFile) : "";
|
||||
const rows = await withDatabaseRecovery("get_diary_entry", () => sql<DiaryEntryRow[]>`
|
||||
SELECT *
|
||||
FROM decision_center_diary_entries
|
||||
WHERE (${date || null}::date IS NOT NULL AND entry_date = ${date || null}::date)
|
||||
OR (${date || null}::date IS NULL AND id = ${key})
|
||||
WHERE (
|
||||
(${date || null}::date IS NOT NULL AND entry_date = ${date || null}::date)
|
||||
OR (${date || null}::date IS NULL AND id = ${key})
|
||||
)
|
||||
AND (${sourceFile || null}::text IS NULL OR source_file = ${sourceFile || null})
|
||||
ORDER BY imported_at DESC
|
||||
LIMIT 1
|
||||
`, { retryRead: true });
|
||||
if (rows.length === 0) throw new HttpError(404, "diary entry not found", { key });
|
||||
if (rows.length === 0) throw new HttpError(404, "diary entry not found", { key, ...(sourceFile ? { sourceFile } : {}) });
|
||||
return diaryEntryFromRow(rows[0]!);
|
||||
}
|
||||
|
||||
@@ -1012,8 +1051,11 @@ async function upsertDiaryEntryByKey(key: string, input: Record<string, unknown>
|
||||
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})
|
||||
WHERE (
|
||||
(${keyDate || null}::date IS NOT NULL AND entry_date = ${keyDate || null}::date)
|
||||
OR (${keyDate || null}::date IS NULL AND id = ${key})
|
||||
)
|
||||
AND (${sourceFile || null}::text IS NULL OR source_file = ${sourceFile || null})
|
||||
ORDER BY imported_at DESC
|
||||
LIMIT 1
|
||||
`, { retryRead: true });
|
||||
@@ -1147,7 +1189,7 @@ async function route(req: Request): Promise<Response> {
|
||||
if (diaryMatch !== null) {
|
||||
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) });
|
||||
if (method === "GET") return jsonResponse({ ok: true, entry: await getDiaryEntry(key, { sourceFile: sourceFileFilterFromUrl(url) }) });
|
||||
if (method === "PUT") {
|
||||
const result = await upsertDiaryEntryByKey(key, await readJsonBody(req));
|
||||
return jsonResponse(result, result.action === "created" ? 201 : 200);
|
||||
|
||||
Reference in New Issue
Block a user