feat: add decision center document contract
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
export type DecisionDocumentType = "DCSN" | "GOAL" | "PLAN" | "RPRT" | "ACTN" | "ISSU" | "RETR" | "RQST" | "RESP" | "MINS";
|
||||
export type DecisionDocumentPriority = "P0" | "P1" | "P2" | "P3";
|
||||
|
||||
export interface DecisionDocumentNumber {
|
||||
docNo: string;
|
||||
docType: DecisionDocumentType;
|
||||
docPriority: DecisionDocumentPriority;
|
||||
docYear: number;
|
||||
docSeq: number;
|
||||
}
|
||||
|
||||
export interface LegacyDocumentSource {
|
||||
id?: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
tags?: unknown;
|
||||
}
|
||||
|
||||
export class DocumentContractError extends Error {
|
||||
readonly detail: Record<string, string | number | boolean | null | string[]>;
|
||||
|
||||
constructor(message: string, detail: Record<string, string | number | boolean | null | string[]> = {}) {
|
||||
super(message);
|
||||
this.name = "DocumentContractError";
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
export const decisionDocumentTypes: readonly DecisionDocumentType[] = ["DCSN", "GOAL", "PLAN", "RPRT", "ACTN", "ISSU", "RETR", "RQST", "RESP", "MINS"];
|
||||
export const decisionDocumentPriorities: readonly DecisionDocumentPriority[] = ["P0", "P1", "P2", "P3"];
|
||||
|
||||
export const legacyDocumentNumberMap: Readonly<Record<string, string>> = {
|
||||
dc_decision_thesis_unidesk_integration_rule: "DC-DCSN-P0-2026-001",
|
||||
dc_goal_big_paper_submission: "DC-GOAL-P0-2026-001",
|
||||
dc_goal_small_paper_submission_gate: "DC-GOAL-P0-2026-002",
|
||||
};
|
||||
|
||||
const documentTypeValues = new Set<string>(decisionDocumentTypes);
|
||||
const documentPriorityValues = new Set<string>(decisionDocumentPriorities);
|
||||
const docNoSearchPattern = /\bDC-(DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS)-(P[0-3])-(\d{4})-(\d{1,9})\b/iu;
|
||||
|
||||
function textValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value).trim();
|
||||
return "";
|
||||
}
|
||||
|
||||
function legacyKey(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function buildDocumentNumber(docType: DecisionDocumentType, docPriority: DecisionDocumentPriority, docYear: number, docSeq: number): string {
|
||||
return `DC-${docType}-${docPriority}-${docYear}-${String(docSeq).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
export function parseDocumentNumber(value: unknown, field = "docNo"): DecisionDocumentNumber {
|
||||
const raw = textValue(value).toUpperCase();
|
||||
const match = raw.match(/^DC-(DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS)-(P[0-3])-(\d{4})-(\d{1,9})$/u);
|
||||
if (match === null) {
|
||||
throw new DocumentContractError(`${field} must match DC-<TYPE>-<PRIORITY>-<YEAR>-<SEQ>`, {
|
||||
field,
|
||||
value: textValue(value),
|
||||
allowedTypes: [...decisionDocumentTypes],
|
||||
allowedPriorities: [...decisionDocumentPriorities],
|
||||
example: "DC-GOAL-P0-2026-001",
|
||||
});
|
||||
}
|
||||
const docSeq = Number(match[4]);
|
||||
const docYear = Number(match[3]);
|
||||
if (!Number.isInteger(docSeq) || docSeq <= 0) {
|
||||
throw new DocumentContractError(`${field} sequence must be a positive integer`, { field, value: textValue(value) });
|
||||
}
|
||||
return {
|
||||
docNo: buildDocumentNumber(match[1] as DecisionDocumentType, match[2] as DecisionDocumentPriority, docYear, docSeq),
|
||||
docType: match[1] as DecisionDocumentType,
|
||||
docPriority: match[2] as DecisionDocumentPriority,
|
||||
docYear,
|
||||
docSeq,
|
||||
};
|
||||
}
|
||||
|
||||
export function tryParseDocumentNumber(value: unknown): DecisionDocumentNumber | null {
|
||||
try {
|
||||
return parseDocumentNumber(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDocumentType(value: unknown, field = "docType"): DecisionDocumentType {
|
||||
const raw = textValue(value).toUpperCase();
|
||||
if (!documentTypeValues.has(raw)) {
|
||||
throw new DocumentContractError(`${field} must be one of: ${decisionDocumentTypes.join(", ")}`, {
|
||||
field,
|
||||
value: textValue(value),
|
||||
allowed: [...decisionDocumentTypes],
|
||||
});
|
||||
}
|
||||
return raw as DecisionDocumentType;
|
||||
}
|
||||
|
||||
export function parseOptionalDocumentType(value: unknown, field = "docType"): DecisionDocumentType | "" {
|
||||
const raw = textValue(value);
|
||||
return raw ? parseDocumentType(raw, field) : "";
|
||||
}
|
||||
|
||||
export function parseDocumentPriority(value: unknown, field = "docPriority"): DecisionDocumentPriority {
|
||||
const raw = textValue(value).toUpperCase();
|
||||
if (!documentPriorityValues.has(raw)) {
|
||||
throw new DocumentContractError(`${field} must be one of: ${decisionDocumentPriorities.join(", ")}`, {
|
||||
field,
|
||||
value: textValue(value),
|
||||
allowed: [...decisionDocumentPriorities],
|
||||
});
|
||||
}
|
||||
return raw as DecisionDocumentPriority;
|
||||
}
|
||||
|
||||
export function parseOptionalDocumentPriority(value: unknown, field = "docPriority"): DecisionDocumentPriority | "" {
|
||||
const raw = textValue(value);
|
||||
return raw ? parseDocumentPriority(raw, field) : "";
|
||||
}
|
||||
|
||||
export function parseDocumentYear(value: unknown, field = "docYear"): number {
|
||||
const raw = textValue(value);
|
||||
const year = Number(raw);
|
||||
if (!/^\d{4}$/u.test(raw) || !Number.isInteger(year) || year < 1970 || year > 2100) {
|
||||
throw new DocumentContractError(`${field} must be a four-digit year between 1970 and 2100`, { field, value: raw });
|
||||
}
|
||||
return year;
|
||||
}
|
||||
|
||||
export function parseDocumentSequence(value: unknown, field = "docSeq"): number {
|
||||
const raw = textValue(value);
|
||||
const seq = Number(raw);
|
||||
if (!/^\d+$/u.test(raw) || !Number.isInteger(seq) || seq <= 0 || seq > 999_999_999) {
|
||||
throw new DocumentContractError(`${field} must be a positive integer`, { field, value: raw });
|
||||
}
|
||||
return seq;
|
||||
}
|
||||
|
||||
export function parseIssuedAt(value: unknown, field = "issuedAt"): string {
|
||||
const raw = textValue(value);
|
||||
if (!raw) return "";
|
||||
const date = /^\d{4}-\d{2}-\d{2}$/u.test(raw) ? new Date(`${raw}T00:00:00.000Z`) : new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new DocumentContractError(`${field} must be an ISO timestamp or YYYY-MM-DD date`, { field, value: raw });
|
||||
}
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
export function normalizeDocumentNumberList(value: unknown, field: string): string[] {
|
||||
if (value === null || value === undefined || value === "") return [];
|
||||
const rawItems = typeof value === "string"
|
||||
? value.split(",")
|
||||
: Array.isArray(value)
|
||||
? value
|
||||
: null;
|
||||
if (rawItems === null) throw new DocumentContractError(`${field} must be a document number or array of document numbers`, { field });
|
||||
const normalized = rawItems
|
||||
.map((item) => textValue(item))
|
||||
.filter(Boolean)
|
||||
.map((item) => parseDocumentNumber(item, field).docNo);
|
||||
return [...new Set(normalized)].slice(0, 100);
|
||||
}
|
||||
|
||||
function docNoFromText(text: string, options: { prefixOnly?: boolean } = {}): DecisionDocumentNumber | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
const docNoTagged = trimmed.match(/^doc-no\s*:\s*(DC-[A-Z0-9-]+)/iu);
|
||||
if (docNoTagged !== null) return tryParseDocumentNumber(docNoTagged[1] ?? "");
|
||||
const prefix = trimmed.match(/^\[?\s*(DC-[A-Z0-9-]+)\s*\]?/iu);
|
||||
if (prefix !== null) return tryParseDocumentNumber(prefix[1] ?? "");
|
||||
if (options.prefixOnly === true) return null;
|
||||
const match = trimmed.match(docNoSearchPattern);
|
||||
return match === null ? null : tryParseDocumentNumber(match[0]);
|
||||
}
|
||||
|
||||
function mappedLegacyDocNo(value: string): DecisionDocumentNumber | null {
|
||||
const mapped = legacyDocumentNumberMap[legacyKey(value)];
|
||||
return mapped === undefined ? null : parseDocumentNumber(mapped);
|
||||
}
|
||||
|
||||
function firstParagraph(body: string): string {
|
||||
return body
|
||||
.replace(/\r\n?/gu, "\n")
|
||||
.split(/\n\s*\n/gu)
|
||||
.map((part) => part.trim())
|
||||
.find(Boolean) ?? "";
|
||||
}
|
||||
|
||||
export function extractDocumentNumberFromLegacy(source: LegacyDocumentSource): DecisionDocumentNumber | null {
|
||||
const id = textValue(source.id);
|
||||
const idMapped = mappedLegacyDocNo(id);
|
||||
if (idMapped !== null) return idMapped;
|
||||
|
||||
const tags = Array.isArray(source.tags) ? source.tags.map((tag) => textValue(tag)).filter(Boolean) : [];
|
||||
for (const tag of tags) {
|
||||
const mapped = mappedLegacyDocNo(tag);
|
||||
if (mapped !== null) return mapped;
|
||||
const tagged = tag.match(/^doc-no\s*:\s*(.+)$/iu);
|
||||
if (tagged !== null) {
|
||||
const parsed = tryParseDocumentNumber(tagged[1] ?? "");
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
const parsed = docNoFromText(tag);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
|
||||
const title = textValue(source.title);
|
||||
const titleMapped = mappedLegacyDocNo(title.split(/\s+/u)[0] ?? "");
|
||||
if (titleMapped !== null) return titleMapped;
|
||||
const titleDocNo = docNoFromText(title, { prefixOnly: true });
|
||||
if (titleDocNo !== null) return titleDocNo;
|
||||
|
||||
const paragraph = firstParagraph(textValue(source.body));
|
||||
const paragraphMapped = Object.entries(legacyDocumentNumberMap).find(([legacy]) => paragraph.toLowerCase().includes(legacy));
|
||||
if (paragraphMapped !== undefined) return parseDocumentNumber(paragraphMapped[1]);
|
||||
return docNoFromText(paragraph);
|
||||
}
|
||||
@@ -1,6 +1,24 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import postgres from "postgres";
|
||||
import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../../shared/src/rotating-jsonl";
|
||||
import {
|
||||
DocumentContractError,
|
||||
buildDocumentNumber,
|
||||
decisionDocumentPriorities,
|
||||
decisionDocumentTypes,
|
||||
extractDocumentNumberFromLegacy,
|
||||
normalizeDocumentNumberList,
|
||||
parseDocumentNumber,
|
||||
parseDocumentPriority,
|
||||
parseDocumentSequence,
|
||||
parseDocumentType,
|
||||
parseDocumentYear,
|
||||
parseIssuedAt,
|
||||
tryParseDocumentNumber,
|
||||
type DecisionDocumentNumber,
|
||||
type DecisionDocumentPriority,
|
||||
type DecisionDocumentType,
|
||||
} from "./document-contract";
|
||||
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
||||
type JsonRecord = Record<string, JsonValue>;
|
||||
@@ -9,6 +27,7 @@ type DecisionRecordType = "meeting" | "decision" | "goal" | "external_goal" | "i
|
||||
type RequirementRecordType = Exclude<DecisionRecordType, "meeting">;
|
||||
type DecisionRecordLevel = "G0" | "G1" | "G2" | "G3" | "P0" | "P1" | "P2" | "P3" | "none";
|
||||
type DecisionRecordStatus = "active" | "blocked" | "parked" | "done";
|
||||
type SqlExecutor = postgres.Sql | postgres.TransactionSql;
|
||||
|
||||
interface RuntimeConfig {
|
||||
host: string;
|
||||
@@ -28,6 +47,16 @@ interface DecisionRecordRow {
|
||||
linked_goal_id: string | null;
|
||||
tags: JsonValue;
|
||||
evidence_links: JsonValue;
|
||||
doc_no: string;
|
||||
doc_type: string;
|
||||
doc_priority: string;
|
||||
doc_year: number | null;
|
||||
doc_seq: number | null;
|
||||
signer: string;
|
||||
issued_at: Date | string | null;
|
||||
effective_scope: string;
|
||||
supersedes: JsonValue;
|
||||
superseded_by: JsonValue;
|
||||
source: string;
|
||||
source_session: string;
|
||||
issue_id: string;
|
||||
@@ -49,6 +78,16 @@ interface DecisionRecord extends JsonRecord {
|
||||
linkedGoalId: string | null;
|
||||
tags: string[];
|
||||
evidenceLinks: string[];
|
||||
docNo: string;
|
||||
docType: string;
|
||||
docPriority: string;
|
||||
docYear: number | null;
|
||||
docSeq: number | null;
|
||||
signer: string;
|
||||
issuedAt: string;
|
||||
effectiveScope: string;
|
||||
supersedes: string[];
|
||||
supersededBy: string[];
|
||||
source: string;
|
||||
sourceSession: string;
|
||||
issueId: string;
|
||||
@@ -194,16 +233,50 @@ function jsonResponse(body: JsonValue, status = 200): Response {
|
||||
|
||||
function errorToJson(error: unknown): JsonRecord {
|
||||
if (error instanceof HttpError) return { name: error.name, message: error.message, status: error.status, detail: error.detail };
|
||||
if (error instanceof DocumentContractError) return { name: error.name, message: error.message, status: 400, detail: error.detail };
|
||||
if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack || "" };
|
||||
return { message: String(error) };
|
||||
}
|
||||
|
||||
function databaseErrorToHttp(error: unknown): HttpError | null {
|
||||
const record = typeof error === "object" && error !== null ? error as Record<string, unknown> : {};
|
||||
const code = typeof record.code === "string" ? record.code : "";
|
||||
const constraint = typeof record.constraint_name === "string"
|
||||
? record.constraint_name
|
||||
: typeof record.constraint === "string"
|
||||
? record.constraint
|
||||
: "";
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (code === "23505" && /doc_no|document/iu.test(`${constraint} ${message}`)) {
|
||||
return new HttpError(409, "document number already exists", {
|
||||
code: "doc_no_conflict",
|
||||
constraint,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function responseError(error: unknown): HttpError | DocumentContractError | unknown {
|
||||
return databaseErrorToHttp(error) ?? error;
|
||||
}
|
||||
|
||||
function errorResponse(error: unknown): Response {
|
||||
const status = error instanceof HttpError ? error.status : 500;
|
||||
const body = error instanceof HttpError
|
||||
? { ok: false, error: error.message, ...error.detail }
|
||||
: { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||
log(status >= 500 ? "error" : "warn", "request_failed", { status, error: errorToJson(error) });
|
||||
const normalized = responseError(error);
|
||||
const status = normalized instanceof HttpError ? normalized.status : normalized instanceof DocumentContractError ? 400 : 500;
|
||||
const detail = normalized instanceof HttpError || normalized instanceof DocumentContractError ? normalized.detail : {};
|
||||
const message = normalized instanceof Error ? normalized.message : String(normalized);
|
||||
const body = {
|
||||
ok: false,
|
||||
error: message,
|
||||
...detail,
|
||||
errorInfo: {
|
||||
name: normalized instanceof Error ? normalized.name : "Error",
|
||||
message,
|
||||
status,
|
||||
detail,
|
||||
},
|
||||
};
|
||||
log(status >= 500 ? "error" : "warn", "request_failed", { status, error: errorToJson(normalized) });
|
||||
return jsonResponse(body, status);
|
||||
}
|
||||
|
||||
@@ -271,6 +344,182 @@ function asStringArray(value: unknown, field: string): string[] {
|
||||
return [...new Set(items)];
|
||||
}
|
||||
|
||||
function jsonStringArray(value: JsonValue): string[] {
|
||||
return Array.isArray(value) ? value.map(String).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function hasAny(input: Record<string, unknown>, fields: string[]): boolean {
|
||||
return fields.some((field) => field in input);
|
||||
}
|
||||
|
||||
function currentUtcYear(): number {
|
||||
return new Date().getUTCFullYear();
|
||||
}
|
||||
|
||||
function priorityFromLevel(level: DecisionRecordLevel): DecisionDocumentPriority | "" {
|
||||
return level === "P0" || level === "P1" || level === "P2" || level === "P3" ? level : "";
|
||||
}
|
||||
|
||||
function documentTypeFromRecordType(type: DecisionRecordType): DecisionDocumentType {
|
||||
if (type === "decision") return "DCSN";
|
||||
if (type === "goal" || type === "external_goal" || type === "internal_goal") return "GOAL";
|
||||
if (type === "meeting") return "MINS";
|
||||
if (type === "blocker" || type === "debt") return "ISSU";
|
||||
return "RPRT";
|
||||
}
|
||||
|
||||
function existingDocumentNumber(record: DecisionRecord): DecisionDocumentNumber | null {
|
||||
return record.docNo ? tryParseDocumentNumber(record.docNo) : null;
|
||||
}
|
||||
|
||||
function inputDocumentNumber(input: Record<string, unknown>): DecisionDocumentNumber | null {
|
||||
const raw = asString(input.docNo ?? input.docNumber ?? input.documentNo ?? input.documentNumber);
|
||||
return raw ? parseDocumentNumber(raw, "docNo") : null;
|
||||
}
|
||||
|
||||
function assertDocumentComponentsMatch(input: Record<string, unknown>, doc: DecisionDocumentNumber): void {
|
||||
if ("docType" in input && parseDocumentType(input.docType) !== doc.docType) throw new HttpError(400, "docType does not match docNo", { docNo: doc.docNo, docType: asString(input.docType), expected: doc.docType });
|
||||
if ("docPriority" in input && parseDocumentPriority(input.docPriority) !== doc.docPriority) throw new HttpError(400, "docPriority does not match docNo", { docNo: doc.docNo, docPriority: asString(input.docPriority), expected: doc.docPriority });
|
||||
if ("docYear" in input && parseDocumentYear(input.docYear) !== doc.docYear) throw new HttpError(400, "docYear does not match docNo", { docNo: doc.docNo, docYear: asString(input.docYear), expected: doc.docYear });
|
||||
if ("docSeq" in input && parseDocumentSequence(input.docSeq) !== doc.docSeq) throw new HttpError(400, "docSeq does not match docNo", { docNo: doc.docNo, docSeq: asString(input.docSeq), expected: doc.docSeq });
|
||||
}
|
||||
|
||||
async function nextDocumentSequence(client: SqlExecutor, docType: DecisionDocumentType, docPriority: DecisionDocumentPriority, docYear: number): Promise<number> {
|
||||
await client`LOCK TABLE decision_center_records IN SHARE ROW EXCLUSIVE MODE`;
|
||||
const rows = await client<{ next_seq: number }[]>`
|
||||
SELECT COALESCE(max(doc_seq), 0)::int + 1 AS next_seq
|
||||
FROM decision_center_records
|
||||
WHERE doc_type = ${docType}
|
||||
AND doc_priority = ${docPriority}
|
||||
AND doc_year = ${docYear}
|
||||
`;
|
||||
return Number(rows[0]?.next_seq ?? 1);
|
||||
}
|
||||
|
||||
function documentInputPresent(input: Record<string, unknown>): boolean {
|
||||
return hasAny(input, ["docNo", "docNumber", "documentNo", "documentNumber", "docType", "docPriority", "docYear", "docSeq"]);
|
||||
}
|
||||
|
||||
async function documentNumberForCreate(
|
||||
client: SqlExecutor,
|
||||
input: Record<string, unknown>,
|
||||
draft: { id: string; type: DecisionRecordType; level: DecisionRecordLevel; title: string; body: string; tags: string[] },
|
||||
): Promise<DecisionDocumentNumber | null> {
|
||||
const explicitDocNo = inputDocumentNumber(input);
|
||||
if (explicitDocNo !== null) {
|
||||
assertDocumentComponentsMatch(input, explicitDocNo);
|
||||
return explicitDocNo;
|
||||
}
|
||||
|
||||
const legacyDocNo = extractDocumentNumberFromLegacy(draft);
|
||||
if (legacyDocNo !== null && !documentInputPresent(input)) return legacyDocNo;
|
||||
|
||||
if (!documentInputPresent(input)) return null;
|
||||
|
||||
const docType = "docType" in input ? parseDocumentType(input.docType) : documentTypeFromRecordType(draft.type);
|
||||
const fallbackPriority = priorityFromLevel(draft.level);
|
||||
if (!("docPriority" in input) && !fallbackPriority) {
|
||||
throw new HttpError(400, "docPriority is required when assigning a document number without docNo", { allowed: [...decisionDocumentPriorities] });
|
||||
}
|
||||
const docPriority = "docPriority" in input ? parseDocumentPriority(input.docPriority) : fallbackPriority as DecisionDocumentPriority;
|
||||
const docYear = "docYear" in input ? parseDocumentYear(input.docYear) : currentUtcYear();
|
||||
const docSeq = "docSeq" in input ? parseDocumentSequence(input.docSeq) : await nextDocumentSequence(client, docType, docPriority, docYear);
|
||||
return parseDocumentNumber(buildDocumentNumber(docType, docPriority, docYear, docSeq));
|
||||
}
|
||||
|
||||
async function documentNumberForUpdate(
|
||||
client: SqlExecutor,
|
||||
input: Record<string, unknown>,
|
||||
existing: DecisionRecord,
|
||||
nextType: DecisionRecordType,
|
||||
nextLevel: DecisionRecordLevel,
|
||||
): Promise<DecisionDocumentNumber | null> {
|
||||
const explicitDocNo = inputDocumentNumber(input);
|
||||
if (explicitDocNo !== null) {
|
||||
assertDocumentComponentsMatch(input, explicitDocNo);
|
||||
return explicitDocNo;
|
||||
}
|
||||
|
||||
const existingDoc = existingDocumentNumber(existing);
|
||||
if (!documentInputPresent(input)) return existingDoc;
|
||||
|
||||
const docType = "docType" in input
|
||||
? parseDocumentType(input.docType)
|
||||
: existingDoc?.docType ?? documentTypeFromRecordType(nextType);
|
||||
const fallbackPriority = existingDoc?.docPriority ?? priorityFromLevel(nextLevel);
|
||||
if (!("docPriority" in input) && !fallbackPriority) {
|
||||
throw new HttpError(400, "docPriority is required when assigning a document number without docNo", { allowed: [...decisionDocumentPriorities] });
|
||||
}
|
||||
const docPriority = "docPriority" in input ? parseDocumentPriority(input.docPriority) : fallbackPriority as DecisionDocumentPriority;
|
||||
const docYear = "docYear" in input ? parseDocumentYear(input.docYear) : existingDoc?.docYear ?? currentUtcYear();
|
||||
const keepsExistingSeq = existingDoc !== null
|
||||
&& existingDoc.docType === docType
|
||||
&& existingDoc.docPriority === docPriority
|
||||
&& existingDoc.docYear === docYear;
|
||||
const docSeq = "docSeq" in input
|
||||
? parseDocumentSequence(input.docSeq)
|
||||
: keepsExistingSeq
|
||||
? existingDoc.docSeq
|
||||
: await nextDocumentSequence(client, docType, docPriority, docYear);
|
||||
return parseDocumentNumber(buildDocumentNumber(docType, docPriority, docYear, docSeq));
|
||||
}
|
||||
|
||||
function parseDocumentDateColumn(value: string): string | null {
|
||||
return value ? parseIssuedAt(value) : null;
|
||||
}
|
||||
|
||||
function documentColumnsForInsert(doc: DecisionDocumentNumber | null, input: Record<string, unknown>): {
|
||||
docNo: string;
|
||||
docType: string;
|
||||
docPriority: string;
|
||||
docYear: number | null;
|
||||
docSeq: number | null;
|
||||
signer: string;
|
||||
issuedAt: string | null;
|
||||
effectiveScope: string;
|
||||
supersedes: string[];
|
||||
supersededBy: string[];
|
||||
} {
|
||||
return {
|
||||
docNo: doc?.docNo ?? "",
|
||||
docType: doc?.docType ?? "",
|
||||
docPriority: doc?.docPriority ?? "",
|
||||
docYear: doc?.docYear ?? null,
|
||||
docSeq: doc?.docSeq ?? null,
|
||||
signer: asString(input.signer),
|
||||
issuedAt: parseDocumentDateColumn(asString(input.issuedAt)),
|
||||
effectiveScope: asString(input.effectiveScope),
|
||||
supersedes: normalizeDocumentNumberList(input.supersedes, "supersedes"),
|
||||
supersededBy: normalizeDocumentNumberList(input.supersededBy, "supersededBy"),
|
||||
};
|
||||
}
|
||||
|
||||
function documentColumnsForUpdate(doc: DecisionDocumentNumber | null, input: Record<string, unknown>, existing: DecisionRecord): {
|
||||
docNo: string;
|
||||
docType: string;
|
||||
docPriority: string;
|
||||
docYear: number | null;
|
||||
docSeq: number | null;
|
||||
signer: string;
|
||||
issuedAt: string | null;
|
||||
effectiveScope: string;
|
||||
supersedes: string[];
|
||||
supersededBy: string[];
|
||||
} {
|
||||
return {
|
||||
docNo: doc?.docNo ?? "",
|
||||
docType: doc?.docType ?? "",
|
||||
docPriority: doc?.docPriority ?? "",
|
||||
docYear: doc?.docYear ?? null,
|
||||
docSeq: doc?.docSeq ?? null,
|
||||
signer: "signer" in input ? asString(input.signer) : existing.signer,
|
||||
issuedAt: "issuedAt" in input ? parseDocumentDateColumn(asString(input.issuedAt)) : existing.issuedAt || null,
|
||||
effectiveScope: "effectiveScope" in input ? asString(input.effectiveScope) : existing.effectiveScope,
|
||||
supersedes: "supersedes" in input ? normalizeDocumentNumberList(input.supersedes, "supersedes") : existing.supersedes,
|
||||
supersededBy: "supersededBy" in input ? normalizeDocumentNumberList(input.supersededBy, "supersededBy") : existing.supersededBy,
|
||||
};
|
||||
}
|
||||
|
||||
function summaryFromBody(body: string): string {
|
||||
return body
|
||||
.split("\n")
|
||||
@@ -294,8 +543,18 @@ function recordFromRow(row: DecisionRecordRow, options: { includeBody?: boolean
|
||||
body: includeBody ? 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) : [],
|
||||
tags: jsonStringArray(row.tags),
|
||||
evidenceLinks: jsonStringArray(row.evidence_links),
|
||||
docNo: row.doc_no || "",
|
||||
docType: row.doc_type || "",
|
||||
docPriority: row.doc_priority || "",
|
||||
docYear: row.doc_year === null ? null : Number(row.doc_year),
|
||||
docSeq: row.doc_seq === null ? null : Number(row.doc_seq),
|
||||
signer: row.signer || "",
|
||||
issuedAt: iso(row.issued_at),
|
||||
effectiveScope: row.effective_scope || "",
|
||||
supersedes: jsonStringArray(row.supersedes),
|
||||
supersededBy: jsonStringArray(row.superseded_by),
|
||||
source: row.source,
|
||||
sourceSession: row.source_session,
|
||||
issueId: row.issue_id,
|
||||
@@ -377,6 +636,16 @@ async function ensureSchema(): Promise<void> {
|
||||
linked_goal_id TEXT,
|
||||
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
evidence_links JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
doc_no TEXT NOT NULL DEFAULT '',
|
||||
doc_type TEXT NOT NULL DEFAULT '',
|
||||
doc_priority TEXT NOT NULL DEFAULT '',
|
||||
doc_year INTEGER,
|
||||
doc_seq INTEGER,
|
||||
signer TEXT NOT NULL DEFAULT '',
|
||||
issued_at TIMESTAMPTZ,
|
||||
effective_scope TEXT NOT NULL DEFAULT '',
|
||||
supersedes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
superseded_by JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
source_session TEXT NOT NULL DEFAULT '',
|
||||
issue_id TEXT NOT NULL DEFAULT '',
|
||||
@@ -391,16 +660,54 @@ async function ensureSchema(): Promise<void> {
|
||||
`;
|
||||
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 ADD COLUMN IF NOT EXISTS doc_no TEXT NOT NULL DEFAULT ''`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS doc_type TEXT NOT NULL DEFAULT ''`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS doc_priority TEXT NOT NULL DEFAULT ''`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS doc_year INTEGER`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS doc_seq INTEGER`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS signer TEXT NOT NULL DEFAULT ''`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS issued_at TIMESTAMPTZ`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS effective_scope TEXT NOT NULL DEFAULT ''`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS supersedes JSONB NOT NULL DEFAULT '[]'::jsonb`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS superseded_by JSONB NOT NULL DEFAULT '[]'::jsonb`;
|
||||
await sql`ALTER TABLE decision_center_records DROP CONSTRAINT IF EXISTS decision_center_records_type_check`;
|
||||
await sql`ALTER TABLE decision_center_records DROP CONSTRAINT IF EXISTS decision_center_records_doc_shape_check`;
|
||||
await clearInvalidDocumentNumbers();
|
||||
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`
|
||||
ALTER TABLE decision_center_records
|
||||
ADD CONSTRAINT decision_center_records_doc_shape_check
|
||||
CHECK (
|
||||
(
|
||||
doc_no = ''
|
||||
AND doc_type = ''
|
||||
AND doc_priority = ''
|
||||
AND doc_year IS NULL
|
||||
AND doc_seq IS NULL
|
||||
)
|
||||
OR (
|
||||
doc_no ~ '^DC-(DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS)-P[0-3]-[0-9]{4}-[0-9]{3,9}$'
|
||||
AND doc_type IN ('DCSN', 'GOAL', 'PLAN', 'RPRT', 'ACTN', 'ISSU', 'RETR', 'RQST', 'RESP', 'MINS')
|
||||
AND doc_priority IN ('P0', 'P1', 'P2', 'P3')
|
||||
AND doc_year BETWEEN 1970 AND 2100
|
||||
AND doc_seq > 0
|
||||
AND doc_no = 'DC-' || doc_type || '-' || doc_priority || '-' || doc_year::text || '-' || lpad(doc_seq::text, 3, '0')
|
||||
)
|
||||
)
|
||||
`;
|
||||
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 clearDuplicateDocumentNumbers();
|
||||
await backfillLegacyDocumentNumbers();
|
||||
await clearDuplicateDocumentNumbers();
|
||||
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_decision_center_records_doc_no_unique ON decision_center_records(doc_no) WHERE doc_no <> ''`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_records_doc_filter ON decision_center_records(doc_type, doc_priority, doc_year, doc_seq) WHERE doc_no <> ''`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS decision_center_diary_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -506,33 +813,167 @@ function health(): JsonRecord {
|
||||
};
|
||||
}
|
||||
|
||||
async function clearInvalidDocumentNumbers(): Promise<void> {
|
||||
const rows = await sql<{ id: string; doc_no: string }[]>`
|
||||
UPDATE decision_center_records
|
||||
SET
|
||||
doc_no = '',
|
||||
doc_type = '',
|
||||
doc_priority = '',
|
||||
doc_year = NULL,
|
||||
doc_seq = NULL,
|
||||
updated_at = updated_at
|
||||
WHERE NOT (
|
||||
(
|
||||
doc_no = ''
|
||||
AND doc_type = ''
|
||||
AND doc_priority = ''
|
||||
AND doc_year IS NULL
|
||||
AND doc_seq IS NULL
|
||||
)
|
||||
OR (
|
||||
doc_no ~ '^DC-(DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS)-P[0-3]-[0-9]{4}-[0-9]{3,9}$'
|
||||
AND doc_type IN ('DCSN', 'GOAL', 'PLAN', 'RPRT', 'ACTN', 'ISSU', 'RETR', 'RQST', 'RESP', 'MINS')
|
||||
AND doc_priority IN ('P0', 'P1', 'P2', 'P3')
|
||||
AND doc_year BETWEEN 1970 AND 2100
|
||||
AND doc_seq > 0
|
||||
AND doc_no = 'DC-' || doc_type || '-' || doc_priority || '-' || doc_year::text || '-' || lpad(doc_seq::text, 3, '0')
|
||||
)
|
||||
)
|
||||
RETURNING id, doc_no
|
||||
`;
|
||||
if (rows.length > 0) {
|
||||
log("warn", "invalid_document_numbers_cleared_for_schema_check", {
|
||||
clearedCount: rows.length,
|
||||
examples: rows.slice(0, 20).map((row) => ({ id: row.id, docNo: row.doc_no })),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function clearDuplicateDocumentNumbers(): Promise<void> {
|
||||
const rows = await sql<{ id: string; doc_no: string }[]>`
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
id,
|
||||
doc_no,
|
||||
row_number() OVER (PARTITION BY doc_no ORDER BY updated_at DESC, created_at DESC, id ASC) AS rank
|
||||
FROM decision_center_records
|
||||
WHERE doc_no <> ''
|
||||
)
|
||||
UPDATE decision_center_records record
|
||||
SET
|
||||
doc_no = '',
|
||||
doc_type = '',
|
||||
doc_priority = '',
|
||||
doc_year = NULL,
|
||||
doc_seq = NULL,
|
||||
updated_at = updated_at
|
||||
FROM ranked
|
||||
WHERE record.id = ranked.id
|
||||
AND ranked.rank > 1
|
||||
RETURNING record.id, ranked.doc_no
|
||||
`;
|
||||
if (rows.length > 0) {
|
||||
log("warn", "duplicate_document_numbers_cleared_for_unique_index", {
|
||||
clearedCount: rows.length,
|
||||
docNos: [...new Set(rows.map((row) => row.doc_no))].slice(0, 20),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function backfillLegacyDocumentNumbers(): Promise<void> {
|
||||
const rows = await sql<DecisionRecordRow[]>`
|
||||
SELECT *
|
||||
FROM decision_center_records
|
||||
WHERE doc_no = ''
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 5000
|
||||
`;
|
||||
let updatedCount = 0;
|
||||
let skippedCount = 0;
|
||||
for (const row of rows) {
|
||||
const doc = extractDocumentNumberFromLegacy({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
tags: row.tags,
|
||||
});
|
||||
if (doc === null) continue;
|
||||
const updated = await sql<{ id: string }[]>`
|
||||
UPDATE decision_center_records
|
||||
SET
|
||||
doc_no = ${doc.docNo},
|
||||
doc_type = ${doc.docType},
|
||||
doc_priority = ${doc.docPriority},
|
||||
doc_year = ${doc.docYear},
|
||||
doc_seq = ${doc.docSeq},
|
||||
updated_at = updated_at
|
||||
WHERE id = ${row.id}
|
||||
AND doc_no = ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM decision_center_records existing
|
||||
WHERE existing.doc_no = ${doc.docNo}
|
||||
AND existing.id <> ${row.id}
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
if (updated.length > 0) updatedCount += 1;
|
||||
else skippedCount += 1;
|
||||
}
|
||||
if (updatedCount > 0 || skippedCount > 0) {
|
||||
log("info", "legacy_document_numbers_backfilled", { updatedCount, skippedCount });
|
||||
}
|
||||
}
|
||||
|
||||
async function createRecord(input: Record<string, unknown>): Promise<DecisionRecord> {
|
||||
const body = asText(input.body ?? input.summary ?? input.markdown);
|
||||
const title = asString(input.title) || titleFromMarkdown(body, "Untitled decision record");
|
||||
validateRecordDraft(title, body);
|
||||
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, source_session, issue_id, task_id, commit_id
|
||||
) VALUES (
|
||||
${id},
|
||||
${parseRecordType(input.type, "meeting")},
|
||||
${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)}
|
||||
)
|
||||
RETURNING *
|
||||
`);
|
||||
log("info", "record_created", { id: rows[0]?.id ?? id, type: rows[0]?.type ?? "", level: rows[0]?.level ?? "" });
|
||||
const type = parseRecordType(input.type, "meeting");
|
||||
const level = parsePriority(input, "none");
|
||||
const status = parseStatus(input.status, "active");
|
||||
const tags = asStringArray(input.tags, "tags");
|
||||
const evidenceLinks = asStringArray(input.evidenceLinks ?? input.evidence, "evidenceLinks");
|
||||
const rows = await withDatabaseRecovery("create_record", () => sql.begin(async (client) => {
|
||||
const doc = await documentNumberForCreate(client, input, { id, type, level, title, body, tags });
|
||||
const documentColumns = documentColumnsForInsert(doc, input);
|
||||
return await client<DecisionRecordRow[]>`
|
||||
INSERT INTO decision_center_records (
|
||||
id, type, level, status, title, body, linked_goal_id, tags, evidence_links,
|
||||
doc_no, doc_type, doc_priority, doc_year, doc_seq, signer, issued_at, effective_scope, supersedes, superseded_by,
|
||||
source, source_session, issue_id, task_id, commit_id
|
||||
) VALUES (
|
||||
${id},
|
||||
${type},
|
||||
${level},
|
||||
${status},
|
||||
${title},
|
||||
${body},
|
||||
${asString(input.linkedGoalId) || null},
|
||||
${client.json(tags)},
|
||||
${client.json(evidenceLinks)},
|
||||
${documentColumns.docNo},
|
||||
${documentColumns.docType},
|
||||
${documentColumns.docPriority},
|
||||
${documentColumns.docYear},
|
||||
${documentColumns.docSeq},
|
||||
${documentColumns.signer},
|
||||
${documentColumns.issuedAt},
|
||||
${documentColumns.effectiveScope},
|
||||
${client.json(documentColumns.supersedes)},
|
||||
${client.json(documentColumns.supersededBy)},
|
||||
${asString(input.source)},
|
||||
${asString(input.sourceSession)},
|
||||
${asString(input.issueId ?? input.issue ?? input.linkedIssue ?? input.linkedTask ?? input.taskId)},
|
||||
${asString(input.taskId)},
|
||||
${asString(input.commitId)}
|
||||
)
|
||||
RETURNING *
|
||||
`;
|
||||
}));
|
||||
log("info", "record_created", { id: rows[0]?.id ?? id, type: rows[0]?.type ?? "", level: rows[0]?.level ?? "", docNo: rows[0]?.doc_no ?? "" });
|
||||
return recordFromRow(rows[0]!);
|
||||
}
|
||||
|
||||
@@ -540,39 +981,57 @@ async function updateRecord(id: string, input: Record<string, unknown>): Promise
|
||||
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;
|
||||
const nextType = "type" in input ? parseRecordType(input.type, existing.type) : existing.type;
|
||||
const nextLevel = "level" in input || "priority" in input ? parsePriority(input, existing.level) : existing.level;
|
||||
const nextStatus = "status" in input ? parseStatus(input.status, existing.status) : existing.status;
|
||||
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 || "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()
|
||||
WHERE id = ${id}
|
||||
RETURNING *
|
||||
`);
|
||||
const rows = await withDatabaseRecovery("update_record", () => sql.begin(async (client) => {
|
||||
const doc = await documentNumberForUpdate(client, input, existing, nextType, nextLevel);
|
||||
const documentColumns = documentColumnsForUpdate(doc, input, existing);
|
||||
return await client<DecisionRecordRow[]>`
|
||||
UPDATE decision_center_records
|
||||
SET
|
||||
type = ${nextType},
|
||||
level = ${nextLevel},
|
||||
status = ${nextStatus},
|
||||
title = ${title},
|
||||
body = ${body},
|
||||
linked_goal_id = ${"linkedGoalId" in input ? asString(input.linkedGoalId) || null : existing.linkedGoalId},
|
||||
tags = ${"tags" in input ? client.json(asStringArray(input.tags, "tags")) : client.json(existing.tags)},
|
||||
evidence_links = ${"evidenceLinks" in input || "evidence" in input ? client.json(asStringArray(input.evidenceLinks ?? input.evidence, "evidenceLinks")) : client.json(existing.evidenceLinks)},
|
||||
doc_no = ${documentColumns.docNo},
|
||||
doc_type = ${documentColumns.docType},
|
||||
doc_priority = ${documentColumns.docPriority},
|
||||
doc_year = ${documentColumns.docYear},
|
||||
doc_seq = ${documentColumns.docSeq},
|
||||
signer = ${documentColumns.signer},
|
||||
issued_at = ${documentColumns.issuedAt},
|
||||
effective_scope = ${documentColumns.effectiveScope},
|
||||
supersedes = ${client.json(documentColumns.supersedes)},
|
||||
superseded_by = ${client.json(documentColumns.supersededBy)},
|
||||
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()
|
||||
WHERE id = ${existing.id}
|
||||
RETURNING *
|
||||
`;
|
||||
}));
|
||||
return recordFromRow(rows[0]!);
|
||||
}
|
||||
|
||||
async function upsertRequirementRecord(input: Record<string, unknown>): Promise<JsonRecord> {
|
||||
const id = asString(input.id);
|
||||
if (id) {
|
||||
const docNo = inputDocumentNumber(input)?.docNo ?? "";
|
||||
if (id || docNo) {
|
||||
try {
|
||||
const existing = await getRecord(id);
|
||||
const existing = await getRecordByIdOrDocNo(id, docNo);
|
||||
if (!requirementRecordTypes.has(existing.type as RequirementRecordType)) {
|
||||
throw new HttpError(409, "existing record is not a requirement-management record", { id, type: existing.type });
|
||||
throw new HttpError(409, "existing record is not a requirement-management record", { id: existing.id, docNo: existing.docNo, type: existing.type });
|
||||
}
|
||||
const record = await updateRecord(id, {
|
||||
const record = await updateRecord(existing.id, {
|
||||
...input,
|
||||
type: "type" in input ? parseRequirementRecordType(input.type, existing.type as RequirementRecordType) : existing.type,
|
||||
});
|
||||
@@ -591,13 +1050,41 @@ async function upsertRequirementRecord(input: Record<string, unknown>): Promise<
|
||||
return { ok: true, action: "created", record };
|
||||
}
|
||||
|
||||
async function getRecordByIdOrDocNo(id: string, docNo: string): Promise<DecisionRecord> {
|
||||
const rows = await withDatabaseRecovery("get_record_by_id_or_doc_no", () => sql<DecisionRecordRow[]>`
|
||||
SELECT *
|
||||
FROM decision_center_records
|
||||
WHERE (${id || null}::text IS NOT NULL AND id = ${id || null})
|
||||
OR (${docNo || null}::text IS NOT NULL AND doc_no = ${docNo || null})
|
||||
ORDER BY
|
||||
CASE WHEN id = ${id || ""} THEN 0 ELSE 1 END,
|
||||
updated_at DESC
|
||||
LIMIT 1
|
||||
`, { retryRead: true });
|
||||
if (rows.length === 0) throw new HttpError(404, "decision record not found", { id, docNo });
|
||||
return recordFromRow(rows[0]!);
|
||||
}
|
||||
|
||||
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 });
|
||||
const docNo = tryParseDocumentNumber(id)?.docNo ?? "";
|
||||
const rows = await withDatabaseRecovery("get_record", () => sql<DecisionRecordRow[]>`
|
||||
SELECT *
|
||||
FROM decision_center_records
|
||||
WHERE id = ${id}
|
||||
OR (${docNo || null}::text IS NOT NULL AND doc_no = ${docNo || null})
|
||||
ORDER BY CASE WHEN id = ${id} THEN 0 ELSE 1 END
|
||||
LIMIT 1
|
||||
`, { retryRead: true });
|
||||
if (rows.length === 0) throw new HttpError(404, "decision record not found", { id });
|
||||
return recordFromRow(rows[0]!);
|
||||
}
|
||||
|
||||
async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}): Promise<DecisionRecord[]> {
|
||||
const docNoRaw = asString(url.searchParams.get("docNo") ?? url.searchParams.get("doc-no") ?? url.searchParams.get("documentNo") ?? url.searchParams.get("documentNumber"));
|
||||
const docNo = docNoRaw ? parseDocumentNumber(docNoRaw, "docNo").docNo : "";
|
||||
const docType = asString(url.searchParams.get("docType") ?? url.searchParams.get("doc-type"));
|
||||
const docPriority = asString(url.searchParams.get("docPriority") ?? url.searchParams.get("doc-priority"));
|
||||
const docYearRaw = asString(url.searchParams.get("docYear") ?? url.searchParams.get("doc-year") ?? url.searchParams.get("year"));
|
||||
const type = asString(url.searchParams.get("type"));
|
||||
const status = asString(url.searchParams.get("status"));
|
||||
const level = asString(url.searchParams.get("level"));
|
||||
@@ -609,6 +1096,11 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
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 (docType && !decisionDocumentTypes.includes(docType.toUpperCase() as DecisionDocumentType)) throw new HttpError(400, "unsupported docType filter", { docType, allowed: [...decisionDocumentTypes] });
|
||||
if (docPriority && !decisionDocumentPriorities.includes(docPriority.toUpperCase() as DecisionDocumentPriority)) throw new HttpError(400, "unsupported docPriority filter", { docPriority, allowed: [...decisionDocumentPriorities] });
|
||||
const normalizedDocType = docType ? parseDocumentType(docType, "docType") : "";
|
||||
const normalizedDocPriority = docPriority ? parseDocumentPriority(docPriority, "docPriority") : "";
|
||||
const docYear = docYearRaw ? parseDocumentYear(docYearRaw, "docYear") : null;
|
||||
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 });
|
||||
@@ -628,6 +1120,16 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
linked_goal_id,
|
||||
tags,
|
||||
evidence_links,
|
||||
doc_no,
|
||||
doc_type,
|
||||
doc_priority,
|
||||
doc_year,
|
||||
doc_seq,
|
||||
signer,
|
||||
issued_at,
|
||||
effective_scope,
|
||||
supersedes,
|
||||
superseded_by,
|
||||
source,
|
||||
source_session,
|
||||
issue_id,
|
||||
@@ -636,7 +1138,11 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
created_at,
|
||||
updated_at
|
||||
FROM decision_center_records
|
||||
WHERE (${type || null}::text IS NULL OR type = ${type || null})
|
||||
WHERE (${docNo || null}::text IS NULL OR doc_no = ${docNo || null})
|
||||
AND (${normalizedDocType || null}::text IS NULL OR doc_type = ${normalizedDocType || null})
|
||||
AND (${normalizedDocPriority || null}::text IS NULL OR doc_priority = ${normalizedDocPriority || null})
|
||||
AND (${docYear}::int IS NULL OR doc_year = ${docYear})
|
||||
AND (${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'))
|
||||
AND (${status || null}::text IS NULL OR status = ${status || null})
|
||||
AND (${level || null}::text IS NULL OR level = ${level || null})
|
||||
@@ -646,6 +1152,17 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
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 WHEN doc_no = '' THEN 1 ELSE 0 END ASC,
|
||||
doc_type ASC NULLS LAST,
|
||||
CASE doc_priority
|
||||
WHEN 'P0' THEN 0
|
||||
WHEN 'P1' THEN 1
|
||||
WHEN 'P2' THEN 2
|
||||
WHEN 'P3' THEN 3
|
||||
ELSE 4
|
||||
END ASC,
|
||||
doc_year ASC NULLS LAST,
|
||||
doc_seq ASC NULLS LAST,
|
||||
CASE level
|
||||
WHEN 'G0' THEN 0
|
||||
WHEN 'P0' THEN 1
|
||||
@@ -718,12 +1235,20 @@ async function importMeeting(input: Record<string, unknown>): Promise<JsonRecord
|
||||
taskId: asString(input.taskId),
|
||||
commitId: asString(input.commitId),
|
||||
};
|
||||
const documentFields = ["docNo", "docNumber", "documentNo", "documentNumber", "docType", "docPriority", "docYear", "docSeq", "signer", "issuedAt", "effectiveScope", "supersedes", "supersededBy"];
|
||||
for (const field of documentFields) {
|
||||
if (field in input) base[field] = input[field];
|
||||
}
|
||||
const meeting = await createRecord(base);
|
||||
const decisionInputs = normalizeDecisionDrafts(input.decisions);
|
||||
const decisions: DecisionRecord[] = [];
|
||||
for (const decision of decisionInputs) {
|
||||
const inheritedBase = { ...base };
|
||||
for (const field of documentFields) {
|
||||
if (!(field in decision)) delete inheritedBase[field];
|
||||
}
|
||||
decisions.push(await createRecord({
|
||||
...base,
|
||||
...inheritedBase,
|
||||
...decision,
|
||||
type: "decision",
|
||||
linkedGoalId: asString(decision.linkedGoalId) || meeting.linkedGoalId,
|
||||
@@ -734,7 +1259,8 @@ async function importMeeting(input: Record<string, unknown>): Promise<JsonRecord
|
||||
}
|
||||
|
||||
async function deleteRecord(id: string): Promise<JsonRecord> {
|
||||
const rows = await withDatabaseRecovery("delete_record", () => sql<DecisionRecordRow[]>`DELETE FROM decision_center_records WHERE id = ${id} RETURNING *`);
|
||||
const existing = await getRecord(id);
|
||||
const rows = await withDatabaseRecovery("delete_record", () => sql<DecisionRecordRow[]>`DELETE FROM decision_center_records WHERE id = ${existing.id} RETURNING *`);
|
||||
if (rows.length === 0) throw new HttpError(404, "decision record not found", { id });
|
||||
log("info", "record_deleted", { id });
|
||||
return { ok: true, deleted: recordFromRow(rows[0]!) };
|
||||
|
||||
Reference in New Issue
Block a user