// 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 { 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[]; }; const ECOURT_PREFIX = "ecourt:"; function externalDocumentDTO(row: FileRow): ExternalDocumentDTO { return { id: row.id, externalFileId: row.externalId && row.externalId.startsWith(ECOURT_PREFIX) ? row.externalId.slice(ECOURT_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; 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; 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 { 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(); 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(); 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(); 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) ?? [], })); }