eCourt-note/lib/external-api.ts
Your Name a02e983ba8 feat(n11): add base64 document staging from e-Court
เพิ่มทางเข้าเอกสารสำหรับ e-Court ที่ไม่มี URL ให้ดึง — POST /api/note/files/stage
(x-api-key DOCVER_API_KEY) ส่งเนื้อไฟล์ PDF/รูปเป็น base64 ระบบเก็บใน RAM staging
(TTL sliding 8 ชม. + LRU + cap 100MB/ไฟล์ 256MB รวม — ห้ามเขียน disk/DB/MinIO
แม้แต่ไบต์เดียวตามโจทย์) ส่วน annotation/bookmark เก็บถาวรตามระบบเดิม:

- lib/staged-docs.ts: Map บน globalThis + sentinel "staged:<uuid>.<ext>" ใน
  pdf_url (ext จาก magic bytes — ไม่ต้อง migration) + hasStagedDoc แยกจาก getStagedDoc
- lib/ecourt-fid.ts: external_id เก็บ HMAC-SHA256(pepper, fid) แทน fid ดิบ
  (env FID_HASH_PEPPER fail-closed) + แปลง row เก่าด้วย npm run migrate:ecourt-fid
- route stage: อ่าน body แบบ stream นับไบต์ + idle 60 วิ/total 10 นาที = 408 +
  concurrency default 1 = 429 + base64 strict + magic bytes กำหนด ext เอง
- registerStagedFile: ลำดับกัน race 23505 — stage ใต้ id ก่อน insert โดนชนแล้ว
  re-stage ใต้ id ผู้ชนะ + try/finally คืน RAM ผู้แพ้ทุกทางออก
- ทางอ่าน 4 จุด: /api/note/pdf + export route (410 gate ไทย), resolvePublicPdfPath
  guard PdfPathError, PDFViewer โหลดผ่าน route + objectURL (gate ครบทุกชนิดไฟล์)
- session route (D5): ลิงก์ fid-only ตัดสิน staging ตั้งแต่แลก token — มี bytes
  ได้ fileId / หมดอายุ fileError "พ้นช่วงจัดเตรียม" / token ออกทุกกรณี
- Swagger UI: endpoint ใหม่ + คู่มือ retry (408/429/5xx ยิงซ้ำได้ stage ซ้ำปลอดภัย)
- MockStageForm บน /note/dev ทดสอบ 3 ขั้นในหน้าเดียว (ต้องถือ API key จริง)

Verify ครบตาม docs/PLAN-STAGED.md: tsc สะอาด + vitest 59 เคสใหม่ผ่าน + E2E
browser จริง (textLayer ไทยเรียงถูก / export ฝัง annotation / restart ได้
fileError ตั้งแต่ bootstrap / RAM 100MB: peak WS +580MB commit จริง +92MB) —
ผ่าน code review รอบ 7 แก้ครบทุก finding

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-05 12:53:56 +07:00

314 lines
17 KiB
TypeScript

// N10 (PLAN-API.md D2) — ส่วนกลางของ POST /api/note/external/documents
// (ถามด้วย fileId และ/หรือ typeId — ส่งทั้งคู่ = AND เหลือเอกสารเดียว, คำขอผู้ใช้ 2026-09-25)
// ใช้ loadDocumentBundles (bulk) ร่วมทุกทางให้รูปร่าง bundle ตรงกันเป๊ะ
// read-only ทั้งหมด: ไม่เขียน DB ไม่ publish SSE ไม่ revalidatePath
// (snapshot ณ ตอนเรียก — คนละ context กับ Server Action ใน invariant #4)
import { and, asc, inArray } from "drizzle-orm";
import { db } from "@/db";
import { ECOURT_ID_PREFIX } from "@/lib/ecourt-fid";
import {
annotationReplies,
annotationType,
annotations,
bookmarks,
files,
toAnnotationDTO,
toBookmarkDTO,
toReplyDTO,
type AnnotationDTO,
type AnnotationRow,
type AnnotationType,
type BookmarkRow,
type BookmarkDTO,
type ReplyDTO,
} from "@/db/schema";
import { withBasePath } from "@/utils/base-path";
type FileRow = typeof files.$inferSelect;
/** ข้อมูลเอกสารใน response ของ N10 — externalFileId เป็น null สำหรับไฟล์
* url:-fallback ของ N8 หรืออัปโหลด N4 ที่อยู่ในกลุ่ม (mirror กฎ typeId: null) */
export type ExternalDocumentDTO = {
id: string;
externalFileId: string | null;
title: string;
typeId: string | null;
createdAt: string;
};
/** AnnotationDTO พร้อม url (deep link F12 `?ann=`) + replies ต่อรายการ
* (PLAN-API.md D2 — url เป็น URL เต็ม: origin จาก request ที่เรียกเข้ามา
* ส่งผ่าน parameter `origin` ของ loadDocumentBundles + basePath) */
export type ExternalAnnotationDTO = AnnotationDTO & {
url: string;
replies: ReplyDTO[];
};
/** BookmarkDTO พร้อม url — ไปที่หน้าของ bookmark ด้วย `?page=` 1-based
* (เลขเดียวกับลิงก์ View ของหน้าสรุป/กลุ่ม — F12 ไม่มี anchor รายการ bookmark) */
export type ExternalBookmarkDTO = BookmarkDTO & { url: string };
/** bundle ยุบชั้น (2026-09-25 ตามคำขอผู้ใช้): field ของเอกสารอยู่ระดับเดียวกับ
* annotations/bookmarks เลย — ไม่มี object `document` ซ้อนอีกต่อไป */
export type DocumentBundle = ExternalDocumentDTO & {
annotations: ExternalAnnotationDTO[];
bookmarks: ExternalBookmarkDTO[];
};
export type IncludeParts = {
annotations: boolean;
bookmarks: boolean;
/** ผู้บันทึก (filter.userId normalize เป็น array) — ว่าง = ทุกคน; กรองตามเจ้าของ
* รายการทั้ง annotations/bookmarks (replies ตามมาครบกับ annotation ที่ผ่านตัวกรอง) */
userIds: string[];
/** ชนิดของ annotation (filter.annotationTypes) — ว่าง = ทุกชนิด; มีผลเฉพาะเมื่อรวม annotations */
annotationTypes: AnnotationType[];
};
// external_id ของเอกสาร e-Court เก็บ "ecourt:" + hash ของ fid (lib/ecourt-fid —
// 2026-11-04 ไม่มี fid ดิบใน DB) → externalFileId ที่คืนให้ผู้เรียกจึงเป็นค่า hash
// (one-way — ผู้เรียกถือ fid จริงอยู่แล้วเพราะเป็นคนส่งมา ค่านี้ใช้เทียบความเหมือนได้อย่างเดียว)
function externalDocumentDTO(row: FileRow): ExternalDocumentDTO {
return {
id: row.id,
externalFileId:
row.externalId && row.externalId.startsWith(ECOURT_ID_PREFIX)
? row.externalId.slice(ECOURT_ID_PREFIX.length)
: null,
title: row.title,
typeId: row.typeId,
createdAt: row.createdAt.toISOString(),
};
}
/**
* validate JSON body ของ POST /api/note/external/documents (PLAN-API.md D2) — strict กัน
* typo เงียบ ๆ: รับเฉพาะ fileId/typeId/filter · fileId/typeId ต้องเป็น string
* (trim แล้วว่าง = ถือว่าไม่ได้ส่ง field นั้น) · ต้องมี fileId หรือ typeId
* อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข
* (AND — คำขอผู้ใช้ 2026-09-25 หลังทดสอบจริง · ก่อนหน้าเคยเป็น OR แล้วเป็น
* 400 กำกวม) · filter ถ้าส่งต้องเป็น object ที่มีเฉพาะ field type/userId/
* annotationTypes — type ค่า "bookmarks"/"annotations" (ไม่ส่ง = ทั้งสอง ส่วน
* ที่ไม่เลือกคืนเป็น array ว่างตาม contract คงที่) · userId (string หรือ array)
* กรองตามเจ้าของรายการทั้ง annotations/bookmarks · annotationTypes (string
* หรือ array ค่า enum annotation_type — ค่าอื่น = 400 บอกค่าที่รับ) มีผล
* เฉพาะ annotations (เพิ่ม 2026-09-25 ตามคำขอผู้ใช้ · เดิมชื่อ field "types"
* เปลี่ยน 2026-09-25 กันสับสนกับ filter.type)
*/
export type ParsedQueryBody =
| { ok: false; error: string }
| { ok: true; mode: "file"; fileId: string; typeId: string | undefined; parts: IncludeParts }
| { ok: true; mode: "type"; typeId: string; parts: IncludeParts };
const BODY_FIELDS = ["fileId", "typeId", "filter"];
function trimOrUndefined(value: unknown): string | undefined {
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
}
/** normalize filter field ที่รับ string เดี่ยวหรือ array — trim แล้วตัดค่าว่างทิ้ง
* (ผลลัพธ์ว่าง = ไม่ได้กรอง กฎเดียวกับ fileId ว่าง = ไม่ได้ส่ง) */
function stringOrArray(
value: unknown,
field: string,
): { ok: true; values: string[] } | { ok: false; error: string } {
if (value === undefined) return { ok: true, values: [] };
const raw = Array.isArray(value) ? value : [value];
for (const item of raw) {
if (typeof item !== "string") {
return { ok: false, error: `filter.${field} ต้องเป็น string หรือ array ของ string` };
}
}
return { ok: true, values: raw.map((item) => item.trim()).filter((item) => item !== "") };
}
export function parseQueryBody(body: unknown): ParsedQueryBody {
if (body === null || typeof body !== "object" || Array.isArray(body)) {
return { ok: false, error: "body ต้องเป็น JSON object" };
}
const raw = body as Record<string, unknown>;
for (const key of Object.keys(raw)) {
if (!BODY_FIELDS.includes(key)) {
return {
ok: false,
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ fileId, typeId, filter)`,
};
}
}
if (raw.fileId !== undefined && typeof raw.fileId !== "string") {
return { ok: false, error: "fileId ต้องเป็น string" };
}
if (raw.typeId !== undefined && typeof raw.typeId !== "string") {
return { ok: false, error: "typeId ต้องเป็น string" };
}
const fileId = trimOrUndefined(raw.fileId);
const typeId = trimOrUndefined(raw.typeId);
const parts: IncludeParts = {
annotations: true,
bookmarks: true,
userIds: [],
annotationTypes: [],
};
if (raw.filter !== undefined) {
const filter = raw.filter;
if (filter === null || typeof filter !== "object" || Array.isArray(filter)) {
return { ok: false, error: 'filter ต้องเป็น object เช่น { "type": "bookmarks" }' };
}
for (const key of Object.keys(filter)) {
if (key !== "type" && key !== "userId" && key !== "annotationTypes") {
return {
ok: false,
error: "filter รับเฉพาะ field 'type', 'userId', 'annotationTypes'",
};
}
}
const filterRecord = filter as Record<string, unknown>;
const type = filterRecord.type;
if (type !== undefined && !(typeof type === "string" && type.trim() === "")) {
// "" หลัง trim = ไม่ได้กรอง เอาทั้งสอง — กฎเดียวกับ fileId ว่าง = ไม่ได้ส่ง
// (คำขอผู้ใช้ 2026-09-24 · เดิมรอบสองเป็น strict: ค่าใดที่ไม่ใช่สองค่านี้ = 400)
if (type !== "bookmarks" && type !== "annotations") {
return { ok: false, error: "filter.type รับค่า bookmarks หรือ annotations เท่านั้น" };
}
parts.annotations = type === "annotations";
parts.bookmarks = type === "bookmarks";
}
// userId กรองตามเจ้าของรายการทั้ง annotations/bookmarks — ไม่มีจริงใน DB = รายการ
// ว่าง ไม่ใช่ 400 (ผู้ใช้เปลี่ยนได้ตลอด ตรวจก็เสีย query) · annotationTypes ค่าไม่
// รู้จัก = 400 กัน typo เงียบเหมือน filter.type — มีผลเฉพาะ annotations (เลือก
// bookmarks อย่างเดียว = ไม่มี annotation ให้กรองอยู่แล้ว)
const userIds = stringOrArray(filterRecord.userId, "userId");
if (!userIds.ok) return { ok: false, error: userIds.error };
parts.userIds = userIds.values;
const annotationTypes = stringOrArray(filterRecord.annotationTypes, "annotationTypes");
if (!annotationTypes.ok) return { ok: false, error: annotationTypes.error };
// enumValues ของ drizzle เป็น tuple [AnnotationType, ...] ทำ includes(string)
// ไม่ได้ — ขยายเป็น readonly string[] แล้วตรวจผ่าน type guard แทน as cast
const enumValues: readonly string[] = annotationType.enumValues;
const isAnnotationType = (value: string): value is AnnotationType =>
enumValues.includes(value);
if (!annotationTypes.values.every(isAnnotationType)) {
return {
ok: false,
error: `filter.annotationTypes รับค่า ${annotationType.enumValues.join(", ")} เท่านั้น`,
};
}
parts.annotationTypes = annotationTypes.values.filter(isAnnotationType);
}
// mode "file" = มี fileId (typeId อาจมาด้วยสำหรับ AND) / mode "type" = typeId เดี่ยว —
// discriminated ให้ route ใช้ typeId ของ mode "type" ได้โดยไม่ต้อง assert
if (fileId) return { ok: true, mode: "file", fileId, typeId, parts };
if (typeId) return { ok: true, mode: "type", typeId, parts };
return { ok: false, error: "ต้องระบุ fileId หรือ typeId อย่างน้อยหนึ่งอย่าง" };
}
/**
* โหลด bundle ของหลายเอกสารด้วยชุด query รวม (bulk) — annotations/bookmarks
* คู่ขนานด้วย inArray ต่อทุกไฟล์ + replies ตามหลัง = 3 query ต่อ request ไม่ว่า
* กลุ่มจะกี่ไฟล์ (แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่มด้วย Promise.all —
* code review 2026-09-25: กลุ่มใหญ่กด connection pool) แล้วจัดกลุ่มรายไฟล์ใน
* memory โดย push ตามลำดับ query จึงรักษาลำดับเดิม: annotations (pageIndex,
* createdAt) เหมือนหน้าสรุป F15/CSV · bookmarks ตาม N5 (pageIndex → yOffset →
* createdAt) · replies ของทุก annotation เรียง createdAt (pattern เดียวกับ
* initial data ของหน้า annotate)
*/
export async function loadDocumentBundles(
fileRows: FileRow[],
parts: IncludeParts,
origin: string,
): Promise<DocumentBundle[]> {
if (fileRows.length === 0) return [];
const fileIds = fileRows.map((row) => row.id);
// annotations กับ bookmarks อิสระต่อกันจึงยิงคู่ขนาน (pattern หน้า annotate) —
// ส่วนที่ไม่เลือกไม่ยิง query เลย คืน array ว่างตาม contract คงที่;
// userIds/annotationTypes กรองใน SQL เลย (ว่าง = ไม่ใส่เงื่อนไข = ทุกคน/ทุกชนิด)
const [annotationRows, bookmarkRows] = await Promise.all([
parts.annotations
? db
.select()
.from(annotations)
.where(
and(
inArray(annotations.fileId, fileIds),
...(parts.userIds.length
? [inArray(annotations.userId, parts.userIds)]
: []),
...(parts.annotationTypes.length
? [inArray(annotations.type, parts.annotationTypes)]
: []),
),
)
.orderBy(asc(annotations.pageIndex), asc(annotations.createdAt))
: ([] as AnnotationRow[]),
parts.bookmarks
? db
.select()
.from(bookmarks)
.where(
and(
inArray(bookmarks.fileId, fileIds),
...(parts.userIds.length ? [inArray(bookmarks.userId, parts.userIds)] : []),
),
)
.orderBy(
asc(bookmarks.pageIndex),
asc(bookmarks.yOffset),
asc(bookmarks.createdAt),
)
: ([] as BookmarkRow[]),
]);
const annotationIds = annotationRows.map((row) => row.id);
const replyRows = annotationIds.length
? await db
.select()
.from(annotationReplies)
.where(inArray(annotationReplies.annotationId, annotationIds))
.orderBy(asc(annotationReplies.createdAt))
: [];
const repliesByAnnotation = new Map<string, ReplyDTO[]>();
for (const row of replyRows) {
const dto = toReplyDTO(row);
const list = repliesByAnnotation.get(dto.annotationId);
if (list) list.push(dto);
else repliesByAnnotation.set(dto.annotationId, [dto]);
}
// จัดกลุ่มรายไฟล์ (push ตามลำดับ query = ลำดับในไฟล์เดียวกันคงเดิม ไม่ต้อง
// sort ซ้ำ; ลำดับ documents ตาม fileRows ที่ route ส่งมา) — url กลับมาหน้า
// viewer เป็น URL เต็ม (คำขอผู้ใช้ 2026-09-25): origin ที่ route อ่านจาก
// request ของผู้เรียก + basePath ผ่าน withBasePath (อ่านค่าจาก env ที่ inline
// ตอน build) — annotation ไปรายการด้วย ?ann= · bookmark ไปหน้าด้วย ?page=
// 1-based เหมือนลิงก์ View ของหน้าสรุป/กลุ่ม
const annotationsByFile = new Map<string, ExternalAnnotationDTO[]>();
for (const row of annotationRows) {
const dto: ExternalAnnotationDTO = {
...toAnnotationDTO(row),
url: `${origin}${withBasePath(`/annotate/${row.fileId}`)}?ann=${row.id}`,
replies: repliesByAnnotation.get(row.id) ?? [],
};
const list = annotationsByFile.get(row.fileId);
if (list) list.push(dto);
else annotationsByFile.set(row.fileId, [dto]);
}
const bookmarksByFile = new Map<string, ExternalBookmarkDTO[]>();
for (const row of bookmarkRows) {
const dto: ExternalBookmarkDTO = {
...toBookmarkDTO(row),
url: `${origin}${withBasePath(`/annotate/${row.fileId}`)}?page=${row.pageIndex + 1}`,
};
const list = bookmarksByFile.get(row.fileId);
if (list) list.push(dto);
else bookmarksByFile.set(row.fileId, [dto]);
}
return fileRows.map((file) => ({
...externalDocumentDTO(file),
annotations: annotationsByFile.get(file.id) ?? [],
bookmarks: bookmarksByFile.get(file.id) ?? [],
}));
}