From f4d46c7720d9f65a8939d0929203b0d2d37b26e1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 25 Sep 2026 12:02:42 +0700 Subject: [PATCH] =?UTF-8?q?external-api:=20N10=20API=20=E0=B8=94=E0=B8=B6?= =?UTF-8?q?=E0=B8=87=E0=B8=82=E0=B9=89=E0=B8=AD=E0=B8=A1=E0=B8=B9=E0=B8=A5?= =?UTF-8?q?=E0=B9=83=E0=B8=AB=E0=B9=89=E0=B8=A3=E0=B8=B0=E0=B8=9A=E0=B8=9A?= =?UTF-8?q?=E0=B8=A0=E0=B8=B2=E0=B8=A2=E0=B8=99=E0=B8=AD=E0=B8=81=E0=B8=94?= =?UTF-8?q?=E0=B9=89=E0=B8=A7=E0=B8=A2=20API=20Key=20+=20=E0=B8=96?= =?UTF-8?q?=E0=B8=AD=E0=B8=94=20F16=20export=20CSV=20/=20N7=20bookmark=20J?= =?UTF-8?q?SON=20=E0=B9=81=E0=B8=97=E0=B8=99=E0=B8=94=E0=B9=89=E0=B8=A7?= =?UTF-8?q?=E0=B8=A2=20N10=20=E2=80=94=20(1)=20endpoint=20=E0=B9=80?= =?UTF-8?q?=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7=20POST=20/api/external/doc?= =?UTF-8?q?uments=20(JSON=20body=20{fileId=20=E0=B9=81=E0=B8=A5=E0=B8=B0/?= =?UTF-8?q?=E0=B8=AB=E0=B8=A3=E0=B8=B7=E0=B8=AD=20typeId=20=E2=80=94=20?= =?UTF-8?q?=E0=B8=AA=E0=B9=88=E0=B8=87=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87?= =?UTF-8?q?=E0=B8=84=E0=B8=B9=E0=B9=88=20=3D=20AND=20=E0=B9=80=E0=B8=AB?= =?UTF-8?q?=E0=B8=A5=E0=B8=B7=E0=B8=AD=E0=B9=80=E0=B8=AD=E0=B8=81=E0=B8=AA?= =?UTF-8?q?=E0=B8=B2=E0=B8=A3=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7?= =?UTF-8?q?,=20filter:{type=20bookmarks|annotations,=20userId=20string|arr?= =?UTF-8?q?ay,=20annotationTypes=20=E0=B8=84=E0=B9=88=E0=B8=B2=20enum}})?= =?UTF-8?q?=20=E0=B9=83=E0=B8=AB=E0=B9=89=20backend=20e-Court=20=E0=B9=80?= =?UTF-8?q?=E0=B8=A3=E0=B8=B5=E0=B8=A2=E0=B8=81=20server-to-server=20?= =?UTF-8?q?=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=20header=20x-api-key=20?= =?UTF-8?q?=E0=B8=84=E0=B8=B7=E0=B8=99=20{documents:[=E2=80=A6]}=20?= =?UTF-8?q?=E0=B8=A3=E0=B8=B9=E0=B8=9B=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2?= =?UTF-8?q?=E0=B8=A7=E0=B9=80=E0=B8=AA=E0=B8=A1=E0=B8=AD=20=E2=80=94=20bun?= =?UTF-8?q?dle=20=E0=B8=A2=E0=B8=B8=E0=B8=9A=E0=B8=8A=E0=B8=B1=E0=B9=89?= =?UTF-8?q?=E0=B8=99=20field=20=E0=B9=80=E0=B8=AD=E0=B8=81=E0=B8=AA?= =?UTF-8?q?=E0=B8=B2=E0=B8=A3=20+=20annotations=20(=E0=B8=9E=E0=B8=A3?= =?UTF-8?q?=E0=B9=89=E0=B8=AD=E0=B8=A1=20url=20deep=20link=20+=20replies)?= =?UTF-8?q?=20+=20bookmarks=20=E0=B8=A3=E0=B8=B0=E0=B8=94=E0=B8=B1?= =?UTF-8?q?=E0=B8=9A=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B8=81?= =?UTF-8?q?=E0=B8=B1=E0=B8=99,=20url=20=E0=B9=80=E0=B8=9B=E0=B9=87?= =?UTF-8?q?=E0=B8=99=20URL=20=E0=B9=80=E0=B8=95=E0=B9=87=E0=B8=A1=20origin?= =?UTF-8?q?=20=E0=B8=88=E0=B8=B2=E0=B8=81=20request=20=E0=B8=9C=E0=B8=B9?= =?UTF-8?q?=E0=B9=89=E0=B9=80=E0=B8=A3=E0=B8=B5=E0=B8=A2=E0=B8=81=20(x-for?= =?UTF-8?q?warded-proto/host=20fallback=20host=20=E0=B8=95=E0=B8=A3?= =?UTF-8?q?=E0=B8=87)=20+=20basePath=20(=E0=B9=80=E0=B8=94=E0=B8=B4?= =?UTF-8?q?=E0=B8=A1=202=20GET=20query-param=20=E0=B8=A3=E0=B8=A7=E0=B8=A1?= =?UTF-8?q?=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=E0=B9=80=E0=B8=AA=E0=B9=89?= =?UTF-8?q?=E0=B8=99=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7=202026-0?= =?UTF-8?q?9-24=20=C2=B7=20OR=E2=86=92AND=20+=20url=20relative=E2=86=92?= =?UTF-8?q?=E0=B9=80=E0=B8=95=E0=B9=87=E0=B8=A1=20+=20filter.types?= =?UTF-8?q?=E2=86=92annotationTypes=20=E0=B8=81=E0=B8=B1=E0=B8=99=E0=B8=AA?= =?UTF-8?q?=E0=B8=B1=E0=B8=9A=E0=B8=AA=E0=B8=99=E0=B8=81=E0=B8=B1=E0=B8=9A?= =?UTF-8?q?=20filter.type=202026-09-25)=20(2)=20lib/external-api-auth.ts?= =?UTF-8?q?=20=E2=80=94=20requireExternalApiKey=20=E0=B9=80=E0=B8=97?= =?UTF-8?q?=E0=B8=B5=E0=B8=A2=E0=B8=9A=20SHA-256=20+=20crypto.timingSafeEq?= =?UTF-8?q?ual,=20env=20EXTERNAL=5FAPI=5FKEY=20=E0=B9=84=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B8=95=E0=B8=B1=E0=B9=89=E0=B8=87/=E0=B8=A7=E0=B9=88?= =?UTF-8?q?=E0=B8=B2=E0=B8=87=20=3D=20401=20fail=20closed,=20=E0=B8=82?= =?UTF-8?q?=E0=B9=89=E0=B8=AD=E0=B8=84=E0=B8=A7=E0=B8=B2=E0=B8=A1=20401=20?= =?UTF-8?q?=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B8=81=E0=B8=B1?= =?UTF-8?q?=E0=B8=99=E0=B8=81=E0=B8=B1=E0=B8=99=20enumerate=20+=20external?= =?UTF-8?q?ApiError=20=E0=B8=84=E0=B8=B7=E0=B8=99=20JSON=20{"error":=20?= =?UTF-8?q?=E0=B9=84=E0=B8=97=E0=B8=A2}=20=E0=B9=83=E0=B8=8A=E0=B9=89?= =?UTF-8?q?=E0=B8=A3=E0=B9=88=E0=B8=A7=E0=B8=A1=E0=B8=97=E0=B8=B8=E0=B8=81?= =?UTF-8?q?=20route=20=E2=80=94=20module=20DB-free=20=E0=B9=83=E0=B8=AB?= =?UTF-8?q?=E0=B9=89=20tests/lib/external-api-auth.test.ts=20(5=20tests)?= =?UTF-8?q?=20=E0=B8=A3=E0=B8=B1=E0=B8=99=E0=B9=84=E0=B8=94=E0=B9=89?= =?UTF-8?q?=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=95=E0=B9=89=E0=B8=AD=E0=B8=87?= =?UTF-8?q?=E0=B8=A1=E0=B8=B5=20DATABASE=5FURL=20(3)=20lib/external-api.ts?= =?UTF-8?q?=20parseQueryBody=20strict=20=E0=B8=81=E0=B8=B1=E0=B8=99=20typo?= =?UTF-8?q?=20=E0=B9=80=E0=B8=87=E0=B8=B5=E0=B8=A2=E0=B8=9A=20=E0=B9=86=20?= =?UTF-8?q?(field=20=E0=B8=99=E0=B8=AD=E0=B8=81=203=20=E0=B8=95=E0=B8=B1?= =?UTF-8?q?=E0=B8=A7=20=3D=20400,=20annotationTypes=20=E0=B8=84=E0=B9=88?= =?UTF-8?q?=E0=B8=B2=E0=B8=99=E0=B8=AD=E0=B8=81=20enum=20=3D=20400=20?= =?UTF-8?q?=E0=B8=9A=E0=B8=AD=E0=B8=81=E0=B8=84=E0=B9=88=E0=B8=B2=E0=B8=97?= =?UTF-8?q?=E0=B8=B5=E0=B9=88=E0=B8=A3=E0=B8=B1=E0=B8=9A)=20+=20loadDocume?= =?UTF-8?q?ntBundles=20bulk=20inArray=20=3D=203=20query=20=E0=B8=95?= =?UTF-8?q?=E0=B9=88=E0=B8=AD=20request=20=E0=B9=84=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B8=A7=E0=B9=88=E0=B8=B2=E0=B8=81=E0=B8=A5=E0=B8=B8=E0=B9=88?= =?UTF-8?q?=E0=B8=A1=E0=B8=81=E0=B8=B5=E0=B9=88=E0=B9=84=E0=B8=9F=E0=B8=A5?= =?UTF-8?q?=E0=B9=8C=20=E0=B8=81=E0=B8=A3=E0=B8=AD=E0=B8=87=20userIds/anno?= =?UTF-8?q?tationTypes=20=E0=B9=83=E0=B8=99=20SQL=20=E0=B9=80=E0=B8=A3?= =?UTF-8?q?=E0=B8=B5=E0=B8=A2=E0=B8=87=20F15/N5=20(4)=20route:=20auth=20?= =?UTF-8?q?=E0=B8=81=E0=B9=88=E0=B8=AD=E0=B8=99=E0=B9=81=E0=B8=95=E0=B8=B0?= =?UTF-8?q?=20DB=20+=20=E0=B8=84=E0=B8=A3=E0=B8=AD=E0=B8=9A=20try/catch=20?= =?UTF-8?q?=E0=B8=97=E0=B8=B8=E0=B8=81=20DB=20call=20=E0=B8=A5=E0=B9=89?= =?UTF-8?q?=E0=B8=A1=20=3D=20log=20=E0=B8=9D=E0=B8=B1=E0=B9=88=E0=B8=87=20?= =?UTF-8?q?server=20+=20500=20JSON=20=E0=B8=95=E0=B8=B2=E0=B8=A1=20contrac?= =?UTF-8?q?t=20=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=9B=E0=B8=A5=E0=B9=88?= =?UTF-8?q?=E0=B8=AD=E0=B8=A2=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20500=20?= =?UTF-8?q?=E0=B8=82=E0=B8=AD=E0=B8=87=20framework,=20read-only=20?= =?UTF-8?q?=E0=B9=84=E0=B8=A1=E0=B9=88=20publish=20SSE=20=E0=B9=84?= =?UTF-8?q?=E0=B8=A1=E0=B9=88=20revalidatePath=20=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B8=A1=E0=B8=B5=20CORS,=20REDACTION=20=E0=B8=A3?= =?UTF-8?q?=E0=B8=A7=E0=B8=A1=E0=B9=83=E0=B8=99=E0=B8=9C=E0=B8=A5=E0=B8=A5?= =?UTF-8?q?=E0=B8=B1=E0=B8=9E=E0=B8=98=E0=B9=8C=20(5)=20=E0=B8=96=E0=B8=AD?= =?UTF-8?q?=E0=B8=94=20F16/N7=20=E0=B8=95=E0=B8=B2=E0=B8=A1=E0=B8=84?= =?UTF-8?q?=E0=B8=B3=E0=B8=82=E0=B8=AD=E0=B8=9C=E0=B8=B9=E0=B9=89=E0=B9=83?= =?UTF-8?q?=E0=B8=8A=E0=B9=89=202026-09-25=20(N10=20=E0=B8=84=E0=B8=A3?= =?UTF-8?q?=E0=B8=AD=E0=B8=9A=E0=B8=84=E0=B8=A5=E0=B8=B8=E0=B8=A1=E0=B8=82?= =?UTF-8?q?=E0=B9=89=E0=B8=AD=E0=B8=A1=E0=B8=B9=E0=B8=A5=E0=B8=8A=E0=B8=B8?= =?UTF-8?q?=E0=B8=94=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B8=81?= =?UTF-8?q?=E0=B8=B1=E0=B8=99=E0=B8=9E=E0=B8=A3=E0=B9=89=E0=B8=AD=E0=B8=A1?= =?UTF-8?q?=20auth):=20=E0=B8=A5=E0=B8=9A=20app/api/annotations/export-csv?= =?UTF-8?q?=20+=20app/api/bookmarks/export=20+=20utils/csv.ts=20+=20tests/?= =?UTF-8?q?utils/csv.test.ts=20+=20=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1?= =?UTF-8?q?=E0=B8=AA=E0=B9=88=E0=B8=87=E0=B8=AD=E0=B8=AD=E0=B8=81=202=20?= =?UTF-8?q?=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1=E0=B8=9A=E0=B8=99=E0=B8=AB?= =?UTF-8?q?=E0=B8=99=E0=B9=89=E0=B8=B2=E0=B8=AA=E0=B8=A3=E0=B8=B8=E0=B8=9B?= =?UTF-8?q?=20+=20=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1=20JSON=20=E0=B9=83?= =?UTF-8?q?=E0=B8=99=20sidebar=20+=20=E0=B8=A3=E0=B8=B9=E0=B8=9B=20require?= =?UTF-8?q?ment=201.png/2.png=20(=E0=B8=9A=E0=B8=A3=E0=B8=A3=E0=B8=97?= =?UTF-8?q?=E0=B8=B1=E0=B8=94=E0=B8=AD=E0=B9=89=E0=B8=B2=E0=B8=87=E0=B8=96?= =?UTF-8?q?=E0=B8=B6=E0=B8=87=E0=B9=83=E0=B8=99=20gap-analysis.md=20?= =?UTF-8?q?=E0=B9=80=E0=B8=9B=E0=B8=A5=E0=B8=B5=E0=B9=88=E0=B8=A2=E0=B8=99?= =?UTF-8?q?=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20historical=20note)=20(6)?= =?UTF-8?q?=20docs=20sync=20=E0=B8=84=E0=B8=A3=E0=B8=9A:=20PLAN-API.md=20?= =?UTF-8?q?=E0=B9=81=E0=B8=9C=E0=B8=99=E0=B8=89=E0=B8=9A=E0=B8=B1=E0=B8=9A?= =?UTF-8?q?=E0=B9=80=E0=B8=95=E0=B9=87=E0=B8=A1=209=20=E0=B8=A3=E0=B8=AD?= =?UTF-8?q?=E0=B8=9A=20+=20PLAN-FEAT.md=20=C2=A77=20N10=20+=20PLAN-BOOKMAR?= =?UTF-8?q?K-PDF.md/gap-analysis.md=205.3.14=20=E0=B9=80=E0=B8=9B=E0=B8=A5?= =?UTF-8?q?=E0=B8=B5=E0=B9=88=E0=B8=A2=E0=B8=99=E0=B8=95=E0=B8=AD=E0=B8=9A?= =?UTF-8?q?=E0=B8=94=E0=B9=89=E0=B8=A7=E0=B8=A2=20N10=20+=20CLAUDE.md=20+?= =?UTF-8?q?=20README=20(env/=E0=B8=95=E0=B8=B1=E0=B8=A7=E0=B8=AD=E0=B8=A2?= =?UTF-8?q?=E0=B9=88=E0=B8=B2=E0=B8=87=20curl/troubleshooting)=20+=20.env.?= =?UTF-8?q?example=20+=20.claude/rules/data-layer.md=20=E2=80=94=20?= =?UTF-8?q?=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=20code=20review=202=20?= =?UTF-8?q?=E0=B8=A3=E0=B8=AD=E0=B8=9A=202026-09-25:=20=E0=B9=81=E0=B8=81?= =?UTF-8?q?=E0=B9=89=20500=20JSON=20=E0=B8=95=E0=B8=AD=E0=B8=99=20DB=20?= =?UTF-8?q?=E0=B8=A5=E0=B9=89=E0=B8=A1=20+=20=E0=B8=96=E0=B8=AD=E0=B8=94?= =?UTF-8?q?=20fan-out=20=E0=B8=95=E0=B9=88=E0=B8=AD=E0=B9=84=E0=B8=9F?= =?UTF-8?q?=E0=B8=A5=E0=B9=8C=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20bulk?= =?UTF-8?q?=20+=20=E0=B8=96=E0=B8=AD=E0=B8=94=20as=20cast=20=E0=B9=81?= =?UTF-8?q?=E0=B8=97=E0=B8=99=20type=20guard=20(tsc=20+=20vitest=20149/149?= =?UTF-8?q?)=20(=E0=B8=84=E0=B8=B3=E0=B8=82=E0=B8=AD=E0=B8=9C=E0=B8=B9?= =?UTF-8?q?=E0=B9=89=E0=B9=83=E0=B8=8A=E0=B9=89=202026-09-25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- .claude/rules/data-layer.md | 1 + .env.example | 3 + CLAUDE.md | 9 +- README.md | 39 ++- app/annotate/[id]/summary/page.tsx | 28 +- app/api/annotations/export-csv/route.ts | 55 ---- app/api/bookmarks/export/route.ts | 52 ---- app/api/external/documents/route.ts | 98 +++++++ components/sidebar/AnnotationSidebar.tsx | 13 +- docs/PLAN-API.md | 82 ++++-- docs/PLAN-BOOKMARK-PDF.md | 2 + docs/PLAN-FEAT.md | 16 +- docs/requirement/1.png | Bin 133116 -> 0 bytes docs/requirement/2.png | Bin 367778 -> 0 bytes docs/requirement/gap-analysis.md | 6 +- lib/external-api-auth.ts | 43 ++++ lib/external-api.ts | 312 +++++++++++++++++++++++ tests/lib/external-api-auth.test.ts | 74 ++++++ tests/utils/csv.test.ts | 36 --- utils/csv.ts | 9 - 20 files changed, 656 insertions(+), 222 deletions(-) delete mode 100644 app/api/annotations/export-csv/route.ts delete mode 100644 app/api/bookmarks/export/route.ts create mode 100644 app/api/external/documents/route.ts delete mode 100644 docs/requirement/1.png delete mode 100644 docs/requirement/2.png create mode 100644 lib/external-api-auth.ts create mode 100644 lib/external-api.ts create mode 100644 tests/lib/external-api-auth.test.ts delete mode 100644 tests/utils/csv.test.ts delete mode 100644 utils/csv.ts diff --git a/.claude/rules/data-layer.md b/.claude/rules/data-layer.md index 6bd6e41..0f258aa 100644 --- a/.claude/rules/data-layer.md +++ b/.claude/rules/data-layer.md @@ -22,3 +22,4 @@ paths: - `files.pdf_url` เก็บ path **รูปภาพ** (jpg/jpeg/png/tif/tiff) ใน `public/` ได้เหมือน PDF (N3) — **ไม่มี column kind**: ชนิดไฟล์ derive จาก extension ผ่าน `utils/file-kind.ts` เท่านั้น (จะเพิ่ม column ต้องแก้แผนก่อน); TIFF หลายเฟรม = `pageIndex` ต่อเฟรม (1 เฟรม = 1 หน้า) - Server Action `uploadFile` (`app/actions/files.ts`, N4): validate extension (whitelist `SAMPLE_FILE_EXTENSIONS`) + ≤ 100MB ก่อนเขียน — บันทึกเป็นไฟล์**ใหม่** `public/uploads/.` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row (รวม `type_id` จากช่องกลุ่มทางเลือกของฟอร์ม — 2026-09-23) แล้ว `revalidatePath("/")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/` - `files.type_id` (N9 — รหัสกลุ่มเอกสาร จากลิงก์ e-Court `?typeId=` หรือช่อง "กลุ่ม typeId" ของฟอร์มอัปโหลด N4): **เขียนสองที่** — (1) `registerExternalFile` ของ N8 (insert ครั้งแรก + update เมื่อลิงก์รอบใหม่ส่ง `typeId` มา — ลิงก์ไม่ส่ง = คงเดิม ห้ามเคลียร์) (2) `uploadFile` ของ N4 ช่องทางเลือกในฟอร์มอัปโหลด — insert ตอนสร้าง row ใหม่เท่านั้น ไม่มีหน้าทางแก้กลุ่มของไฟล์เดิม; normalize ผ่าน `normDocumentGroup` ของ `utils/external-file.ts` ที่เดียว (trim → ว่าง = NULL, cap 200 — responsive audit 2026-09-24 รวมจากเดิมที่เป็นสองสำเนา mirroring กัน) — N9 อ่านอย่างเดียว (หน้า `/` จัด section + `/groups/[typeId]`) ไม่มี Server Action/SSE ใหม่; กลุ่ม = ค่า distinct ของ `type_id` ณ ตอน query — ไม่มีตารางกลุ่ม +- API ภายนอก (N10 — `app/api/external/documents` เส้นเดียว **POST + JSON body** `{fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}}` — ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข): **read-only ทั้งหมด** — ไม่เขียน DB ไม่ publish SSE event ไม่ `revalidatePath` (snapshot ณ ตอนเรียก ต่าง context กับ Server Action ใน invariant #4); auth ผ่าน `requireExternalApiKey` ของ `lib/external-api-auth.ts` เป็นอย่างแรกก่อนแตะ DB เสมอ (header `x-api-key` เทียบ env `EXTERNAL_API_KEY` แบบ timing-safe) — **env ว่าง/ไม่ตั้ง = fail closed 401 ทุก request** ไม่มี mode เปิดฟรี; ผู้เรียกคือ backend e-Court (server-to-server) จึงไม่ใส่ CORS headers; validation ของ body (`parseQueryBody`) + การ query/mapper (`loadDocumentBundles` — bulk `inArray` ทุกไฟล์ในชุด query เดียว ไม่ยิงต่อไฟล์) รวมอยู่ `lib/external-api.ts` จุดเดียว ห้ามแยกสำเนา; route ครอบ try/catch ทุก DB call — query ล้ม = log ฝั่ง server + **500 JSON ตาม contract** ของ endpoint (ไม่ปล่อยเป็น 500 ของ framework); `lib/external-api-auth.ts` ห้าม import module ที่ลาก `@/db` มา (unit test ต้องรันได้ไม่ต้องตั้ง DATABASE_URL) diff --git a/.env.example b/.env.example index cb82ad0..7e1100d 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,6 @@ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note # N8 (optional): allowlist โฮสต์ลิงก์ไฟล์รีโมต — ไม่ตั้ง = admincourt.go.th # EXTERNAL_FILE_HOSTS=admincourt.go.th +# N10 (optional): API key ของ /api/external/* (backend e-Court เรียก server-to-server) +# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร +# EXTERNAL_API_KEY= diff --git a/CLAUDE.md b/CLAUDE.md index 37fd3d7..3425796 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,8 @@ lib/server-pdf.ts ← path guard อ่าน PDF จาก public/ (export lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court) lib/external-import.ts ← N8 registerExternalFile — ลงทะเบียนเอกสารจากลิงก์ e-Court metadata-only (dedupe external_id + refresh pdf_url/typeId — เรียกระหว่าง render หน้า / และ /annotate ไม่ใช่ Server Action, ดู carve-out ใน invariant #4; `firstSearchParam` อยู่ที่ `utils/external-file.ts` แทน) lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allowlist (validate ซ้ำหลัง redirect + จำกัด 100MB + timeout 60s — /api/pdf กับ export ใช้ร่วม) +lib/external-api-auth.ts ← N10 auth ของ /api/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB) +lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม) utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม) @@ -60,14 +62,13 @@ app/actions/auth.ts ← Server Action login (mock user_x/Demo1234 → ตั app/actions/files.ts ← Server Action uploadFile — validate + เขียนไฟล์ใหม่ที่ public/uploads/ + ช่องกลุ่ม typeId ทางเลือก normalize เหมือน N8 (N4) app/api/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=) app/api/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8) -app/api/annotations/export-csv/route.ts ← export สรุป CSV พร้อม BOM (F16) -app/api/bookmarks/export/route.ts ← ส่งออกรายการ bookmark เป็น JSON (N7 — ข้อมูลล้วนจาก DB ไม่โหลดไฟล์ต้นฉบับ; เดิมเป็นรายงาน PDF เปลี่ยนตามคำขอผู้ใช้ 2026-09-17) +app/api/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก) app/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route) app/page.tsx ← รายการไฟล์จัด section ตามกลุ่ม type_id + "ไม่มีกลุ่ม" ท้ายสุด (N9 — server component) app/groups/[typeId]/page.tsx ← N9 หน้าเอกสารในกลุ่ม (server component force-dynamic — files where type_id orderBy createdAt, ไม่มี = 404; ~~หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"~~ ถอดออกตามคำขอผู้ใช้ 2026-09-24; **redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar** — explorer 2 คอลัมน์ ตัวกรองซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์: query ทั้งหมดฝั่ง server แล้วส่ง `GroupFileSummary[]` + `GroupListItem[]` (เรียง ไฟล์ตาม createdAt → หน้า → bookmark ก่อน annotation → createdAt) ให้ `GroupExplorer` กรองใน memory, แถวรายการ deep link `?ann=`/`?page=` เหมือนหน้าสรุป F12 + ไฟล์ที่มาต้น meta line) app/login/page.tsx ← หน้า login (มี session แล้ว redirect /) app/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer -app/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark ของไฟล์เดียว (server component — query ทั้งหมดฝั่ง server แล้วส่ง `ExplorerItem[]` เรียง หน้า → หน้าเดียวกัน bookmark ก่อน annotation (bookmark ต่อหน้าเรียงตาม yOffset N5 เหมือนเดิม, annotation ตาม createdAt) ให้ `SummaryExplorer` กรองใน memory; design "แฟ้มสำนวน" 2026-09-17 แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio + รายการตามหน้าจุดไข่ปลา → **redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer 2 คอลัมน์แบบหน้ากลุ่ม N9 โดยมิติแรกของตัวกรองเป็น "หน้า" แทน "ไฟล์"** — แถบหัวน้ำเงิน + action bar (กลับหน้าอ่าน/ส่งออก CSV F16/ส่งออก Bookmark JSON N7) + empty state คงเดิม, ไฟล์ว่าง = ไม่ mount explorer เลย; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + แผงตัวกรอง/แถบ action ซ่อนด้วย print:hidden — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ
(กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`) +app/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark ของไฟล์เดียว (server component — query ทั้งหมดฝั่ง server แล้วส่ง `ExplorerItem[]` เรียง หน้า → หน้าเดียวกัน bookmark ก่อน annotation (bookmark ต่อหน้าเรียงตาม yOffset N5 เหมือนเดิม, annotation ตาม createdAt) ให้ `SummaryExplorer` กรองใน memory; design "แฟ้มสำนวน" 2026-09-17 แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio + รายการตามหน้าจุดไข่ปลา → **redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer 2 คอลัมน์แบบหน้ากลุ่ม N9 โดยมิติแรกของตัวกรองเป็น "หน้า" แทน "ไฟล์"** — แถบหัวน้ำเงิน + action bar (กลับหน้าอ่าน — ปุ่มส่งออก CSV F16/ส่งออก Bookmark JSON N7 ถอด 2026-09-25 แทนด้วย N10 API) + empty state คงเดิม, ไฟล์ว่าง = ไม่ mount explorer เลย; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + แผงตัวกรอง/แถบ action ซ่อนด้วย print:hidden — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ
(กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`) components/summary/SummaryExplorer.tsx ← client root ของหน้าสรุป (redesign 2026-09-24) — state ตัวกรอง `FacetFilterState` จากแผงกลาง explorer + กรองใน memory + facet count/static users derive เอง + มิติ "หน้า" เป็นตัวเลือกแบบ single-select เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (`optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหน้าเยอะดันกลุ่มอื่นออกจอ) + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `
`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `
` ไม่ใช่ `
` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน) components/summary/SummaryList.tsx ← คอลัมน์ content ของหน้าสรุป — การ์ดรายหน้า "1 หน้า 1 การ์ด" หัวการ์ด sticky ระดับ viewport (เลขหน้า + บรรทัดสรุปหน้า `pageSummaryLine` **คำนวณจากรายการที่เห็นจริงหลังกรอง** + ปุ่ม "เปิดหน้านี้"; กลับเป็น position static ตอนพิมพ์ผ่านกฎ `.page-head` ใน globals.css) + แถว passive จุดคลิกเดียวคือปุ่ม `` "View" เหมือน GroupList (deep link `?ann=`/`?page=` — bookmark ไปหน้า, annotation ไปรายการ; meta line ไม่มี segment ไฟล์เพราะไฟล์เดียวทั้งหน้า) + header "พบ N รายการ" (aria-live) + ปุ่ม "ล้างตัวกรอง" (เฉพาะมีตัวกรอง active) + empty state กรองจนไม่เหลือ + **ตอนพิมพ์แสดงฉบับเต็มเสมอ** — ตัวกรองที่ตั้งค้างต้องไม่ตัดรายการออกเงียบ ๆ บนกระดาษ: ตอน activeCount > 0 render ชุดบนจอ (print:hidden) + ชุดเต็ม (hidden print:block, ไม่มี id/aria-labelledby ซ้ำ) แยกกัน; anchor `#page-N` ของ section คงจาก design เดิมให้ลิงก์เก่ายังใช้ได้ components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า) @@ -88,7 +89,7 @@ components/DotGrid.tsx ← กริดจุดอันตรกิริย ## ขอบเขต — ห้ามทำเกิน (Out of Scope) -> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย + ช่องกลุ่ม typeId ทางเลือก ไม่กรอก = ไม่มีกลุ่ม (N4 — เพิ่มช่องกลุ่ม 2026-09-23 ตามคำขอผู้ใช้), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md` — **2026-09-17 เปลี่ยน deliverable เป็นส่งออก JSON** แทนรายงาน PDF ตามคำขอผู้ใช้: flat array `BookmarkDTO` เรียง N5 + envelope fileId/title/exportedAt ผ่าน route เดิม), เปิดเอกสารจากลิงก์ e-Court `?fileUrl=&fileId=&typeId=` — ลงทะเบียน metadata-only (dedupe `external_id`, ไม่เก็บสำเนาไฟล์) + ดึงไฟล์สดตอนเปิดดู/export ผ่าน allowlist + การ์ดจำลองลิงก์บนหน้ารายการ + รับ path ในเครื่อง (`/samples/…`) เพื่อทดสอบ grouping ใน dev (N8 — แผน `docs/PLAN-ECOURT.md`, implement ครบแล้ว 2026-09-22 รวม D5 login วนกลับ), กลุ่มเอกสารตาม `type_id` จากลิงก์ e-Court — หน้ารายการ `/` จัด section ต่อกลุ่ม + ส่วน "ไม่มีกลุ่ม" ท้ายสุด และหน้า `/groups/[typeId]` แสดงเอกสารที่รู้จักของกลุ่ม (~~หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"~~ ถอดออก 2026-09-24 ตามคำขอผู้ใช้) + ~~การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ทั้งไฟล์~~ (2026-09-23 ตามคำขอผู้ใช้ — ปิด gap 5.3.10 ครึ่งหลัง bookmark รวมทั้งกลุ่ม; **redesign 2026-09-24 ตามคำขอผู้ใช้**: ถอด navbar + ตัวกรองฝั่งซ้าย (ไฟล์/ชนิดรายการ/ชนิด annotation/ผู้บันทึก) + list เดียวรวมทั้งกลุ่มไม่แยกไฟล์ ผ่าน `components/groups/`); อ่าน `type_id` อย่างเดียว — เขียนสองที่: N8 `registerExternalFile` + ช่องกลุ่มทางเลือกของฟอร์มอัปโหลด N4 `uploadFile` (2026-09-23 ตามคำขอผู้ใช้ — insert ตอนสร้าง row ใหม่เท่านั้น normalize เหมือน N8: trim → ว่าง = NULL, cap 200) ไม่มี action/SSE ใหม่) (N9 — แผน `docs/PLAN-DOCSET.md`) +> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON), F16 (export CSV) และ N7 (ส่งออก Bookmark JSON) ที่ถูกถอดออกจากโค้ดแล้ว — F16/N7 ถอด 2026-09-25 ตามคำขอผู้ใช้ แทนด้วย N10 API (ระบบภายนอกดึงข้อมูลผ่าน `/api/external/documents`)**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), ~~export CSV (F16)~~ (ถอดแล้ว — แทนด้วย N10), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย + ช่องกลุ่ม typeId ทางเลือก ไม่กรอก = ไม่มีกลุ่ม (N4 — เพิ่มช่องกลุ่ม 2026-09-23 ตามคำขอผู้ใช้), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), ~~รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md` — **2026-09-17 เปลี่ยน deliverable เป็นส่งออก JSON** แทนรายงาน PDF ตามคำขอผู้ใช้: flat array `BookmarkDTO` เรียง N5 + envelope fileId/title/exportedAt ผ่าน route เดิม)~~ (**ถอดออกจากโค้ดแล้ว 2026-09-25 ตามคำขอผู้ใช้ — แทนด้วย N10 API**: route + ปุ่มหน้าสรุป + ปุ่มใน sidebar ถอดครบ), เปิดเอกสารจากลิงก์ e-Court `?fileUrl=&fileId=&typeId=` — ลงทะเบียน metadata-only (dedupe `external_id`, ไม่เก็บสำเนาไฟล์) + ดึงไฟล์สดตอนเปิดดู/export ผ่าน allowlist + การ์ดจำลองลิงก์บนหน้ารายการ + รับ path ในเครื่อง (`/samples/…`) เพื่อทดสอบ grouping ใน dev (N8 — แผน `docs/PLAN-ECOURT.md`, implement ครบแล้ว 2026-09-22 รวม D5 login วนกลับ), กลุ่มเอกสารตาม `type_id` จากลิงก์ e-Court — หน้ารายการ `/` จัด section ต่อกลุ่ม + ส่วน "ไม่มีกลุ่ม" ท้ายสุด และหน้า `/groups/[typeId]` แสดงเอกสารที่รู้จักของกลุ่ม (~~หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"~~ ถอดออก 2026-09-24 ตามคำขอผู้ใช้) + ~~การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ทั้งไฟล์~~ (2026-09-23 ตามคำขอผู้ใช้ — ปิด gap 5.3.10 ครึ่งหลัง bookmark รวมทั้งกลุ่ม; **redesign 2026-09-24 ตามคำขอผู้ใช้**: ถอด navbar + ตัวกรองฝั่งซ้าย (ไฟล์/ชนิดรายการ/ชนิด annotation/ผู้บันทึก) + list เดียวรวมทั้งกลุ่มไม่แยกไฟล์ ผ่าน `components/groups/`); อ่าน `type_id` อย่างเดียว — เขียนสองที่: N8 `registerExternalFile` + ช่องกลุ่มทางเลือกของฟอร์มอัปโหลด N4 `uploadFile` (2026-09-23 ตามคำขอผู้ใช้ — insert ตอนสร้าง row ใหม่เท่านั้น normalize เหมือน N8: trim → ว่าง = NULL, cap 200) ไม่มี action/SSE ใหม่) (N9 — แผน `docs/PLAN-DOCSET.md`), API ดึงข้อมูลให้ระบบภายนอกด้วย **API Key เดียวใน env `EXTERNAL_API_KEY`** — endpoint เดียว `POST /api/external/documents` (JSON body `{fileId และ/หรือ typeId, filter:{type: bookmarks|annotations, userId, annotationTypes}}` — เดิม 2 GET query-param รวมเป็นเส้นเดียวตามคำขอผู้ใช้ 2026-09-24 + รับ fileId/typeId พร้อมกันเป็น AND เหลือเอกสารเดียวตามคำขอผู้ใช้ 2026-09-25) ให้ backend e-Court เรียก server-to-server ผ่าน header `x-api-key` คืน `{documents:[{field เอกสาร + annotations(พร้อม url deep link กลับหน้า /annotate/[id] + replies) + bookmarks(พร้อม url) ระดับเดียวกัน}]}` รูปเดียวเสมอ — ไม่เขียน DB ไม่ publish SSE ไม่ revalidatePath, ไม่มี CORS, REDACTION รวมในผลลัพธ์ (key เชื่อถือระดับ backend e-Court เท่านั้น) (N10 — แผน `docs/PLAN-API.md`) - ไม่มี auth จริง/roles (login เป็น mock หน้า `/login` ตั้ง cookie `oacCase` — ไม่ได้ตรวจกับระบบ e-Court จริง), ไม่มี presence — undo/redo เป็น history ฝั่ง client ต่อ session ต่อผู้ใช้ ไม่เก็บใน DB (D10) ส่วน version history/กู้คืน (F19) เก็บในตาราง `annotation_versions` - Annotation: สร้าง + ลบ + แก้ไข/ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา/แก้ไขข้อความ **ของตัวเอง ในหน้าเดียวกัน** — resolve เป็นสิ่งเดียวที่ใครก็ toggle ได้ (D6) diff --git a/README.md b/README.md index 8d8b89d..d5fdf07 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,12 @@ docker run -d --name judes-note-db \ ```env DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note +# N10 (optional): API key ของ /api/external/* สำหรับ backend e-Court เรียกแบบ server-to-server +# ไม่ตั้งหรือเว้นว่าง = API ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร +# EXTERNAL_API_KEY= ``` -(เทียบกับ `.env.example` — มีแค่ตัวเดียว) +(เทียบกับ `.env.example`) ### 4. สร้างตารางในฐานข้อมูล @@ -89,12 +92,46 @@ Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกร ทดสอบ realtime: เปิดหน้า annotate ไฟล์เดียวกันใน 2 แท็บ แล้ววาด/comment จากแท็บหนึ่ง — อีกแท็บเห็นทันทีโดยไม่ต้อง refresh +## API สำหรับระบบภายนอก (N10) + +Read-only API ให้ **backend ของ e-Court** เรียกแบบ server-to-server (ไม่มีหน้า UI) — endpoint เดียว `POST /api/external/documents` (JSON body) ตรวจสิทธิ์ด้วย API Key เดียวใน env `EXTERNAL_API_KEY` ส่งผ่าน header `x-api-key` (env ไม่ตั้ง/ว่าง = ปฏิเสธทุก request — fail closed) — รายละเอียดเต็ม: `docs/PLAN-API.md` + +```jsonc +// POST /docedit/note/api/external/documents (URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath) +// headers: x-api-key + content-type: application/json +{ + "fileId": "", // ถามเอกสารเดียว — ส่งพร้อม typeId ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข (AND) + "typeId": "", // ถามทุกเอกสารในกลุ่ม N9 — กลุ่มว่าง = 200 documents: [] + "filter": { + "type": "bookmarks", // optional: "bookmarks" | "annotations" — ไม่ส่ง (หรือ type เป็น "") = ทั้งสอง + "userId": "", // optional: string หรือ array — กรองตามเจ้าของรายการ (ทั้ง annotations/bookmarks) + "annotationTypes": ["COMMENT"], // optional: ค่าจาก HIGHLIGHT|DRAW|COMMENT|SHAPE|REDACTION|TEXTBOX|STAMP — เฉพาะ annotations + } +} +``` + +- Response 200 เป็นรูปเดียวเสมอ: `{ "documents": [ { "id", "externalFileId", "title", "typeId", "createdAt", "annotations": [… พร้อม url + replies], "bookmarks": [… พร้อม url] } ] }` (field เอกสารอยู่ระดับเดียวกับ annotations/bookmarks) — ถามด้วย fileId ได้ array ยาว 1 ชิ้น (ไม่เคยเปิดในระบบ = 404) +- `url` ต่อรายการ = deep link กลับมาหน้า viewer เป็น **URL เต็มพร้อมใช้ทันที** (`http:///docedit/note/annotate/?ann=` สำหรับ annotation · `?page=<หน้า 1-based>` สำหรับ bookmark) — origin อ่านจาก request ที่เรียกเข้ามา (ผ่าน reverse proxy ต้องตั้งส่งต่อ `x-forwarded-proto`/`x-forwarded-host`) +- error ทุกสถานะ (400/401/404/500) เป็น JSON `{ "error": "<ข้อความไทย>" }` รูปเดียวกันทั้ง endpoint — 500 เกิดเมื่อ query ฐานข้อมูลล้ม (log รายละเอียดฝั่ง server อย่างเดียว) +- ตัวอย่าง: + +```bash +curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \ + -d '{"fileId":"1234","filter":{"type":"bookmarks"}}' \ + "http://localhost:3000/docedit/note/api/external/documents" + +curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \ + -d '{"typeId":"CASE-2569-0001"}' \ + "http://localhost:3000/docedit/note/api/external/documents" +``` + ## Troubleshooting | อาการ | สาเหตุ/ทางแก้ | |---|---| | Realtime ไม่ซิงก์ | sync ใช้ pub/sub **in-memory ของ process เดียว** — ทุกแท็บต้องเข้าผ่าน dev server เดียวกัน (ถ้าวันหนึ่งรันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) | | หน้าเว็บ 500 หลังรัน `next build` ระหว่างที่ dev server กำลังรัน | build เขียนทับ cache ของ dev — ปิด dev server ก่อน build หรือ restart dev server หลัง build | +| `tsc --noEmit` error ชี้ route ที่ถูกลบไปแล้ว (เช่น `app/api/external/document*` หลังรวมเป็น `query` รอบสองของ N10) | type stub ค้างใน `.next/types/` ของ dev/build รอบก่อน — ลบ `.next` หรือรัน `next build`/restart dev server หนึ่งครั้งให้ regenerate แล้วรันใหม่ | | ต่อ DB ไม่ได้ | ตรวจว่า container รันอยู่ (`docker start judes-note-db`) และพอร์ต 54322 ไม่ถูกใช้โดย container อื่น | | Highlight ใช้ไม่ได้กับไฟล์คดีตัวอย่าง | ไฟล์ `01012-*.pdf` ทั้งสองเป็น**ภาพสแกนล้วน** (ไม่มี text layer) — ใช้ `dashboard-report-2026-09-03.pdf` ซึ่งเป็น text-based PDF แทน จึงจะคลุมดำได้ (DRAW/COMMENT/BOOKMARK ใช้ได้ปกติกับทุกไฟล์) | diff --git a/app/annotate/[id]/summary/page.tsx b/app/annotate/[id]/summary/page.tsx index 34f3dd2..a4ca03b 100644 --- a/app/annotate/[id]/summary/page.tsx +++ b/app/annotate/[id]/summary/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { asc, eq } from "drizzle-orm"; import { notFound } from "next/navigation"; -import { ArrowLeft, Braces, ClipboardList, FileDown } from "lucide-react"; +import { ArrowLeft, ClipboardList } from "lucide-react"; import type { ExplorerItem } from "@/components/explorer/FacetFilterPanel"; import { SummaryExplorer } from "@/components/summary/SummaryExplorer"; @@ -14,7 +14,6 @@ import { toAnnotationDTO, toBookmarkDTO, } from "@/db/schema"; -import { withBasePath } from "@/utils/base-path"; // หน้าสรุปหมายเหตุ (F15/D2) — server component รวม annotation (ทุกผู้ใช้) + bookmark // แต่ละแถวกระโดดเป็น deep link `/annotate/?ann=` / `?page=` (F12) @@ -130,8 +129,10 @@ export default async function SummaryPage({ {/* แถบ action — ghost button ขาวมีขอบ ภาษาเดียวกับการ์ดของหน้า (คำขอผู้ใช้ 2026-09-17 แทนลิงก์ข้อความเปล่า — เห็นเป็นปุ่มชัดขึ้น; จอสัมผัสได้ 44px - จากกฎ coarse; ตอนพิมพ์ซ่อนทิ้ง — ปุ่มกลับ/export ไม่มีความหมายบนกระดาษ) */} -
+ จากกฎ coarse; ตอนพิมพ์ซ่อนทิ้ง — ปุ่มกลับไม่มีความหมายบนกระดาษ) + ปุ่มส่งออก CSV (F16) / Bookmark JSON (N7) ถูกถอด 2026-09-25 ตามคำขอผู้ใช้ + — ระบบภายนอกดึงข้อมูลผ่าน N10 API แทนทั้งหมดแล้ว */} +
กลับหน้าอ่าน -
- {/* F16/D3: export สรุปเป็น CSV (UTF-8 BOM) */} - - - ส่งออกสรุป (CSV) - - {/* N7: ส่งออก Bookmark ทั้งหมดเป็น JSON (label บอกขอบเขตเอง — คู่กับ - "ส่งออกสรุป (CSV)" ข้างบน ไม่ต้องพึ่ง title tooltip ที่จอสัมผัสใช้ไม่ได้) */} - - - ส่งออก Bookmark (JSON) - -
{items.length === 0 ? ( diff --git a/app/api/annotations/export-csv/route.ts b/app/api/annotations/export-csv/route.ts deleted file mode 100644 index 6df5104..0000000 --- a/app/api/annotations/export-csv/route.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { asc, eq } from "drizzle-orm"; - -import { db } from "@/db"; -import { annotations, files, toAnnotationDTO } from "@/db/schema"; -import { annotationNote, annotationPreviewText, annotationTypeLabel } from "@/utils/annotation-display"; -import { csvField } from "@/utils/csv"; -import { encodeRFC5987 } from "@/utils/pdf-export"; - -// Export สรุปหมายเหตุเป็น CSV (F16/D3) — ข้อมูลเดียวกับหน้าสรุป (F15) -// ขึ้นต้นด้วย UTF-8 BOM เพื่อให้ Excel ภาษาไทยอ่านถูกต้อง (RFC 4180: ครอบ field -// ด้วย double quote, ขึ้นบรรทัดใหม่ด้วย CRLF) - -const UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -export async function GET(req: Request) { - const fileId = new URL(req.url).searchParams.get("fileId") ?? ""; - if (!UUID_RE.test(fileId)) { - return new Response("fileId ไม่ถูกต้อง", { status: 400 }); - } - - const [file] = await db.select().from(files).where(eq(files.id, fileId)); - if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 }); - - const rows = await db - .select() - .from(annotations) - .where(eq(annotations.fileId, fileId)) - .orderBy(asc(annotations.pageIndex), asc(annotations.createdAt)); - - const header = ["หน้า", "ชนิด", "ผู้เขียน", "ข้อความ", "โน้ต", "สร้างเมื่อ"]; - const lines = rows.map((row) => { - const a = toAnnotationDTO(row); - return [ - String(a.pageIndex + 1), - annotationTypeLabel(a), - a.userName, - annotationPreviewText(a), - annotationNote(a), - new Date(a.createdAt).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" }), - ] - .map(csvField) - .join(","); - }); - //  = UTF-8 BOM — ต้องเป็นตัวแรกสุดของไฟล์เสมอ - const csv = `${[header.map(csvField).join(","), ...lines].join("\r\n")}\r\n`; - - const filename = encodeRFC5987(`${file.title}-summary.csv`); - return new Response(csv, { - headers: { - "content-type": "text/csv; charset=utf-8", - "content-disposition": `attachment; filename="summary.csv"; filename*=UTF-8''${filename}`, - }, - }); -} diff --git a/app/api/bookmarks/export/route.ts b/app/api/bookmarks/export/route.ts deleted file mode 100644 index 675d470..0000000 --- a/app/api/bookmarks/export/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { asc, eq } from "drizzle-orm"; - -import { db } from "@/db"; -import { bookmarks, files, toBookmarkDTO } from "@/db/schema"; -import { encodeRFC5987 } from "@/utils/pdf-export"; - -// ส่งออกรายการ bookmark เป็น JSON (N7 — เดิมเป็นรายงาน PDF ตาม requirement 5.3.15 -// เปลี่ยนตามคำขอผู้ใช้ 2026-09-17) -// - ข้อมูลล้วนจาก DB ไม่โหลดไฟล์เอกสารต้นฉบับเลย (invariant #1) -// - array flat เก็บ parentId ครบ (lossless) เรียงตามลำดับ N5 เหมือน sidebar -// ต้นไม้ reconstruct จาก parentId ได้เอง จึงไม่ต้อง build tree/cycle guard -// - read-only: ไม่ publish event ไม่ revalidatePath (ไม่เขียน DB) - -const UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -export async function GET(req: Request) { - const fileId = new URL(req.url).searchParams.get("fileId") ?? ""; - if (!UUID_RE.test(fileId)) { - return new Response("fileId ไม่ถูกต้อง", { status: 400 }); - } - - const [file] = await db.select().from(files).where(eq(files.id, fileId)); - if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 }); - - const rows = await db - .select() - .from(bookmarks) - .where(eq(bookmarks.fileId, fileId)) - .orderBy( - asc(bookmarks.pageIndex), - asc(bookmarks.yOffset), - asc(bookmarks.createdAt), - ); - - // item เป็น BookmarkDTO ตรง ๆ (camelCase + ISO string — invariant #5) - const payload = { - fileId: file.id, - title: file.title, - exportedAt: new Date().toISOString(), - bookmarks: rows.map(toBookmarkDTO), - }; - - const filename = encodeRFC5987(`${file.title}-bookmarks.json`); - return new Response(JSON.stringify(payload, null, 2) + "\n", { - headers: { - "content-type": "application/json; charset=utf-8", - // filename เป็น ASCII fallback + filename* UTF-8 สำหรับชื่อไทย (pattern F8/F16) - "content-disposition": `attachment; filename="bookmarks.json"; filename*=UTF-8''${filename}`, - }, - }); -} diff --git a/app/api/external/documents/route.ts b/app/api/external/documents/route.ts new file mode 100644 index 0000000..9284201 --- /dev/null +++ b/app/api/external/documents/route.ts @@ -0,0 +1,98 @@ +import { and, asc, eq } from "drizzle-orm"; + +import { db } from "@/db"; +import { files } from "@/db/schema"; +import { externalApiError, requireExternalApiKey } from "@/lib/external-api-auth"; +import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api"; + +// N10 (PLAN-API.md D2) — POST /api/external/documents +// (เดิมชื่อ /api/external/query ถึงรอบสาม — เปลี่ยนชื่อตาม resource ที่คืน 2026-09-25) +// เส้นเดียวถามด้วย JSON body { fileId และ/หรือ typeId, filter } — response +// รูปเดียวเสมอ { documents: [bundle…] } (bundle ยุบชั้น — field เอกสารอยู่ +// ระดับเดียวกับ annotations/bookmarks): ถาม fileId = ยาว 1 ชิ้น (ไม่เคยเปิด +// ในระบบ = 404 — ไฟล์ url: fallback ของ N8 ค้นด้วย ecourt: prefix ไม่ได้ = +// 404 ตามสัญญาลิงก์), ถาม typeId = ทุกไฟล์ในกลุ่ม N9 (กลุ่มว่าง = documents: []), +// ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (2026-09-25 — +// รอบเดียวกันเคยเป็น OR รวมผลแล้วเปลี่ยนหลังทดสอบจริง: ผู้เรียกต้องการเอกสาร +// เดียวตามที่ส่งมา ไม่ใช่ทั้งกลุ่ม) +// read-only: ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก + +const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" }; + +/** origin เต็มของ request นี้ (ใช้ประกอบ url เต็มใน response — คำขอผู้ใช้ + * 2026-09-25): เชื่อ header ของ reverse proxy ก่อน (x-forwarded-proto / + * x-forwarded-host — proxy หลายชั้นส่งเป็น comma list ใช้ค่าแรก) แล้วค่อย + * fallback จาก host/protocol ของ request ตรง ๆ */ +function requestOrigin(req: Request): string { + const url = new URL(req.url); + const proto = + req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ?? + url.protocol.replace(":", ""); + const host = + req.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ?? + req.headers.get("host") ?? + url.host; + return `${proto}://${host}`; +} + +export async function POST(req: Request) { + const unauthorized = requireExternalApiKey(req); + if (unauthorized) return unauthorized; + + let body: unknown; + try { + body = await req.json(); + } catch { + return externalApiError("body ต้องเป็น JSON ที่ถูกต้อง", 400); + } + + const query = parseQueryBody(body); + if (!query.ok) return externalApiError(query.error, 400); + + const origin = requestOrigin(req); + + // ครอบทุก DB call (code review 2026-09-25) — ล้มไม่ว่าเหตุใด (connection + // หลุด/timeout/table มีปัญหา) ต้องได้ 500 JSON ตาม contract เดียวกับ error + // อื่นของ endpoint นี้ ไม่ใช่ 500 ของ framework ที่ format ต่างออกไป; log + // รายละเอียดฝั่ง server อย่างเดียว — ข้อความตอบกลาง ๆ กัน leak SQL/path + try { + if (query.mode === "file") { + // ส่งทั้งคู่ = AND กรองทั้ง externalId และกลุ่มใน query เดียว เหลือเอกสารเดียว + // (external_id unique — match ได้มากสุด 1 ฉบับ) ไม่ตรง = 404 โดยแยกสาเหตุ: + // ไม่เคยเปิดในระบบ หรืออยู่คนละกลุ่มกับ typeId ที่ระบุ + const [file] = await db + .select() + .from(files) + .where( + query.typeId + ? and( + eq(files.externalId, `ecourt:${query.fileId}`), + eq(files.typeId, query.typeId), + ) + : eq(files.externalId, `ecourt:${query.fileId}`), + ) + .limit(1); + if (!file) { + return externalApiError( + query.typeId + ? "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยเปิด หรือไม่ได้อยู่ในกลุ่ม typeId ที่ระบุ)" + : "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยเปิด)", + 404, + ); + } + const documents = await loadDocumentBundles([file], query.parts, origin); + return new Response(JSON.stringify({ documents }), { headers: JSON_HEADERS }); + } + + const fileRows = await db + .select() + .from(files) + .where(eq(files.typeId, query.typeId)) + .orderBy(asc(files.createdAt)); + const documents = await loadDocumentBundles(fileRows, query.parts, origin); + return new Response(JSON.stringify({ documents }), { headers: JSON_HEADERS }); + } catch (err) { + console.error("external/documents: query ฐานข้อมูลล้ม", err); + return externalApiError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500); + } +} diff --git a/components/sidebar/AnnotationSidebar.tsx b/components/sidebar/AnnotationSidebar.tsx index 9d7fbac..f74f2d0 100644 --- a/components/sidebar/AnnotationSidebar.tsx +++ b/components/sidebar/AnnotationSidebar.tsx @@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } fro import { createPortal } from "react-dom"; import { Bookmark, - Braces, Check, ChevronDown, ChevronRight, @@ -1112,16 +1111,8 @@ export default function AnnotationSidebar({ )} - {/* N7: ส่งออกรายการ bookmark เป็น JSON — ลิงก์ธรรมดา pattern Export CSV (F16) - ส่งออกทุกรายการของไฟล์เสมอ ไม่ผูกกับเงื่อนไขค้นหาที่ตั้งไว้ */} - - - + {/* ปุ่มส่งออก JSON (N7) ถูกถอด 2026-09-25 ตามคำขอผู้ใช้ — ระบบภายนอก + ดึงข้อมูลผ่าน N10 API แทน (ปุ่มเดียวที่เหลือคือ toggle เงื่อนไขค้นหา) */}
{bmShowConditions && ( // จังหวะเดียวทุกแถว: label บนบรรทัด + คู่ input แบ่งความกว้าง grid 1fr–1fr diff --git a/docs/PLAN-API.md b/docs/PLAN-API.md index 840ed3a..0e53fa9 100644 --- a/docs/PLAN-API.md +++ b/docs/PLAN-API.md @@ -1,6 +1,6 @@ # แผน N10 — API ดึงข้อมูลเอกสาร/กลุ่มให้ระบบภายนอก (API Key) -> สถานะ: **ร่างแผน (ยังไม่ implement)** — เสนอ 2026-09-22 ตามคำขอผู้ใช้: "เพิ่ม API ดึงข้อมูลเอกสารทีละรายการ หรือทุกเอกสารที่อยู่ในกลุ่มเดียวกัน เอาทั้งหมดหรือเฉพาะ bookmark ใช้ API Key ในการ Authentication" +> สถานะ: **implement แล้ว 2026-09-24** (รันผ่าน `/implement-feat N10`) — เสนอ 2026-09-22 ตามคำขอผู้ใช้: "เพิ่ม API ดึงข้อมูลเอกสารทีละรายการ หรือทุกเอกสารที่อยู่ในกลุ่มเดียวกัน เอาทั้งหมดหรือเฉพาะ bookmark ใช้ API Key ในการ Authentication" > Dependency: **N8** (`docs/PLAN-ECOURT.md` — `external_id` สำหรับค้นด้วย fileId ของ e-Court) + **N9** (`docs/PLAN-DOCSET.md` — `files.type_id` สำหรับค้นตามกลุ่ม) > ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง `docs/PLAN-FEAT.md` §7 (**N10**) + ขยาย whitelist ใน `CLAUDE.md` ก่อน — เป็น task แรกของแผน แล้วรันผ่าน `/implement-feat N10` @@ -10,55 +10,66 @@ - ระบบเราเป็นฝ่ายถูกเรียกเท่านั้นในแผนนี้ (read-only) — ทิศทาง "เราเรียก e-Court" ไม่มี - `middleware.ts` ยกเว้น `/api/*` อยู่แล้ว (ไม่บังคับ login แบบ oacCase) — จึงตรวจ API Key ในตัว route เอง ไม่ชนกับระบบ login เดิม -## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-22) +## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-22 · ปรับรอบสอง 2026-09-24 · รอบสาม 2026-09-25 · รอบสี่ 2026-09-25 · รอบห้า 2026-09-25 · รอบหก 2026-09-25 · รอบเจ็ด 2026-09-25 · รอบแปด 2026-09-25 · รอบเก้า 2026-09-25) 1. **API Key เดียวใน env** `EXTERNAL_API_KEY` — ผู้เรียกคือ backend e-Court ตัวเดียว เปลี่ยน key = แก้ env แล้ว redeploy (ไม่ทำตาราง key ใน DB / UI จัดการ key) 2. **ระบุเอกสารด้วย fileId ของ e-Court** (ค้น `external_id = "ecourt:"`) — ผู้เรียกรู้จัก id ของตัวเอง ไม่ต้องรู้ UUID ของเรา -3. **2 endpoints ตาม pattern query-param ของบ้าน** (เหมือน `/api/annotations/export?fileId=`) ไม่ใช้ path parameter -4. **`include` เลือกส่วนที่เอา**: `annotations`, `bookmarks` คั่น comma — default = ทั้งสอง ("เอาทั้งหมด"), `include=bookmarks` = เฉพาะ bookmark — ส่วนที่ไม่เลือกคืนเป็น array ว่าง (contract คงที่ ง่ายต่อผู้บริโภค) +3. **POST เส้นเดียว + JSON body** `POST /api/external/documents` — รอบแรกเป็น 2 endpoints แบบ GET query-param (ตาม pattern ของบ้าน), **รอบสอง 2026-09-24 ตามคำขอผู้ใช้รวมเป็นเส้นเดียวถามด้วย body** `{fileId | typeId, filter}` — GET ที่แนบ body ไม่ standard จึงใช้ POST (ยัง read-only เหมือนเดิม) · response รูปเดียวเสมอ (`{documents: [...]}`) ผู้เรียกจัดการ contract เดียว · **รอบสาม 2026-09-25 ตามคำขอผู้ใช้: ส่ง `fileId`+`typeId` พร้อมกันได้ = รวมผล OR** (เดิม 400 กำกวม) และ**ยุบชั้น `document` ใน response** ให้ field เอกสารอยู่ระดับเดียวกับ `annotations`/`bookmarks` (ดูง่ายขึ้น) · **รอบสี่ 2026-09-25 ตามคำขอผู้ใช้: เปลี่ยนชื่อ endpoint เป็น `documents`** (เดิม `query` — ตั้งชื่อตาม resource ที่คืน ตรงกับ response `{documents}`) · **รอบห้า 2026-09-25 ตามคำขอผู้ใช้: ส่ง `fileId`+`typeId` พร้อมกัน = AND** เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (แทน OR รวมผลของรอบสาม — ทดสอบจริงแล้ว OR คืนทั้งกลุ่ม ไม่ใช่สิ่งที่ผู้เรียกต้องการ) · ยังไม่มีผู้เรียกจริงจึงแทนที่ขาด (ถอด GET เดิมทั้งสองออก) +4. **`filter.type` เลือกส่วนที่เอา**: `"bookmarks"` | `"annotations"` — ไม่ส่ง `filter` หรือ `type` เป็นค่าว่าง = ทั้งสอง ("เอาทั้งหมด" — กฎเดียวกับ fileId ว่าง = ไม่ได้ส่ง, คำขอผู้ใช้ 2026-09-24) — ส่วนที่ไม่เลือกคืนเป็น array ว่าง (contract คงที่ ง่ายต่อผู้บริโภค) · **รอบหก 2026-09-25 เพิ่มมิติตัวกรอง**: `filter.userId` (กรองตามเจ้าของรายการ) + `filter.annotationTypes` (ชนิดของ annotation — เดิมชื่อ `types` เปลี่ยนรอบแปด) — ดู D2 ## Design ### D1 — Auth (`lib/external-api-auth.ts` ใหม่) - `requireExternalApiKey(req: Request): Response | null` — คืน `null` เมื่อผ่าน / คืน `Response` 401 เมื่อไม่ผ่าน (body JSON `{ "error": "<ข้อความไทย>" }` + `content-type: application/json; charset=utf-8`) -- อ่าน header **`x-api-key`** → เทียบกับ env `EXTERNAL_API_KEY` ด้วย SHA-256 ทั้งสองค่าแล้ว `crypto.timingSafeEqual` (ทั้งสองยาวเท่ากันหลัง hash — กัน timing + length leak; ค่า env ตั้งให้ยาวและสุ่ม เช่น 32+ ตัวอักษร) +- อ่าน header **`x-api-key`** → เทียบกับ env `EXTERNAL_API_KEY` ด้วย SHA-256 ทั้งสองค่าแล้ว `crypto.timingSafeEqual` (ทั้งสองยาวเท่ากันหลัง hash — กัน timing + length leak; ค่า env ตั้งให้ยาวและสุ่ม เช่น 32+ ตัวอักษร; เทียบกับค่า **trim แล้ว** — กัน trailing newline จาก K8s secret / docker env_file ทำ 401 ทั้งระบบทั้งที่ผู้เรียกส่ง key ถูก — code review 2026-09-24) - **env ไม่ตั้งหรือว่าง = ปฏิเสธทุก request** (fail closed — 401 "API ยังไม่ได้ตั้งค่าคีย์") ไม่มี mode เปิดฟรี - ห้าม log ค่า key / header ดิบ (error log บอกแค่ "auth failed") -### D2 — Endpoints (2 routes ใหม่) +### D2 — Endpoint เดียว (POST + JSON body) -URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath: `https:///docedit/note/api/external/...` +URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath: `https:///docedit/note/api/external/documents` -**1) เอกสารเดียว** — `GET /api/external/document?fileId=&include=annotations,bookmarks` +**`POST /api/external/documents`** — header `x-api-key` + `content-type: application/json`, body: ```jsonc { - "document": { // null = 404 (ไม่เคยเปิดในระบบ) - "id": "", // ใช้อ้างต่อใน DTO ข้างล่าง - "externalFileId": "", - "title": "…", - "typeId": "…", // null ได้ (ไม่มีกลุ่ม) - "createdAt": "" - }, - "annotations": [ AnnotationDTO ตาม db/schema.ts พร้อม "replies": ReplyDTO[] ต่อรายการ ], - "bookmarks": [ BookmarkDTO ตาม db/schema.ts ] + "fileId": "", // ถามเอกสารเดียว — ส่งพร้อม typeId ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข (AND) + "typeId": "", // ถามทุกเอกสารในกลุ่ม N9 + "filter": { + "type": "bookmarks", // optional — "bookmarks" | "annotations", ไม่ส่ง = ทั้งสอง + "userId": "", // optional — string หรือ array, กรองตามเจ้าของรายการ (ทั้ง annotations/bookmarks) + "annotationTypes": ["COMMENT", "HIGHLIGHT"], // optional — ค่าจาก enum annotation_type, มีผลเฉพาะ annotations + } } ``` -**2) ทุกเอกสารในกลุ่ม** — `GET /api/external/documents?typeId=&include=…` +Response 200 เป็นรูปเดียวเสมอ (bundle ยุบชั้น — ไม่มี object `document` ซ้อน, 2026-09-25): ```jsonc { - "typeId": "…", - "documents": [ { "document": {…}, "annotations": […], "bookmarks": […] } ] // รูปร่างเดียวกับ endpoint 1 + "documents": [ // ถามด้วย fileId = ยาว 1 ชิ้น (ไม่เคยเปิดในระบบ = 404) · ถามด้วย typeId = ทุกไฟล์ในกลุ่มเรียง files.createdAt (กลุ่มว่าง = []) · ส่งทั้งคู่ = เฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (ยาว 1 ชิ้น เช่นเดียวกับถาม fileId) + { + "id": "", + "externalFileId": "", // null ได้ — เอกสารในกลุ่มที่ไม่มี fileId ของ e-Court (url: fallback ของ N8 / อัปโหลด N4 พร้อมช่องกลุ่ม — mirror กฎ typeId: null) + "title": "…", + "typeId": "…", // null ได้ (ไม่มีกลุ่ม) + "createdAt": "", + "annotations": [ AnnotationDTO ตาม db/schema.ts พร้อม "url" + "replies": ReplyDTO[] ต่อรายการ ], + "bookmarks": [ BookmarkDTO ตาม db/schema.ts พร้อม "url" ต่อรายการ ] + } + ] } ``` +- **`url` ต่อรายการ = deep link กลับมาหน้า viewer ของเรา** (F12 — เพิ่ม 2026-09-25 ตามคำขอผู้ใช้): annotation → `…/annotate/?ann=` (ไปรายการ) · bookmark → `…/annotate/?page=<หน้า 1-based>` (ไปหน้า — เลขเดียวกับลิงก์ View ของหน้าสรุป/กลุ่ม) — เป็น **URL เต็ม (absolute)** เช่น `https:///docedit/note/annotate/?ann=…`: origin อ่านจาก request ที่เรียกเข้ามา (route เชื่อ header `x-forwarded-proto`/`x-forwarded-host` ของ reverse proxy ก่อน — หลายชั้นส่ง comma list ใช้ค่าแรก — แล้ว fallback host/protocol ของ request ตรง ๆ) + basePath ผ่าน `withBasePath` (เดิมเป็น relative path ให้ผู้เรียกเติม origin เอง — เปลี่ยนตามคำขอผู้ใช้ 2026-09-25) + +- **body validation (strict กัน typo เงียบ ๆ):** ต้องเป็น JSON object · รับเฉพาะ 3 field `fileId`/`typeId`/`filter` (field อื่น = 400 บอกชื่อ field) · `fileId`/`typeId` ต้องเป็น string (ชนิดอื่น = 400; trim แล้วว่าง = ถือว่าไม่ได้ส่ง field นั้น) · ต้องมี `fileId` หรือ `typeId` **อย่างน้อยหนึ่งอย่าง** — ขาดทั้งคู่ = 400; **ส่งพร้อมกันได้ = AND** กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข เหลือยาว 1 ชิ้น (รอบห้า 2026-09-25 · รอบสามเคยเป็น OR รวมผล · เดิมส่งพร้อมกัน = 400 กำกวม — ยังไม่มีผู้เรียกจริงจึงเปลี่ยนกติกาได้ขาด) · `filter` ถ้าส่งต้องเป็น object ที่มีเฉพาะ field `type`/`userId`/`annotationTypes` (field อื่นใน filter = 400) — `type` ค่า `"bookmarks"`/`"annotations"` — ค่าว่าง (หลัง trim) หรือไม่มี = ไม่ได้กรอง เอาทั้งสอง ส่วนค่าอื่น = 400 บอกค่าที่รับ · **`userId`** string หรือ array ของ string (trim แล้วค่าว่างทั้งคู่ = ไม่ได้กรอง) กรองตาม**เจ้าของรายการ**ทั้ง annotations/bookmarks — ไม่ใช่ผู้ตอบ (replies ตามมาครบกับ annotation ที่ผ่านตัวกรอง) · **`annotationTypes`** string หรือ array ค่าจาก enum `annotation_type` (`HIGHLIGHT`/`DRAW`/`COMMENT`/`SHAPE`/`REDACTION`/`TEXTBOX`/`STAMP` — ค่าอื่น = 400 บอกค่าที่รับกัน typo เงียบ) มีผลเฉพาะเมื่อรวม annotations (รอบหก 2026-09-25 · เดิมชื่อ field `types` เปลี่ยนรอบแปด) - **ไม่มีเอกสารในกลุ่ม = 200 + `documents: []`** (ต่างจากหน้า UI `/groups/[typeId]` ที่ 404 — API คืน empty เป็นผลลัพธ์สมเหตุสมผลสำหรับ integration) +- โค้ดกลางของ route: `lib/external-api.ts` — `parseQueryBody` (validate body ตามข้อบน คืน discriminated 2 กรณี: `mode: "file"` มี fileId + typeId อาจมาด้วยสำหรับ AND / `mode: "type"` typeId เดี่ยว) + `loadDocumentBundles(fileRows, parts)` (**bulk**: query รวมชุดเดียวด้วย `inArray` ต่อทุกไฟล์ — annotations/bookmarks คู่ขนาน + replies ตามหลัง = 3 query ต่อ request ไม่ว่ากลุ่มจะกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่มด้วย Promise.all ที่กด connection pool เมื่อกลุ่มใหญ่ — code review 2026-09-25) แล้วจัดกลุ่มรายไฟล์ใน memory (push ตามลำดับ query จึงรักษาลำดับเดิมในไฟล์เดียวกัน) map เป็น DTO — สอง mode ใช้ function เดียวกัน (เอกสารเดียว = array ยาว 1) ได้รูปร่าง bundle ตรงกันเป๊ะ; route กรองเงื่อนไขคู่ (`externalId` + `typeId`) ใน query เดียว และ**ครอบ try/catch ทุก DB call** — ล้ม = log ฝั่ง server + **500 JSON ตาม contract** ไม่ปล่อยเป็น 500 ของ framework ที่ format ต่าง - ลำดับ: `documents` ตาม `files.createdAt` (เหมือนหน้า `/groups/[typeId]`) · `annotations` ตาม `(pageIndex, createdAt)` เหมือนหน้าสรุป F15 · `bookmarks` ตาม N5 (`pageIndex → yOffset → createdAt`) - timestamp เป็น ISO string ทั้งหมด (ผ่าน mapper `toDTO` ที่มีอยู่ — invariant #5), `data` คือ JSONB ตาม discriminated union เดิม -- error ทุกอันเป็น JSON `{ "error": "<ไทย>" }`: 400 (param ขาด/`include` ค่าไม่รู้จัก), 401 (key), 404 (เอกสารเดียว: fileId ไม่เคยลงทะเบียน) +- error ทุกอันเป็น JSON `{ "error": "<ไทย>" }`: 400 (body ไม่ใช่ JSON / object ผิดสัญญา / เงื่อนไข fileId–typeId / `filter` ค่าไม่รู้จัก), 401 (key), 404 (ถามด้วย fileId ที่ไม่เคยลงทะเบียน), **500 (query ฐานข้อมูลล้ม — ข้อความกลาง ๆ ไม่แนบรายละเอียด กัน leak SQL/path, log stack ฝั่ง server อย่างเดียว · เพิ่ม code review 2026-09-25)** ### D3 — การ query / สิทธิ์ / ความปลอดภัย @@ -67,14 +78,14 @@ URL เต็มฝั่งผู้เรียกอยู่ใต้ basePa - ค้น `fileId`: `where eq(files.externalId, "ecourt:" + fileId)` — ไฟล์ที่ลงทะเบียนแบบ fallback `"url:"` (ลิงก์ไม่มี fileId) ค้นด้วยวิธีนี้ไม่ได้ = 404 (ยอมรับ — สัญญาลิงก์ N8 แนะนำให้ส่ง fileId เสมอ) - **REDACTION รวมใน `annotations` เมื่อเลือก include** — ผู้ถือ key เห็นตำแหน่งกล่องปิดพราง (DB เก็บแค่พิกัด ไม่มีเนื้อหาใต้กล่อง) — ระบุไว้ในสเปกว่า key นี้เชื่อถือได้ระดับ backend e-Court เท่านั้น - annotation ทุกสถานะรวมหมด (resolved ดูจาก field `resolved` ได้) ทุกผู้ใช้ (userId/userName อยู่ใน DTO) -- route ทั้งสองเรียก `requireExternalApiKey` เป็นอย่างแรกก่อนแตะ DB +- route เรียก `requireExternalApiKey` เป็นอย่างแรกก่อนแตะ DB ## Docs/rules sync (plan-first — ทำเป็น task แรก ก่อนแตะโค้ด) | ไฟล์ | สิ่งที่แก้ | |---|---| | `docs/PLAN-FEAT.md` §7 | หัวข้อใหม่ **N10 · API ดึงข้อมูลเอกสาร/กลุ่ม (API Key)** (format ตาม N5–N7 + อ้างแผนฉบับเต็ม `docs/PLAN-API.md`) | -| `CLAUDE.md` | whitelist เพิ่ม N10 + โครงสร้างไฟล์เพิ่ม `lib/external-api-auth.ts`, `app/api/external/document/route.ts`, `app/api/external/documents/route.ts` + บันทึก env `EXTERNAL_API_KEY` (README `.env.example` ถ้ามี) | +| `CLAUDE.md` | whitelist เพิ่ม N10 + โครงสร้างไฟล์เพิ่ม `lib/external-api-auth.ts`, `lib/external-api.ts`, `app/api/external/documents/route.ts` + บันทึก env `EXTERNAL_API_KEY` (README `.env.example` ถ้ามี) | | `.claude/rules/data-layer.md` | เพิ่ม: routes ของ N10 เป็น read-only ไม่ publish SSE/`revalidatePath`; env `EXTERNAL_API_KEY` ว่าง = fail closed | | `README.md` | สารบัญ env เพิ่ม `EXTERNAL_API_KEY` + ตัวอย่างการเรียก (curl) | @@ -88,18 +99,37 @@ URL เต็มฝั่งผู้เรียกอยู่ใต้ basePa | 4 | Route กลุ่ม | `app/api/external/documents/route.ts` | curl: หลายไฟล์เรียง `createdAt`, typeId ไม่มี = `documents: []`, รูปร่าง document เหมือน endpoint 1 เป๊ะ | | 5 | ปิดงาน | — | plan-reviewer + code-reviewer + vitest + `tsc`; ทดสอบร่วมกับ N8/N9 ด้วยข้อมูลจริงจากลิงก์ e-Court | +> **รอบสอง (2026-09-24 ตามคำขอผู้ใช้):** task 3+4 ถูกแทนที่ — รวม 2 GET เป็น **`POST /api/external/query` เส้นเดียว** (route `app/api/external/query/route.ts` + `parseQueryBody` ใน `lib/external-api.ts` แทน `parseInclude`; ถอด `app/api/external/document/` กับ `app/api/external/documents/` ออก — ยังไม่มีผู้เรียกจริง) ตาม D2 ฉบับใหม่ +> +> **รอบสาม (2026-09-25 ตามคำขอผู้ใช้):** (1) รับ `fileId`+`typeId` **พร้อมกัน** = รวมผล OR dedupe เรียง `files.createdAt` (ถอดกติกาเดิม "ส่งพร้อมกัน = 400 กำกวม") (2) ยุบชั้น `document` ออกจาก response — field เอกสาร (`id`/`externalFileId`/`title`/`typeId`/`createdAt`) ขึ้นมาอยู่ระดับเดียวกับ `annotations`/`bookmarks` ให้อ่านง่ายขึ้น (3) เพิ่ม `url` deep link กลับมาหน้า `/annotate/[id]` ต่อ annotation/bookmark +> +> **รอบสี่ (2026-09-25 ตามคำขอผู้ใช้):** เปลี่ยนชื่อ endpoint `POST /api/external/query` → **`POST /api/external/documents`** (ย้าย route เป็น `app/api/external/documents/route.ts`) — ตั้งชื่อตาม resource ที่คืน ตรงกับ response `{documents}` · ยังไม่มีผู้เรียกจริงจึงเปลี่ยนได้ขาด ไม่ต้องเก็บ path เดิม +> +> **รอบห้า (2026-09-25 ตามคำขอผู้ใช้):** เปลี่ยน semantics ของการส่ง `fileId`+`typeId` พร้อมกันจาก **OR รวมผล** (รอบสาม) เป็น **AND กรองเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข** — ทดสอบจริงแล้ว OR คืนทั้งกลุ่ม ผู้เรียกต้องการเฉพาะเอกสารตามที่ส่งมา; ไม่ตรงคู่ (ไม่เคยเปิด / คนละกลุ่ม) = 404 แยกสาเหตุในข้อความ · ถอด Map dedupe + sort ที่จำเป็นแค่ตอน OR ออก (เหลือ query เงื่อนไขคู่ในคำสั่งเดียว) +> +> **รอบหก (2026-09-25 ตามคำขอผู้ใช้):** เพิ่มมิติตัวกรองใน `filter` — **`userId`** (string หรือ array — กรองตามเจ้าของรายการทั้ง annotations/bookmarks; replies ตามมาครบกับ annotation ที่ผ่านตัวกรอง ไม่กรองตามผู้ตอบ) + **`types`** (ค่า enum `annotation_type` — มีผลเฉพาะ annotations) ทั้งคู่ค่าว่าง = ไม่ได้กรองตามกฎเดิม · `since` (ดึง incremental) ที่เสนอด้วยถูกตัด — นิยาม "สร้างหลังจาก X" ไม่ครอบคลุมรายการเก่าที่ถูกแก้ไขภายหลัง ถ้าจะมีวันนั้นต้องออกแบบเป็น change feed เต็มรูปแบบ +> +> **รอบเจ็ด (2026-09-25 ตาม code review ของผู้ใช้):** (1) route **ครอบ try/catch ทุก DB call** — ก่อนหน้า query ล้มจะหลุดเป็น unhandled exception กลายเป็น 500 ของ framework ที่ format ต่างจาก JSON error contract ของ endpoint นี้ทั้งหมด (2) **ถอด fan-out ต่อไฟล์** — `loadDocumentBundle` ต่อไฟล์ + `Promise.all` ทั้งกลุ่ม (3 query ต่อไฟล์พร้อมกัน กด connection pool เมื่อกลุ่มใหญ่) เปลี่ยนเป็น `loadDocumentBundles` bulk: `inArray` ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มจะกี่ไฟล์ (ดีกว่า batching เป็นกลุ่มเล็กที่ยังยิง N รอบ) +> +> **รอบแปด (2026-09-25 ตามคำขอผู้ใช้):** เปลี่ยนชื่อ field `filter.types` → **`filter.annotationTypes`** — ชื่อเดิมสั้นจนชนกับ `filter.type` (การเลือก bookmarks/annotations) อ่านสับสน; ยังไม่มีผู้เรียกจริงจึงเปลี่ยนขาด ไม่ต้องรับชื่อเดิม +> +> **รอบเก้า (2026-09-25 ตามคำขอผู้ใช้):** `url` เปลี่ยนจาก relative path เป็น **URL เต็ม** — route อ่าน origin จาก request ของผู้เรียก (`x-forwarded-proto`/`x-forwarded-host` ก่อน แล้ว fallback host/protocol ของ request) ส่งเข้า `loadDocumentBundles` เป็น parameter `origin` — ผู้เรียกใช้ลิงก์ได้ทันทีไม่ต้องประกอบเอง; ข้อควรระวัง: deploy ผ่าน reverse proxy ต้องส่ง header forward ให้ถูกต้อง ไม่งั้น origin จะเป็นของ proxy แทนที่จะเป็น public URL + ## Edge cases (ออกแบบไว้ล่วงหน้า) 1. **env `EXTERNAL_API_KEY` ไม่ตั้ง/ว่าง** → 401 ทุก request (fail closed) — deploy ต้องตั้งก่อนเปิดใช้ 2. **key ผิด** → 401 ข้อความเดียวกับไม่ใส่ key (ไม่บอกว่าขาดอะไร — กัน enumerate) 3. **fileId ที่ e-Court ไม่เคยลิงก์มา** → 404 "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยเปิด)" — ไม่ใช่ 500 4. **ไฟล์ลงทะเบียนแบบ `"url:"`** (ลิงก์ไม่มี fileId) → ค้นไม่ได้ = 404 — เอกสารนี้ต้องเข้าผ่านลิงก์ที่มี fileId ก่อน -5. **`include` ซ้ำค่า / มี whitespace / ค่าไม่รู้จัก** → trim + dedupe; มีค่านอก `{annotations, bookmarks}` สักตัว = 400 บอกค่าที่รับ -6. **ไม่มี param `fileId`/`typeId` เลย** → 400 ข้อความไทยระบุ param ที่ขาด -7. **`typeId` ตัวเปล่า / `include` ตัวเปล่า** → 400 (ไม่ปล่อยผ่านเป็น "ทั้งหมด") +5. **`filter.type` ค่าไม่รู้จัก** (รวมเอกพจน์ "annotation"/"bookmark") → 400 บอกค่าที่รับ · **`type` ค่าว่าง/whitespace ล้วน หรือไม่มี (`filter: {}`)** → ไม่ได้กรอง เอาทั้งสอง (กฎเดียวกับ fileId ว่าง — คำขอผู้ใช้ 2026-09-24 ตอนทดลองยิง `"type": ""` เจอ 400) — เดิมรอบแรกเป็น query-param `include` แบบ comma (trim + dedupe) เปลี่ยนมาเป็น `filter.type` ตามรอบสอง +6. **ไม่มีทั้ง `fileId` และ `typeId`** (ไม่ส่งเลย หรือส่งมาแต่ค่าว่าง/whitespace ล้วนจนไม่เหลือ) → 400 ข้อความไทย (ต้องระบุอย่างน้อยหนึ่งอย่าง) +7. **ส่งทั้ง `fileId` และ `typeId` พร้อมกัน** → AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (รอบห้า 2026-09-25 · รอบสามเคยเป็น OR รวมผล · เดิม = 400 กำกวม) · **ไม่ตรงคู่ = 404** — ไฟล์ไม่เคยเปิดในระบบ หรืออยู่คนละกลุ่มกับ `typeId` ที่ระบุ (แยกสาเหตุในข้อความ) · **field นอกเหนือ `fileId`/`typeId`/`filter`** → 400 บอกชื่อ field (กัน typo เช่น `fileID` เงียบ ๆ) · **`filter` ผิดสัญญา** (ไม่ใช่ object / มี field อื่นนอกจาก `type` / ไม่มี `type`) → 400 8. **เอกสารไม่มี annotation/bookmark เลย** → arrays ว่าง ไม่ใช่ 404 (ไฟล์เคยเปิด = ข้อมูลเอกสารมีค่าเสมอ) 9. **ไฟล์ไม่มีกลุ่ม** → `document.typeId: null` (ไม่ใช่ string ว่าง) 10. **คนละรอบเรียกพร้อมกัน** → read-only ไม่มีสถานะแชร์ — ไม่มี race (pool `prepare: false` ตาม invariant #11) +11. **เอกสารในกลุ่มที่ไม่มี fileId ของ e-Court** (ลงทะเบียนแบบ `"url:"` ของ N8 หรืออัปโหลดผ่าน N4 พร้อมช่องกลุ่ม) → endpoint กลุ่มยังรวมไฟล์เหล่านี้ แต่ `document.externalFileId: null` — mirror กฎ `typeId: null` ของข้อ 9 (endpoint เดียวไม่กระทบ — ค้นด้วย `ecourt:` prefix เจอเฉพาะไฟล์ที่มี fileId เสมอ) +12. **`filter.userId` ที่ไม่มีในระบบ** → ไม่ใช่ 400 — ได้ `annotations: []`/`bookmarks: []` ตาม contract (ตัวเอกสารยังคืนถ้าเคยเปิด) เพราะไม่ validate กับ DB (ผู้ใช้เพิ่ม/เปลี่ยนได้ตลอด ตรวจก็เสีย query เปล่า) · **`filter.annotationTypes` เมื่อเลือก `type: "bookmarks"` อย่างเดียว** → เพิกเฉยเงียบ ๆ ไม่ใช่ 400 (ไม่มี annotation ให้กรองอยู่แล้ว — ค่า annotationTypes ยังถูก validate ตามปกติก่อนถึงขั้นนั้น) +13. **query ฐานข้อมูลล้มกลางคัน** (connection หลุด / timeout / table มีปัญหา) → **500 JSON ตาม contract** ผ่าน `externalApiError` ไม่ใช่ 500 ของ framework ที่ format ต่าง — try/catch ที่ route ครอบทุก DB call (code review 2026-09-25) · log stack ฝั่ง server อย่างเดียว ข้อความตอบเป็นกลาง ๆ ไม่แนบ error ดิบ · ไม่ retry ฝั่งเรา (read-only snapshot — ผู้เรียกตัดสินใจเรียกซ้ำเอง) ## Out of scope (ไม่ทำในแผนนี้) diff --git a/docs/PLAN-BOOKMARK-PDF.md b/docs/PLAN-BOOKMARK-PDF.md index d5ac927..af9b22a 100644 --- a/docs/PLAN-BOOKMARK-PDF.md +++ b/docs/PLAN-BOOKMARK-PDF.md @@ -1,6 +1,8 @@ # แผน 5.3.15 — รายงานรายการ bookmark เป็น PDF (N7) > **อัปเดต 2026-09-17:** ผู้ใช้ขอเปลี่ยน deliverable ของ N7 เป็น**ส่งออก JSON** แทนรายงาน PDF — route เดิม (`app/api/bookmarks/export/route.ts`) ถูกเขียนใหม่เป็น JSON (flat array `BookmarkDTO` เรียง N5 + envelope `{ fileId, title, exportedAt, bookmarks }`) โค้ด PDF ในแผนนี้จึงเป็น **historical record** — สเปกปัจจุบันอยู่ที่ `docs/PLAN-FEAT.md` §7 N7 (ถอด: build tree/cycle guard/ฟอนต์/layout ทั้งหมด) +> +> **อัปเดต 2026-09-25:** N7 ถูก**ถอดออกจากโค้ดทั้งหมด**ตามคำขอผู้ใช้ (route + ปุ่มใน sidebar/หน้าสรุป) — ระบบภายนอกดึงข้อมูล bookmark ผ่าน **N10 API** (`POST /api/external/documents`, แผน `docs/PLAN-API.md`) แทน; ทั้งแผนนี้และ §7 N7 ของ PLAN-FEAT.md เป็น historical record ทั้งคู่แล้ว > สถานะ: **implement เสร็จสมบูรณ์** (2026-09-16) — ครบ 4 task, `tsc` ผ่าน, vitest 120/120, plan-reviewer + code-reviewer ผ่าน (findings ทั้งหมดแก้แล้ว: **บรรทัดแรกของหน้าใหม่ถูกวาดกลับลงหน้าเก่า** — `page.drawText({ y: advanceLine(...) })` ประเมิน base ของ method call ก่อน arguments ตาม spec ทำให้ซ้อนบรรทัดบนสุดของหน้าเก่า → ต้อง advance ก่อนแล้วอ่าน `page` ทีหลัง, **cycle guard ซ้ำ/หาย** — seen set ต่อ root ทำให้วงจรล้วนซ้ำ 2 เท่าและวงจรปน root หายเงียบ → seen ร่วมทุก root + เติมโหนดที่ไม่ถูกเดินเป็นระดับ 1, **ชื่อไฟล์ในหัวรายงานไม่ wrap** — title ยาวถึง 200 ตัวล้นขอบขวา → wrap ด้วย `wrapTextByWidth` เหมือนรายการ) — re-verify หลังแก้ด้วยไฟล์ทดสอบ (40 รายการ + วงจรปน root + title ยาว 200): 4 หน้า **ไม่มีข้อความซ้อนทับเลย** ทุกหน้ามีเนื้อหาจริง วงจรโผล่ครบระดับ 1 · ไฟล์วงจรล้วน 2 รายการ = แบนระดับ 1 ทั้งคู่ · ทดสอบเดิมผ่านครบ: ไฟล์จริง 7 bookmarks ต้นไม้ 2 ระดับ indent x48↔x62 ลำดับ N5 ถูก, 40 รายการ = 5 หน้า + footer ทุกหน้า, 0 bookmark = "ยังไม่มีรายการ…", invalid id = 400, ไม่มีไฟล์ = 404, ฟอนต์ไทย render ถูก (ยืนยันสระผสม "คำสั่ง" ผ่าน render PNG + vision OCR — "คำา" ใน text extraction เป็น artifact ของ ToUnicode ตอน subset ไม่กระทบการแสดงผล), ปุ่มโผล่ใน SSR ของหน้า annotate พร้อม href/aria-label ถูก > อ้างอิง: requirement 5.3.15 (gap analysis `docs/requirement/gap-analysis.md` **กลุ่ม B** — ข้อสุดท้ายที่เหลือ: 5.3.11 = N5 ✅, 5.3.12 = N6 ✅) diff --git a/docs/PLAN-FEAT.md b/docs/PLAN-FEAT.md index 33e1608..9af19ce 100644 --- a/docs/PLAN-FEAT.md +++ b/docs/PLAN-FEAT.md @@ -196,7 +196,7 @@ Index: `(file_id)` - **F13 A6 แก้ไขข้อความ comment:** ปุ่ม "แก้ไข" ใน popover (เจ้าของเท่านั้น) → textarea → `updateAnnotation` (reuse F1) - **F14 D1 ค้นหา:** Action `searchAnnotations(fileId, q)` — `ilike` บน `data->>'text'` และ `data->>'message'` (escape `%`/`_` ใน q) — คีย์ `text` ครอบทั้ง HIGHLIGHT และ TEXTBOX อัตโนมัติ (ชื่อฟิลด์ใน JSONB ตรงกัน); กล่องค้นหาบน sidebar → ผลลัพธ์คลิก → goTo + select - **F15 D2 หน้าสรุป:** `app/annotate/[id]/summary/page.tsx` — server component รวมทุก annotation จัดกลุ่มตามหน้า (หน้า / ชนิด / ผู้เขียน / ข้อความ) + ปุ่มกระโดดเป็น deep link (F12); ทางเข้า = ปุ่มบน toolbar ของ viewer + ลิงก์กลับหน้าอ่านบนหน้าสรุปเอง; label/ข้อความย่อของแต่ละชนิดแชร์กับ sidebar ผ่าน `utils/annotation-display.ts` -- **F16 D3 Export CSV:** `app/api/annotations/export-csv/route.ts?fileId=` — CSV เดียวกับข้อมูลหน้าสรุป, ขึ้นต้นด้วย UTF-8 BOM (เปิดใน Excel ภาษาไทยถูกต้อง); ปุ่มอยู่หน้าสรุป +- **F16 D3 Export CSV:** ~~`app/api/annotations/export-csv/route.ts?fileId=` — CSV เดียวกับข้อมูลหน้าสรุป, ขึ้นต้นด้วย UTF-8 BOM (เปิดใน Excel ภาษาไทยถูกต้อง); ปุ่มอยู่หน้าสรุป~~ **ถอดออกจากโค้ดแล้ว 2026-09-25 ตามคำขอผู้ใช้** — ระบบภายนอกดึงข้อมูลผ่าน N10 API แทน (route + ปุ่มหน้าสรุป + `utils/csv.ts` + test ถอดครบ) - **F17 B2 Text box:** tool `textbox` → คลิกบนหน้า → inline `