eCourt-note/.claude/rules/data-layer.md
Your Name f4d46c7720 external-api: N10 API ดึงข้อมูลให้ระบบภายนอกด้วย API Key + ถอด F16 export CSV / N7 bookmark JSON แทนด้วย N10 — (1) endpoint เดียว POST /api/external/documents (JSON body {fileId และ/หรือ typeId — ส่งทั้งคู่ = AND เหลือเอกสารเดียว, filter:{type bookmarks|annotations, userId string|array, annotationTypes ค่า enum}}) ให้ backend e-Court เรียก server-to-server ผ่าน header x-api-key คืน {documents:[…]} รูปเดียวเสมอ — bundle ยุบชั้น field เอกสาร + annotations (พร้อม url deep link + replies) + bookmarks ระดับเดียวกัน, url เป็น URL เต็ม origin จาก request ผู้เรียก (x-forwarded-proto/host fallback host ตรง) + basePath (เดิม 2 GET query-param รวมเป็นเส้นเดียว 2026-09-24 · OR→AND + url relative→เต็ม + filter.types→annotationTypes กันสับสนกับ filter.type 2026-09-25) (2) lib/external-api-auth.ts — requireExternalApiKey เทียบ SHA-256 + crypto.timingSafeEqual, env EXTERNAL_API_KEY ไม่ตั้ง/ว่าง = 401 fail closed, ข้อความ 401 เดียวกันกัน enumerate + externalApiError คืน JSON {"error": ไทย} ใช้ร่วมทุก route — module DB-free ให้ tests/lib/external-api-auth.test.ts (5 tests) รันได้ไม่ต้องมี DATABASE_URL (3) lib/external-api.ts parseQueryBody strict กัน typo เงียบ ๆ (field นอก 3 ตัว = 400, annotationTypes ค่านอก enum = 400 บอกค่าที่รับ) + loadDocumentBundles bulk inArray = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ กรอง userIds/annotationTypes ใน SQL เรียง F15/N5 (4) route: auth ก่อนแตะ DB + ครอบ try/catch ทุก DB call ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework, read-only ไม่ publish SSE ไม่ revalidatePath ไม่มี CORS, REDACTION รวมในผลลัพธ์ (5) ถอด F16/N7 ตามคำขอผู้ใช้ 2026-09-25 (N10 ครอบคลุมข้อมูลชุดเดียวกันพร้อม auth): ลบ app/api/annotations/export-csv + app/api/bookmarks/export + utils/csv.ts + tests/utils/csv.test.ts + ปุ่มส่งออก 2 ปุ่มบนหน้าสรุป + ปุ่ม JSON ใน sidebar + รูป requirement 1.png/2.png (บรรทัดอ้างถึงใน gap-analysis.md เปลี่ยนเป็น historical note) (6) docs sync ครบ: PLAN-API.md แผนฉบับเต็ม 9 รอบ + PLAN-FEAT.md §7 N10 + PLAN-BOOKMARK-PDF.md/gap-analysis.md 5.3.14 เปลี่ยนตอบด้วย N10 + CLAUDE.md + README (env/ตัวอย่าง curl/troubleshooting) + .env.example + .claude/rules/data-layer.md — ผ่าน code review 2 รอบ 2026-09-25: แก้ 500 JSON ตอน DB ล้ม + ถอด fan-out ต่อไฟล์เป็น bulk + ถอด as cast แทน type guard (tsc + vitest 149/149) (คำขอผู้ใช้ 2026-09-25)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 12:02:42 +07:00

14 KiB
Raw Blame History

paths
db/**
app/actions/**

Data Layer Rules (รายละเอียด: docs/PLAN.md §3, §4)

  • โครงสร้างตาราง/enum/JSONB union อ้าง §3 — หลังแก้ db/schema.ts ต้องรัน /sync-db และ npx drizzle-kit generate ทุกครั้ง: dev sync ด้วย push แต่ image Docker รัน scripts/migrate.mjs จากไฟล์ SQL ใน drizzle/ เท่านั้น — ลืม generate = schema ฝั่ง server ตกรุ่นจน query พัง (เคยหลุด N6 bookmarks.user_name → หน้า annotate 500 ทั้งหน้าบน Docker) ; ไฟล์ที่ generated ควร idempotent (IF NOT EXISTS) เพราะ migrate.mjs ถือ error already-exists ทั้งไฟล์ว่า applied หมด
  • Enum/union ขยายตาม PLAN-FEAT.md §1 ทีละ task — ตอนนี้เพิ่มแล้ว: SHAPE + ShapeData (rect/ellipse เก็บ rect bbox, line/arrow เก็บ from/to ตาม D3), REDACTION + RedactionData ({ rect } — ดำทึบ ไม่มีสีให้เปลี่ยน), TEXTBOX + TextboxData (F17 — { position, text, color, fontSize, bold?, opacity?, align?, segments?, width? } position เป็นมุมซ้ายบนของกล่องเสมอ (align จัดข้อความในกล่องอย่างเดียวแบบ Smallpdf — กล่องไม่ขยับ), fontSize = % ของ width; bold/opacity(0–1)/align('left'|'center'|'right')/width (กล่องกว้างคงที่ % ของ width — สไตล์ Smallpdf: มี = กล่องกว้างคงที่ลากขอบปรับได้, ไม่มี = shrink-to-fit แถวเดิม) เป็น optional ไม่มี = ตัวปกติ/ทึบเต็ม/ชิดซ้าย/หุ้มข้อความ — bold ตอน export ต้องมี public/fonts/Sarabun-Bold.ttf ไม่งั้น fallback เป็น Regular; segments? = ตัวหนาเฉพาะส่วน (rich text — editor เป็น contentEditable + Ctrl/Cmd+B, เก็บเฉพาะเมื่อมี bold run) โดย field text เก็บข้อความรวม (concat) เสมอเพื่อค้นหา F14/preview sidebar และ export ใช้ wrapStyledText วาดแบบ run ผสม), STAMP + StampData (F18 — { label, position, scale, color? } scale = ความกว้างกรอบเป็น % ของ width, color optional ไม่มี = STAMP_COLOR), DrawData.widths? (F7 pressure, optional ยาวเท่า paths; Point define ที่ db/schema.ts แล้ว re-export จาก utils/coordinates.ts), DrawData.pen? ('pencil' | 'highlighter' — optional ไม่มี = pencil; ค่า preset ต่อ pen อยู่ที่ DRAW_PENS ใน utils/annotation-display.ts), DrawData.opacity? (0–1 optional — เส้นใหม่เก็บชัดเสมอ แถวเดิม fallback ตาม preset ของ pen ผ่าน effectiveDrawOpacity), HighlightData.style? ('highlight' | 'underline' | 'strikethrough' | 'squiggle' — optional ไม่มี = ไฮไลต์ทึบตามเดิม; เลือกจาก popup หลังคลุมดำ), HighlightData.opacity? (0–1 optional ไม่มี = ทึบเต็ม — ตั้งจาก dropdown ของ tool highlight ตอนสร้าง + แผง selection แก้ได้หลังสร้าง; export: ไฮไลต์ทึบคูณเข้าค่าเดิม 0.35 ส่วนเส้นทุกแบบใช้ตรง ๆ) — เพิ่ม type ใหม่ทุกครั้งต้องเติม case ใน validateAnnotationData ครบถ้วน (invariant #8)
  • column data ใช้ $type<AnnotationData>() (discriminated union ตาม enum type) — ห้าม any หรือ object type กว้าง — note?: string (โน้ตคำอธิบายของรายการ เช่น เหตุผลที่วาด — PLAN-FEAT.md §7 N1) มีในทุกชนิดยกเว้น COMMENT (มี message ทำหน้าที่นี้อยู่แล้ว) — validator vNote trim แล้ว ค่าว่าง = ไม่มีโน้ต; อ่านค่าผ่าน annotationNote() ใน utils/annotation-display.ts เท่านั้น (sidebar/สรุป/CSV ใช้ร่วม)
  • DTO (AnnotationDTO, BookmarkDTO): timestamp เป็น ISO string เสมอ ผ่าน mapper toDTO — ห้ามส่ง Date object ข้าม server→client
  • Server Actions: validate input ก่อนทุกครั้ง (type ถูกต้อง, ฟิลด์ครบ, pageIndex ≥ 0) ก่อนแตะ DB; เขียนด้วย DATABASE_URL ตรง ๆ + publish event ผ่าน lib/annotate-events.ts หลังเขียนสำเร็จ + revalidatePath หน้า viewer+summary ของ fileId นั้น (helper revalidateFilePages ในไฟล์เดียวกัน — invalidate Router Cache ฝั่ง client เพราะ force-dynamic ปิดแค่ฝั่ง server: ไม่งั้นกด back แล้วเข้าไฟล์ใหม่ได้ initial data เก่าจนต้อง hard refresh; action ใหม่ที่เขียน DB ต้องเรียกทุกครั้งเช่นเดียวกับ publish event)
  • db/index.ts: postgres.js + Drizzle, prepare: false เสมอ, DATABASE_URL ชี้ที่ plain PostgreSQL container judes-note-db (พอร์ต 54322 — ไม่ใช่ Supabase stack) และ pool ต้องเก็บบน globalThis — Next dev hot-reload re-evaluate module ได้บ่อย ถ้าสร้าง pool ใน module scope ตรง ๆ pool เก่าไม่ถูกปิดและสะสมจนชน max_connections ("too many clients already" — pattern เดียวกับ lib/annotate-events.ts)
  • FK file_id ของ annotations/bookmarks: ON DELETE CASCADE
  • Bookmarks ลำดับชั้น (N2): parent_id uuid NULL ไม่ทำ FK — ลบหัวข้อแม่ต้อง re-parent ลูกขึ้น root (update ... set parent_id = null) + delete ใน transaction เดียวกัน แล้ว publish update event ต่อลูก; จำกัด 3 ระดับ (BOOKMARK_MAX_DEPTH=3): create เช็ค depth(parent) < 3, ย้ายเช็ค depth(parent)+subtreeHeight ≤ 3 + ห้ามย้ายเข้า subtree ตัวเอง — helper lockedDepth/lockedSubtreeHeight/isDescendant ต้อง .for("update") ทุกแถวที่เดินผ่าน ใน tx เดียวกับ write (กัน race เกินความลึก/orphan) + note text NULL (ว่าง/whitespace = null) — validator vBookmarkNote
  • Bookmarks ผู้บันทึก + ค้นหา (N6): column user_name text NOT NULL DEFAULT '' — createBookmark รับ+validate userName เสมอ (updateBookmark ไม่แตะ — เจ้าของเปลี่ยนไม่ได้); row เดิม backfill จาก annotations (จับคู่ user_id) ครั้งเดียวหลัง /sync-db; '' = แสดง "ไม่ทราบชื่อ" ฝั่ง UI — Server Action searchBookmarks เป็น read-only: ไม่ publish event ไม่ revalidatePath (ไม่เขียน DB); เงื่อนไขวันที่รับ YYYY-MM-DD ตีความเป็น +07:00 คงที่ (ไทยไม่มี DST — deterministic ไม่ขึ้นกับ tz ของ server); ช่วงหน้าเป็น 1-based ตรงที่ผู้ใช้เห็นแล้วแปลงเป็น pageIndex 0-based ตอน query
  • ตาราง annotation_replies (F9/D2): FK annotation_id → annotations CASCADE + index (annotation_id) — ไม่ใช้ parentId ในตารางเดิม; ลบ reply ได้เฉพาะเจ้าของ (guard userId เหมือน annotation); actions publish event table: "replies" หลังเขียนสำเร็จ
  • annotations.resolved (NOT NULL default false) + resolved_at (NULL) — F10/A5: setAnnotationResolved ใครก็ได้ toggle (D6 คลายคืนได้ ต่างจาก delete ที่จำกัดเจ้าของ) + publish update event; ไม่เข้า history undo ของ F2
  • ตาราง annotation_versions (F19/A7): annotation_id เป็น uuid ไม่ FK (D8 — snapshot ตอน delete ต้องรอดจาก CASCADE ของแม่) + file_id FK CASCADE + page_index (restore ต้องรู้หน้า) + kind text 'update' | 'delete' + index (file_id); updateAnnotation/deleteAnnotation snapshot row เดิม ก่อนเขียน/ลบ ใน transaction เดียวกัน; restoreVersion(versionId, userId) — เจ้าของเท่านั้น (D9): guard userId ทั้ง row ปัจจุบัน (ต้นทางยังอยู่) และ snapshot (ต้นทางถูกลบ) / ต้นทางยังอยู่ = เขียนกลับ row เดิม (snapshot ปัจจุบันก่อน) + publish update event / ต้นทางถูกลบ = สร้างใหม่ id ใหม่ + publish create event (ห้ามสร้างสำเนาใหม่ทุกกรณี — เคยทำให้เกิดรายการซ้ำบนหน้า)
  • ห้ามเพิ่มตาราง/column ที่ไม่มีในแผน (ดู out of scope §9) โดยไม่อัปเดต docs/PLAN.md ก่อน
  • 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/<uuid>.<ext> เท่านั้น (ชื่อจาก 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)