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>
This commit is contained in:
parent
df502088b7
commit
f4d46c7720
20 changed files with 656 additions and 222 deletions
312
lib/external-api.ts
Normal file
312
lib/external-api.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
// N10 (PLAN-API.md D2) — ส่วนกลางของ POST /api/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/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) ?? [],
|
||||
}));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue