Skip to content

OUT-3929 | Gate IU notifications via platform notification settings - #1378

Merged
arpandhakal merged 8 commits into
feature/iu-emailfrom
OUT-3929-per-category-iu-notification-gating
Jul 13, 2026
Merged

OUT-3929 | Gate IU notifications via platform notification settings#1378
arpandhakal merged 8 commits into
feature/iu-emailfrom
OUT-3929-per-category-iu-notification-gating

Conversation

@arpandhakal

@arpandhakal arpandhakal commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

What & why

Implements OUT-3929. The Assembly platform lets IUs toggle Product/Email per notification type on /settings/notifications and enforces those prefs at send time — a notification is gated per-IU only when it carries a notificationSettingId, and a send carries exactly one (one category). This wires Tasks into that mechanism and removes the interim IU_EMAIL_ALWAYS_ENABLED env flag.

Fresh start off feature/iu-email; supersedes the earlier single-setting attempt (old PR #1377).

The core constraint

A combined grouped email mixes categories, so it can only be gated all-or-nothing against one setting id — you can't drop just the "assigned" slice for an IU who disabled assigned while keeping "comment". Doing that in one email requires reading a specific IU's preferences, and no such API exists in the SDK/docs today (prefs are only applied at send time). So this PR gates what it can now and leaves a seam for later.

How it works

  • Declared settings → ids. CopilotAPI.getNotificationSettings() resolves the install by appId and fetches installs/{id}/notification-settings. resolveIuNotificationSettingId({ category }) maps a category → declared setting id by label (per-workspace cache, 5-min TTL, config only). Returns undefined when undeclared / on fetch error.
  • In-product: every IU send carries the category id → platform gates the in-product surface per IU (works today).
  • Email (grouped): IU emails are always buffered (cross-category windows, unchanged grouping). At flush, the summary carries notificationSettingId only when the whole window is a single category (then the platform gates it per IU); a single-event replay carries its own id. Mixed-category summaries send without an id (not per-IU gated yet — see below).
  • Filter seam: filterEventsForIuPreferences in flush-grouped-email.ts is a pass-through (TODO(OUT-3929)) where per-IU category filtering will go once Assembly exposes a preference-read endpoint — then a mixed window can be trimmed to allowed categories and an all-disabled recipient skipped.
  • Replies are now buffered as COMMENT events (were direct sends), so they group and gate like top-level comments; the IU in-product still fires immediately with the id.
  • Kill switch removed: IU_EMAIL_ALWAYS_ENABLED is gone; the platform is the gate.

Category → label mapping (declare these in App Setup, exact strings)

Category Label
ASSIGNED New task assigned
COMMENT New comment on a task
COMPLETED Task completed

⚠️ Notes / limitations

  • Blocked dependency: true per-IU gating of a mixed-category combined email needs a per-IU preference read endpoint (being confirmed with Assembly). Until then, mixed-category summaries aren't gated per-IU; single-category windows, in-product, and replays are.
  • Until a category's setting is declared in the dashboard, its sends carry no id (in-product ungated, email grouped but ungated) — safe, just not yet gated.
  • Behavior change: CU reply emails are now grouped (up to a 5-min delay) instead of sent immediately — consistent with how top-level CU comments already behave.

Greptile

  • P1 (ungated direct reply email): resolved — replies now go through the buffer, no direct-send path.
  • P2 (raw token in log): dropped this.token from the _getNotificationSettings log line.

Testing

  • yarn tsc, yarn lint:check, yarn prettier clean.
  • Unit: 177 passing — resolver (label mapping, undeclared, fail-safe, cache), notification.service (always-buffer, id on in-product + buffered email, cross-category windows), flush (single-category window attaches id; mixed window omits it).
  • The pre-existing authenticate.test.ts p-retry ESM failure is unrelated and untouched.
  • Manual (flagged staging workspace, settings declared): assignment/comment/completion + reply to an IU within 5 min → one combined email; toggle a category off → single-category windows for it stop, in-product stops, mixed windows still send (interim), no app-side errors.

🤖 Generated with Claude Code

…settings

Implements OUT-3929. IUs can now toggle Product/Email per notification type on
the Assembly /settings/notifications page; the platform enforces those prefs at
send time. The app declares one setting per category (assigned/comment/completed),
fetches their stable ids, and wires them into IU sends.

- add notificationSettingId to the notification body + NotificationSetting(s)
  schemas; CopilotAPI.getNotificationSettings() resolves the install by appId and
  fetches installs/{id}/notification-settings
- resolveIuNotificationSetting({category}) maps a category -> declared setting by
  label, returning { id, emailEnabled }; per-workspace cache (5m TTL, config only,
  never IU prefs). Fails closed on error and when a category isn't declared yet:
  no id, email withheld
- IU sends attach the category id to the in-product dispatch so the platform gates
  in-product per IU. The email surface is gated app-side: a grouped summary is
  cross-category and can't carry a per-category id, so an IU email is only buffered
  when the category's declared setting enables the email surface. Grouped windows
  stay cross-category. Reply job dispatches directly, so it just passes the id.
- remove the IU_EMAIL_ALWAYS_ENABLED env kill switch: IU sends always request both
  surfaces and the platform is the sole gate

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jul 8, 2026

Copy link
Copy Markdown

OUT-3929

@vercel

vercel Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tasks-app Ready Ready Preview, Comment Jul 13, 2026 9:56am

Request Review

@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown

Greptile Summary

This PR wires Tasks into the Assembly platform's per-IU notification preference system and removes the interim IU_EMAIL_ALWAYS_ENABLED env flag. The actual per-IU gating is deliberately disabled today (notificationSettingId = undefined) because the platform has no endpoint to read an individual IU's preferences — the infrastructure (resolver, schema, plumbing, flush logic) is fully built and tested, ready to be activated by uncommenting two call sites once that endpoint ships.

  • Core gating infrastructure: resolveNotificationSettingId.ts maps category → declared setting id with a per-workspace 5-min TTL cache; CopilotAPI.getNotificationSettings() fetches the install's declared settings; notificationSettingId is threaded through buildNotificationDetails, bufferGroupedEmailEvent, and sendGroupedEmail. At flush, singleCategorySettingId attaches the id when an IU's window is a single category; mixed-category windows send without one.
  • Reply refactor: IU replies now fire in-product immediately and buffer the email as a COMMENT grouped event (same path as top-level comments), eliminating a direct-send path for IU email. Client reply emails are also buffered for consistency.
  • Bug fix included: A missing break before the default case in getNotificationParties (after CommentToIU) is added, preventing fall-through that would have clobbered senderId and recipientId.

Confidence Score: 5/5

Safe to merge — all active code paths are correct, and the only concern is a future-state issue in the filter seam that is currently a pass-through and cannot be triggered today.

The null-suppression handling from createNotification is correctly guarded in every call site. The singleCategorySettingId edge cases (empty array, all-undefined ids, mixed ids) all resolve to undefined as intended. The reply refactor correctly separates the in-product send from the buffered email path. The one non-trivial concern — markIuRecipientSent being called even when filterEventsForIuPreferences returns an empty list — only matters when the filter is implemented; today the filter is a pass-through and the code behaves identically to before.

flush-grouped-email.ts — the IU flush loop's interaction with the future filter seam should be revisited when filterEventsForIuPreferences is implemented.

Important Files Changed

Filename Overview
src/app/api/notification/notification.service.ts Adds notificationSettingId plumbing to create/bulkCreate/bufferGroupedEmailEvent; hardcodes it to undefined for now (gating disabled); adds break before default case in getNotificationParties (bug fix); promotes bufferGroupedEmailEvent from private to public for external callers.
src/app/api/notification/resolveNotificationSettingId.ts New module: resolves a per-workspace label→id map for declared notification settings with 5-min TTL cache; fail-open (undefined) on fetch errors; currently unused (callers commented out).
src/jobs/notifications/flush-grouped-email.ts Adds IU-specific flush path: filterEventsForIuPreferences seam (pass-through), singleCategorySettingId attaches notificationSettingId to single-category grouped summaries; markIuRecipientSent is still called when the filter empties the event list (future correctness concern).
src/jobs/notifications/send-reply-create-notifications.ts Significant refactor: IU replies now fire in-product immediately and buffer the email as a COMMENT grouped event; client replies also buffered; previous direct-send path removed; notificationSettingId disabled (undefined) pending platform endpoint.
src/utils/CopilotAPI.ts Adds getNotificationSettings() (resolves install by appId, fetches notification-settings endpoint); _createNotification now returns null on platform suppression (2xx, no created object) instead of crashing the schema parse.
src/jobs/notifications/send-grouped-email.ts Adds optional notificationSettingId to payload; return type widened to string
src/types/common.ts Adds notificationSettingId field to NotificationRequestBodySchema, plus NotificationSettingSchema and NotificationSettingsResponseSchema for the new API response.
src/app/api/notification/notification.service.test.ts Adds mock for getNotificationSettings, clears the resolver cache between cases, and adds an ungated describe block that verifies notificationSettingId remains undefined in the current disabled state.
src/jobs/notifications/flush-grouped-email.test.ts New tests verify single-category IU window attaches notificationSettingId and mixed-category window omits it; both cases exercised cleanly.
src/config/index.ts Removes iuEmailAlwaysEnabled (IU_EMAIL_ALWAYS_ENABLED env flag); platform is now the gate.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller as Task/Job Caller
    participant NS as NotificationService
    participant GEBuf as GroupedEmailEvents (DB)
    participant Flush as flushGroupedEmail job
    participant API as CopilotAPI

    Note over Caller,API: IU in-product (immediate)
    Caller->>NS: create(action, task, opts)
    NS->>NS: "buildNotificationDetails(notificationSettingId=undefined)"
    NS->>API: "createNotification({inProduct, notificationSettingId})"
    API-->>NS: "NotificationCreatedResponse | null"
    NS->>NS: if null return (suppressed by platform)

    Note over Caller,API: IU email (buffered)
    NS->>GEBuf: "bufferGroupedEmailEvent({individualEmail: {notificationSettingId}})"
    GEBuf-->>NS: window created or joined

    Note over Flush,API: Flush (5-min window)
    Flush->>GEBuf: readUnsentWindowEvents
    GEBuf-->>Flush: events[]
    Flush->>Flush: filterEventsForIuPreferences (pass-through today)
    alt single event
        Flush->>API: createNotification(individualEmail verbatim)
    else multi-event, single category
        Flush->>Flush: singleCategorySettingId returns settingId
        Flush->>API: "sendGroupedEmail({notificationSettingId: settingId})"
    else multi-event, mixed categories
        Flush->>Flush: singleCategorySettingId returns undefined
        Flush->>API: sendGroupedEmail(no notificationSettingId)
    end
    Flush->>GEBuf: markIuRecipientSent

    Note over Caller,API: Reply notifications (new path)
    Caller->>NS: bufferGroupedEmailEvent(COMMENT, emailBody)
    Caller->>API: "createNotification({inProduct only})"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller as Task/Job Caller
    participant NS as NotificationService
    participant GEBuf as GroupedEmailEvents (DB)
    participant Flush as flushGroupedEmail job
    participant API as CopilotAPI

    Note over Caller,API: IU in-product (immediate)
    Caller->>NS: create(action, task, opts)
    NS->>NS: "buildNotificationDetails(notificationSettingId=undefined)"
    NS->>API: "createNotification({inProduct, notificationSettingId})"
    API-->>NS: "NotificationCreatedResponse | null"
    NS->>NS: if null return (suppressed by platform)

    Note over Caller,API: IU email (buffered)
    NS->>GEBuf: "bufferGroupedEmailEvent({individualEmail: {notificationSettingId}})"
    GEBuf-->>NS: window created or joined

    Note over Flush,API: Flush (5-min window)
    Flush->>GEBuf: readUnsentWindowEvents
    GEBuf-->>Flush: events[]
    Flush->>Flush: filterEventsForIuPreferences (pass-through today)
    alt single event
        Flush->>API: createNotification(individualEmail verbatim)
    else multi-event, single category
        Flush->>Flush: singleCategorySettingId returns settingId
        Flush->>API: "sendGroupedEmail({notificationSettingId: settingId})"
    else multi-event, mixed categories
        Flush->>Flush: singleCategorySettingId returns undefined
        Flush->>API: sendGroupedEmail(no notificationSettingId)
    end
    Flush->>GEBuf: markIuRecipientSent

    Note over Caller,API: Reply notifications (new path)
    Caller->>NS: bufferGroupedEmailEvent(COMMENT, emailBody)
    Caller->>API: "createNotification({inProduct only})"
Loading

Reviews (3): Last reviewed commit: "chore(notifications): ship IU notificati..." | Re-trigger Greptile

Comment thread src/jobs/notifications/send-reply-create-notifications.ts Outdated
Comment thread src/utils/CopilotAPI.ts Outdated
Comment thread src/app/api/notification/resolveNotificationSettingId.ts Outdated
…Greptile

Reworks IU notification gating per the platform's real mechanism (per-IU prefs are
only enforced at send time via notificationSettingId; there's no read API yet).

- resolver back to id-only (resolveIuNotificationSettingId); drops the surfaces/
  emailEnabled buffer-time gate — gating now lives on the send + at flush
- create()/createBulkNotification always buffer IU emails and attach the category id
  to the in-product dispatch (platform gates in-product per IU) and to the buffered
  email (for the flush single-category gate)
- flush: attach notificationSettingId to an IU grouped summary only when the whole
  window is one category (mixed windows can't gate against one id); add a
  filterEventsForIuPreferences seam (pass-through, TODO(OUT-3929)) for when Assembly
  exposes a per-IU preference read endpoint
- replies now buffer as COMMENT events (were direct sends) so they group + gate like
  top-level comments; IU in-product still fires immediately. Removes the ungated
  direct-reply path (Greptile P1)
- Greptile P2: stop logging the raw token in _getNotificationSettings

Windows stay cross-category (combined email). True per-IU gating of a mixed summary
is blocked on the read endpoint being confirmed with Assembly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Deployment failed with the following error:

Deploying Serverless Functions to multiple regions is restricted to the Pro and Enterprise plans.

Learn More: https://vercel.link/multiple-function-regions

@arpandhakal

Copy link
Copy Markdown
Collaborator Author

@greptile took a different approach. Could you re review?

@arpandhakal arpandhakal self-assigned this Jul 8, 2026
Comment thread src/app/api/tasks/task-notifications.service.ts Outdated
Comment thread src/jobs/notifications/flush-grouped-email.ts
@arpandhakal
arpandhakal requested a review from priosshrsth July 8, 2026 12:30
…efault

getNotificationParties' CommentToIU case had no break and fell through to default,
which strict-parses me().id and task.assigneeId — throwing (ZodError "Required") when
me() returns null or the task is unassigned. The comment job only consumes
recipientIds/senderCompanyId (both set in the CommentToIU case), so the fallthrough's
values were unused anyway.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Logs the payload for both flush send paths (individual replay + grouped summary)
via the trigger logger, for debugging IU/CU email delivery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a notificationSettingId is passed and the recipient IU has every requested
surface turned off, the platform suppresses the notification: the call succeeds
(2xx) with no created object. _createNotification now returns null in that case
instead of throwing on the strict schema parse, and callers skip the DB save.
Non-suppressible callers (reminders, webhook, backfill, validate-count) guard the
null defensively (they never pass a settingId).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stop attaching notificationSettingId (set undefined; resolve calls commented) so IUs
receive all email + in-product notifications without platform gating. Per-IU gating
is blocked on Copilot exposing a preference-read endpoint; re-enable by restoring the
commented resolve calls (and implementing flush-time preference filtering).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/app/api/notification/notification.service.ts Outdated
Comment thread src/app/api/notification/notification.service.ts Outdated
Comment thread src/app/api/notification/notification.service.ts Outdated
Comment thread src/app/api/tasks/task-notifications.service.ts Outdated
Comment thread src/types/common.ts

@priosshrsth priosshrsth left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@arpandhakal
arpandhakal merged commit e2174de into feature/iu-email Jul 13, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants