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 ช่อง:
- ชื่อหมวดหมู่ — บังคับกรอก สูงสุด 255 ตัวอักษร พร้อมตัวนับ ทุกครั้งที่พิมพ์ ระบบสร้าง Slug ให้อัตโนมัติ (คำอธิบายใต้ช่องระบุว่า "รหัสสำหรับ URL สร้างจากชื่อโดยอัตโนมัติ") — และเงื่อนไขของการสร้างอัตโนมัติคือ "อยู่ในโหมดสร้างใหม่ หรือ ยังไม่มี slug เดิม" จึงไม่ทับ slug ของหมวดที่บันทึกไปแล้ว
- Slug — บังคับกรอก สูงสุด 255 ตัวอักษร ตรวจด้วย regex
^[a-z0-9-]+$ที่ฝั่งฟอร์มพร้อมข้อความ "Slug ต้องประกอบด้วยตัวพิมพ์เล็ก ตัวเลข และเครื่องหมายขีดกลางเท่านั้น" - คำอธิบาย — ไม่บังคับ สูงสุด 1,000 ตัวอักษร พร้อมตัวนับ
- ลำดับการเรียง — ตัวเลข ≥ 0 ค่าเริ่มต้น 0
- สถานะ — บังคับเลือก ค่าเริ่มต้น 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 ของ relationsubcategories) log ที่เขียนออกมายังบันทึกจำนวนหมวดย่อยที่ถูกลบไว้ด้วย (subcategoriesDeleted) — แต่content_pageที่ผูกหมวดนี้อยู่ ไม่ได้ถูกแตะเลย - แก้แค่ชื่อหมวดหมู่ ก็ทำให้ "อัปเดตล่าสุด" ของหมวดย่อยทุกตัวขยับตามไปด้วย —
Update()บันทึกแถวหมวดหมู่ แล้ว วนบันทึกซ้ำหมวดหมู่ย่อยทุกตัวที่ preload มา ทั้งที่ไม่มีค่าใดเปลี่ยน เพื่อให้ hookBeforeUpdateยิงและดัน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ที่ฝั่งหน้าเว็บ) จึงยังนับหมวดย่อยที่ลบทิ้งไปแล้ว ในทางกลับกัน endpointGET /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เลย (ต่างจากname255 และthumbnail500 ที่มีจริง)
เมนูที่เกี่ยวข้องกันในเส้นทางนี้
- 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บน subjectline-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 → 500internal/modules/contentcategory/service.go— cascade soft delete, การบันทึกซ้ำหมวดย่อยตอน update, การเรียงลำดับ,GetCategoryStatsinternal/modules/contentcategory/dto.go— transform lowercase/trim ของ slug, regex, กฎความยาวinternal/entities/content_category.go,internal/entities/content_subcategory.go—deleted_dateเป็นคอลัมน์ธรรมดา ไม่ใช่gorm.DeletedAtinternal/modules/auth/policy.go—CheckPoliciesที่เป็น no-opinternal/modules/modulegate/coverage_test.go— รายการโมดูลที่มี ModuleGate จริงและโมดูลที่ยกเว้น
ดูรายละเอียดเชิงเทคนิคเพิ่มเติม (เวอร์ชันก่อนหน้าของทีมเอกสาร) ที่ หมวดหมู่เนื้อหา (Content Category)