OUT-3929 | Gate IU notifications via platform notification settings - #1378
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR wires Tasks into the Assembly platform's per-IU notification preference system and removes the interim
Confidence Score: 5/5Safe 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
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})"
%%{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})"
Reviews (3): Last reviewed commit: "chore(notifications): ship IU notificati..." | Re-trigger Greptile |
…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>
|
Deployment failed with the following error: Learn More: https://vercel.link/multiple-function-regions |
|
@greptile took a different approach. Could you re review? |
…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>
…ks-app into OUT-3929-per-category-iu-notification-gating
What & why
Implements OUT-3929. The Assembly platform lets IUs toggle Product/Email per notification type on
/settings/notificationsand enforces those prefs at send time — a notification is gated per-IU only when it carries anotificationSettingId, and a send carries exactly one (one category). This wires Tasks into that mechanism and removes the interimIU_EMAIL_ALWAYS_ENABLEDenv flag.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
CopilotAPI.getNotificationSettings()resolves the install byappIdand fetchesinstalls/{id}/notification-settings.resolveIuNotificationSettingId({ category })maps a category → declared setting id by label (per-workspace cache, 5-min TTL, config only). Returnsundefinedwhen undeclared / on fetch error.notificationSettingIdonly 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).filterEventsForIuPreferencesinflush-grouped-email.tsis 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.IU_EMAIL_ALWAYS_ENABLEDis gone; the platform is the gate.Category → label mapping (declare these in App Setup, exact strings)
ASSIGNEDNew task assignedCOMMENTNew comment on a taskCOMPLETEDTask completedGreptile
this.tokenfrom the_getNotificationSettingslog line.Testing
yarn tsc,yarn lint:check,yarn prettierclean.authenticate.test.tsp-retry ESM failure is unrelated and untouched.🤖 Generated with Claude Code