All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 52s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
เตรียม basePath /docedit เป็นพื้นที่กลางให้แอปพี่น้อง (wordaddin ใน อนาคต) — เดิม basePath ครอบ /docedit/note ทั้งหมดจนแชร์ prefix ไม่ได้ (Next มี basePath ได้ทางเดียว) และจัด API แยกต้นไม้ตามที่ผู้ใช้ขอ (1) โครงสร้างใหม่: basePath = /docedit + route หน้าทั้งหมดย้ายเข้า app/note/** + API 4 เส้นย้ายเข้า app/api/note/** + statics ย้ายเข้า public/note/** (samples, uploads, pdf.worker) — หน้า/statics URL ปลายทาง คงเดิมเป๊ะ (/docedit/note/…) ส่วน URL API เปลี่ยนเป็น /docedit/api/note/… ตามคำขอผู้ใช้ (N10 endpoint เปลี่ยน — ต้องแจ้ง backend e-Court ก่อนใช้) (2) กลไก: withBasePath (utils/base-path.ts) เติม base + app path /note ยกเว้น path ที่ขึ้นต้น /api/ เติมแค่ base (caller เขียน /api/note/… เต็ม); resolvePublicPdfPath root ที่ public/note (traversal guard เดิม); middleware matcher ยกเว้นใหม่ (note/login, api/note, note/uploads, note/samples, note/pdf.worker, _next, fonts, favicon) + redirect /note/login; next.config เพิ่ม redirects /docedit -> /docedit/note; navigation ภายใน 19 จุด + revalidatePath 3 จุด ใส่ prefix /note; pdf_url ใน DB เป็น logical path ไม่แก้แม้แต่ตัวเดียว (seed/upload เขียนค่าเดิม, browser เติมผ่าน withBasePath, disk เติมผ่าน guard) (3) ตามแก้: patch-pdf-worker (DEST public/note + mkdirSync ปลายทาง), seed-samples (SAMPLES_DIR), uploadFile (UPLOAD_DIR), Dockerfile/ .dockerignore/.gitignore/docker-compose.test.yml, tests 2 ไฟล์ที่อ้าง disk path จริง, sync เอกสารทั้งหมด (CLAUDE.md invariant #6/#12 + tree, README, docs/API-DOCUMENTS.md, .claude/rules/*, sync-db skill) (4) !! ตอน deploy ต้องย้าย volume ตาม: mount /app/public/uploads -> /app/public/note/uploads + ย้ายไฟล์ใน volume ให้ตรงตำแหน่ง ไม่งั้นไฟล์ ที่เคยอัปโหลด (พร้อม annotation ที่ผูกอยู่) 404 ทันทีที่สลับ image (5) verify: build ผ่าน (route table /api/note/** + /note/** ครบ), tsc, vitest 215/215, curl ครบทุก endpoint (login/redirect chain, worker, samples, uploads, SSE, api/pdf, export, N8 register, N10 bundle + deep link), Playwright browser จริง (canvas + textLayer ไทย render, browser ยิง URL ใหม่ทั้งหมดไม่มี request ไป URL เก่า), plan-reviewer + code-reviewer 3 รายงาน 0 blocking Co-Authored-By: Claude Code <noreply@anthropic.com>
15 KiB
15 KiB
| paths | ||
|---|---|---|
|
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 พัง (เคยหลุด N6bookmarks.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 เก็บrectbbox, 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) โดย fieldtextเก็บข้อความรวม (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;Pointdefine ที่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 ตาม enumtype) — ห้ามanyหรือ object type กว้าง —note?: string(โน้ตคำอธิบายของรายการ เช่น เหตุผลที่วาด — PLAN-FEAT.md §7 N1) มีในทุกชนิดยกเว้น COMMENT (มีmessageทำหน้าที่นี้อยู่แล้ว) — validatorvNotetrim แล้ว ค่าว่าง = ไม่มีโน้ต; อ่านค่าผ่านannotationNote()ในutils/annotation-display.tsเท่านั้น (sidebar/สรุป/CSV ใช้ร่วม) - DTO (
AnnotationDTO,BookmarkDTO): timestamp เป็น ISO string เสมอ ผ่าน mappertoDTO— ห้ามส่งDateobject ข้าม server→client - Server Actions: validate input ก่อนทุกครั้ง (type ถูกต้อง, ฟิลด์ครบ,
pageIndex ≥ 0) ก่อนแตะ DB; เขียนด้วยDATABASE_URLตรง ๆ + publish event ผ่านlib/annotate-events.tsหลังเขียนสำเร็จ +revalidatePathหน้า viewer+summary ของ fileId นั้น (helperrevalidateFilePagesในไฟล์เดียวกัน — 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 containerjudes-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 ตัวเอง — helperlockedDepth/lockedSubtreeHeight/isDescendantต้อง.for("update")ทุกแถวที่เดินผ่าน ใน tx เดียวกับ write (กัน race เกินความลึก/orphan) +note text NULL(ว่าง/whitespace = null) — validatorvBookmarkNote - Bookmarks ผู้บันทึก + ค้นหา (N6): column
user_name text NOT NULL DEFAULT ''—createBookmarkรับ+validateuserNameเสมอ (updateBookmarkไม่แตะ — เจ้าของเปลี่ยนไม่ได้); row เดิม backfill จากannotations(จับคู่user_id) ครั้งเดียวหลัง/sync-db;''= แสดง "ไม่ทราบชื่อ" ฝั่ง UI — Server ActionsearchBookmarksเป็น read-only: ไม่ publish event ไม่revalidatePath(ไม่เขียน DB); เงื่อนไขวันที่รับYYYY-MM-DDตีความเป็น +07:00 คงที่ (ไทยไม่มี DST — deterministic ไม่ขึ้นกับ tz ของ server); ช่วงหน้าเป็น 1-based ตรงที่ผู้ใช้เห็นแล้วแปลงเป็นpageIndex0-based ตอน query - ตาราง
annotation_replies(F9/D2): FKannotation_id→ annotations CASCADE + index(annotation_id)— ไม่ใช้parentIdในตารางเดิม; ลบ reply ได้เฉพาะเจ้าของ (guarduserIdเหมือน annotation); actions publish eventtable: "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_idFK CASCADE +page_index(restore ต้องรู้หน้า) +kindtext'update' | 'delete'+ index(file_id);updateAnnotation/deleteAnnotationsnapshot row เดิม ก่อนเขียน/ลบ ใน transaction เดียวกัน;restoreVersion(versionId, userId)— เจ้าของเท่านั้น (D9): guarduserIdทั้ง 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/note/ได้เหมือน 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 (whitelistSAMPLE_FILE_EXTENSIONS) + ≤ 100MB ก่อนเขียน — ชื่อไฟล์ต้องผ่านdecodeUploadFilenameของutils/upload-name.tsก่อนใช้ (ทั้ง extension และ title): undici ถอดContent-Dispositionเป็น latin1 ทำชื่อ UTF-8 เพี้ยน (ชื่อไทยกลายเป็น "คำ…" — 2026-10-05) — บันทึกเป็นไฟล์ใหม่public/note/uploads/<uuid>.<ext>เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row (รวมtype_idจากช่องกลุ่มทางเลือกของฟอร์ม — 2026-09-23) แล้วrevalidatePath("/note")— ไม่ 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/note/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 เสมอ (headerx-api-keyเทียบ envEXTERNAL_API_KEYแบบ timing-safe) — env ว่าง/ไม่ตั้ง = fail closed 401 ทุก request ไม่มี mode เปิดฟรี; ผู้เรียกคือ backend e-Court (server-to-server) จึงไม่ใส่ CORS headers; validation ของ body (parseQueryBody) + การ query/mapper (loadDocumentBundles— bulkinArrayทุกไฟล์ในชุด 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)