"""Feed-domain notification dispatchers. Wraps :mod:`app.notifications.service` for the feed-specific events: * ``feed.mention.post`` — someone @ed you in a post * ``feed.mention.comment`` — someone @ed you in a comment * ``feed.comment.added`` — someone commented on your post * ``feed.like.aggregated`` — inbox-only "+1 like" hint * ``feed.blessing.received`` — someone sent you a birthday/anniversary blessing * ``feed.blessing.aggregated`` — …and the N after it, folded into that card * ``feed.celebration.self`` — it's your day (birthday / anniversary) * ``feed.celebration.lead`` — your subordinate / HRBP-charge has a celebration Bell-icon inbox is the contract for every recipient — every event writes a ``notification_outbox`` row with ``status=DELIVERED`` via ``NotificationService.push_inbox_and_feishu``, so the recipient sees the ping in the in-app inbox even if Feishu delivery later fails. Feishu DM is best-effort on top of that (no retry queue) for the transient social events. Birthday + anniversary self/lead celebrations still go through ``dispatch`` because the cron's once-a-day cadence makes the retry machinery worth the latency. Blessings used to use ``dispatch`` too, and that quietly broke the contract above: ``dispatch``'s retry ladder terminates at ``status=DEAD``, and ``inbox_service._inbox_visible_clause`` hides DEAD rows whose ``template_key`` is social (``feed.blessing.`` is). Anyone without a working Feishu binding therefore lost their blessings outright. They are on ``push_inbox_and_feishu`` now. Per-person preferences let users mute Feishu pushes on comments (``feed.notify_comment_feishu``) and on blessings (``feed.notify_blessing_feishu``); both default ON. Like pings are inbox-only by design (no Feishu push regardless of pref). Which celebrations exist at all is governed upstream by ``feed.celebration_opt_out`` in ``celebration_service``. Language -------- The 2026-06-01 "feed system text is English-only" decision is **reverted** for the four social events (``feed.mention.post`` / ``feed.mention.comment`` / ``feed.comment.added`` / ``feed.like.aggregated``): they land in the same bell as every other domain's notification, and an English headline next to a Chinese one is what users reported as 中英混排. Their copy now lives in ``app.notifications.templates.feed`` and is rendered per recipient in zh-CN / en-US / ja-JP; the ``title`` / ``body_text`` still passed on the envelope are the fallback path only. Each call site puts the interpolated values (author name, post body, …) on ``NotificationEnvelope.context`` so the template — and the bell's re-render of it — has something to substitute. The blessing events (``feed.blessing.received.*`` / ``feed.blessing.aggregated``) are templated the same way. The cron-driven ``feed.celebration.self.*`` / ``feed.celebration..*`` cards are the remaining producer-rendered English; they need their own pass (their card sections have no template counterpart). User-typed content (post body, comment body, blessing message) is never translated: it ships verbatim in whatever language its author wrote it. """ from __future__ import annotations import logging import random import uuid as _uuid from collections.abc import Iterable from datetime import UTC, datetime, timedelta from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.txn import best_effort from app.domains.feed.models import ( DEFAULT_PREFERENCES, PREF_NOTIFY_BLESSING_FEISHU, PREF_NOTIFY_COMMENT_FEISHU, PREF_NOTIFY_LIKE_AGGREGATE, FeedBlessing, FeedCelebration, FeedComment, FeedPost, IdentityPersonPreference, ) from app.identity.person_models import IdentityPerson from app.notifications.models import NotificationOutbox from app.notifications.service import ( CardSection, LocalizedText, NotificationEnvelope, NotificationService, ) logger = logging.getLogger(__name__) CHANNELS_FEISHU = ["feishu"] # Mirror of ``web/src/pages/feed/feedAssetsData.ts`` sticker maps. Used to # weave the picked sticker's emoji + label into the Feishu push so the # recipient sees the same "tone" the sender chose — a cake-sticker # blessing reads cake-y in the DM, not flat text. Defensive: an unknown # key (added on the FE without a backend bump) falls back to a gift. _BLESSING_STICKERS: dict[str, tuple[str, str]] = { # birthday "cake": ("🎂", "Cake"), "gift": ("🎁", "Gift"), "balloon": ("🎈", "Balloon"), "party": ("🎉", "Party"), "cupcake": ("🧁", "Cupcake"), "champagne": ("🥂", "Cheers"), "rose": ("🌹", "Rose"), "sparkle": ("✨", "Sparkle"), # anniversary "trophy": ("🏆", "Trophy"), "medal": ("🏅", "Medal"), "star": ("⭐", "Star"), "fire": ("🔥", "On Fire"), "rocket": ("🚀", "Rocket"), "handshake": ("🤝", "Together"), "clap": ("👏", "Applause"), "tada": ("🎊", "Celebrate"), } # Feishu-card footer pool. Each card picks one at random so the bottom # line doesn't become wallpaper after a few notifications. Keep each line # short, warm, and brand-consistent. # # Localized now that ``card_footer`` accepts a :class:`LocalizedText` and the # notification layer resolves it PER RECIPIENT. Previously these were plain # English strings shipped to every employee regardless of language — and the # note in this spot said as much, deferring the fix to "a notifications-layer # change". That change landed; this is it. _FOOTER_POOL: tuple[LocalizedText, ...] = ( LocalizedText( zh="Helix · 让美好的瞬间被看见", en="Helix · keep the good moments visible", ja="Helix · いい瞬間を、見えるところに", ), LocalizedText( zh="Helix · 小事也值得记一笔", en="Helix · small things, on the record", ja="Helix · 小さなことも、ちゃんと残す", ), LocalizedText( zh="Helix · 别让小小的高光溜走", en="Helix · don't let the small wins slip by", ja="Helix · ささやかな一歩も見逃さない", ), LocalizedText( zh="Helix · 因为日常本身也很重要", en="Helix · because the everyday stuff matters too", ja="Helix · 毎日のことにも、意味がある", ), LocalizedText( zh="Helix · 这面墙记得,哪怕你忘了", en="Helix · the wall remembers, even when you don't", ja="Helix · あなたが忘れても、ここは覚えている", ), LocalizedText( zh="Helix · 附赠一点点温度", en="Helix · sent with a little extra warmth", ja="Helix · ほんの少しの温かさを添えて", ), ) def _pick_footer() -> LocalizedText: return random.choice(_FOOTER_POOL) def _truncate(text: str, n: int) -> str: text = text or "" return text if len(text) <= n else text[:n] + "…" async def _pref( db: AsyncSession, person_id: str, key: str, ) -> object | None: row = (await db.execute( select(IdentityPersonPreference.pref_value).where( IdentityPersonPreference.person_id == person_id, IdentityPersonPreference.pref_key == key, ) )).scalar_one_or_none() return row if row is not None else DEFAULT_PREFERENCES.get(key) async def _get_person( db: AsyncSession, person_id: str | None, ) -> IdentityPerson | None: if not person_id: return None try: return await db.get(IdentityPerson, _uuid.UUID(str(person_id))) except (ValueError, TypeError): return None async def _load_persons_for_visibility( db: AsyncSession, person_ids: set[str], ) -> dict[str, IdentityPerson]: """Batch-load persons with the relationships ``FeedService._can_view_post`` reads — ``user_roles`` (→ ``_is_admin``) and ``org_memberships`` (→ ``primary_department``). A bare ``db.get`` leaves both lazy, so the visibility check would trip ``MissingGreenlet`` under the async session and silently drop the mention notification. ``AuthUserRole.role`` is ``lazy='joined'`` so selectinload on ``user_roles`` brings the role too. """ ids: list[_uuid.UUID] = [] for pid in person_ids: try: ids.append(_uuid.UUID(str(pid))) except (ValueError, TypeError): continue if not ids: return {} rows = ( await db.execute( select(IdentityPerson) .where(IdentityPerson.id.in_(ids)) .options( selectinload(IdentityPerson.user_roles), selectinload(IdentityPerson.org_memberships), ) ) ).scalars().all() return {str(p.id): p for p in rows} def _post_url(post_id: str) -> str: return f"/feed/p/{post_id}" def _celebration_url(celebration_id: str) -> str: return f"/feed/celebrate/{celebration_id}" async def _post_owner_person_id( db: AsyncSession, post: FeedPost, ) -> str | None: """Who "owns" a post for notification purposes. For a user post that's the author. For a SYSTEM post (``system_birthday`` / ``system_anniversary``) ``author_person_id`` is NULL by construction — the cron writes it — so the owner is the celebrant the post is about. Without this the birthday person got zero notice of the likes and comments landing on their own birthday card, while the UI showed them a like button and a comment drawer. Aggregate banners have neither an author nor a celebration; they return None and notify nobody, which is correct — a banner isn't anyone's. """ if post.author_person_id is not None: return str(post.author_person_id) if post.celebration_id: celebration = await db.get(FeedCelebration, post.celebration_id) if celebration is not None: return str(celebration.person_id) return None # ────────────────────────────────────────────────────────────────── # Mentions # ────────────────────────────────────────────────────────────────── async def notify_mentions_on_post( db: AsyncSession, *, post: FeedPost, mentioned_person_ids: Iterable[str], ) -> None: author = await _get_person(db, str(post.author_person_id)) if author is None: return svc = NotificationService(db) # Same-domain import (avoids a module-level cycle): reuse the canonical # visibility check so a mention can't leak a restricted post's body to # someone who isn't allowed to see the post itself. from app.domains.feed.service import FeedService feed_svc = FeedService(db) # Cap the fan-out to the SAME limit storage applies (create_user_post # truncates the stored mentions to MAX_MENTIONS_PER_POST). The request # schema has no max_items and the router passes the RAW list, so without # this cap one public post with N mentioned_person_ids fans out N Feishu # DMs while only 20 are stored — an org-wide spam vector. De-dupe + # exclude the author, preserve order, then cap. from app.domains.feed.service import MAX_MENTIONS_PER_POST candidate_ids = list(dict.fromkeys( str(m) for m in mentioned_person_ids if str(m) != str(post.author_person_id) ))[:MAX_MENTIONS_PER_POST] targets = await _load_persons_for_visibility(db, candidate_ids) for mid in candidate_ids: target = targets.get(mid) if target is None or target.employment_status != "active": continue # Visibility gate: a department/private post must not deliver its # body to a mentioned person outside its visibility scope — the # /feed/p/{id} deep-link would 403 them, but the inbox row + Feishu # DM would otherwise leak up to 500 chars of the body anyway. if not feed_svc._can_view_post(post, target): continue # Fallback copy only — the recipient-language subject comes from # the ``feed.mention.post`` template; ``context`` feeds it. author_name = author.display_name or "A teammate" title = f"📣 {author_name} mentioned you in a post" body_text = _truncate(post.body or "", 500) try: await svc.push_inbox_and_feishu( NotificationEnvelope( recipient=target, title=title, body_text=body_text, channels=list(CHANNELS_FEISHU), template_key="feed.mention.post", context={ "author_name": author_name, "post_body": body_text, }, url=_post_url(post.id), related_resource_type="feed_post", related_resource_id=_uuid.UUID(post.id), card_subtitle="You've been tagged — tap to jump in", card_footer=_pick_footer(), ), ) except Exception: logger.exception("feed.notify_mentions_on_post failed pid=%s mid=%s", post.id, mid) async def notify_mentions_on_comment( db: AsyncSession, *, comment: FeedComment, mentioned_person_ids: Iterable[str], ) -> None: author = await _get_person(db, str(comment.author_person_id)) if author is None: return svc = NotificationService(db) # Load the parent post once so we can apply the same visibility gate as # the post-mention path: a mention in a comment under a restricted post # must not leak the comment body to someone who can't view the thread. from app.domains.feed.service import FeedService feed_svc = FeedService(db) parent_post = await db.get(FeedPost, str(comment.post_id)) # Cap the fan-out (same rationale as the post-mention path): the request # list is uncapped, so bound comment-mention DMs to MAX_MENTIONS_PER_POST. from app.domains.feed.service import MAX_MENTIONS_PER_POST candidate_ids = list(dict.fromkeys( str(m) for m in mentioned_person_ids if str(m) != str(comment.author_person_id) ))[:MAX_MENTIONS_PER_POST] targets = await _load_persons_for_visibility(db, candidate_ids) for mid in candidate_ids: target = targets.get(mid) if target is None or target.employment_status != "active": continue if parent_post is None or not feed_svc._can_view_post(parent_post, target): continue # Fallback copy only — see ``notify_mentions_on_post``. author_name = author.display_name or "A teammate" title = f"📣 {author_name} mentioned you in a comment" body_text = _truncate(comment.body or "", 500) try: await svc.push_inbox_and_feishu( NotificationEnvelope( recipient=target, title=title, body_text=body_text, channels=list(CHANNELS_FEISHU), template_key="feed.mention.comment", context={ "author_name": author_name, "comment_body": body_text, }, url=_post_url(comment.post_id), related_resource_type="feed_comment", related_resource_id=_uuid.UUID(comment.id), card_subtitle="You've been tagged — tap to jump in", card_footer=_pick_footer(), ), ) except Exception: logger.exception( "feed.notify_mentions_on_comment failed cid=%s mid=%s", comment.id, mid, ) # ────────────────────────────────────────────────────────────────── # Comments # ────────────────────────────────────────────────────────────────── async def notify_comment_added( db: AsyncSession, *, comment: FeedComment, ) -> None: """Notify a post's owner that someone commented on it. Always writes the inbox row (bell icon lights up). Honors the recipient's ``feed.notify_comment_feishu`` preference for the optional Feishu push on top. Skips silently if the commenter == the owner (own comment). "Owner" is the author for user posts and the celebrant for system birthday / anniversary posts — see ``_post_owner_person_id``. """ post = await db.get(FeedPost, comment.post_id) if post is None: return owner_id = await _post_owner_person_id(db, post) if owner_id is None: return if owner_id == str(comment.author_person_id): return recipient = await _get_person(db, owner_id) if recipient is None or recipient.employment_status != "active": return commenter_name = "An anonymous colleague" if ( comment.author_visibility == "anonymous" ) else None if commenter_name is None: commenter = await _get_person(db, str(comment.author_person_id)) if commenter is None: return commenter_name = commenter.display_name or "A teammate" sticker_emoji, _ = _BLESSING_STICKERS.get( comment.sticker_key or "", ("", ""), ) # Warmer headline — leads with a chat icon so the recipient knows # this is a social ping, not an alert. title = f"💬 {commenter_name} replied to your post" # Subtitle previews the original post so the recipient remembers # what they wrote without having to click through. A system post's # ``body`` is generated prose the recipient never wrote (and, since # migration 456, only the fallback copy of it) — quoting it back at # them would be both meaningless and off-language, so celebration # posts get a fixed subtitle instead. if post.celebration_id: subtitle = "On your celebration page" else: post_preview = _truncate((post.body or "").strip().splitlines()[0] if post.body else "", 80) subtitle = ( f"On your post: “{post_preview}”" if post_preview else "On one of your posts" ) # Render the comment as the body once — same anti-duplication # contract as ``notify_blessing_received``. Drop any extra "their # comment" section that would just repeat the message. body_text = ( f"{sticker_emoji} {_truncate(comment.body or '', 500)}" if sticker_emoji else _truncate(comment.body or "", 500) ) sections: list[CardSection] = [] allow_feishu = await _pref(db, str(recipient.id), PREF_NOTIFY_COMMENT_FEISHU) svc = NotificationService(db) try: await svc.push_inbox_and_feishu( NotificationEnvelope( recipient=recipient, title=title, body_text=body_text, channels=list(CHANNELS_FEISHU), template_key="feed.comment.added", context={ "commenter_name": commenter_name, "comment_body": body_text, }, url=_post_url(post.id), related_resource_type="feed_post", related_resource_id=_uuid.UUID(post.id), card_subtitle=subtitle, card_sections=sections, card_footer=_pick_footer(), ), feishu=bool(allow_feishu), ) except Exception: logger.exception("notify_comment_added failed cid=%s", comment.id) # ────────────────────────────────────────────────────────────────── # Likes — inbox only, aggregated by post # ────────────────────────────────────────────────────────────────── async def notify_like_added( db: AsyncSession, *, post_id: str, liker: IdentityPerson, ) -> None: """Inbox-only "+1 like" hint. Default-on (the pref was flipped from opt-in to opt-out 2026-06-02 — see ``DEFAULT_PREFERENCES`` comment), but users who explicitly set ``feed.notify_like_aggregate=False`` on /me/settings retain their opt-out: we still consult the pref. A prior incarnation dropped the pref check entirely, which broke the opt-out contract for anyone who'd already toggled the setting off. """ post = await db.get(FeedPost, post_id) if post is None: return # System celebration posts have no author — the celebrant owns them. # See ``_post_owner_person_id``. owner_id = await _post_owner_person_id(db, post) if owner_id is None: return if owner_id == str(liker.id): return pref = await _pref(db, owner_id, PREF_NOTIFY_LIKE_AGGREGATE) if not pref: return recipient = await _get_person(db, owner_id) if recipient is None or recipient.employment_status != "active": return # Fallback copy only — the recipient-language subject comes from the # ``feed.like.aggregated`` template. The body carries the post's first # 80 chars (user-typed content keeps its original language). # # ``display_name`` is nullable, and every sibling call site guards it # with the same ``or "A teammate"`` fallback — this one didn't, so a # liker whose HRIS row has no display name rendered a literal # "None liked your post" in the author's bell. liker_name = ( "An anonymous colleague" if post.author_visibility == "anonymous" else liker.display_name or "A teammate" ) title = f"{liker_name} liked your post" # A system post's ``body`` is generated prose in ONE language (the # fallback copy behind ``body_i18n_key``); pasting it into the preview # would put an English sentence in a Chinese reader's bell — the exact # 中英混排 complaint. The title alone carries the message for those. body = ( "" if post.celebration_id else post.body[:80] + ("…" if len(post.body) > 80 else "") ) try: await NotificationService(db).push_inbox_and_feishu( NotificationEnvelope( recipient=recipient, title=title, body_text=body, channels=[], # inbox only — no Feishu pings on likes template_key="feed.like.aggregated", context={ "liker_name": liker_name, "post_preview": body, }, url=_post_url(post.id), related_resource_type="feed_post", related_resource_id=_uuid.UUID(post.id), ), feishu=False, ) except Exception: logger.exception("notify_like_added failed pid=%s", post.id) # ────────────────────────────────────────────────────────────────── # Blessings # ────────────────────────────────────────────────────────────────── #: Blessings for one celebration that land within this window of the last #: card fold into that card instead of minting a new one. A well-liked #: person in a 200-head department collects dozens of blessings in a #: morning; one Feishu card each is a denial-of-service dressed as #: goodwill. Comments already have a mute switch and likes are aggregated #: — blessings had neither, and they are the burstiest of the three. _BLESSING_AGGREGATE_WINDOW = timedelta(minutes=30) #: Template of the folded card. Registered in #: ``app/notifications/templates/feed.py`` (zh / en / ja). _BLESSING_AGGREGATED_TEMPLATE = "feed.blessing.aggregated" #: ``DEFAULT_PREFERENCES`` has no entry for the blessing switch, so ``_pref`` #: returns None when the person never touched it. Blessings default ON, same #: as comments — an absent row must not read as "muted". _BLESSING_FEISHU_DEFAULT = True async def _blessing_feishu_allowed( db: AsyncSession, person_id: str, ) -> bool: stored = await _pref(db, person_id, PREF_NOTIFY_BLESSING_FEISHU) if stored is None: return _BLESSING_FEISHU_DEFAULT return bool(stored) async def _recent_blessing_row( db: AsyncSession, *, recipient_id: _uuid.UUID, celebration_id: str, now: datetime, ) -> NotificationOutbox | None: """The most recent blessing notification for this celebration still inside the aggregation window, if any.""" return (await db.execute( select(NotificationOutbox) .where( NotificationOutbox.recipient_person_id == recipient_id, NotificationOutbox.related_resource_type == "feed_celebration", # CHAR(36) column: compare as the canonical string, the way every # other look-back in the codebase does. Binding a ``UUID`` object # against a CHAR column is driver-dependent. NotificationOutbox.related_resource_id == str(celebration_id), NotificationOutbox.template_key.like("feed.blessing.%"), NotificationOutbox.created_at >= now - _BLESSING_AGGREGATE_WINDOW, ) .order_by(NotificationOutbox.created_at.desc()) .limit(1) )).scalars().first() async def notify_blessing_received( db: AsyncSession, *, blessing_id: str, ) -> None: """Tell the celebrant someone blessed them. Delivery is ``push_inbox_and_feishu``, NOT ``dispatch``: the bell is the contract (this module's docstring says so), and ``dispatch``'s retry ladder ends at ``status=DEAD``, which ``inbox_service._inbox_visible_clause`` deliberately hides for social templates. Every employee without a working Feishu binding was therefore losing their birthday blessings entirely — no card, no bell, no trace. Bursts fold: within ``_BLESSING_AGGREGATE_WINDOW`` of the previous card the existing inbox row is rewritten into "N colleagues have sent you blessings today" and re-flagged unread, and no second Feishu push is sent. Nothing is lost — the row still deep-links to the celebration page, which lists every blessing individually. """ blessing = await db.get(FeedBlessing, blessing_id) if blessing is None: return celebration = await db.get(FeedCelebration, blessing.celebration_id) if celebration is None: return recipient = await _get_person(db, str(celebration.person_id)) sender = await _get_person(db, str(blessing.sender_person_id)) if recipient is None or sender is None: return if recipient.employment_status != "active": return is_birthday = celebration.celebration_type == "birthday" sender_name = sender.display_name or "A teammate" emoji, sticker_label = _BLESSING_STICKERS.get( blessing.sticker_key or "", ("🎁", "Gift"), ) # The blessing message IS the body — render it once. No hand-holding # "tap below" copy beneath; the primary button under the card is # already self-explanatory for an adult reader. User-typed content, so # it ships verbatim in whatever language the sender wrote it. body_text = _truncate(blessing.message, 500) url = _celebration_url(celebration.id) now = datetime.now(UTC) # SAVEPOINT-isolated, not a bare try/except. This whole block runs inside # the caller's request transaction (the router commits after us), and a # swallowed DB error would leave that transaction poisoned — every # subsequent statement fails and the blessing the user just sent is lost, # which is strictly worse than "the blessing saved, the ping didn't". # ``best_effort`` also deliberately re-raises 1205/1213/1305: those kill the # transaction server-side and the caller must restart, not continue. async with best_effort( db, label="feed.notify_blessing_received", log=logger, ) as step: recent = await _recent_blessing_row( db, recipient_id=recipient.id, celebration_id=celebration.id, now=now, ) total = (await db.execute( select(func.count()).select_from(FeedBlessing).where( FeedBlessing.celebration_id == celebration.id, ) )).scalar_one() or 1 if recent is not None: # Fold into the card already sitting in their bell. ``read_at`` # is cleared so a fold that arrives after they read the first # one still shows up as new; ``created_at`` is deliberately NOT # touched — it anchors the window, and moving it would let a # steady drip of blessings extend the quiet period forever. # # SAVEPOINT, not a bare write: this runs inside the caller's # request transaction (the router commits after us), and a # swallowed DB error here would poison that transaction rather # than degrade to "the blessing was saved, the ping wasn't". async with db.begin_nested(): recent.template_key = _BLESSING_AGGREGATED_TEMPLATE recent.title = ( f"🎉 {total} teammates have sent you blessings today" ) recent.body_text = body_text recent.url = url recent.context = { "count": total, "blessing_body": body_text, } recent.read_at = None await db.flush() folded = True else: folded = False if not folded: # Fallback copy only — the recipient-language subject comes from the # ``feed.blessing.received.*`` template; ``context`` feeds it. if is_birthday: title = f"🎂 A birthday blessing for you — from {sender_name}" elif celebration.years > 0: title = ( f"🎉 A {celebration.years}-year anniversary blessing " f"for you — from {sender_name}" ) else: title = f"🎉 A work-anniversary blessing for you — from {sender_name}" # Subtitle — the sticker the sender picked, italicised by the card # builder (grey one-liner under the title). subtitle = f"{emoji} They picked “{sticker_label}” for you" sections: list[CardSection] = [] # Header colour: carmine for birthday, indigo for anniversary — # both warm/festive, distinct from the operational palette # (blue/orange/red etc) used for incidents. template_key = ( "feed.blessing.received.birthday" if is_birthday else "feed.blessing.received.anniversary" ) allow_feishu = await _blessing_feishu_allowed(db, str(recipient.id)) await NotificationService(db).push_inbox_and_feishu( NotificationEnvelope( recipient=recipient, title=title, body_text=body_text, channels=list(CHANNELS_FEISHU), template_key=template_key, context={ "sender_name": sender_name, "blessing_body": body_text, "years": celebration.years, }, url=url, related_resource_type="feed_celebration", related_resource_id=_uuid.UUID(celebration.id), card_subtitle=subtitle, card_sections=sections, card_footer=_pick_footer(), # Celebratory cards read warmer at heading-3 size — # the blessing message is the main payload, the rest # are decoration. card_body_size="heading_3", ), feishu=allow_feishu, ) if not step.ok: logger.warning( "notify_blessing_received degraded bid=%s: %s", blessing.id, step.error, ) # ────────────────────────────────────────────────────────────────── # Celebrations (cron-driven) # ────────────────────────────────────────────────────────────────── async def notify_celebration_self( db: AsyncSession, *, celebration: FeedCelebration, ) -> bool: """Push 'today is YOUR day' to the celebrant. Returns True if delivered (or at least dispatched), False if skipped. (The annotation used to say ``None`` while the body returned a bool that ``notify_today_celebrations`` branches on — the caller's ``ok`` was never ``None``.) """ recipient = await _get_person(db, str(celebration.person_id)) if recipient is None or recipient.employment_status != "active": return False is_birthday = celebration.celebration_type == "birthday" # ``"".split()`` is ``[]``, so the previous ``.split()[0] or "you"`` # raised IndexError for anyone whose ``display_name`` is NULL or blank # — the ``or "you"`` never got a chance to run. ``celebration_service`` # swallows the exception and classifies it as transient, so that # person's birthday push failed permanently and re-errored every tick. # Take the fallback BEFORE indexing. first_name = ((recipient.display_name or "").split() or ["you"])[0] # Copy lives in the registered三语 templates (``templates/feed.py``); the # envelope keeps an English fallback for the pre-template outbox rows. Chrome # is LocalizedText — this card goes to EVERY employee on their birthday, and # shipped English-only to the 107 zh_CN readers until 2026-08-17. if is_birthday: title = f"🎂 Happy Birthday, {first_name}!" subtitle = LocalizedText( zh="今天属于你,好好享受", en="Today is yours — go enjoy it", ja="今日はあなたの日です。楽しんでください", ) body_text = ( "Wishing you a fantastic day full of joy, cake, and " "the small surprises that make a year special." ) section_title = LocalizedText( zh="🎈 今天会发生什么", en="🎈 What to expect today", ja="🎈 今日この後の流れ", ) section_body = LocalizedText( zh=( "同事们可以在你的庆祝页留下祝福,整天都可以。每一条都会出现在" "你的动态墙上,以后随时可以回看。" ), en=( "Teammates can drop a blessing on your celebration page " "throughout the day. Each one shows up on your moments " "wall — easy to revisit later." ), ja=( "同僚は一日を通してお祝いページにメッセージを残せます。" "すべてあなたのモーメントウォールに集まり、後からいつでも" "見返せます。" ), ) elif celebration.years > 0: title = f"🎉 Happy {celebration.years}-year anniversary, {first_name}!" subtitle = LocalizedText( zh=f"在 PingCAP 的第 {celebration.years} 年 —— 值得记一笔", en=f"{celebration.years} years with PingCAP — that's a milestone", ja=f"PingCAP で {celebration.years} 年 —— 節目の日です", ) body_text = ( f"Thank you for {celebration.years} years of building, " "shipping, and showing up. Today the team gets to " "celebrate you." ) section_title = LocalizedText( zh="🏅 今天会发生什么", en="🏅 What to expect today", ja="🏅 今日この後の流れ", ) section_body = LocalizedText( zh=( "同事们可以在你的周年墙上留言;经理和 HRBP 也会收到提醒," "所以今天大概会收到几条暖心消息。" ), en=( "Teammates can leave a note on your anniversary wall — " "managers and HRBPs get a heads-up too, so expect a few " "warm pings throughout the day." ), ja=( "同僚は記念日ウォールにメッセージを残せます。上長と HRBP にも" "通知が届くので、今日は温かいメッセージが届くはずです。" ), ) else: title = f"🎉 Welcome aboard, {first_name}!" subtitle = LocalizedText( zh="你的第一个工作周年就快到了", en="Your first work anniversary is just around the corner", ja="最初の入社記念日がもうすぐです", ) body_text = ( "Thank you for joining PingCAP. The team is glad you're here." ) section_title = LocalizedText( zh="🎁 接下来会发生什么", en="🎁 What to expect", ja="🎁 これからの流れ", ) section_body = LocalizedText( zh=( "同事们可以在你的动态页留下欢迎;你也随时可以过去打个招呼。" ), en=( "Teammates can leave a quick welcome on your moments page. " "Drop in whenever you'd like to say hi back." ), ja=( "同僚があなたのモーメントページに歓迎のメッセージを残せます。" "いつでも気軽に返してください。" ), ) # Three keys, not two: the ``years == 0`` branch says "welcome aboard", # which a ``{years}``-year anniversary template cannot also say. if is_birthday: template_key = "feed.celebration.self.birthday" elif celebration.years > 0: template_key = "feed.celebration.self.anniversary" else: template_key = "feed.celebration.self.welcome" svc = NotificationService(db) await svc.dispatch( NotificationEnvelope( recipient=recipient, title=title, body_text=body_text, channels=list(CHANNELS_FEISHU), template_key=template_key, url=_celebration_url(celebration.id), related_resource_type="feed_celebration", related_resource_id=_uuid.UUID(celebration.id), card_subtitle=subtitle, card_sections=[CardSection( title=section_title, body=section_body, tone="hint", )], card_footer=_pick_footer(), card_body_size="heading_3", context={ "first_name": first_name, "years": str(celebration.years), }, ), trace_id=f"feed.celebration.self:{celebration.id}", ) return True async def notify_celebration_lead( db: AsyncSession, *, celebration: FeedCelebration, recipient: IdentityPerson, role: str, ) -> None: """Push 'your subordinate / HR-charge X is celebrating' to manager / HRBP. ``role`` ∈ {'manager', 'hrbp'}. """ if recipient.employment_status != "active": return person = await _get_person(db, str(celebration.person_id)) if person is None: return name = person.display_name or person.work_email or "Team member" is_birthday = celebration.celebration_type == "birthday" # Copy in the registered三语 templates; chrome LocalizedText — see # ``notify_celebration_self`` for why this card cannot be English-only. if is_birthday: title = f"🎂 Today is {name}'s birthday" subtitle = LocalizedText( zh="小提醒 —— TA 大概会很高兴收到你的一句话", en="A small heads-up — they might appreciate a note from you", ja="ひとことメッセージがきっと喜ばれます", ) hint = LocalizedText( zh=( "在 TA 的动态页留一句就很有分量,不用长篇大论 —— 一个表情 + " "一句话就够了。" ), en=( "A short message on their moments page goes a long way. " "Doesn't need to be elaborate — a sticker + one line is " "more than enough." ), ja=( "モーメントページへの短いひとことでも十分伝わります。" "スタンプ + 一行でも大丈夫です。" ), ) else: title = ( f"🎉 {name} hits {celebration.years} years at PingCAP today" ) subtitle = LocalizedText( zh=f"入职 {celebration.years} 周年 —— 值得说一句", en=( f"{celebration.years}-year work anniversary — a milestone " "worth acknowledging" ), ja=f"入社 {celebration.years} 周年 —— ひとこと伝えたい節目です", ) hint = LocalizedText( zh=( f"到了第 {celebration.years} 年,哪怕一句话也有分量。动态页会把" "所有留言收在一处,以后 TA 随时可以回看。" ), en=( f"Even a one-liner means something at the {celebration.years}-" "year mark. The moments page collects every note in one place " "for them to revisit later." ), ja=( f"{celebration.years} 年目には、ひとことでも意味があります。" "モーメントページにすべて集まり、後から見返せます。" ), ) body_text = "Send them a blessing on the moments page." template_key = ( f"feed.celebration.{role}.birthday" if is_birthday else f"feed.celebration.{role}.anniversary" ) svc = NotificationService(db) await svc.dispatch( NotificationEnvelope( recipient=recipient, title=title, body_text=body_text, channels=list(CHANNELS_FEISHU), template_key=template_key, url=_celebration_url(celebration.id), related_resource_type="feed_celebration", related_resource_id=_uuid.UUID(celebration.id), card_subtitle=subtitle, card_sections=[CardSection( title=LocalizedText( zh="你为什么会收到这条", en="Why you're getting this", ja="この通知が届いた理由", ), body=hint, tone="hint", )], card_footer=_pick_footer(), context={"name": name, "years": str(celebration.years)}, ), trace_id=f"feed.celebration.{role}:{celebration.id}:{recipient.id}", ) __all__ = [ "notify_blessing_received", "notify_celebration_lead", "notify_celebration_self", "notify_comment_added", "notify_like_added", "notify_mentions_on_comment", "notify_mentions_on_post", ]