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 41s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 3s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
Co-Authored-By: Claude Code <noreply@anthropic.com>
18 KiB
18 KiB
แผน 5.3.10 — ชุดเอกสาร (Document Set) + bookmark รวมทั้งชุด (N8)
สถานะ: ร่างแผน (ยังไม่ implement) — เสนอ 2026-09-17 อ้างอิง: requirement 5.3.10 (gap analysis
docs/requirement/gap-analysis.mdกลุ่ม C): "แสดง bookmark ของเอกสารแต่ละไฟล์ หรือชุดของเอกสารได้" — ครึ่งแรก (รายไฟล์) ✅ ทำแล้วใน sidebar ครึ่งหลัง (ชุดของเอกสาร) คือ gap นี้ ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลงdocs/PLAN-FEAT.md§7 (N8) + ขยาย whitelist ในCLAUDE.md+ อัปเดตdocs/PLAN.md§3 (schema) ก่อน — เป็น task แรกของแผนนี้ แล้วรันผ่าน/implement-feat N8
Context
- ตอนนี้ไฟล์เป็นลิสต์แบนหน้าเดียว (หน้า
/เรียงตามcreatedAt) — ไม่มี concept การจัดกลุ่มไฟล์ และ bookmark ดูได้เฉพาะภายในไฟล์ที่เปิด (แท็บ "ที่คั่นหน้า" + หน้าสรุป F15 ซึ่งเป็น annotation ไม่ใช่ bookmark) - กรณีจริงของศาล: คดีหนึ่งงวด = หลายเอกสาร (คำฟ้อง / คำพิพากษา / คำสั่งศาล) — ผู้ใช้อยากเห็น bookmark รวมทุกไฟล์ของคดีเดียวกันในหน้าเดียว แล้วคลิกกระโดดเข้าไปอ่านตรงจุดนั้น
- ของพร้อมใช้ทั้งหมด: โครงสร้างต้นไม้ bookmark + ลำดับ N5 (
pageIndex → yOffset → createdAt), deep link F12 (?page=อ่านตอน mount), mock login e-Court (ทุกหน้าถูกmiddleware.tsบังคับ login แล้ว)
การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-17)
- ไฟล์หนึ่งอยู่ชุดเดียว — column
set_id(nullable) บนตารางfiles— ไม่ทำ many-to-many (เอกสารหนึ่งฉบับผูกคดีเดียวตามธรรมชาติของงาน) - หน้า bookmark รวมชุดเป็นหน้าใหม่
/sets/[id]เข้าจากหน้ารายการ (หน้า/จัดกลุ่มการ์ดไฟล์ตามชุด + หัวชุดมีลิงก์เปิด) — ไม่ยัดลง sidebar ของ viewer (แคบ 288px และต้องเปิดไฟล์ก่อนถึงเห็น) - จัดการชุดผ่าน UI บนหน้ารายการ — ฟอร์มเล็ก "สร้างชุด" + ปุ่มบนการ์ดไฟล์ย้ายเข้า/ออกชุด + เมนูแก้ชื่อ/ลบชุด (ทุกคนที่ login จัดการได้ — roles ยัง out of scope ตาม 5.3.23)
Design
D1 — Schema (db/schema.ts + drizzle/)
export const documentSets = pgTable("document_sets", {
id: uuid("id").primaryKey().defaultRandom(),
title: text("title").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
});
// files เพิ่ม:
setId: uuid("set_id").references(() => documentSets.id, { onDelete: "set null" }),
// + index ("files_set_id_idx") บน set_id
- ตาราง
document_setsประกาศก่อนfiles(drizzlereferencesต้องเห็น table ก่อน) - ไม่เก็บผู้สร้างชุด (ไม่มี user_id/user_name) — ชุดเป็นของกลางของคดี ใครก็จัดการได้ และไม่มี UI ใดในสเปกนี้ต้องโชว์ผู้สร้าง
onDelete: "set null"— ลบชุดแล้วไฟล์ในชุดตกกลับเป็น "ไม่อยู่ชุด" ไฟล์ไม่ถูกลบ (คนละเรื่องกับ CASCADE ของ annotations ที่ผูกไฟล์)- ไม่มี DTO/mapper ใหม่ — หน้าที่ใช้เป็น server component ทั้งหมด ส่งข้ามไป client เฉพาะ
{id, title}ธรรมดา (createdAt ไม่ต้องส่ง — ไม่มี UI โชว์; ถ้าส่งต้อง ISO string ตาม invariant #5) - migration:
/sync-db(push) +npx drizzle-kit generate— rule data-layer: Docker migrate จาก SQL ในdrizzle/เท่านั้น
D2 — Server Actions (app/actions/sets.ts)
"use server" + validate ก่อนทุกครั้ง (pattern app/actions/files.ts):
createSet(title)— title trim, ว่าง = error, ยาว ≤ 200 (เทียบfiles.title) → insertrenameSet(setId, title)— validate UUID + title เหมือนกัน + ชุุดมีจริง → updatedeleteSet(setId)— validate UUID + มีจริง → delete (ไฟล์ในชุดกลายเป็น null อัตโนมัติจาก FK)setFileSet(fileId, setId | null)— validate ไฟล์มีจริง + (ถ้าไม่ null) ชุดมีจริง → updatefiles.set_id- ไม่ publish SSE event — การจัดชุดไม่แตะข้อมูลรายไฟล์ใน viewer (annotation/bookmark เดิมทั้งหมด) จึงไม่มีอะไรให้ sync ผ่าน
/api/annotations/stream(ซึ่งผูกfileIdรายไฟล์อยู่แล้ว) — เหมือนuploadFileที่ใช้revalidatePathอย่างเดียว - revalidatePath ทุก action:
/เสมอ +/sets/{setId}เมื่อแก้ชุดนั้น (rename) — กัน Router Cache ฝั่ง client ค้างหน้าเก่า (เหตุผลเดียวกับrevalidateFilePages) - สิทธิ์: ทุกคนที่ login — action เรียก
getSessionUser()defense ซ้อน middleware (คืน error ถ้าไม่มี session)
D3 — หน้ารายการ / จัดกลุ่มตามชุด
- query
files+documentSets→ จัดกลุ่ม: section ต่อชุด (เรียงcreatedAt) — หัวชุด = ชื่อชุด + จำนวนไฟล์ + ลิงก์ "bookmark ของชุด" →/sets/{id}(icon Bookmark) + เมนู ⋯ (แก้ชื่อ / ลบชุด — confirm ก่อนลบ) - การ์ดไฟล์ในชุดเรียง
createdAtเหมือนเดิม + ปุ่ม Annotate เดิม + ปุ่ม "ใส่ชุด" เปิด dropdown เลือกชุดที่มีอยู่ / "ออกจากชุด" (ถ้าอยู่ชุด) - ไฟล์ที่
set_idnull → section "ไม่อยู่ชุด" ไว้ท้ายสุด (ใกล้ฟอร์มสร้าง/อัปโหลด — workflow อัปโหลดแล้วค่อยย้ายเข้าชุด) — ซ่อนเมื่อว่าง - ฟอร์มเล็ก "สร้างชุด" — section ของตัวเองคู่ "เพิ่มเอกสาร" (client component: input ชื่อ + ปุ่ม,
useTransition+ actioncreateSet) - client component ใหม่:
components/CreateSetForm.tsx(ฟอร์มสร้างชุด),components/SetFileMenu.tsx(dropdown ย้ายเข้า/ออกชุดบนการ์ด + เมนูแก้ชื่อ/ลบของหัวชุด — รับ prop list ชุุด{id, title}[]จาก server)
D4 — หน้า /sets/[id] — bookmark รวมทั้งชุด (app/sets/[id]/page.tsx)
Server component force-dynamic (มี login check ซ้อน middleware เหมือนหน้า /):
- โหลด: ชุุด (ไม่มี = 404) + ไฟล์ในชุุด (
orderBy createdAt) + bookmark ของทุกไฟล์ (inArray(fileId, …)orderBy asc(pageIndex), asc(yOffset), asc(createdAt)— N5) - แสดง: หัวชุด (ชื่อ + "N เอกสาร · M รายการ") + ลิงก์กลับ
/ - จัดกลุ่มตามไฟล์ (หัวไฟล์ = ชื่อไฟล์) ตามลำดับไฟล์ในชุด → ข้างในแสดงต้นไม้ bookmark 3 ระดับ (indent เหมือน sidebar — build
childrenOf/rootsจาก array ที่เรียง N5 แล้ว + orphan → root + cycle guard seen ชุดเดียวร่วมทุก root เหมือน pattern N7/route เดิม) read-only: ชื่อ + "หน้า N · ผู้บันทึก · วันที่" + โน้ต — คลิกทั้งแถว = ลิงก์/annotate/{fileId}?page={หน้า}กระโดดเข้า viewer ตรงหน้า (deep link F12 — ⚠️ implement ต้องเช็คจาก F12 จริงว่า?page=เป็น 1-based หรือ 0-based ให้ตรงกับปุ่มกระโดดของหน้าสรุป F15) - ไม่มีปุ่มแก้ไข/ลบ bookmark ที่นี่ (จัดการที่ viewer เท่านั้น) + ไม่มี SSE (snapshot ณ ตอนโหลด เหมือนหน้าสรุป F15)
- ปุ่มส่งออก JSON (N7) ไม่มีหน้านี้ — N7 รับ
fileIdเดียว การขยายเป็นชุดอยู่ใน out of scope
D5 — สิทธิ์/การเข้าถึง
middleware.tsบังคับ login ทุก path อยู่แล้ว —/sets/*+ actions เข้า่ายใน net อัตโนมัติ- ใครก็จัดการชุดได้ (สร้าง/แก้ชื่อ/ลบ/ย้ายไฟล์) — ไม่มี roles (5.3.23 ยัง out of scope)
Docs/rules sync (plan-first — ทำเป็น task แรก ก่อนแตะโค้ด)
| ไฟล์ | สิ่งที่แก้ |
|---|---|
docs/PLAN.md §3 |
เพิ่มตาราง document_sets + column files.set_id (FK SET NULL) — rule data-layer: ห้ามเพิ่มตารางโดยไม่แก้ PLAN.md ก่อน |
docs/PLAN-FEAT.md §7 |
หัวข้อใหม่ N8 · ชุดเอกสาร + bookmark รวมทั้งชุด (format ตาม N5–N7 + อ้างแผนฉบับเต็ม docs/PLAN-DOCSET.md) |
CLAUDE.md |
whitelist ฟีเจอร์เสริมหลังแผน เพิ่ม N8 + โครงสร้างไฟล์เพิ่ม app/actions/sets.ts, app/sets/[id]/page.tsx, components/CreateSetForm.tsx, components/SetFileMenu.tsx |
.claude/rules/data-layer.md |
เพิ่ม: document_sets + files.set_id (FK SET NULL ไม่ใช่ CASCADE), actions ของ sets ไม่ publish SSE (ไม่แตะข้อมูลรายไฟล์) แต่ revalidatePath("/") (+/sets/[id] ตอน rename) เสมอ |
docs/requirement/gap-analysis.md— ปรับ 5.3.10 ⚠️ → ✊ หลัง implement เสร็จเท่านั้น (task ปิดงาน) ไม่ใช่ตอนนี้.claude/rules/realtime.md,pdf-viewer.md,coordinates.mdไม่แตะ (ไม่มี SSE event ใหม่ ไม่แตะ viewer/พิกัด)
ลำดับ task + verify (ทุก task: npx tsc --noEmit ผ่าน)
| # | งาน | ไฟล์หลัก | Verify เพิ่มเติม |
|---|---|---|---|
| 1 | Docs sync N8 | ตารางด้านบน | สเปก §7 ครบตาม format N5–N7; PLAN.md §3 มีตาราง/column ใหม่ |
| 2 | Schema + migrate | db/schema.ts, drizzle/ |
/sync-db + drizzle-kit generate → psql \d files เห็น set_id + \d document_sets มีตาราง; SQL generated idempotent (IF NOT EXISTS) |
| 3 | Server Actions | app/actions/sets.ts |
ผ่าน UI ใน task 4–5; title ว่าง/ยาว = error; setId/fileId ไม่มีจริง = error ไม่ throw ระเบิดหน้า |
| 4 | หน้ารายการจัดกลุ่มชุด + ฟอร์มสร้าง + ย้ายไฟล์ | app/page.tsx, components/CreateSetForm.tsx, components/SetFileMenu.tsx |
Playwright: สร้างชุุด → การ์ดจัดกลุ่มถูก → ย้ายไฟล์เข้า/ออกชุด → แก้ชื่อ/ลบชุดแล้วไฟล์ไม่หาย (กลับ "ไม่อยู่ชุด"); regression: อัปโหลด N4 + Annotate ยังทำงาน |
| 5 | หน้า /sets/[id] |
app/sets/[id]/page.tsx |
Playwright: bookmark หลายไฟล์แสดงครบ เรียง ไฟล์ → N5 ต้นไม้ indent ถูก; คลิกกระโดด /annotate/{fileId}?page= แล้ว viewer ไปถูกหน้า; ชุดว่าง / ไฟล์ไม่มี bookmark / ชุดที่ถูกลบ = 404 |
| 6 | ปิดงาน | — | plan-reviewer + code-reviewer + vitest + tsc + gap-analysis 5.3.10 → ✅ |
Edge cases (ออกแบบไว้ล่วงหน้า)
- ลบชุดที่มีไฟล์ → FK
ON DELETE SET NULL— ไฟล์ตกกลับ "ไม่อยู่ชุด" ไม่ถูกลบ (มี confirm dialog กันมือเพื่อน) - เข้า
/sets/[id]ชุดที่ถูกลบ → 404 - ชื่อชุดซ้ำ → อนุญาต (ไม่ unique — คดีต่างงวดชื่อคล้ายกันได้ ไม่มีเหตุผลบังคับ)
- ชื่อชุดว่าง/whitespace/ยาวเกิน 200 → action validate (trim → ว่าง = error, clamp 200)
- ชุดว่าง (ไม่มีไฟล์) → หน้า set แสดง "ยังไม่มีเอกสารในชุดนี้" — ชุดว่างยังโชว์หน้า
/ได้ (สร้างชุดรอไว้ก่อนแล้วค่อยย้ายไฟล์เข้า) - ไฟล์ในชุดไม่มี bookmark → หัวไฟล์ + "ไม่มีที่คั่นหน้าในไฟล์นี้" (เห็นว่าไฟล์อยู่ชุดแต่ว่าง — ไม่หายเงียบ)
- cycle ใน bookmark tree (แก้ตรง DB หลุด guard ของ action) → เหมือน N7: DFS seen ชุุดเดียวร่วมทุก root + เติมโหนดที่ไม่ถูกเดินเป็นระดับ 1
?page=format ของ F12 → เช็คจากโค้ดจริง (1-based/0-based) ให้ตรงกับปุ่มกระโดดหน้าสรุป F15 — อย่าเดา- Dev DB แชร์กับการทดสอบของผู้ใช้ → เทส Playwright สร้างชุด/ย้ายไฟล์ด้วย id ที่จับเป๊ะ ไม่ลบ/รีเซ็ตข้อมูลทั้งหมด
- ไฟล์ถูก "อยู่ชุด" แล้วเลือกย้ายเข้าชุดเดิม → update ค่าเดิม idempotent ไม่ error
Out of scope (ไม่ทำในแผนนี้)
- ไฟล์อยู่หลายชุด (many-to-many) — ตัดสินใจแล้ว: ชุุดเดียว
- export/JSON bookmark ตามชุด (ขยาย N7 รับ
setId) — N7 ยังรับfileIdเดียว - roles/สิทธิ์การจัดการชุด (gap 5.3.23)
- reorder ไฟล์ในชุด / reorder ชุด / ค้นหาข้ามชุด (ขยาย N6 เป็นหลายไฟล์)
- หน้ารวมทุกชุด
/sets(ทางเข้าอยู่หน้ารายการแล้ว) - seed ตัวอย่างชุดใน
npm run seed-samples(สร้างผ่าน UI ได้ — ไม่บังคับ) - annotation รวมชุด (bookmark พอตาม requirement 5.3.10)
Verification (ภาพรวม end-to-end)
npm run dev→ login → หน้า/: สร้างชุด "คดีตัวอย่าง" → ย้ายไฟล์ 2 ไฟล์เข้าชุุด → การ์ดจัดกลุ่มใต้หัวชุด- หัวชุด → "bookmark ของชุด" →
/sets/[id]: เห็น bookmark ทั้งสองไฟล์ จัดกลุ่มตามไฟล์ เรียง N5 ต้นไม้ indent ถูก - คลิกรายการ → กระโดดเข้า viewer ไฟล์นั้นถูกหน้า
- ลบชุด → ไฟล์ทั้งสองกลับมาอยู่ "ไม่อยู่ชุด" ครบ ไม่มีอะไรหาย
- Regression: annotate / ค้นหา N6 / export JSON N7 / อัปโหลด N4 ยังทำงานเหมือนเดิม