eCourt-note/docs/PLAN-DOCSET.md
Your Name 6940b3870d
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
docs: ร่างแผน N8 ชุดเอกสาร (Document Set) + bookmark รวมทั้งชุด — ปิด gap 5.3.10 ครึ่งหลัง ("หรือชุดของเอกสาร"): ตาราง document_sets + files.set_id nullable (ไฟล์หนึ่งอยู่ชุดเดียว onDelete set null — ลบชุดไฟล์ไม่หาย คนละเรื่องกับ CASCADE ของ annotations) + หน้าใหม่ /sets/[id] bookmark รวมทุกไฟล์ของชุดคลิกกระโดดผ่าน deep link F12 + จัดการชุดบนหน้ารายการ (สร้าง/ย้ายเข้า-ออก/แก้ชื่อ/ลบ — ทุกคนที่ login จัดการได้ roles ยัง out of scope ตาม 5.3.23) — ยังไม่ implement (เสนอ 2026-09-17 รอ /implement-feat N8 ตาม governance: ต้องเขียนสเปกลง PLAN-FEAT.md §7 + ขยาย whitelist ใน CLAUDE.md ก่อน)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 13:43:16 +07:00

18 KiB
Raw Blame History

แผน 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)

  1. ไฟล์หนึ่งอยู่ชุดเดียว — column set_id (nullable) บนตาราง files — ไม่ทำ many-to-many (เอกสารหนึ่งฉบับผูกคดีเดียวตามธรรมชาติของงาน)
  2. หน้า bookmark รวมชุดเป็นหน้าใหม่ /sets/[id] เข้าจากหน้ารายการ (หน้า / จัดกลุ่มการ์ดไฟล์ตามชุด + หัวชุดมีลิงก์เปิด) — ไม่ยัดลง sidebar ของ viewer (แคบ 288px และต้องเปิดไฟล์ก่อนถึงเห็น)
  3. จัดการชุดผ่าน 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 (drizzle references ต้องเห็น 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) → insert
  • renameSet(setId, title) — validate UUID + title เหมือนกัน + ชุุดมีจริง → update
  • deleteSet(setId) — validate UUID + มีจริง → delete (ไฟล์ในชุดกลายเป็น null อัตโนมัติจาก FK)
  • setFileSet(fileId, setId | null) — validate ไฟล์มีจริง + (ถ้าไม่ null) ชุดมีจริง → update files.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_id null → section "ไม่อยู่ชุด" ไว้ท้ายสุด (ใกล้ฟอร์มสร้าง/อัปโหลด — workflow อัปโหลดแล้วค่อยย้ายเข้าชุด) — ซ่อนเมื่อว่าง
  • ฟอร์มเล็ก "สร้างชุด" — section ของตัวเองคู่ "เพิ่มเอกสาร" (client component: input ชื่อ + ปุ่ม, useTransition + action createSet)
  • 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 (ออกแบบไว้ล่วงหน้า)

  1. ลบชุดที่มีไฟล์ → FK ON DELETE SET NULL — ไฟล์ตกกลับ "ไม่อยู่ชุด" ไม่ถูกลบ (มี confirm dialog กันมือเพื่อน)
  2. เข้า /sets/[id] ชุดที่ถูกลบ → 404
  3. ชื่อชุดซ้ำ → อนุญาต (ไม่ unique — คดีต่างงวดชื่อคล้ายกันได้ ไม่มีเหตุผลบังคับ)
  4. ชื่อชุดว่าง/whitespace/ยาวเกิน 200 → action validate (trim → ว่าง = error, clamp 200)
  5. ชุดว่าง (ไม่มีไฟล์) → หน้า set แสดง "ยังไม่มีเอกสารในชุดนี้" — ชุดว่างยังโชว์หน้า / ได้ (สร้างชุดรอไว้ก่อนแล้วค่อยย้ายไฟล์เข้า)
  6. ไฟล์ในชุดไม่มี bookmark → หัวไฟล์ + "ไม่มีที่คั่นหน้าในไฟล์นี้" (เห็นว่าไฟล์อยู่ชุดแต่ว่าง — ไม่หายเงียบ)
  7. cycle ใน bookmark tree (แก้ตรง DB หลุด guard ของ action) → เหมือน N7: DFS seen ชุุดเดียวร่วมทุก root + เติมโหนดที่ไม่ถูกเดินเป็นระดับ 1
  8. ?page= format ของ F12 → เช็คจากโค้ดจริง (1-based/0-based) ให้ตรงกับปุ่มกระโดดหน้าสรุป F15 — อย่าเดา
  9. Dev DB แชร์กับการทดสอบของผู้ใช้ → เทส Playwright สร้างชุด/ย้ายไฟล์ด้วย id ที่จับเป๊ะ ไม่ลบ/รีเซ็ตข้อมูลทั้งหมด
  10. ไฟล์ถูก "อยู่ชุด" แล้วเลือกย้ายเข้าชุดเดิม → 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)

  1. npm run dev → login → หน้า /: สร้างชุด "คดีตัวอย่าง" → ย้ายไฟล์ 2 ไฟล์เข้าชุุด → การ์ดจัดกลุ่มใต้หัวชุด
  2. หัวชุด → "bookmark ของชุด" → /sets/[id]: เห็น bookmark ทั้งสองไฟล์ จัดกลุ่มตามไฟล์ เรียง N5 ต้นไม้ indent ถูก
  3. คลิกรายการ → กระโดดเข้า viewer ไฟล์นั้นถูกหน้า
  4. ลบชุด → ไฟล์ทั้งสองกลับมาอยู่ "ไม่อยู่ชุด" ครบ ไม่มีอะไรหาย
  5. Regression: annotate / ค้นหา N6 / export JSON N7 / อัปโหลด N4 ยังทำงานเหมือนเดิม