Skip to main content

Content Management — Categories

ภาพรวม​

Categories คือหมวดหมู่ระดับบนสุดของระบบเนื้อหา ใช้จัดกลุ่มหน้าเนื้อหาให้เป็นระเบียบ และเป็นจุดตั้งต้นของเมนู Subcategories ซึ่งแตกย่อยลงไปได้อีกหลายระดับ ควรวางโครงหมวดหมู่ไว้ก่อนเริ่มสร้างหน้าเนื้อหาจริง เพราะหมวดหมู่ที่สร้างไว้จะถูกดึงไปใช้เป็นตัวเลือกในเมนู Content Pages และ Content Links

จุดสำคัญที่ต้องรู้ก่อนเริ่มคือ การลบหมวดหมู่ลบหมวดหมู่ย่อยทั้งชุดไปด้วยจริง ๆ (soft delete แบบ cascade ที่ฝั่งเซิร์ฟเวอร์) แต่ ไม่แตะหน้าเนื้อหา ที่ผูกอยู่เลย — หน้าเนื้อหาเหล่านั้นจะเหลือ categoryId ที่ชี้ไปยังหมวดที่ถูกลบแล้ว

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

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

1. ดูรายการหมวดหมู่​

เปิดเมนู Content Management > Categories หัวข้อหน้าคือ หมวดหมู่เนื้อหา ตารางแสดง 7 คอลัมน์ — ชื่อ (คลิกได้ ไปหน้าแก้ไข), Slug (แสดงเป็น code), หมวดหมู่ย่อย (ปุ่มลิงก์พร้อม badge ตัวเลข สีน้ำเงินถ้ามีมากกว่า 0 สีเทาถ้าเป็น 0), ลำดับการเรียง, สถานะ (ป้ายตัวพิมพ์ใหญ่ เขียว/ส้ม/แดง), อัปเดตล่าสุด (รูปแบบ MMM DD, YYYY) และเมนูจุดสามจุดทางขวา

ตัวกรองมี 2 ช่อง — ช่องค้นหาแบบกดปุ่ม/Enter (ค้นหาหมวดหมู่...) และ กรองตามสถานะ ที่มีเพียง 2 ตัวเลือกคือ active และ inactive (ไม่มีตัวเลือก delete)

2. สร้างหรือแก้ไขหมวดหมู่​

กดปุ่ม สร้างหมวดหมู่ เข้าฟอร์มคอลัมน์เดียว (กว้างสุด 600px) มี 5 ช่อง:

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

กดปุ่ม สร้างหมวดหมู่ / อัปเดตหมวดหมู่ แล้วระบบเด้งกลับหน้ารายการทันทีเมื่อสำเร็จ

3. ข้ามไปดูหมวดหมู่ย่อย​

กดตัวเลขจำนวนหมวดย่อยในตาราง หรือเลือก ดูหมวดหมู่ย่อย จากเมนูจุดสามจุด ระบบพาไปเมนู Subcategories พร้อมส่งทั้ง categoryId และ categoryName (encode แล้ว) ไปเป็น query parameter ให้หน้านั้นกรองและแสดงชื่อหมวดแม่ได้ทันทีโดยไม่ต้องยิง API ซ้ำ

4. ลบหมวดหมู่​

จากเมนูจุดสามจุด เลือก ลบ ระบบเปิดกล่องยืนยันหัวข้อ ลบหมวดหมู่ ระบุชื่อหมวดที่จะลบ พร้อมคำเตือนสีแดงว่า "การดำเนินการนี้จะลบหมวดหมู่ย่อยทั้งหมดภายใต้หมวดหมู่นี้ด้วย" ปุ่มยืนยันเป็นปุ่มสีแดง

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

ยืนยันจาก internal/modules/contentcategory/{controller,service,dto}.go และ internal/entities/content_category.go ของ line-management-cms-api-go:

  • คำเตือน "จะลบหมวดหมู่ย่อยทั้งหมด" เป็นเรื่องจริง ไม่ใช่แค่คำเตือนบนหน้าจอ — Remove() อ่านหมวดหมู่พร้อม preload หมวดหมู่ย่อยทั้งหมด แล้วเซ็ต deleted_date + status = delete + updated_by ให้ทั้งตัวแม่และลูกทุกตัว ในทรานแซกชันเดียว (ต้นฉบับใช้ TypeORM cascade save ของ relation subcategories) log ที่เขียนออกมายังบันทึกจำนวนหมวดย่อยที่ถูกลบไว้ด้วย (subcategoriesDeleted) — แต่ content_page ที่ผูกหมวดนี้อยู่ ไม่ได้ถูกแตะเลย
  • แก้แค่ชื่อหมวดหมู่ ก็ทำให้ "อัปเดตล่าสุด" ของหมวดย่อยทุกตัวขยับตามไปด้วย — Update() บันทึกแถวหมวดหมู่ แล้ว วนบันทึกซ้ำหมวดหมู่ย่อยทุกตัวที่ preload มา ทั้งที่ไม่มีค่าใดเปลี่ยน เพื่อให้ hook BeforeUpdate ยิงและดัน content_subcategory.updated_date เหมือนที่ TypeORM cascade ทำในต้นฉบับ ความเห็นในโค้ดระบุพฤติกรรมนี้ไว้ชัดเจนว่าเป็นการทำซ้ำผลข้างเคียงโดยเจตนา
  • slug ถูก lowercase + trim ให้อัตโนมัติที่ backend ก่อนตรวจ regex — DTO มี transform value?.toLowerCase().trim() ทำงานก่อน @Matches(/^[a-z0-9-]+$/) ดังนั้นถ้าส่ง " My-Slug " มาตรง ๆ ผ่าน API จะถูกแปลงเป็น my-slug และผ่านการตรวจ — ต่างจากเมนู Content Pages ที่ไม่มี transform นี้ และปฏิเสธตัวพิมพ์ใหญ่ทันที ทั้งสองเมนูใช้ regex เดียวกันแต่ให้ผลไม่เหมือนกัน
  • slug ต้องไม่ซ้ำ "ต่อหนึ่ง LINE OA" และตอนแก้ไขตรวจเฉพาะเมื่อ slug เปลี่ยน — เงื่อนไขคือ line_oa_id + slug + deleted_date IS NULL ถ้าซ้ำได้ 409 "A category with this slug already exists" ที่น่าสังเกตคือ slug ของหมวดที่ถูกลบไปแล้วนำกลับมาใช้ซ้ำได้ เพราะเงื่อนไขกรอง deleted_date IS NULL ออกไป
  • จำนวนหมวดหมู่ย่อยในตารางนับหมวดที่ถูกลบไปแล้วรวมด้วย — คอลัมน์ deleted_date ของ content_subcategory ประกาศเป็นคอลัมน์ธรรมดา (*httpx.JSTime) ไม่ใช่ gorm.DeletedAt ดังนั้น Preload("Subcategories") ใน FindAll() จึงดึงมาทั้งหมดโดยไม่กรอง soft delete — badge ตัวเลขในหน้ารายการ (คำนวณจาก record.subcategories.length ที่ฝั่งหน้าเว็บ) จึงยังนับหมวดย่อยที่ลบทิ้งไปแล้ว ในทางกลับกัน endpoint GET /content-categories/dropdown-group กรองในโค้ด Go จริง (status == active && deletedDate == nil) ทำให้ตัวเลขสองที่ไม่ตรงกันได้
  • GET /content-categories/stats มีปัญหาเดียวกันและหนักกว่า — คำสั่ง SQL ใช้ LEFT JOIN content_subcategory และ LEFT JOIN content_page โดยไม่มีเงื่อนไข deleted_date IS NULL บนตารางที่ join เข้ามาเลย (มีแต่บนตาราง content_category เอง) ทั้ง subcategoriesCount และ contentPagesCount จึงนับรายการที่ลบไปแล้วด้วย และค่าที่คืนมาเป็น สตริง (เพราะ COUNT() ของ Postgres เป็น bigint) ไม่ใช่ตัวเลข
  • คำอธิบายเรื่องการเรียงลำดับในฟอร์มไม่ตรงกับหน้ารายการ — ข้อความใต้ช่อง "ลำดับการเรียง" บอกว่า "หมวดหมู่ที่มีลำดับเท่ากันจะเรียงตามชื่อ" แต่ FindAll() เรียงด้วย sort_order ASC, created_date DESC (ใหม่ก่อน) — การเรียงตามชื่อเป็นพฤติกรรมของ endpoint dropdown (sort_order ASC, name ASC) เท่านั้น
  • GET /content-categories/:id แยกความต่างระหว่าง "ไม่มี" กับ "ไม่ใช่ของคุณ" — คำสั่ง query ค้นหาด้วย id + deleted_date IS NULL โดยไม่กรอง line_oa_id แล้วจึงเรียก validateOwnership ทีหลัง ผลคือ id ที่มีอยู่จริงแต่เป็นของ OA อื่นตอบ 403 "You do not have permission to access this category" ส่วน id ที่ไม่มีเลยตอบ 404 "Category not found" — เป็นช่องให้เดาได้ว่า id ใดมีอยู่จริงในระบบ
  • :id ที่ไม่ใช่ตัวเลขทำให้ได้ 500 ไม่ใช่ 404 — parseParamID เลียนพฤติกรรม +id ของ JavaScript ตรง ๆ: "abc" กลายเป็น NaN ซึ่งฝั่ง Postgres ปฏิเสธ ระบบจึงคืน 500 พร้อมข้อความ invalid input syntax for type integer: "NaN" ตามที่ความเห็นในโค้ดอธิบายว่าเป็นการทำซ้ำพฤติกรรมของ API เดิมอย่างเจาะจง
  • การกรองสถานะมีพฤติกรรมพิเศษ — ถ้าไม่ส่ง status มา หรือส่งมาเป็น delete ระบบจะเปลี่ยนไปใช้เงื่อนไข status != 'delete' แทน — พูดอีกอย่างคือไม่มีทางเรียกดูหมวดหมู่ที่ถูกลบผ่าน API นี้ได้เลย แม้จะระบุ status=delete มาก็ตาม
  • ความยาวคำอธิบายเป็นข้อจำกัดฝั่งหน้าเว็บเท่านั้น — ฟอร์มจำกัด 1,000 ตัวอักษร แต่ DTO ฝั่ง backend ตรวจแค่ว่าเป็นสตริง ไม่มี MaxLength เลย (ต่างจาก name 255 และ thumbnail 500 ที่มีจริง)

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

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

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

  • ช่องค้นหาต้องกด Enter สองครั้ง — ฟังก์ชันค้นหาเซ็ต state ของคำค้นแล้วเรียกโหลดข้อมูลทันทีในบรรทัดถัดไป ซึ่งยังอ่านค่าคำค้นเดิมจาก closure และ effect ที่โหลดข้อมูลก็ไม่ได้ผูก searchText ไว้ใน dependency ผลคือครั้งแรกที่กดค้นหาจะส่งคำค้นก่อนหน้าไป ต้องกดซ้ำอีกครั้ง (หรือเปลี่ยนหน้า/สถานะ) จึงจะเห็นผลจริง
  • ตั้งชื่อหมวดหมู่เป็นภาษาไทยจะได้ slug ว่าง — ตัวสร้าง slug ของเมนูนี้ตัดทุกอักขระที่ไม่ใช่ [a-z0-9\s-] ทิ้งโดยไม่มีการถอดอักษรไทยเป็นละติน (ต่างจากเมนู Content Pages ที่มีตารางถอดอักษรในตัว) ชื่อภาษาไทยล้วนจึงเหลือ slug เป็นค่าว่าง แล้วฟอร์มจะฟ้องว่า "กรุณากรอก Slug" — ต้องพิมพ์ slug เองในกรณีนี้
  • ลบหมวดหมู่แล้ว หน้าเนื้อหาที่ผูกไว้จะยังผูกอยู่กับหมวดที่ไม่มีแล้ว ควรย้ายหน้าเนื้อหาไปหมวดอื่นก่อนลบ
  • หมวดหมู่สถานะ inactive ยังอยู่ในหน้ารายการ (ใช้ตัวกรองเห็นได้) แต่จะ หายจากตัวเลือก dropdown ในเมนู Content Pages และ Content Links เพราะ endpoint dropdown กรองเฉพาะ active — เป็นวิธี "ซ่อนหมวดหมู่จากผู้ใช้ปลายทาง" โดยไม่ต้องลบ
  • ตัวเลือก ลำดับการเรียง มีผลกับการจัดเรียงจริงในทุก endpoint แต่หน้ารายการไม่มีปุ่มลากวางเพื่อจัดลำดับ — ต้องแก้ตัวเลขในฟอร์มรายหมวด (ต่างจากเมนู Subcategories ที่ลากวางได้)
  • ผู้ใช้คนอื่นใน LINE OA เดียวกันแก้และลบหมวดหมู่ได้ทั้งหมด — validateOwnership ของโมดูลนี้ตรวจแค่ line_oa_id เท่านั้น (ไม่ตรวจผู้สร้างหรือองค์กรเหมือนโมดูล Content Pages)

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

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

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

Frontend (cms-web-2026)

  • src/app/content-category/page.tsx — ตาราง 7 คอลัมน์, ตัวกรอง, badge จำนวนหมวดย่อย, กล่องยืนยันการลบ
  • src/app/content-category/form/page.tsx — ฟอร์ม 5 ช่อง, กฎ validation ฝั่งหน้าเว็บ, ตัวสร้าง slug อัตโนมัติ
  • src/services/content-category.service.ts — endpoint ที่หน้าเว็บเรียกใช้ (getList, getById, getDropdown, create, update, deleteById)
  • messages/th/content-category.json, messages/en/content-category.json — ข้อความบนหน้าจอทั้งชุด
  • src/components/layout/sidemenu/sidemenu.container.tsx — การตรวจสิทธิ์ระดับเมนู content-management

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

  • internal/modules/contentcategory/controller.go — 8 เส้นทาง, การไม่มี ModuleGate, parseParamID และพฤติกรรม NaN → 500
  • internal/modules/contentcategory/service.go — cascade soft delete, การบันทึกซ้ำหมวดย่อยตอน update, การเรียงลำดับ, GetCategoryStats
  • internal/modules/contentcategory/dto.go — transform lowercase/trim ของ slug, regex, กฎความยาว
  • internal/entities/content_category.go, internal/entities/content_subcategory.go — deleted_date เป็นคอลัมน์ธรรมดา ไม่ใช่ gorm.DeletedAt
  • internal/modules/auth/policy.go — CheckPolicies ที่เป็น no-op
  • internal/modules/modulegate/coverage_test.go — รายการโมดูลที่มี ModuleGate จริงและโมดูลที่ยกเว้น

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