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
|
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 กัน
|
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) ?? [],
|
|
|
|
|
}));
|
|
|
|
|
}
|