Skip to content

SDD-018 — Sự kiện cộng đồng cho Marlin ​

Nemo12 gặp bố mẹ ngoài đời trước khi con vào học: buổi cafe 5 người, webinar ôn thi, buổi chia sẻ. Mảng này kế thừa trang Events đang chạy ở legacy family.chuyenchon.com (SRC-013) và là mảng đầu tiên trong Marlins không nói về một đứa con cụ thể — nên nó không đi qua resolveLearnerAccess, quyền ở đây là quyền trên chỗ ngồi của chính mình.

Phạm vi đợt này đúng ba việc chủ dự án nêu (SRC-122): xem danh sách · xem chi tiết · đăng ký, và chỉ đăng ký được buổi sắp diễn ra.

Sửa 2026-09-14 (SRC-711), trang chi tiết. Bỏ khối "Thời gian" ở cột phải (hero ngay trên đã nói đủ ngày, giờ, độ dài — nhắc lại chỉ đẩy phần cần bấm xuống thấp); lịch trình đổi từ timeline sang accordion; đường vào phòng của buổi hiện cả với buổi đã diễn ra, vì ở đó còn câu hỏi và reflection để đọc lại; chân trang Marlins mang câu của riêng cộng đồng bố mẹ qua prop tagline của SiteFooter (bản canonical không đổi mặc định, nên learn và web giữ nguyên).

Sửa 2026-09-14 (SRC-711). "Chỉ buổi sắp diễn ra" là luật của đăng ký, không phải của danh sách. Trang /events ban đầu gọi scope=upcoming, nên một buổi vừa diễn ra hôm qua làm cả trang rỗng kèm câu "chưa có buổi nào" — trong khi buổi ấy vẫn còn phòng, còn câu hỏi và reflection mà bố mẹ cần quay lại đọc. Nay trang gọi scope=all và tách hai nhóm Sắp diễn ra / Đã diễn ra; can_register vẫn do backend chốt nên buổi cũ hiện ra mà không mở cửa đăng ký. Ba mảng của legacy cố ý để lại: bình chọn câu hỏi trước buổi, wizard khai hồ sơ bắt buộc trước khi giữ chỗ, và bảng admin xem toàn bộ hồ sơ người đăng ký (§6).

1. Nguyên tắc ​

  1. Backend chốt "còn đăng ký được không", client chỉ hiển thị. Đồng hồ máy người dùng lệch là chuyện thường; nếu UI tự so ngày giờ thì hai người sẽ thấy hai câu trả lời khác nhau cho cùng một buổi.
  2. Một hàm duy nhất ra quyết định (registrationState) và cả ba API cùng gọi nó, nên thẻ danh sách và trang chi tiết không bao giờ nói khác nhau. Ở legacy luật này nằm rải trong JSX, dẫn tới thẻ ghi "còn chỗ" trong khi trang chi tiết đã khoá.
  3. Khoá theo user_id, không theo email (QG-004, RISK-001) — khác legacy vốn dùng email làm khoá chính của đăng ký.
  4. Không phát tán thông tin liên lạc của phụ huynh khác: danh sách người đăng ký chỉ có tên hiển thị và ghi chú, không email/điện thoại kể cả đã che (§5).

2. Dữ liệu (migration 0039_community_events.sql) ​

text
community_events: id (slug, dùng thẳng trên URL), series, title, summary,
  goal, audience, agenda_json ([{t,label}]), notes (mỗi dòng một lưu ý),
  starts_at (ISO 8601 CÓ offset), duration_min, mode (offline|online),
  venue?, address?, capacity? (NULL = không giới hạn), fee?, fee_note?,
  status (active|cancelled), created_at
community_event_registrations: (event_id, user_id) PK, status (joined|cancelled),
  display_name, note?, created_at, updated_at
  • Không tách metadata/nội dung như labs (SDD-010 §10): một sự kiện chỉ vài KB và danh sách sắp diễn ra không quá vài chục dòng.
  • starts_at bắt buộc mang offset. Truy vấn dùng datetime(starts_at) >= datetime('now') để SQLite quy về UTC — so chuỗi trần sẽ lệch đúng bằng offset (7 tiếng với giờ Việt Nam).
  • Huỷ đăng ký giữ lại dòng với status='cancelled' thay vì xoá: cần biết ai từng giữ chỗ rồi bỏ, và đăng ký lại không sinh dòng thứ hai.

3. API (/v1/events, module workers/api/src/modules/events) ​

RouteViệc
GET /v1/events?scope=upcoming|allDanh sách; mặc định chỉ buổi sắp diễn ra, sớm nhất trước
GET /v1/events/{eventId}Chi tiết: mục tiêu, đối tượng, lịch trình, lưu ý, chi phí, người đã đăng ký
POST /v1/events/{eventId}/registerGiữ chỗ, kèm ghi chú tuỳ chọn (vd giờ có mặt)
POST /v1/events/{eventId}/unregisterBỏ chỗ của chính mình

registrationState trả về cancelled · past · remaining · full · registered · can_register, và can_register là cờ duy nhất UI đọc. Đóng đăng ký khi: sự kiện đã huỷ · đã tới giờ bắt đầu · hết chỗ (với người chưa giữ chỗ) · đã giữ chỗ rồi.

Sự kiện đã diễn ra vẫn mở được bằng link cũ (chi tiết trả 200) — chỉ là không đăng ký được nữa. Link bố mẹ gửi cho nhau không chết sau buổi đó.

Lỗi theo taxonomy SDD-006 §11: NOT_FOUND cho id lạ; CONFLICT cho đã huỷ / đã diễn ra / hết chỗ, kèm câu tiếng Việt đọc là hiểu.

3b. Chỗ ngồi và cuộc đua giành ghế cuối ​

D1 không khoá hàng, nên đếm-rồi-ghi có khe hở kinh điển: hai người bấm cùng lúc vào chỗ cuối đều thấy còn trống. Cách xử lý (kế thừa legacy): tiền kiểm tra nhanh → ghi → đếm lại; ai làm tràn thì tự trả chỗ và nhận CONFLICT. Bấm đăng ký lần thứ hai chỉ cập nhật ghi chú, không ăn thêm ghế.

4. Domain events ​

community.event.registered và community.event.unregistered (v1, payload chỉ {event_id, user_id} — AS-03.3.4). Chưa có consumer nghiệp vụ: đây là nhật ký để đợt sau nối vào Context Engine ("bố mẹ chịu đi gặp trực tiếp" là tín hiệu mức độ đồng hành, SDD-002 §5b).

5. Quyền riêng tư (QG-008) ​

user_id luôn lấy từ session, không bao giờ nhận từ body — nên không có đường nào sửa hộ đăng ký của người khác. Danh sách người đăng ký trả về tên hiển thị + ghi chú, bỏ hẳn email/điện thoại mà legacy còn trả về dưới dạng đã che.

6. Màn hình (apps/marlins/src/Events.tsx) ​

Tab Sự kiện cạnh Gia đình và Khám phá; #/events danh sách, #/events/{id} chi tiết (mỗi buổi một URL để bố mẹ gửi link cho nhau).

Theo DS-001 §5b: thẻ chỉ có khối ngày + tên + một dòng phụ (giờ · nơi · số chỗ), cả thẻ bấm được nên không có link "Chi tiết" riêng, "đã giữ chỗ" là chấm xanh chứ không phải nhãn chữ. Trang chi tiết chia hai cột: Mục tiêu · Dành cho ai · Lịch trình · Trước khi đến bên trái; Thời gian · Địa điểm · Chi phí · Đăng ký · Đã đăng ký bên phải. Chữ trên hero để nguyên màu của n12-hero (trắng) — ghi đè text-ocean-900 lên nền teal đậm là lỗi tương phản AS-09.3.1.

Khi không đăng ký được, màn hình nói rõ vì sao (đã huỷ / đã diễn ra / đủ người) thay vì để một cái nút xám.

6b. Tương tác trong buổi (SRC-711) ​

Đợt SRC-122 dừng ở "giữ chỗ". Chỉ đạo chủ dự án 2026-09-13 mở tiếp phần diễn ra bên trong buổi: host tạo hoạt động, cả phòng trả lời, màn chiếu cập nhật trực tiếp.

Bốn dạng hoạt động, một bảng ​

event_activities.kind nhận question · poll · reflection_4f · reflection_ssc. Hai khung reflection không có bảng riêng: chúng là câu hỏi mở có nhiều ô, và ô nào nằm ở event_activity_answers.slot. Tách bảng thì mở/đóng, thứ tự, quyền host phải viết lại bốn lần và đến lúc lệch nhau sẽ không ai biết bên nào đúng.

DạngNgười trả lời làm gìTrần max_answers_per_person đếm theo
questionviết tự do, ủng hộ ý của nhaumỗi người
pollchọn một phương án host đưa ra— (một người một phiếu, ép bằng khoá chính)
reflection_4fFacts · Feelings · Findings · Future, trả lời một vài ô cũng đượcmỗi người mỗi ô
reflection_sscStart · Stop · Continue, cùng cơ chếmỗi người mỗi ô

Trần reflection đếm theo Ô chứ không theo hoạt động: nếu không, ai viết kỹ ô Facts sẽ không còn lượt nào cho ô Future.

Ai là host ​

community_events.host_user_id (migration 0215), hoặc role admin. Không route nào nhận host từ body. Nhờ khoá theo từng buổi, mời một mentor chủ trì một buổi không phải cấp quyền admin toàn hệ thống cho họ.

Ba vòng quyền ​

  1. requireSession — mọi route, kể cả WebSocket.
  2. Đã bấm Tham gia buổi — chưa giữ chỗ thì GET /activities trả mảng rỗng (không phải 403: màn hình cần vẽ được lời mời, và một lỗi đỏ ở đó đọc như hỏng hóc).
  3. Host — tạo/mở/đóng/xoá, Live Stage, Back-stage.

Live Stage = Durable Object (EVENT_STAGE) ​

Durable Object đầu tiên của hệ thống. Vì sao không polling: 60 người trong phòng, polling 2 giây là 1.800 request mỗi phút mà phần lớn trả về "không có gì mới", và con số trên màn chiếu vẫn trễ.

Ranh giới trách nhiệm, phần dễ làm sai nhất:

Ở đâu
Nguồn sự thật (câu trả lời, phiếu)D1. Mọi lượt ghi đi qua route HTTP → D1 → notifyStage()
Danh sách kết nối đang mởDO, một instance mỗi buổi
Trạng thái nghiệp vụ trong DOKhông có. DO bị thu hồi giữa buổi thì không mất phiếu nào

Bản phát không mang dữ liệu riêng của ai — không "tôi đã vote gì", không tên người viết. Một bản phát đi tới mọi kết nối (kể cả máy người tham gia), nên bất cứ thứ gì riêng tư lọt vào đây là lọt sang người khác. Hệ quả kiến trúc: màn người tham gia dùng bản phát như tiếng chuông rồi tự gọi GET /activities của riêng mình; chỉ Live Stage vẽ thẳng từ bản phát.

Nhịp phát được gộp 80ms: 30 người cùng bấm trong một giây là một bản phát, không phải 30.

Ba màn, ba việc khác nhau ​

MànURLCho aiCó tên người không
Event Room/events/{id}/roommọi người trong buổicó — danh sách có mặt, chỉ mở cho người đã tham gia
Live Stage/events/{id}/livehost, chiếu lên tườngkhông — chỉ con số
Back-stage/events/{id}/backstagehost, trên máy mìnhcó — gồm cả ai chưa làm gì

Tên người chỉ đi qua đường HTTP có cổng host, nên chúng không thể xuất hiện trên bản phát chung. Đó là lý do "ai chưa bắt đầu" nằm ở Back-stage chứ không nằm trên màn chiếu.

Live Stage hiển thị ba con số cạnh nhau — trong buổi · đã bắt đầu · đã trả lời. Khoảng cách giữa hai số sau là thứ host cần thấy: nó nói đề bài khó hay phòng đang bận.

Khu hỏi host ​

Một question đặc biệt (is_ask_host = 1, id suy ra từ event_id nên dựng lười là idempotent thật): luôn mở, host không tạo và không xoá được. Mọi người hỏi bất cứ lúc nào, ai cũng ủng hộ hoặc bỏ ủng hộ được, câu nhiều ủng hộ nhất nổi lên đầu. Nó dùng chung toàn bộ đường code với câu hỏi thường — không có nhánh riêng nào cho nó ngoài cờ đó.

Chưa làm trong đợt này ​

Của legacyVì sao để lại
Bình chọn 5 câu hỏi quan tâm nhất trước buổiNgoài phạm vi SRC-122; chỉ có nghĩa khi mỗi buổi đã có sẵn ngân hàng câu hỏi
Wizard bắt khai hồ sơ bố mẹ + con trước khi giữ chỗNemo12 đã có hồ sơ gia đình thật (families, learners) — nên lấy từ đó, không hỏi lại; cần một vòng thiết kế riêng
Bảng admin xem toàn bộ hồ sơ người đăng kýThuộc admin.nemo12.com, và phải đi kèm audit log (QG-008)

Quản lý nội dung sự kiện đợt này bằng seed script (scripts/seed-events.sql), giống cách Orca khởi động (SDD-016 §6).

6c. Brand, announcement và banner (SRC-1312, chỉ đạo 10.10.2026) ​

Màn Events của Dolphin có thêm ba thứ cho mỗi buổi:

  • Brand: cột community_events.brand (migration 0341), chữ tự do. Ô nhập gợi ý NEMO IELTS, NEMO SAT, NEMO SPEAK, NEMO GRAMMAR, NEXT STEP từ hằng BRAND_SUGGESTIONS trong apps/mentors/src/eventMedia.ts, và người biên tập gõ được tên mới. Chủ dự án chốt: danh sách brand KHÔNG lưu trong database; thêm gợi ý là sửa hằng ấy. Để trống thì mọi thứ đề "NEMO12".
  • Ba dạng announcement: tin nhắn ngắn (Zalo, Messenger), bài đăng mạng xã hội, email gửi phụ huynh. Dựng bằng mẫu từ đúng các ô đã lưu, không gọi AI: một câu bịa về giờ hay phí trong thư gửi bố mẹ đắt hơn nhiều so với một câu văn kém bay bổng. Ngày theo DD.MM.YYYY, giờ theo múi Việt Nam.
  • Banner: chuyển sang Banner Studio, xem §6e.

Không thứ nào trong ba thứ trên được lưu: chúng dựng lại mỗi lần mở, nên sửa thông tin buổi là announcement và ảnh đổi theo ngay. Test: apps/mentors/src/eventMedia.test.ts.

6d. Checklist vận hành của buổi (SRC-1312, chỉ đạo 10.10.2026) ​

Mỗi buổi có một checklist ba chặng (trước, trong, sau buổi) và ba cụm việc (truyền thông tới người tham gia, khách mời và chuyên môn, logistics như địa điểm, Zoom, chỗ ngồi). Hai bảng (migration 0342):

  • event_checklist_tasks: danh sách việc CHUNG cho mọi buổi, nạp sẵn 20 việc mặc định. Mentor thêm một việc là việc ấy hiện ở mọi buổi, để lần sau không phải tạo lại. Không có xoá (luật Dolphin); archived_at để ẩn.
  • event_checklist_marks: tick và ghi chú của TỪNG buổi, kèm người sửa gần nhất. Không có dòng nghĩa là chưa làm.

API trong workers/api/src/modules/events/checklist.ts: GET và PUT /v1/events/{eventId}/checklist cho nhân sự (mentor, staff, admin) và chủ buổi; POST /v1/events/{eventId}/checklist/tasks chỉ cho nhân sự, vì việc thêm vào hiện ở buổi của người khác. PUT chỉ ghi ô có trong body, nên một người tick lúc người khác đang gõ ghi chú không làm mất phần của ai. Màn: apps/mentors/src/EventChecklist.tsx, tick thẳng, ghi chú theo luật đọc trước rồi Edit. Test: checklist.test.ts.

6e. Banner Studio của một buổi (SRC-1312, chỉ đạo 10.10.2026) ​

"Với một event thì cần làm một loạt banner." Ba trang có URL riêng trên Dolphin: /events/:id/banners (loạt banner chia trước, trong, sau buổi), /events/:id/banners/new (chọn mẫu), /events/:id/banners/:bannerId (xem chỉ đọc, tải PNG; Edit mới mở ô sửa, Save/Cancel tại chỗ).

  • 30 mẫu trong apps/mentors/src/bannerTemplates.ts: 14 trước buổi (thông báo, lưu ngày, đếm ngược 7 ngày và 1 ngày, hôm nay, khách mời, chương trình, lý do nên đến, câu hỏi lớn, chỗ cuối, miễn phí, địa điểm, online, thư mời), 7 trong buổi, 9 sau buổi. Mỗi mẫu là dữ liệu (bố cục, giọng màu, chữ mặc định có chỗ trống {title}, {date}...), một hàm vẽ chung cho mọi mẫu.
  • Sub-brand đổi bảng màu, không đổi mẫu: 30 mẫu dùng được cho mọi sub-brand (NEMO SPEAK, NEMO WALK, NEMO IELTS, NEMO SAT...), kể cả tên gõ tay (màu băm từ tên).
  • Ba khổ: vuông 1080×1080, dọc 1080×1350, ngang 1200×630. Chữ tự thu cỡ cho vừa khung, không bị cắt.
  • Bộ mặc định khi buổi chưa có banner: 3 trước, 3 trong, 4 sau.
  • Màu nền: sáu màu cố định của mỗi brand (Night, Deep, Brand, Soft, Light, Paper), chữ và màu nhấn đổi theo nền để đủ tương phản. Hình trang trí vẽ bằng thuật toán, 16 dạng (sóng, vòng tròn, hoa giấy, halftone...), ẩn/hiện được. Nhãn góc phải (kiểu "K2 · Khai giảng 27.10") có ở mọi mẫu. Ba lựa chọn này lưu trong fields_json với khoá _bg, _deco, _decoOn, không thêm cột.
  • Hay dùng lên đầu: GET /v1/banner-usage đếm mẫu, hình trang trí và nền trên mọi banner chưa ẩn của mọi buổi; Dolphin xếp danh sách mẫu, hình trang trí và màu nền theo số đó, ba cái đầu có nhãn "Hay dùng". Không có bảng đếm riêng: đếm thẳng từ event_banners, nên không có hai nơi phải khớp.
  • Lưu: bảng event_banners (migration 0343) giữ mẫu, khổ, sub-brand và CHỈ những ô người dùng đã sửa khác chữ mặc định. Ô chưa sửa bám theo thông tin buổi, nên đổi giờ buổi là banner đổi theo. Ảnh không lưu. Không có xoá; "Ẩn banner" đặt archived_at.
  • API workers/api/src/modules/events/banners.ts, quyền như checklist. Test: banners.test.ts, apps/mentors/src/bannerTemplates.test.ts.

Không có skill hay thư viện sẵn nào trên GitHub làm đúng việc này (tra 10.10.2026): các thư viện DOM sang ảnh (snapdom, html-to-image) cần thêm phụ thuộc và tải font qua mạng, còn skill canvas-design của Anthropic chỉ làm ảnh lẻ. Canvas thuần đủ dùng và không thêm gói nào.

Video của buổi gặp (SRC-720, chỉ đạo 2026-09-15) ​

Yêu cầu, và điều đã nói rõ trước khi làm ​

Chủ dự án: mỗi buổi có chỗ gắn link YouTube dạng unlisted, learner xem ngay trên web và không đi sang YouTube. Đã trình bày giới hạn trước khi làm, và chủ dự án chốt: "giấu, không phải chặn".

Cái làm được là bịt mọi đường rời trang thông thường. Cái KHÔNG làm được, và không tài liệu nào của Nemo12 được nói ngược lại: video unlisted là video ai có ID cũng xem được, mà ID phải nằm trong trang thì trình phát mới chạy. Người mở công cụ dev đọc được nó trong mười giây.

Hệ quả vận hành: đừng đặt vào đây video mà việc lọt ra ngoài gây hại thật. Chỗ này dành cho bản ghi buổi gặp. Muốn chặn thật thì phải rời YouTube sang dịch vụ có URL ký hạn (Cloudflare Stream).

Lưu ID, không lưu URL ​

community_events.youtube_video_id (migration 0219) chịu CHECK đúng dạng ID: 11 ký tự, chữ, số, gạch dưới, gạch nối. Lý do không phải gọn gàng mà là an toàn: cột này được ghép thẳng vào src của một <iframe>. Nhận URL tự do nghĩa là mỗi chỗ render phải tự nhớ kiểm tra lại.

youtubeVideoId() phân tích bằng URL rồi đối chiếu host theo danh sách trắng, không bóc bằng regex: cách regex nhận nhầm https://evil.example/youtube.com/watch?v=… vì chuỗi có khớp trong khi host hoàn toàn khác. Test giữ đúng ca đó.

Server trả về địa chỉ nhúng dựng sẵn, không trả ID trần: luật dựng địa chỉ vì thế chỉ có một bản.

Ai gắn được ​

Chỉ host_user_id của buổi, hoặc admin, qua PUT /v1/events/{id}/video. Dùng lại đúng isHost đã là cổng của mọi hoạt động trong buổi thay vì dựng một luật quyền thứ hai. Nhận URL ở dạng người ta thật sự dán vào; server lo bóc ID. Chuỗi rỗng là cách gỡ video.

Hai lớp phủ, kích thước ĐO từ trình phát thật ​

Tham số nhúng: youtube-nocookie.com + rel=0 + modestbranding=1 + playsinline=1 + fs=0 + iv_load_policy=3. Bỏ fs vì ở chế độ toàn màn hình YouTube hiện thanh tiêu đề rõ hơn.

Còn lại hai chỗ bấm được dẫn ra ngoài, và kích thước lớp phủ lấy từ ảnh chụp trình phát thật chạy trên một origin thật (2026-09-15), không ước lượng:

ChỗĐo đượcLớp phủ
Tiêu đề + tên kênh, mép trêncao ~18% khungtop-0, cao 20%
Nút "Watch on YouTube", góc dưới phảirộng ~33%, cao ~14%bottom-0 right-0, 38% × 20%

Bản đầu chỉ có một dải trên cao 56px; ảnh chụp cho thấy nút "Watch on YouTube" nằm nguyên vẹn bên dưới, tức là cánh cửa chính vẫn mở trong khi bình luận trong code nói đã bịt. Đây là lý do phải mở trình phát thật ra nhìn thay vì tin vào một con số gõ ra từ trí nhớ.

Dùng phần trăm chứ không pixel để khung thu nhỏ trên điện thoại thì lớp phủ vẫn đúng chỗ. Góc dưới trái còn một nút tròn nhỏ chưa che, vì che nó sẽ đụng vào hàng điều khiển phát.

Trace ​

REQSection
REQ-PAR-16 (danh sách sự kiện sắp diễn ra)§2, §3, §6
REQ-PAR-16 (chi tiết từng sự kiện, mỗi buổi một URL)§3, §6
REQ-PAR-16 (đăng ký/huỷ, chỉ buổi sắp diễn ra, tôn trọng số chỗ)§3, §3b
REQ-PAR-16 (không lộ liên lạc của phụ huynh khác)§5
REQ-PAR-16 (tương tác trong buổi: câu hỏi, poll, reflection, Live Stage)§6b

Liên quan: SDD-001 §2 (module boundary), SDD-006 §11 (error taxonomy), legacy-migration (nguồn portal_events của family.chuyenchon.com), SDD-002 §5b (nơi domain event sẽ nối vào đợt sau), DS-001 §5b (luật tối giản UI).