Skip to content

Notification Conventions ​

Status: Active Last reviewed: 2026-10-21

How TwoMore classifies, delivers, and gates push notifications. Covers the 정보성 vs 광고성 split (정통망법 §50), Android channel setup, the iOS soft-prompt timing rule, and server-enforced frequency caps. Rationale and history live in CLAUDE.md; the machine-enforced subset is in AGENTS.md.

Alert-state model (class / lifecycle / urgency) ​

  • Formal model → packages/app/src/domain/rules/alert-state.ts (2026-07-31 formalization of logic that already existed in signalBand() — zero behavior change, see the table-driven regression test alert-state.test.ts). Three axes, table-driven (same idiom as STRIP_CHIP_CLASS in domain/session-strip.ts):
    • class (AlertClass: obligation | event) — obligation = urgent severity (critical|high) AND currently active (isSignalActive — not resolved/dismissed/expired); everything else is event. classifySignal(signal, now) derives this from existing Signal fields only, no schema change.
    • lifecycle (AlertLifecycle: open | read | resolved | dismissed) — priority resolvedAt > dismissedAt > acknowledgedAt > untouched. Obligations are special: acknowledging one does NOT advance it past open — only a server-side RESOLVE does (the pre-existing "stays todo even when acknowledged" rule).
    • urgency (AlertUrgency) — reuses SignalSeverity directly, no parallel scale.
    • Admin-attention rows (the virtual, non-persisted club counter rollup — presentation/utils/club-admin-attention.ts) are obligations BY CONSTRUCTION: ADMIN_ATTENTION_ALERT_STATE / classifyAdminAttention(total) — always open while the total is positive, null (self-hides) at 0.
    • signalBand() (domain/rules/signal-resolver.ts) PROJECTS from classifySignal for its todo determination instead of re-deriving urgency+activity inline. 알림 no longer draws bands (flat v2, 2026-10-21): it sections by time and filters by 처리할 일 (below).
    • deriveBellState({ unreadVisibleTotal }) → { tone: 'unread' | 'idle', count }. The bell is 알림's unread rows, one per group, with no netting (owner review 46, D4; W4 batch 18): an urgent row is one unread row like the rest, so the bell equals the 안 읽음 value on the page. There is no attention tone; what waits on the viewer is 할 일 on 홈 and 처리할 일 on 알림, never the bell. The count is signals_unread_visible_count, which shares the one visibility rule of the feed, signals_visible_counts, signals_open_requests() and signals_acknowledge_visible_unread (30 days, not dismissed, not chat, the preference gate; migration 00727). The obligation counts (useOpenObligationSignalCounts, getOpenObligationCounts) are retired.

Unread-rows bell + notification-center settings entry ​

  • useBellAction (packages/features/home/src/cards/use-bell-action.ts) — the home tab's bell as a Header action, coveredByWebNav (and the web nav's 알림 item), projected via useNotificationBellState → deriveBellState. Tone → badge: unread = $badgeErrorBg/$badgeErrorText (red); idle = no badge. the action's name is notifications.bellA11yLabel(n); the badge is the Header's count Tag. The bell is 알림's 안 읽음 with no netting (owner review 46, D4; landed W4 batch 18).
  • 알림's header → back, 알림, and one gear (알림 설정 → routes.signalPreferences); no ⋯, no Select mode, no 모두 읽음, no row menu (W4 batch 5, L3-13: one home per action). The unread rows a viewer saw are acknowledged in one batch when they leave the page; a row's dot clears when it opens.

알림 (flat v2, L3-13; the owner, 2026-10-20) ​

  • One feed, three time sections, one filter. 오늘 / 이번 주 / 이전 알림 over the last 30 days, newest first (no severity sort); the first head carries the filter (전체, 안 읽음, 처리할 일) and no 모두 읽음 (owner review 48: a row is read when it is opened, and the dots still showing count as seen when the viewer leaves; the shipped header's ⋯ keeps 모두 읽음 until the screen is rebuilt from the board). Reading a row clears its dot and never moves it. buildNotificationSections (domain/rules/notification-feed.rules.ts) is the one sectioning; the screen is NotificationCenterScreen (screen blueprint, home).
  • 처리할 일 is server truth, never a client guess. It is the alerts that ask the viewer to act while their request is open: signals_open_requests() (migration 00699) returns the ids of the viewer's visible alerts whose request is open (입금 확인: the submitted hold is unconfirmed and the viewer is its host or an official; 가입 신청: the request is pending, never revived by a later one; 참가비 입금: the viewer's hold is idle on an open paid session). A row leaves the view when its request is settled, never when it is read. The kinds live in two places held together by a parity test: REQUEST_SIGNAL_TYPES and the SQL type list. A new request kind (사유 인정 요청, 신고, 친구 요청) adds its type to both and its open rule to the function.
  • Deleting an alert never touches its request. 삭제 is the row's swipe (the kit SwipeAction: a full swipe commits, a mouse drag on the web, a 삭제 custom action for screen readers) and is signals_dismiss_batch, with 되돌리기 for 8 s (signals_undismiss_batch) and no Dialog.
  • No admin rows in 알림. The officer counters (가입/입금확인/미납/신고/게스트) belong to 홈's 할 일 (deriveHomeTasks plus the counters); 알림 lists alerts only.
  • A delayed answer notice (00754). An answer on a 사유 인정 request tells the requester only after the 10 minutes in which the answerer can undo it: the tick signals_tick_excuse_answers emits attendance_excuse_accepted / attendance_excuse_declined once, and an answer that was undone sends nothing. The request and its 3-day reminder to the deciders are two types (excuse_requested, excuse_request_reminder) so the reminder pushes again. The in-app row names the requester (the lead's ruling, 2026-11-12: signal-copy.ts words it with the actor and its fold); the push names no one (notification-copy.ts and actor-copy.ts add no name for these types) and neither carries the category or the note.

Admin-attention grouping (retired from 알림, 2026-10-21) ​

  • KIND-GROUPS (owner ruling, improvement plan 2026-09-23, ROUND-4) — the retired 할 일 band keyed admin work by KIND, never by club or by underlying item count. It left 알림 with the bands (above); the grouping function and the per-(club, kind) dismissal stay for 홈's 할 일. groupAdminAttentionByKind (packages/app/src/presentation/utils/club-admin-attention.ts) buckets every open admin obligation into the canon 가입/입금확인/미납/신고/게스트 order; a kind held by exactly ONE club renders as that club's own direct row (tap → the kind's screen); a kind held by 2+ clubs renders as ONE expandable group-header row ("N개 클럽") that reveals its per-club rows only while expanded — never one row per club by default, and never a warning-toned card (a neutral row carries a warning dot instead). Every filter chip on the notification center counts the ROWS it reveals, not the raw signal quantity behind them — 전체 includes the admin rows. Dismissal has the same unit as the row: one (club, kind) pair (admin-attention-ack.store, keyed by club then kind): dismissing 가입 never hides the same club's 미납, the bell total drops by that kind only, and the row returns when its own count changes (2026-09-24 review — the first build still dismissed the whole club, hiding unrelated kinds and undercounting the bell). Evidence: decision log (ROUND 4 — KIND-GROUPS).

Service vs marketing (§50) ​

  • Push notification kind → classify as 정보성 (service) or 광고성 (marketing) at design time per 정통망법 §50. Service pushes (RSVP, match start, dues, scores, attendance) are EXEMPT from §50 — no marketing consent required. Marketing pushes require (a) active grant for marketing_push purpose in user_consent, (b) "(광고)" prefix in title, (c) 1-tap unsubscribe deep-link, (d) 광고 prefix in the send-push edge function payload. Night-time (21:00–08:00 KST) marketing requires the SEPARATE marketing_push_night consent grant (§50-8). The 13-category signal_category enum (session / matchup / rsvp / dues / club / social / progression / rating / trust / payment / interclub / chat / system) is all 정보성 — no §50 plumbing needed for any of these categories.

Android channels ​

  • Android notification channels → MUST exist before send-push references them. The 13-channel set (session / matchup / rsvp / dues / club / social / progression / rating / trust / payment / interclub / chat / system) is created in notification.expo.ts via ensureAndroidChannels(). Adding a 14th category: add to signal_category enum first (migration), then add the channel config to the adapter, then bump the app version and trigger a new EAS build — channel config is native and is NOT OTA-able.

Push message fields (send-push, W4 batch 18) ​

supabase/functions/send-push/push-message.ts builds the Expo message for one claimed alert (pure; the clock is a parameter). The fields:

  • ttl — seconds until the alert's expires_at, capped at four weeks; with no usable expires_at (absent, unparseable or already past) the severity default: low 6 h, medium and high 24 h, critical 72 h. signals_claim_pushable does not return expires_at yet, so today every push takes the severity default; the builder honours the column as soon as the claim returns it.
  • interruptionLevel (iOS) — passive for low severity, active otherwise. Never time-sensitive until the W5 native build carries the Time Sensitive Notifications entitlement; sending it without the entitlement is a provider error, so the builder's type does not allow it.
  • threadId (iOS) — the group key: the dedup_key without its trailing :{recipient} part (a key with no user part is its own thread; no key, no thread). A grouped alert and its repeats stack under one thread.
  • badge (iOS) — the unread_count the claim returns (the bell's number, chat excluded); omitted when unknown, since 0 clears the badge.
  • Copy — actor-copy.ts names the actor for the request and reply kinds ("{name}님 외 N명이 …" once a group has several distinct payload.actorIds; the name is resolved at read time from context.actorUserId, never stored) and counts the repeats of 새 일정 / 새 글 ("새 일정 N개가 올라왔어요"). 친구 요청 and 신고 have fixed bodies that name no one. The keys are held to the client's by push-copy-sync.test.ts; the sentences are the push's own.

Permission soft-prompt ​

  • The notification permission status says each thing by its own name: undetermined is the one value that means "never asked: ask now"; unavailable means the platform has no push (the browser build answers it from both getPermissionStatus and requestPermission); the hook returns null until the read settles. Only undetermined opens the soft prompt, and nothing offers to turn push on where it is unavailable.
  • Permission soft-prompt → NEVER request notification permission on cold-mount. Use PushPermissionSoftPromptSheet from @twomore/sessions, triggered after the user's first RSVP success. iOS dismissal is one-shot — burning the prompt at cold-mount means the user can never re-enable without going to Settings manually.

Frequency caps ​

  • Frequency caps → signals_pick_pushable enforces 10/day per user + 3/day per (user, category) with a critical-signal bypass. Never override the cap at the call site. New high-frequency signal types must respect the cap or users will uninstall.

클럽 대항전 notices (follow-up batch C, 2026-11-12) ​

Six types in the interclub category: interclub_challenge_received (an open request folded per club, while the challenge is proposed), interclub_challenge_accepted and interclub_challenge_declined (two notices to the proposing club's officials), interclub_challenge_cancelled (the other club's officials), interclub_match_scheduled (a session tagged: the other club's officials) and interclub_challenge_settled (every active member of both clubs, once). Ids only in context and payload (clubId is always the recipient's club, otherClubId the opposite one); no person's or club's name in a push or a payload (the client resolves titles from ids; push bodies are fixed). A tap on any of them lands on /clubs/{id}/challenges. Every type is in every registry (the signal-completeness test) and signals_open_requests carries the request kind.

가입 제안 notices (batch 57, migration 00766) ​

Two types in the club category. club_membership_offered is a request kind (in REQUEST_SIGNAL_TYPES and in signals_open_requests(), never folded): it goes to the offered person when an official's 지명 or 가입 제안 보내기 inserts the pending request (source = 'admin_nominated'), and it is open while that request is pending for that person. Their accept, decline, the official's 제안 취소, a guest's removal, the club's archive and the person joining the club by any other path all end the request, so the notice closes with no write; an official's 제안 취소 (and the archive) sends the person no join_request_decided, because the person never applied. Offers made before the migration got their notice from its backfill (the same dedup key, so a re-run adds nothing); it opens the club's page (the band with 수락 and 거절). club_membership_offer_answered is an event (not a request): it goes once to each active official, on a decline only (the officials already get the member-joined notice when a person accepts, so a second notice would double it); it opens 멤버 관리. Ids only in context and payload (no official is named in the person's notice); the push titles are in send-push's copy, which must be redeployed with this migration. An offer no longer sends the officials the "X applied" notice.

See also ​

  • AGENTS.md — COMP-7, ARCH-9 (machine-enforced notification subset).
  • Conventions › Korean Market — marketing push §50 title format, consent grants, and Alimtalk rules.
  • CLAUDE.md — orchestration core + the pointer index back to this doc.

Markdown remains the source of truth. Run yarn docs:check before handoff.