บอร์ดประกาศ - ฟีดและการตั้งกระทู้
ภาพรวม
บอร์ดประกาศ (bulletin board) ออกแบบให้เหมือน "บอร์ดติดประกาศในห้องพนักงาน" เปิดให้สมาชิกของ OA เข้ามาอ่านและตั้งประกาศได้ พร้อมระบบหมวดหมู่ ประกาศปักหมุด รีแอ็กชันด้วยอิโมจิ และการแสดงความคิดเห็น เหมาะกับการสื่อสารภายในองค์กร ประกาศของร้าน หรือชุมชนที่ต้องการพื้นที่พูดคุยแบบเบา ๆ
จุดเด่นทางการออกแบบคือ โพสต์ของสมาชิกไม่แสดงชื่อผู้เขียน (pseudonymity) API จะส่งกลับมาเพียงประเภทผู้เขียน (สมาชิก LINE หรือแอดมิน) และธงบอกว่าเป็นโพสต์ ของผู้เรียกเองหรือไม่ ชื่อที่แสดงบนหน้าจอจึงถูกกำหนดฝั่ง client คือ โพสต์ของตัวเอง แสดงว่า "คุณ" โพสต์ของแอดมินแสดงเป็นชื่อ OA และโพสต์อื่นแสดงว่า "สมาชิก"
Business Flow
- ผู้ใช้เปิด
/{hash}/bulletin - ระบบยืนยันตัวตนเป็นลูกโซ่: แปลง hash เป็น LIFF ID → เข้าสู่ระบบผ่าน LIFF → ประกอบ header สำหรับเรียก API (ทั้ง ID token และ access token หากไม่มี ID token จะใช้ access token แทน)
- โหลดข้อมูลสองชุดพร้อมกัน:
- ข้อมูลบอร์ด — การตั้งค่า หมวดหมู่ ชื่อและรูป OA
- รายการประกาศ — ใช้ cursor pagination ครั้งละ 20 รายการ (ค่า limit ต้องอยู่ในช่วง 1–50 มิฉะนั้น server จะรีเซ็ตเป็น 20 เงียบ ๆ และจะไม่ส่ง cursor หน้าถัดไปกลับมา)
- ตั้งชื่อหน้าเบราว์เซอร์ตามชื่อ OA และติดตั้งชุดสีของธีมให้ทั้งหน้า
- เรนเดอร์หน้าบอร์ด: แถบเลือกหมวดหมู่ ช่องตั้งประกาศ รายการประกาศปักหมุด (จะมาเฉพาะหน้าแรกและเฉพาะเมื่อไม่ได้กรองหมวด) แล้วต่อด้วยรายการประกาศทั่วไป และปุ่มโหลดเพิ่ม
- แตะที่ประกาศเพื่อเข้าสู่หน้ารายละเอียด (
/{hash}/bulletin/{postId}) - การตั้งประกาศ: กรอกหัวข้อ (บังคับ ไม่เกิน 200 ตัวอักษร) เนื้อหา แนบรูปได้ตามจำนวนที่การตั้งค่าบอร์ดอนุญาต และเลือกหมวดหมู่ได้เฉพาะหมวด ที่เปิดให้สมาชิกโพสต์ เมื่อส่งสำเร็จระบบจะโหลดฟีดใหม่
- การนับจำนวนตัวอักษรใช้หน่วย code point ไม่ใช่หน่วยความยาวสตริงแบบ JavaScript เพื่อให้ตรงกับการนับ rune ฝั่ง Go ซึ่งสำคัญมากกับภาษาไทยและอิโมจิ
- กรณีโหลดบอร์ดไม่สำเร็จ ระบบแยกความหมายของ error ให้ผู้ใช้เข้าใจ:
- แอดมินแพลตฟอร์มปิดฟีเจอร์นี้ไว้ — แสดงข้อความว่าฟีเจอร์ปิดใช้งานอยู่
- ผู้ใช้ไม่มีสิทธิ์ดูบอร์ด — แสดงหน้าจอเดี่ยว ๆ โดยไม่แสดงส่วนหัวของบอร์ดเลย
- เซสชันหมดอายุ — แจ้งให้เข้าสู่ระบบใหม่
หน้าจอและองค์ประกอบหลัก
หน้าบอร์ดประกอบขึ้นจากส่วนย่อยที่แยกหน้าที่กันชัดเจน (อยู่ใต้
src/app/[hash]/bulletin/):
- ส่วนหัวบอร์ด (
BoardHeader) — แถบ chip สำหรับเลือกหมวดหมู่ แสดงหมวดทั้งหมด รวมถึงหมวดที่โพสต์ได้เฉพาะแอดมิน (เพื่อให้ผู้ใช้ยังกรองอ่านได้) - ช่องตั้งประกาศ (
ComposeSheet) — ฟอร์มหัวข้อ เนื้อหา แนบรูป และเลือกหมวด - การ์ดประกาศ (
PostSheet) — ใช้ร่วมกับหน้ารายละเอียด โดยสลับ layout ได้ - องค์ประกอบประกอบ — ตะแกรงรูปภาพ (
ImageGrid) ป้ายผู้เขียนแบบไม่ระบุตัวตน (AuthorMark) ตราประทับ (Stamp) แถบรีแอ็กชัน (ReactionBar) และหน้าจอ สถานะว่าง/ผิดพลาด (EmptyBoard) - ตัวห่อธีม (
BoardRoot) — ติดตั้งตัวแปรสีของธีมลงใน CSS
ชุดธีมถูกกำหนดเป็น preset สี่แบบ (staff-room, paper, slate, brand)
มีทั้งโหมดสว่างและมืด และค่าคอนทราสต์ผ่านมาตรฐาน WCAG
ฟอนต์โหลดผ่าน next/font โดยใช้ Noto Sans Thai สำหรับข้อความทั่วไป
และ IBM Plex Mono เฉพาะกับตัวเลข
ฝั่งข้อมูลใช้ hook แยกตามความรับผิดชอบ (useBulletinAuth, useBulletinBoard,
useBulletinFeed, useColorScheme) และรวมการเรียก API ไว้ที่
src/service/bulletin.service.ts
Endpoint ที่ใช้ในหน้านี้
| Method | Path |
|---|---|
| GET | /bulletin/{hash} |
| GET | /bulletin/{hash}/posts?category=&cursor=&limit= |
| POST | /bulletin/{hash}/posts |
| PUT | /bulletin/{hash}/reactions |
จุดเชื่อมต่อกับฟีเจอร์อื่น
- TanStack Query — ใช้ infinite query สำหรับ cursor pagination โดยต้องตรวจว่า cursor หน้าถัดไปเป็นสตริงที่ไม่ว่างก่อนจะโหลดต่อ เพราะฝั่ง API ละ field นี้เมื่อไม่มีค่า
- การอัปโหลดไฟล์ — ใช้กับการแนบรูปในประกาศ (ดู file-upload)
- หน้ารายละเอียดและระบบสิทธิ์ — ต่อเนื่องจากหน้านี้โดยตรง (ดู bulletin-post-detail และ bulletin-moderation)
- ระบบยืนยันตัวตนของบอร์ด ถูกนำไปใช้ซ้ำในฟีเจอร์บัตรสะสมแต้มทั้งฝั่งลูกค้า และฝั่งพนักงาน เพราะลำดับการยืนยันตัวตนเหมือนกันทุกขั้น (ดู loyalty-card และ loyalty-staff)
- มีชุดทดสอบครอบคลุมส่วนตั้งประกาศ การ์ดประกาศ แถบรีแอ็กชัน สิทธิ์การโพสต์ตามหมวด และการแปลง error
รายละเอียดฝั่ง Backend (Client API)
ด่านแรก — middleware เปิด/ปิดแอปรายองค์กร
route group /bulletin/:hash ทั้งกลุ่มถูกครอบด้วย middleware ที่ตรวจว่าองค์กรนี้เปิดแอป
bulletin ไว้หรือไม่ (อ่านจากตาราง line_oa_app) เดิมฝั่ง CMS บังคับกฎนี้อยู่แล้วด้วยการซ่อนเมนู
แต่ ลิงก์ LIFF สาธารณะไม่มีการเช็คเลย ทำให้แอปที่ถูกปิดยังใช้งานได้จากฝั่งลูกค้า
middleware นี้จึงเป็นชั้นที่ client-web ข้ามไม่ได้
- แถวที่ระบุชัดว่าปิด → 403 message
APP_DISABLED(fail closed) — สตริงนี้คือค่าที่หน้าเว็บ แปลงเป็นหน้าจอ "ฟีเจอร์นี้ยังไม่เปิดใช้งาน" จึงห้ามเปลี่ยนโดยไม่แก้ทั้งสองฝั่ง - ไม่มีแถว
line_oa_appเลย ให้อ่านว่า "ปิด" (ใช้COALESCEให้เป็น false) ไม่ใช่ทำให้ OA หลุดจากผลลัพธ์ - แต่ถ้า lookup ล้มเหลว หรือ hash ไม่ตรงกับ OA ใดเลย → fail open ปล่อยผ่านให้ handler ตัดสินเอง (ซึ่งจะตอบ 404) เพราะการสะดุดชั่วคราวของ lookup เชิงธรรมาภิบาลไม่ควรล็อกลูกค้า ออกจากแอปที่เปิดอยู่จริง
บันไดสิทธิ์ — resolveAccess และเหตุผลของลำดับ
ทุก request ของโดเมน bulletin ไม่ว่าอ่านหรือเขียน วิ่งผ่าน resolveAccess ครั้งเดียวต่อ request
แล้วส่งบริบทสิทธิ์ที่ได้ต่อลงไป ไม่มี handler ใดคำนวณสิทธิ์ใหม่ ลำดับ 5 ขั้นเป็นส่วนหนึ่งของ
ความปลอดภัย เพราะทุกอย่างที่มีผลข้างเคียงต้องเกิด หลัง การผูก token กับ channel:
- resolve OA และ LINE Login channel ของมันจาก
:hash— ไม่พบ → 404"board not found" - verify LIFF token กับ channel ของ OA นั้น — ไม่ผ่าน → 401 (นี่คือ channel binding: token ที่ valid พิสูจน์แค่ว่ามีบัญชี LINE อยู่ ไม่ได้พิสูจน์ว่าเป็นของ tenant นี้)
- สร้างแถวบอร์ดให้ OA ถ้ายังไม่มี
- ค้น
line_user— การค้นไม่เจอไม่ได้พิสูจน์ว่าไม่มีแถว เพราะ query กรอง status/deleted_date อยู่ จึงเรียกการสร้าง guest แบบ conflict-safe ที่รายงานกลับว่าในตารางจริงมีอะไร: ถ้ามีแถวอยู่ แต่ถูกปิดหรือถูกลบ นั่นคือ การปฏิเสธโดยเจตนา → 403BULLETIN_USER_INACTIVEไม่ใช่การรับเข้าใหม่เป็น guest - โหลดรายการ block ที่ยัง active แล้วประกอบบริบทสิทธิ์ (ผู้ใช้, ชนิดผู้ใช้, OA, บอร์ด, settings, audience, block)
การตั้งค่าบอร์ดและค่า default
settings เป็น jsonb ที่ถูก unmarshal ทับลงบนค่า default ดังนั้น key ที่ไม่มีใน blob
จะคงค่า default และ JSON ที่พังรูปจะ degrade เป็น default ทั้งชุด (ไม่ใช่บอร์ดที่ปฏิเสธทุกคน)
จุดที่ต้องระวัง: array ว่างใน view_access/write_access = deny-all ที่ถูกต้องตามกฎ
แต่ค่า null = "ไม่ได้ตั้ง" จึงกลับไปใช้ default
ค่า default ที่ backend ใช้: view_access เป็น member และ guest, write_access เป็น member,
ไม่ต้องอนุมัติโพสต์และคอมเมนต์, รูปต่อโพสต์ 4 รูป, รูปต่อคอมเมนต์ 2 รูป, ความยาวเนื้อหา 2000,
ความยาวคอมเมนต์ 500, เปิดคอมเมนต์เป็นค่าเริ่มต้น, เปิดให้ผู้ใช้รายงานเนื้อหา และชุดอิโมจิ 6 ตัว
GET /api/bulletin/:hash — ข้อมูลบอร์ด
- ต้องผ่าน
CanView()ก่อน (403BULLETIN_VIEW_FORBIDDENถ้าไม่ผ่าน) - รายการหมวดหมู่ถูกกรองอีกครั้งตอนส่งออก ด้วยกฎการมองเห็นหมวด เพราะ SQL ที่ดึงหมวด กรองแค่ board/status/deleted เท่านั้น ไม่รู้เรื่องโหมดการเข้าถึง — ถ้าไม่กรองที่ชั้นนี้ guest และ member ที่อยู่นอก audience จะอ่าน ชื่อหมวด และ audience id ของหมวดที่จำกัดไว้ได้ ซึ่งชื่อหมวดเองอาจเป็นความลับ (เช่น "หารือเรื่องเลิกจ้าง") จึงต้องถูกตัดออกทั้งก้อน
- รูป OA resolve เป็น public URL และเป็น สตริงว่าง เมื่อไม่มีรูป ไม่ใช่ URL ครึ่งๆ ที่ชี้ไป bucket root เพื่อให้ฝั่งเว็บ fallback ด้วยการเช็ค falsy ธรรมดาได้
กฎการมองเห็นหมวดหมู่ — 2 แกนที่เป็นอิสระกัน
- แกนอ่าน — โหมด inherit ใช้สิทธิ์ระดับบอร์ด (guest เห็นด้วย); โหมด restricted ที่ไม่ระบุ audience = member ทุกคน; restricted ที่ระบุ audience = member ที่ audience ทับกัน restricted บังคับ member-only เสมอ เพราะ guest ไม่เคยอยู่ใน audience ที่มีความหมาย
- แกนเขียน — ค่า member (default) แปลว่า LINE user โพสต์ได้; ค่า admin แปลว่าโพสต์ได้จาก CMS เท่านั้น สองแกนประกอบกันได้ เช่น อ่านได้ทุกคนแต่โพสต์ได้เฉพาะแอดมิน = หมวด "ประกาศ"
audience_idsที่อ่านเป็น array ไม่ได้ (เช่นเป็น null หรือ element ไม่ใช่ integer) ถือเป็น deny ไม่ใช่ขยายเป็น "member ทุกคน" — มีเฉพาะกรณีที่ไม่มีค่าเลยจึงหมายถึง member ทั้งหมด- กฎชุดนี้มี ฝาแฝดเป็น SQL ที่ใช้กรองฟีด ทั้งสองฝั่งต้องตรงกันและมีเทสต์ตรึงคู่กันไว้
GET /api/bulletin/:hash/posts — ฟีด
- หมวดที่ parse ไม่ได้ → 400
"invalid category"แต่ cursor ที่พังรูปถูกยอมรับเป็น "เริ่มจากบนสุด" ไม่ใช่ 400 — เป็นความไม่สมมาตรที่ตั้งใจ เพราะ cursor เป็นค่าที่ระบบส่งให้ ไม่ใช่ค่าที่ผู้ใช้พิมพ์ - แถบปักหมุดแนบมาเฉพาะหน้าแรกที่ไม่กรองหมวด เพราะแถบนี้ไม่ได้ paginate ถ้าแนบทุกหน้า จะซ้ำ; เพดานอยู่ที่ 5 โพสต์ ซึ่งเป็นค่าคงที่ในโค้ด ไม่ใช่ setting (endpoint ปักหมุดฝั่ง CMS ปฏิเสธเมื่อเกินจำนวนนี้)
- กฎการมองเห็นโพสต์ถูกเขียนไว้ใน SQL ของฟีดเลย: โพสต์ published เห็นได้ถ้าเห็นหมวดของมัน, โพสต์ pending เห็นได้เฉพาะเจ้าของ, โพสต์ hidden/deleted ไม่มีใครเห็น
- cursor — หน้าที่เต็ม limit จะได้ cursor ถัดไปเป็น base64 ของเวลาและ id ของแถวสุดท้าย
โดยอ้าง
last_activity_dateซึ่งเป็นคอลัมน์ที่ฟีดเรียงจริง (ถ้าใช้created_dateหน้า 2 จะทั้งซ้ำและข้าม); cursor เป็น opaque โดยเจตนา client สร้างเองไม่ได้ - สถานะรีแอ็กชันของผู้เรียกถูก decorate เป็น batch เดียว ครอบทั้งแถบปักหมุดและฟีด ทุกการ์ดจึง boot มาพร้อมรู้ว่าตัวเองกดอะไรไว้ ไม่มี query ต่อการ์ด
- โครงข้อมูลที่ส่งออกอ่าน
author_line_user_idเพียงเพื่อคำนวณธง "เป็นของฉัน" แล้วไม่คัดลอก ค่าไปที่ใดเลย — นี่คือกลไกที่ทำให้ pseudonymity เป็นจริงที่ระดับ API ไม่ใช่แค่ระดับ UI
ข้อสังเกตอื่น
- id ของ bulletin เป็น global serial ดังนั้นทุก query ต้องรับ
lineOaIDประกอบด้วย — id เพียงตัวเดียวไม่เคยพิสูจน์ความเป็นเจ้าของ - ความล้มเหลวทุกแบบ (ไม่มี / คนละ tenant / ถูกลบ / อยู่ในหมวดที่มองไม่เห็น) ยุบเป็น 404 เดียวกัน ไม่ใช่ 403 ซึ่งจะเป็นการยืนยันว่าของนั้นมีอยู่