eCourt-note/lib/external-api.ts

313 lines
16 KiB
TypeScript
Raw Normal View History

refactor(routes): move routes under /note, APIs under /api/note เตรียม basePath /docedit เป็นพื้นที่กลางให้แอปพี่น้อง (wordaddin ใน อนาคต) — เดิม basePath ครอบ /docedit/note ทั้งหมดจนแชร์ prefix ไม่ได้ (Next มี basePath ได้ทางเดียว) และจัด API แยกต้นไม้ตามที่ผู้ใช้ขอ (1) โครงสร้างใหม่: basePath = /docedit + route หน้าทั้งหมดย้ายเข้า app/note/** + API 4 เส้นย้ายเข้า app/api/note/** + statics ย้ายเข้า public/note/** (samples, uploads, pdf.worker) — หน้า/statics URL ปลายทาง คงเดิมเป๊ะ (/docedit/note/…) ส่วน URL API เปลี่ยนเป็น /docedit/api/note/… ตามคำขอผู้ใช้ (N10 endpoint เปลี่ยน — ต้องแจ้ง backend e-Court ก่อนใช้) (2) กลไก: withBasePath (utils/base-path.ts) เติม base + app path /note ยกเว้น path ที่ขึ้นต้น /api/ เติมแค่ base (caller เขียน /api/note/… เต็ม); resolvePublicPdfPath root ที่ public/note (traversal guard เดิม); middleware matcher ยกเว้นใหม่ (note/login, api/note, note/uploads, note/samples, note/pdf.worker, _next, fonts, favicon) + redirect /note/login; next.config เพิ่ม redirects /docedit -> /docedit/note; navigation ภายใน 19 จุด + revalidatePath 3 จุด ใส่ prefix /note; pdf_url ใน DB เป็น logical path ไม่แก้แม้แต่ตัวเดียว (seed/upload เขียนค่าเดิม, browser เติมผ่าน withBasePath, disk เติมผ่าน guard) (3) ตามแก้: patch-pdf-worker (DEST public/note + mkdirSync ปลายทาง), seed-samples (SAMPLES_DIR), uploadFile (UPLOAD_DIR), Dockerfile/ .dockerignore/.gitignore/docker-compose.test.yml, tests 2 ไฟล์ที่อ้าง disk path จริง, sync เอกสารทั้งหมด (CLAUDE.md invariant #6/#12 + tree, README, docs/API-DOCUMENTS.md, .claude/rules/*, sync-db skill) (4) !! ตอน deploy ต้องย้าย volume ตาม: mount /app/public/uploads -> /app/public/note/uploads + ย้ายไฟล์ใน volume ให้ตรงตำแหน่ง ไม่งั้นไฟล์ ที่เคยอัปโหลด (พร้อม annotation ที่ผูกอยู่) 404 ทันทีที่สลับ image (5) verify: build ผ่าน (route table /api/note/** + /note/** ครบ), tsc, vitest 215/215, curl ครบทุก endpoint (login/redirect chain, worker, samples, uploads, SSE, api/pdf, export, N8 register, N10 bundle + deep link), Playwright browser จริง (canvas + textLayer ไทย render, browser ยิง URL ใหม่ทั้งหมดไม่มี request ไป URL เก่า), plan-reviewer + code-reviewer 3 รายงาน 0 blocking Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-06 14:55:07 +07:00
// N10 (PLAN-API.md D2) — ส่วนกลางของ POST /api/note/external/documents
external-api: N10 API ดึงข้อมูลให้ระบบภายนอกด้วย API Key + ถอด F16 export CSV / N7 bookmark JSON แทนด้วย N10 — (1) endpoint เดียว POST /api/external/documents (JSON body {fileId และ/หรือ typeId — ส่งทั้งคู่ = AND เหลือเอกสารเดียว, filter:{type bookmarks|annotations, userId string|array, annotationTypes ค่า enum}}) ให้ backend e-Court เรียก server-to-server ผ่าน header x-api-key คืน {documents:[…]} รูปเดียวเสมอ — bundle ยุบชั้น field เอกสาร + annotations (พร้อม url deep link + replies) + bookmarks ระดับเดียวกัน, url เป็น URL เต็ม origin จาก request ผู้เรียก (x-forwarded-proto/host fallback host ตรง) + basePath (เดิม 2 GET query-param รวมเป็นเส้นเดียว 2026-09-24 · OR→AND + url relative→เต็ม + filter.types→annotationTypes กันสับสนกับ filter.type 2026-09-25) (2) lib/external-api-auth.ts — requireExternalApiKey เทียบ SHA-256 + crypto.timingSafeEqual, env EXTERNAL_API_KEY ไม่ตั้ง/ว่าง = 401 fail closed, ข้อความ 401 เดียวกันกัน enumerate + externalApiError คืน JSON {"error": ไทย} ใช้ร่วมทุก route — module DB-free ให้ tests/lib/external-api-auth.test.ts (5 tests) รันได้ไม่ต้องมี DATABASE_URL (3) lib/external-api.ts parseQueryBody strict กัน typo เงียบ ๆ (field นอก 3 ตัว = 400, annotationTypes ค่านอก enum = 400 บอกค่าที่รับ) + loadDocumentBundles bulk inArray = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ กรอง userIds/annotationTypes ใน SQL เรียง F15/N5 (4) route: auth ก่อนแตะ DB + ครอบ try/catch ทุก DB call ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework, read-only ไม่ publish SSE ไม่ revalidatePath ไม่มี CORS, REDACTION รวมในผลลัพธ์ (5) ถอด F16/N7 ตามคำขอผู้ใช้ 2026-09-25 (N10 ครอบคลุมข้อมูลชุดเดียวกันพร้อม auth): ลบ app/api/annotations/export-csv + app/api/bookmarks/export + utils/csv.ts + tests/utils/csv.test.ts + ปุ่มส่งออก 2 ปุ่มบนหน้าสรุป + ปุ่ม JSON ใน sidebar + รูป requirement 1.png/2.png (บรรทัดอ้างถึงใน gap-analysis.md เปลี่ยนเป็น historical note) (6) docs sync ครบ: PLAN-API.md แผนฉบับเต็ม 9 รอบ + PLAN-FEAT.md §7 N10 + PLAN-BOOKMARK-PDF.md/gap-analysis.md 5.3.14 เปลี่ยนตอบด้วย N10 + CLAUDE.md + README (env/ตัวอย่าง curl/troubleshooting) + .env.example + .claude/rules/data-layer.md — ผ่าน code review 2 รอบ 2026-09-25: แก้ 500 JSON ตอน DB ล้ม + ถอด fan-out ต่อไฟล์เป็น bulk + ถอด as cast แทน type guard (tsc + vitest 149/149) (คำขอผู้ใช้ 2026-09-25) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 12:02:42 +07:00
// (ถามด้วย 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(),
};
}
/**
refactor(routes): move routes under /note, APIs under /api/note เตรียม basePath /docedit เป็นพื้นที่กลางให้แอปพี่น้อง (wordaddin ใน อนาคต) — เดิม basePath ครอบ /docedit/note ทั้งหมดจนแชร์ prefix ไม่ได้ (Next มี basePath ได้ทางเดียว) และจัด API แยกต้นไม้ตามที่ผู้ใช้ขอ (1) โครงสร้างใหม่: basePath = /docedit + route หน้าทั้งหมดย้ายเข้า app/note/** + API 4 เส้นย้ายเข้า app/api/note/** + statics ย้ายเข้า public/note/** (samples, uploads, pdf.worker) — หน้า/statics URL ปลายทาง คงเดิมเป๊ะ (/docedit/note/…) ส่วน URL API เปลี่ยนเป็น /docedit/api/note/… ตามคำขอผู้ใช้ (N10 endpoint เปลี่ยน — ต้องแจ้ง backend e-Court ก่อนใช้) (2) กลไก: withBasePath (utils/base-path.ts) เติม base + app path /note ยกเว้น path ที่ขึ้นต้น /api/ เติมแค่ base (caller เขียน /api/note/… เต็ม); resolvePublicPdfPath root ที่ public/note (traversal guard เดิม); middleware matcher ยกเว้นใหม่ (note/login, api/note, note/uploads, note/samples, note/pdf.worker, _next, fonts, favicon) + redirect /note/login; next.config เพิ่ม redirects /docedit -> /docedit/note; navigation ภายใน 19 จุด + revalidatePath 3 จุด ใส่ prefix /note; pdf_url ใน DB เป็น logical path ไม่แก้แม้แต่ตัวเดียว (seed/upload เขียนค่าเดิม, browser เติมผ่าน withBasePath, disk เติมผ่าน guard) (3) ตามแก้: patch-pdf-worker (DEST public/note + mkdirSync ปลายทาง), seed-samples (SAMPLES_DIR), uploadFile (UPLOAD_DIR), Dockerfile/ .dockerignore/.gitignore/docker-compose.test.yml, tests 2 ไฟล์ที่อ้าง disk path จริง, sync เอกสารทั้งหมด (CLAUDE.md invariant #6/#12 + tree, README, docs/API-DOCUMENTS.md, .claude/rules/*, sync-db skill) (4) !! ตอน deploy ต้องย้าย volume ตาม: mount /app/public/uploads -> /app/public/note/uploads + ย้ายไฟล์ใน volume ให้ตรงตำแหน่ง ไม่งั้นไฟล์ ที่เคยอัปโหลด (พร้อม annotation ที่ผูกอยู่) 404 ทันทีที่สลับ image (5) verify: build ผ่าน (route table /api/note/** + /note/** ครบ), tsc, vitest 215/215, curl ครบทุก endpoint (login/redirect chain, worker, samples, uploads, SSE, api/pdf, export, N8 register, N10 bundle + deep link), Playwright browser จริง (canvas + textLayer ไทย render, browser ยิง URL ใหม่ทั้งหมดไม่มี request ไป URL เก่า), plan-reviewer + code-reviewer 3 รายงาน 0 blocking Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-06 14:55:07 +07:00
* validate JSON body ของ POST /api/note/external/documents (PLAN-API.md D2) — strict กัน
external-api: N10 API ดึงข้อมูลให้ระบบภายนอกด้วย API Key + ถอด F16 export CSV / N7 bookmark JSON แทนด้วย N10 — (1) endpoint เดียว POST /api/external/documents (JSON body {fileId และ/หรือ typeId — ส่งทั้งคู่ = AND เหลือเอกสารเดียว, filter:{type bookmarks|annotations, userId string|array, annotationTypes ค่า enum}}) ให้ backend e-Court เรียก server-to-server ผ่าน header x-api-key คืน {documents:[…]} รูปเดียวเสมอ — bundle ยุบชั้น field เอกสาร + annotations (พร้อม url deep link + replies) + bookmarks ระดับเดียวกัน, url เป็น URL เต็ม origin จาก request ผู้เรียก (x-forwarded-proto/host fallback host ตรง) + basePath (เดิม 2 GET query-param รวมเป็นเส้นเดียว 2026-09-24 · OR→AND + url relative→เต็ม + filter.types→annotationTypes กันสับสนกับ filter.type 2026-09-25) (2) lib/external-api-auth.ts — requireExternalApiKey เทียบ SHA-256 + crypto.timingSafeEqual, env EXTERNAL_API_KEY ไม่ตั้ง/ว่าง = 401 fail closed, ข้อความ 401 เดียวกันกัน enumerate + externalApiError คืน JSON {"error": ไทย} ใช้ร่วมทุก route — module DB-free ให้ tests/lib/external-api-auth.test.ts (5 tests) รันได้ไม่ต้องมี DATABASE_URL (3) lib/external-api.ts parseQueryBody strict กัน typo เงียบ ๆ (field นอก 3 ตัว = 400, annotationTypes ค่านอก enum = 400 บอกค่าที่รับ) + loadDocumentBundles bulk inArray = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ กรอง userIds/annotationTypes ใน SQL เรียง F15/N5 (4) route: auth ก่อนแตะ DB + ครอบ try/catch ทุก DB call ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework, read-only ไม่ publish SSE ไม่ revalidatePath ไม่มี CORS, REDACTION รวมในผลลัพธ์ (5) ถอด F16/N7 ตามคำขอผู้ใช้ 2026-09-25 (N10 ครอบคลุมข้อมูลชุดเดียวกันพร้อม auth): ลบ app/api/annotations/export-csv + app/api/bookmarks/export + utils/csv.ts + tests/utils/csv.test.ts + ปุ่มส่งออก 2 ปุ่มบนหน้าสรุป + ปุ่ม JSON ใน sidebar + รูป requirement 1.png/2.png (บรรทัดอ้างถึงใน gap-analysis.md เปลี่ยนเป็น historical note) (6) docs sync ครบ: PLAN-API.md แผนฉบับเต็ม 9 รอบ + PLAN-FEAT.md §7 N10 + PLAN-BOOKMARK-PDF.md/gap-analysis.md 5.3.14 เปลี่ยนตอบด้วย N10 + CLAUDE.md + README (env/ตัวอย่าง curl/troubleshooting) + .env.example + .claude/rules/data-layer.md — ผ่าน code review 2 รอบ 2026-09-25: แก้ 500 JSON ตอน DB ล้ม + ถอด fan-out ต่อไฟล์เป็น bulk + ถอด as cast แทน type guard (tsc + vitest 149/149) (คำขอผู้ใช้ 2026-09-25) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 12:02:42 +07:00
* 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) ?? [],
}));
}