Skip to main content

บอร์ดประกาศ - ฟีดและการตั้งกระทู้

ภาพรวม​

บอร์ดประกาศ (bulletin board) ออกแบบให้เหมือน "บอร์ดติดประกาศในห้องพนักงาน" เปิดให้สมาชิกของ OA เข้ามาอ่านและตั้งประกาศได้ พร้อมระบบหมวดหมู่ ประกาศปักหมุด รีแอ็กชันด้วยอิโมจิ และการแสดงความคิดเห็น เหมาะกับการสื่อสารภายในองค์กร ประกาศของร้าน หรือชุมชนที่ต้องการพื้นที่พูดคุยแบบเบา ๆ

จุดเด่นทางการออกแบบคือ โพสต์ของสมาชิกไม่แสดงชื่อผู้เขียน (pseudonymity) API จะส่งกลับมาเพียงประเภทผู้เขียน (สมาชิก LINE หรือแอดมิน) และธงบอกว่าเป็นโพสต์ ของผู้เรียกเองหรือไม่ ชื่อที่แสดงบนหน้าจอจึงถูกกำหนดฝั่ง client คือ โพสต์ของตัวเอง แสดงว่า "คุณ" โพสต์ของแอดมินแสดงเป็นชื่อ OA และโพสต์อื่นแสดงว่า "สมาชิก"

Business Flow​

  1. ผู้ใช้เปิด /{hash}/bulletin
  2. ระบบยืนยันตัวตนเป็นลูกโซ่: แปลง hash เป็น LIFF ID → เข้าสู่ระบบผ่าน LIFF → ประกอบ header สำหรับเรียก API (ทั้ง ID token และ access token หากไม่มี ID token จะใช้ access token แทน)
  3. โหลดข้อมูลสองชุดพร้อมกัน:
    • ข้อมูลบอร์ด — การตั้งค่า หมวดหมู่ ชื่อและรูป OA
    • รายการประกาศ — ใช้ cursor pagination ครั้งละ 20 รายการ (ค่า limit ต้องอยู่ในช่วง 1–50 มิฉะนั้น server จะรีเซ็ตเป็น 20 เงียบ ๆ และจะไม่ส่ง cursor หน้าถัดไปกลับมา)
  4. ตั้งชื่อหน้าเบราว์เซอร์ตามชื่อ OA และติดตั้งชุดสีของธีมให้ทั้งหน้า
  5. เรนเดอร์หน้าบอร์ด: แถบเลือกหมวดหมู่ ช่องตั้งประกาศ รายการประกาศปักหมุด (จะมาเฉพาะหน้าแรกและเฉพาะเมื่อไม่ได้กรองหมวด) แล้วต่อด้วยรายการประกาศทั่วไป และปุ่มโหลดเพิ่ม
  6. แตะที่ประกาศเพื่อเข้าสู่หน้ารายละเอียด (/{hash}/bulletin/{postId})
  7. การตั้งประกาศ: กรอกหัวข้อ (บังคับ ไม่เกิน 200 ตัวอักษร) เนื้อหา แนบรูปได้ตามจำนวนที่การตั้งค่าบอร์ดอนุญาต และเลือกหมวดหมู่ได้เฉพาะหมวด ที่เปิดให้สมาชิกโพสต์ เมื่อส่งสำเร็จระบบจะโหลดฟีดใหม่
  8. การนับจำนวนตัวอักษรใช้หน่วย code point ไม่ใช่หน่วยความยาวสตริงแบบ JavaScript เพื่อให้ตรงกับการนับ rune ฝั่ง Go ซึ่งสำคัญมากกับภาษาไทยและอิโมจิ
  9. กรณีโหลดบอร์ดไม่สำเร็จ ระบบแยกความหมายของ 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 ที่ใช้ในหน้านี้​

MethodPath
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:

  1. resolve OA และ LINE Login channel ของมันจาก :hash — ไม่พบ → 404 "board not found"
  2. verify LIFF token กับ channel ของ OA นั้น — ไม่ผ่าน → 401 (นี่คือ channel binding: token ที่ valid พิสูจน์แค่ว่ามีบัญชี LINE อยู่ ไม่ได้พิสูจน์ว่าเป็นของ tenant นี้)
  3. สร้างแถวบอร์ดให้ OA ถ้ายังไม่มี
  4. ค้น line_user — การค้นไม่เจอไม่ได้พิสูจน์ว่าไม่มีแถว เพราะ query กรอง status/deleted_date อยู่ จึงเรียกการสร้าง guest แบบ conflict-safe ที่รายงานกลับว่าในตารางจริงมีอะไร: ถ้ามีแถวอยู่ แต่ถูกปิดหรือถูกลบ นั่นคือ การปฏิเสธโดยเจตนา → 403 BULLETIN_USER_INACTIVE ไม่ใช่การรับเข้าใหม่เป็น guest
  5. โหลดรายการ 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() ก่อน (403 BULLETIN_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 ซึ่งจะเป็นการยืนยันว่าของนั้นมีอยู่