Skip to main content

Content Management — Subcategories

ภาพรวม​

Subcategories คือชั้นการจัดหมวดที่อยู่ใต้เมนู Categories ซ้อนกันเองได้หลายระดับ แสดงผลเป็น โครงสร้างต้นไม้ (tree) เพื่อให้เห็นความสัมพันธ์แม่-ลูกได้ทันที เหมาะกับทีมคอนเทนต์ที่มีเนื้อหาปริมาณมากและต้องจัดกลุ่มละเอียดกว่าหมวดหมู่หลัก เช่น "โปรโมชัน > ลูกค้าใหม่ > เดือนมกราคม"

จุดสำคัญที่ต้องรู้ก่อนเริ่มคือ ระบบนับหมวดหมู่หลักเป็นระดับที่ 1 และเพดานที่บังคับจริงคือระดับที่ 4 ดังนั้นหมวดหมู่ย่อยมีได้ 3 ชั้น คือ L2, L3, L4 ตรงกับป้ายกำกับสีในหน้าจอ (L2 น้ำเงิน / L3 เขียว / L4 ม่วง) — คำใบ้ในฟอร์มที่เขียนว่า "สูงสุด 4 ระดับ" นับรวมหมวดหมู่หลักไปแล้ว

บทบาทที่ใช้งาน — ทีมคอนเทนต์ที่วางโครงหมวดละเอียดต่อจากเมนู Categories (โมดูลนี้ ไม่มี ModuleGate ที่ backend — ดูหัวข้อ สิทธิ์การเข้าถึง)

เส้นทางผู้ใช้งาน​

1. เข้าหน้าโครงต้นไม้​

เปิดเมนู Content Management > Subcategories ได้ 2 ทาง:

  • เข้าตรง — หน้าจะแสดง หมวดหมู่ย่อยทั้งหมด เป็นแผงพับ (Collapse) แยกตามหมวดหมู่หลัก ระบบดึงรายการหมวดหมู่จาก endpoint dropdown แล้ววน เรียก tree ของแต่ละหมวดทีละคำขอ และกางแผงของหมวดแรกที่มีหมวดย่อยให้อัตโนมัติ
  • กดมาจากเมนู Categories — ระบบส่ง categoryId และ categoryName มาทาง URL หน้านี้จะกรองหมวดที่ระบุไว้ให้ทันทีและแสดงหัวข้อว่า หมวดหมู่ย่อยของ "ชื่อหมวด" พร้อมปุ่ม ดูหมวดหมู่ทั้งหมด สำหรับกลับไปมุมมองรวม

ในมุมมองกรองหมวดเดียว ระบบ กางทุกกิ่งให้อัตโนมัติ และแสดงแถบคำอธิบายระดับ (L2 - หมวดหมู่ย่อย / L3 - หมวดหมู่ย่อยระดับ 2 / L4 - หมวดหมู่ย่อยระดับ 3) พร้อมคำใบ้ท้ายต้นไม้ว่า "ลากรายการเพื่อจัดเรียงลำดับภายในระดับเดียวกัน"

แต่ละกิ่งในต้นไม้แสดง — ที่จับสำหรับลาก, ไอคอนสีตามระดับ, ชื่อ, ป้าย L2/L3/L4, slug แบบ monospace (tooltip เป็น /slug) และเมนูจุดสามจุด

2. เพิ่มหมวดหมู่ย่อย​

  • กด สร้างหมวดหมู่ย่อย ที่หัวหน้าจอ เพื่อสร้างกิ่งระดับ L2
  • หรือเปิดเมนูจุดสามจุดของกิ่งใดก็ได้แล้วเลือก เพิ่มรายการย่อย — เมนูนี้จะ ไม่ปรากฏเลย ถ้ากิ่งนั้นอยู่ระดับ 4 แล้ว ระบบพาไปฟอร์มพร้อมส่ง categoryId และ parentId ให้เติมค่าล่วงหน้า

ฟอร์มมี 7 ช่อง:

  1. หมวดหมู่หลัก — บังคับเลือก ล็อกไว้ในโหมดแก้ไข
  2. หมวดหมู่ย่อยหลัก (ไม่บังคับ) — ตัวเลือกใช้ dropdown แบบลำดับชั้นที่วาดเส้น ├─ / └─ ให้เห็นโครงต้นไม้ (ตัวหนังสือ monospace) ช่องนี้ถูกปิดถ้ายังไม่เลือกหมวดหมู่หลัก และ ล็อกไว้ในโหมดแก้ไข ใต้ช่องมีคำใบ้ว่า "เว้นว่างไว้เพื่อสร้างเป็นรายการย่อยโดยตรงของหมวดหมู่ ระดับปัจจุบัน: N" ซึ่งเปลี่ยนเป็นข้อความสีแดง "ถึงระดับความลึกสูงสุดแล้ว (4 ระดับ) ไม่สามารถเพิ่มรายการย่อยได้" เมื่อระดับของแม่ที่เลือกถึงเพดาน
  3. ชื่อหมวดหมู่ย่อย — บังคับกรอก สูงสุด 255 ตัวอักษร พร้อมตัวนับ สร้าง slug ให้อัตโนมัติ
  4. Slug — บังคับกรอก สูงสุด 255 ตัวอักษร ตรวจ regex ^[a-z0-9-]+$ ที่ฝั่งฟอร์ม
  5. คำอธิบาย — ไม่บังคับ สูงสุด 1,000 ตัวอักษร
  6. ลำดับการเรียง — ตัวเลข ≥ 0 คำใบ้ระบุว่า "ตัวเลขน้อยจะแสดงก่อนภายในหมวดหมู่เดียวกัน"
  7. สถานะ — active / inactive

บันทึกแล้วระบบพากลับไปหน้าต้นไม้ที่กรองหมวดหมู่นั้นไว้ให้

3. จัดลำดับด้วยการลากวาง​

ลากกิ่งไปวางใน ช่องว่างระหว่างกิ่ง เท่านั้น — วางทับตัวกิ่ง (เพื่อทำให้เป็นลูก) จะถูกปฏิเสธด้วยข้อความ "วางระหว่างรายการเพื่อจัดเรียงลำดับ ใช้เมนู 'เพิ่มรายการย่อย' เพื่อสร้างรายการซ้อน" และการลากข้ามแม่จะถูกปฏิเสธด้วย "สามารถจัดเรียงลำดับได้เฉพาะภายในระดับเดียวกันเท่านั้น" (ตรวจสองชั้น: allowDrop ปิดไว้ตั้งแต่ตอนลาก และตรวจซ้ำใน onDrop)

เมื่อวางสำเร็จ หน้าเว็บคำนวณลำดับใหม่ของ พี่น้องทั้งกลุ่ม แล้วส่ง PATCH /content-subcategories/reorder/sort-order ไปทีเดียวพร้อม sortOrder เป็นเลขไล่ตั้งแต่ 0 แล้วจึงโหลดต้นไม้ใหม่

4. ลบกิ่ง​

จากเมนูจุดสามจุด เลือก ลบ หน้าเว็บ ยิงคำสั่งลบแบบไม่บังคับไปก่อน เพื่อให้เซิร์ฟเวอร์เป็นผู้บอกว่ามีสิ่งที่ผูกอยู่หรือไม่ ถ้าไม่มีอะไรผูกก็ลบสำเร็จทันทีพร้อมข้อความ "ลบหมวดหมู่ย่อยสำเร็จ" — ถ้ามี เซิร์ฟเวอร์จะตอบ 409 กลับมา (ดูข้อควรระวังด้านล่างเรื่องกล่องยืนยันที่ตั้งใจให้แสดงจำนวนที่กระทบ)

รายละเอียดสำคัญที่ตรวจสอบจากซอร์สโค้ดจริง​

ยืนยันจาก internal/modules/contentsubcategory/{controller,service,dto}.go ของ line-management-cms-api-go:

  • ระดับชั้นถูกคำนวณจากแม่ที่ฝั่งเซิร์ฟเวอร์ ไม่ได้เชื่อค่าที่ส่งมา — validateParentAndGetLevel() คืน 2 เมื่อไม่มีแม่ ไม่เช่นนั้นคืน parent.Level + 1 และปฏิเสธเมื่อ parent.Level >= 4 ด้วย 400 "Maximum nesting depth reached (4 levels)" DTO มีช่อง level ที่ตรวจ @Min(2) @Max(4) อยู่จริง แต่ service ไม่เคยอ่านค่านั้นเลย — ส่ง level: 4 มาพร้อมกับ parentId ที่อยู่ระดับ 2 ก็ยังได้ระดับ 3 ตามการคำนวณ
  • แม่ต้องอยู่หมวดหมู่หลักเดียวกัน — ถ้า parent.CategoryID != dto.CategoryID จะได้ 400 "Parent subcategory must belong to the same category" และถ้าไม่พบแม่ (หรือแม่ถูกลบไปแล้ว) จะได้ 404 "Parent subcategory not found"
  • slug ต้องไม่ซ้ำ "ต่อหนึ่งหมวดหมู่หลัก" ไม่ใช่ต่อ LINE OA — เงื่อนไขคือ category_id + slug + deleted_date IS NULL (โดย ไม่กรอง line_oa_id เลย) ข้อความชนคือ 409 "A subcategory with this slug already exists in this category" — slug เดียวกันจึงใช้ซ้ำได้ในหมวดหมู่ต่างกัน แต่ห้ามซ้ำแม้จะอยู่กิ่งคนละแม่ในหมวดเดียวกัน
  • slug ถูก lowercase + trim ที่ backend ก่อนตรวจ regex เหมือนเมนู Categories (transform value?.toLowerCase().trim() ทำงานก่อน @Matches) และ name/description ถูก trim ให้ด้วย
  • ส่งค่าที่ไม่ใช่สตริงมาใน name/slug/description จะได้ 500 ไม่ใช่ 400 — เพราะต้นฉบับ TypeScript เรียก value?.trim() ตรง ๆ ซึ่งจะโยน TypeError เมื่อค่าเป็นตัวเลขหรือ object และ ValidationPipe ไม่ได้ดักไว้ พอร์ต Go ทำซ้ำพฤติกรรมนี้อย่างเจาะจงด้วยข้อความ value.trim is not a function (ฟังก์ชัน trimCrash ในโค้ดมีอยู่เพื่อเรื่องนี้ล้วน ๆ)
  • หมวดหมู่ย่อยสถานะ inactive หายไปจากต้นไม้ทั้งหมด — endpoint ที่หน้าจอนี้ใช้คือ .../tree และ .../hierarchical-dropdown ซึ่งกรอง status = 'active' เสมอ ในขณะที่ GET /content-subcategories (endpoint รายการแบบแบ่งหน้า ที่หน้าจอนี้ ไม่ได้เรียกใช้เลย) กรองเพียง status != 'delete' — ตั้งสถานะเป็น inactive จึงเท่ากับ "ซ่อนจากหน้าจอนี้" และ กิ่งลูกทั้งหมดใต้มันจะโผล่ขึ้นมาเป็นรากใหม่ เพราะ buildTree() วางโนดที่หาแม่ในชุดข้อมูลไม่เจอไว้ที่ระดับราก
  • การจัดลำดับตรวจ "ต้องมีแม่เดียวกัน" ที่เซิร์ฟเวอร์จริง — UpdateSortOrder() โหลดทุก id ที่ส่งมา ถ้าจำนวนแถวที่เจอไม่เท่ากับจำนวนรายการที่ส่ง จะได้ 400 "Some subcategories not found" แล้วจึงรวบ parentId ทั้งหมดเป็น set (ใช้ -1 เป็น sentinel แทนค่า null) ถ้ามีมากกว่าหนึ่งค่าจะได้ 400 "Can only reorder subcategories within the same parent" หลังจากนั้นจึงตรวจสิทธิ์เจ้าของทีละแถว และอัปเดต แถวละคำสั่ง SQL ไม่ได้ห่อทรานแซกชัน — ถ้าล้มกลางทาง ลำดับจะค้างครึ่ง ๆ กลาง ๆ
  • การลบแบบบังคับยกเลิกการเชื่อมโยงหน้าเนื้อหา แต่ไม่แตะ updated_date — Remove(force=true) เดินหา id ลูกหลานทั้งหมดแบบ recursive ก่อน แล้ว (1) เซ็ต subcategory_id = NULL ให้ content_page ทุกแถวที่ผูกกิ่งเหล่านั้น (2) soft delete ลูกหลานทั้งหมด (3) soft delete ตัวเอง — ขั้นตอนที่ (1) และ (2) รันด้วย SkipHooks โดยเจตนา เพื่อทำซ้ำพฤติกรรมของ QueryBuilder ต้นฉบับที่ไม่ดัน @UpdateDateColumn — หน้าเนื้อหาที่ถูกยกเลิกการเชื่อมโยงจึงไม่มีร่องรอยว่า "เพิ่งถูกแก้" ในคอลัมน์อัปเดตล่าสุด
  • การลบแบบไม่บังคับตอบ 409 ที่ "ตกจำนวนที่กระทบ" หายไปจาก body — ต้นฉบับ TypeScript โยน ConflictException ที่มี object แนบ affectedContentCount และ hint มาด้วย แต่ตัวกรอง exception อ่านเฉพาะ .message ซึ่งเป็นสตริง ผลคือ body ที่ส่งกลับมีแต่ข้อความ "Cannot delete subcategory with dependencies" ความเห็นในโค้ด Go ระบุการตัดฟิลด์นี้ไว้อย่างชัดเจนว่าเป็นการทำซ้ำพฤติกรรมเดิม — ส่วนกรณีที่ลบสำเร็จ (force=true หรือไม่มีสิ่งใดผูก) จะได้ body ครบ { deleted, affectedContentCount, affectedChildrenCount }
  • GET /content-subcategories/:id และ .../path แยก 403 กับ 404 — query ค้นด้วย id + deleted_date IS NULL โดยไม่กรอง line_oa_id แล้วเรียก validateOwnership ทีหลัง id ของ OA อื่นจึงตอบ 403 "You do not have permission to access this subcategory" ส่วน id ที่ไม่มีเลยตอบ 404 "Subcategory not found" — .../path ยังไล่ขึ้นไปหาแม่ทีละชั้นจนถึงราก แล้วคืนเป็นอาร์เรย์เรียงจากรากลงมา
  • :id ที่ไม่ใช่ตัวเลขทำให้ได้ 500 พร้อมข้อความ invalid input syntax — ทำซ้ำพฤติกรรม +id ของ JavaScript เหมือนโมดูล Categories

เมนูที่เกี่ยวข้องกันในเส้นทางนี้​

  • Categories — เป็นหมวดแม่โดยตรงของโครงต้นไม้นี้ และเป็นจุดเข้าอีกทางหนึ่ง (ตัวเลขจำนวนหมวดย่อย / เมนู "ดูหมวดหมู่ย่อย") การลบหมวดหมู่หลักลบทั้งต้นไม้นี้ทิ้งไปด้วย
  • Content Pages — ใช้ hierarchical-dropdown จากที่นี่มาเป็นตัวเลือกหมวดหมู่ย่อยตอนสร้าง/แก้ไขหน้าเนื้อหา และเป็นสิ่งที่ถูก "ยกเลิกการเชื่อมโยง" เมื่อสั่งลบกิ่งแบบบังคับ
  • Content Links — ใช้หมวดหมู่ย่อยเป็นหนึ่งในเงื่อนไขคัดเลือกเนื้อหา โดยเรียก endpoint hierarchical-dropdown เดียวกัน
  • Menu Builder — เมื่อรายการเมนูอ้างอิงลิงก์รวมเนื้อหา เงื่อนไขหมวดหมู่ย่อยที่ตั้งไว้ก็มีผลกับสิ่งที่ผู้ใช้เห็นในเมนูนั้น

จุดที่ควรระวัง​

  • กล่องยืนยัน "ลบหมวดหมู่ย่อยที่มีรายการเกี่ยวข้อง" ที่ตั้งใจให้แสดงจำนวนที่กระทบไม่มีทางปรากฏ — หน้าเว็บอ่าน affectedContentCount และ affectedChildrenCount จาก body ของ error 409 แต่ backend ตัดสองฟิลด์นั้นออกไป (ดูหัวข้อรายละเอียดข้างต้น) ทั้งสองค่าจึงเป็น undefined → 0 เงื่อนไขที่จะเปิดกล่องไม่เคยเป็นจริง ผู้ใช้เห็นเพียงข้อความ error "ไม่สามารถลบหมวดหมู่ย่อยได้" — ผลคือ ปุ่ม "ลบต่อไป" (force delete) เข้าไม่ถึงจากหน้าจอ ต้องเรียก DELETE /api/content-subcategories/:id?force=true เองถ้าจำเป็น
  • มุมมอง "ดูทั้งหมด" ยิง API หนึ่งคำขอต่อหนึ่งหมวดหมู่แบบเรียงต่อกัน — โค้ดวน for แล้ว await ทีละหมวด ไม่ได้ยิงขนาน ยิ่งมีหมวดหมู่หลักเยอะ หน้ายิ่งโหลดช้าเป็นทวีคูณ (พร้อมกับคำขอ dropdown ตอนต้น)
  • ตั้งหมวดหมู่ย่อยเป็น inactive แล้วโครงต้นไม้เปลี่ยนรูป — กิ่งที่ปิดจะหายไปทั้งกิ่ง และลูกของมันจะเลื่อนขึ้นมาแสดงเป็นรากของหมวดนั้นแทน โดยที่ป้ายระดับ (L3/L4) ยังเป็นค่าเดิม ทำให้เห็นกิ่ง L3 อยู่ที่ตำแหน่งราก
  • ตั้งชื่อหมวดหมู่ย่อยเป็นภาษาไทยจะได้ slug ว่าง — ตัวสร้าง slug ตัดทุกอักขระที่ไม่ใช่ [a-z0-9\s-] ทิ้ง โดยไม่ถอดอักษรไทย (โค้ดชุดเดียวกับเมนู Categories ต่างจากเมนู Content Pages ที่มีตารางถอดอักษร) ต้องพิมพ์ slug เอง
  • ย้ายกิ่งไปอยู่ใต้แม่คนอื่นทำไม่ได้จากหน้าจอ — ทั้งช่องหมวดหมู่หลักและหมวดหมู่ย่อยหลักถูกล็อกในโหมดแก้ไข และการลากวางจำกัดอยู่ในกลุ่มพี่น้องเดียวกัน (ที่ระดับ API นั้น PATCH /:id รับ parentId ได้จริงและเขียนค่าลงไปตรง ๆ โดยไม่คำนวณ level ใหม่ — จึงทำให้ข้อมูล level เพี้ยนได้ถ้าเรียก API ตรง)
  • ความยาวคำอธิบาย 1,000 ตัวอักษรเป็นข้อจำกัดฝั่งหน้าเว็บเท่านั้น — DTO ฝั่ง backend ตรวจแค่ว่าเป็นสตริง (ต่างจาก name 255 และ thumbnail 500 ที่มีจริง)
  • การจัดลำดับไม่ได้ห่อทรานแซกชัน — ถ้าอัปเดตล้มกลางทาง ลำดับของพี่น้องกลุ่มนั้นจะค้างไม่ครบ ให้ลากจัดใหม่อีกครั้ง

สิทธิ์การเข้าถึง (ตรวจสอบจากซอร์สโค้ดจริง)​

  • ฝั่งหน้าเว็บ — หน้าต้นไม้และฟอร์ม ไม่ได้ครอบด้วย PermissionGuard สิ่งเดียวที่ตรวจสิทธิ์คือการแสดงเมนูในแถบด้านข้าง ซึ่งเช็ค ability.can(VIEW, 'content-management') ครอบทั้งกลุ่ม Content Management — พิมพ์ URL /content-subcategory ตรง ๆ ยังเข้าได้แม้ไม่มีสิทธิ์
  • ฝั่งเซิร์ฟเวอร์ — ทั้ง 12 เส้นทางมี policy ประกาศไว้ (READALL/READ/CREATE/UPDATE/DELETE บน subject line-oa) แต่วิ่งผ่าน auth.CheckPolicies ที่เป็น no-op ปล่อยผ่านทุกคำขอ การบังคับจริงเหลือแค่ JWT + validateOwnership ที่เทียบ line_oa_id (Super Admin ข้ามได้ทั้งหมด) และ validateCategory ที่ตรวจ line_oa_id ของหมวดหมู่แม่อีกชั้นก่อนสร้าง
  • ไม่มี ModuleGate ในโมดูลนี้ — ตรวจ contentsubcategory/controller.go แล้วไม่พบการเรียก modulegate.ModuleGate(d, "...") เลย (ต่างจากโมดูล campaign ที่มี) และไม่ปรากฏทั้งในรายการ gatedModules และ exemptModules ของ modulegate/coverage_test.go — ผู้ดูแลระดับแพลตฟอร์มจึง ปิดโมดูลนี้ให้องค์กรใดไม่ได้ เพราะไม่มีจุดบังคับ
  • ขอบเขตข้อมูลถูกจำกัดด้วย LINE OA ในทุก endpoint ที่คืนหลายรายการ (findAll, dropdown, by-category/* ล้วนกรอง line_oa_id ของผู้เรียก) — ยกเว้น findOne, :id/path และการตรวจ slug ซ้ำ ที่ไม่กรอง line_oa_id ในคำสั่ง query
  • ไม่มีเส้นทางสาธารณะ (@Public) ในโมดูลนี้เลย

ไฟล์อ้างอิงจากซอร์สโค้ด​

Frontend (cms-web-2026)

  • src/app/content-subcategory/page.tsx — มุมมองต้นไม้ทั้งสองแบบ, การลากวางจัดลำดับ (allowDrop/onDrop), ตรรกะการลบสองขั้น, MAX_LEVEL และสีป้ายระดับ
  • src/app/content-subcategory/form/page.tsx — ฟอร์ม 7 ช่อง, การล็อกช่องในโหมดแก้ไข, คำใบ้ระดับ, ตัวสร้าง slug อัตโนมัติ
  • src/services/content-subcategory.service.ts — endpoint ที่หน้าเว็บเรียกใช้ (getTreeByCategoryId, getHierarchicalDropdownByCategoryId, updateSortOrder, deleteById(id, force))
  • src/services/axios-instance.ts — handleAxiosError ที่โยน body ของ error ออกมาตรง ๆ (สำคัญกับข้อควรระวังเรื่องกล่องยืนยันการลบ)
  • messages/th/content-subcategory.json, messages/en/content-subcategory.json — ข้อความบนหน้าจอทั้งชุด

Backend (line-management-cms-api-go)

  • internal/modules/contentsubcategory/controller.go — 12 เส้นทาง, การไม่มี ModuleGate, jsUnaryPlusInt64 และพฤติกรรม NaN → 500
  • internal/modules/contentsubcategory/service.go — maxSubcategoryLevel, validateParentAndGetLevel, buildTree, GetHierarchicalDropdown (เส้น ├─/└─), UpdateSortOrder, Remove แบบ force พร้อม SkipHooks
  • internal/modules/contentsubcategory/dto.go — transform lowercase/trim, trimCrash (พฤติกรรม 500), ช่อง level ที่ตรวจแต่ไม่ถูกใช้
  • internal/modules/auth/policy.go — CheckPolicies ที่เป็น no-op
  • internal/modules/modulegate/coverage_test.go — รายการโมดูลที่มี ModuleGate จริงและโมดูลที่ยกเว้น

ดูรายละเอียดเชิงเทคนิคเพิ่มเติม (เวอร์ชันก่อนหน้าของทีมเอกสาร) ที่ หมวดหมู่ย่อยเนื้อหาแบบลำดับชั้น