Skip to content

Implement notification preferences and status event timeline#716

Open
shyim wants to merge 11 commits into
mainfrom
claude/alerts-notifications-shop-status-6rzds7
Open

Implement notification preferences and status event timeline#716
shyim wants to merge 11 commits into
mainfrom
claude/alerts-notifications-shop-status-6rzds7

Conversation

@shyim

@shyim shyim commented Jun 28, 2026

Copy link
Copy Markdown
Member

Summary

This PR introduces a comprehensive notification system overhaul, adding user-configurable notification preferences, multi-channel delivery (in-app and email), localized message rendering, and an environment status change timeline.

Key Changes

Notification System Architecture

  • New notify package: Separates concerns into Event (what happened), Preferences (who gets notified), Channel (how it's delivered), and Translator (what it says)
  • Multi-channel delivery: Implements Channel interface with in-app and email implementations; extensible for future channels (webhook, Slack, etc.)
  • Preference resolver: Database-backed PreferenceResolver honors per-user, per-scope, per-event override chain with inheritance (global → organization → environment)

Notification Preferences

  • Database schema: New notification_preference table with scope (global/organization/environment) × event type × channel model
  • API endpoints:
    • GET/PUT/DELETE /account/notification-preferences for managing preferences
    • GET /notifications/event-types to list available event types and their default channels
  • Frontend UI: Settings page with channel toggles and per-event-type controls; environment modal for per-environment subscriptions
  • Composable: useNotificationPreferences centralizes preference state across components

Localization & Message Rendering

  • Translation keys: Checks and notifications now use messageKey/titleKey with structured params instead of pre-rendered English strings
  • Embedded catalogs: Server-side Translator loads locale JSON files (en, de) with {param} interpolation, matching frontend vue-i18n syntax
  • Fallback chain: Client prefers translation key (rendered in viewer's locale), falls back to server-rendered message
  • User locale: New user.locale column drives server-side email rendering

Status Event Timeline

  • New table: environment_status_event records every status transition with reasons (which checks caused it)
  • API endpoint: GET /environments/{environmentId}/status-events returns timeline
  • Frontend view: DetailStatusHistory.vue displays status changes with check details

Job Refactoring

  • Simplified scrape job: environment_scrape_notify.go refactored to use Dispatcher instead of inline notification logic
  • Status transition detection: Computes StatusReason array (which checks changed level) for timeline persistence
  • Dedup locking: Moved to notify.lockAcquirer for email-level dedup per event type

Account Management

  • User profile endpoint: PATCH /account/me now supports updating locale preference
  • Subscriber resolution: New GetEnvironmentNotificationSubscribers query for environment-scoped notifications

Notable Implementation Details

  • Empty-string sentinels: Notification preference table uses empty strings for "all event types" and "subscription marker" (channel='') to work with UNIQUE constraints
  • Tri-state preferences: Event type and per-environment controls are inherit/on/off; channel controls are boolean
  • Preference inheritance: Resolver walks scope chain (environment → organization → global) to determine final channel list
  • Idempotent in-app delivery: In-app channel upserts on (user_id, key) to avoid duplicates
  • Email dedup: Per-event-type TTL-based locks prevent spam (e.g., 1 hour for status degradation)
  • Graceful degradation: Missing translation keys return raw key; missing preferences fall back to registry defaults

Files Added

  • api/internal/notify/ package (event, channel, dispatcher, preferences, translator, registry, lock)
  • api/internal/handler/notification_preference.go
  • api/internal/database/queries/notification_preference.sql.go
  • api/migrations/000013-000016 (user locale, check keys, preferences, status events)
  • Frontend: useNotificationPreferences.ts, EnvironmentNotificationModal.vue, DetailStatusHistory.vue, i18n.ts
  • Locale catalogs: api/internal/notify/locales/{en,de}.json

https://claude.ai/code/session_01X2GLoyem7aStcR2K5hx5jq

@shyim
shyim force-pushed the claude/alerts-notifications-shop-status-6rzds7 branch from 9e471e9 to c8d1ef0 Compare June 28, 2026 13:58
@datadog-official

This comment has been minimized.

@shyim
shyim force-pushed the claude/alerts-notifications-shop-status-6rzds7 branch from c8d1ef0 to cd87f87 Compare June 28, 2026 14:09
@shyim

shyim commented Jul 5, 2026

Copy link
Copy Markdown
Member Author

@copilot resolve the merge conflicts in this pull request

Copilot AI commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Merged origin/main into the branch. The only conflict was in frontend/src/layouts/AppLayout.vue — resolved by keeping the translation functions (translateNotificationTitle, translateNotificationMessage) from this branch while adopting the updated CSS utility classes (wrap-break-word, wrap-anywhere) from main.

Copilot finished work on behalf of shyim July 5, 2026 06:44
@shyim
shyim marked this pull request as ready for review July 5, 2026 08:05
@greptile-apps

greptile-apps Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR delivers a comprehensive notification system overhaul: a new notify package introduces typed events, pluggable delivery channels (in-app and email), per-user preference resolution with a scope inheritance chain, server-side i18n rendering, and an environment status-change timeline backed by a new environment_status_event table.

  • New notify package: Dispatcher fans out typed Events to resolved channels; PreferenceResolver walks the environment → org → global scope chain to determine which channels each recipient actually receives; Translator loads embedded locale JSON (en/de) with {param} interpolation matching frontend vue-i18n syntax.
  • DB migrations 17–21: add user.locale, environment_check.message_key/params, notification_preference table with subscription-marker sentinel design, environment_status_event table, and a missing ON DELETE CASCADE on user_notification.
  • Preference API + frontend: new CRUD endpoints (GET/PUT/DELETE /account/notification-preferences) and useNotificationPreferences composable; both the SET and DELETE handlers guard against writing or removing the subscription-marker row (channel='').

Confidence Score: 5/5

Safe to merge — the core notification dispatch, preference resolver, and DB migrations are correct; the three findings are bounded in impact and do not affect data integrity or security.

The notification architecture is well-structured and the tests cover the preference resolver and dispatcher thoroughly. The two issues in the scrape path are both bounded: the json.Marshal error discard is practically unreachable for a slice of simple structs, and the pre-migration check fallback only affects the brief window between schema deployment and the first rescrape. The notification-before-transaction ordering was already present in the previous architecture; the new timeline entry makes the inconsistency more visible but does not corrupt any existing data.

api/internal/monitoring/scrape/service.go and api/internal/monitoring/scrape/notify.go — the silent error discard and the pre-migration check key fallback both live here.

Important Files Changed

Filename Overview
api/internal/notify/dispatcher.go New Dispatcher: resolves per-recipient channel preferences, renders messages per locale, fans out delivery; clean separation of concerns, email dedup lock acquired once per event.
api/internal/notify/preferences.go queryResolver walks the scope chain (environment → org → global, event-specific → wildcard) correctly; fails open to defaults on DB error; subscription markers (channel='') are safely ignored by the channel name comparison.
api/internal/monitoring/scrape/notify.go Refactored notification dispatch to use the new Dispatcher; recovery path for pre-migration check rows (MessageKey==nil) falls back to storing rendered English text as a translation key, causing empty reason display in the timeline briefly after deployment.
api/internal/monitoring/scrape/service.go Notification dispatch now happens before persistScrapeResult's transaction; if the transaction rolls back the in-app notification is committed but the status event timeline entry is not. Also has a silent json.Marshal error discard.
api/internal/handler/notification_preference.go New preference CRUD handlers; both SET and DELETE guard against the subscription-marker empty channel via validPreferenceChannels; scope validation also present.
api/migrations/000019_notification_preferences.up.sql Introduces notification_preference table with UNIQUE constraint covering all sentinel columns; data migration from user.notifications JSONB array is correct (handles NULL/empty arrays safely via lateral join).
api/internal/notify/i18n.go Translator loads embedded locale JSON files; gracefully degrades to raw key on missing translation; interpolation leaves unknown tokens untouched; mirrors frontend vue-i18n {param} syntax.
frontend/src/composables/useNotificationPreferences.ts Module-level shared refs for preferences/eventTypes; preference lookups mirror server resolver logic (event-specific wins over wildcard); tri-state DELETE/PUT for environment overrides is correct.
api/migrations/000021_user_notification_cascade.up.sql Adds missing ON DELETE CASCADE to user_notification FK; fixes user deletion failures; aligned with all other user-referencing tables.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Job as Scrape Job
    participant SVC as scrape.Service
    participant Notifier as notify.Dispatcher
    participant Prefs as PreferenceResolver
    participant InApp as inAppChannel
    participant Email as emailChannel
    participant DB as PostgreSQL
    participant TX as DB Transaction

    Job->>SVC: scrapeEnvironment(env)
    SVC->>DB: GetEnvironmentChecks (old checks)
    SVC->>SVC: handleStatusTransition(oldChecks, newChecks)
    SVC->>DB: GetEnvironmentByID (current status)
    SVC->>SVC: computeStatusReasons()
    SVC->>Notifier: Dispatch(event, recipients)
    loop per recipient
        Notifier->>Prefs: Channels(recipient, event, defaults)
        Prefs->>DB: ListNotificationPreferences(userId)
        Prefs-->>Notifier: resolved channel list
        Notifier->>InApp: Send() → UpsertNotification (committed immediately)
        Notifier->>Email: Send() (if dedup lock acquired)
    end
    Notifier-->>SVC: transition struct
    SVC->>TX: persistScrapeResult (begin transaction)
    TX->>DB: UpdateEnvironmentStatus
    TX->>DB: InsertEnvironmentCheck (×N)
    TX->>DB: InsertEnvironmentStatusEvent
    TX-->>SVC: commit / rollback
    Note over Notifier,TX: Notifications committed before transaction —<br/>timeline entry absent if transaction rolls back
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 Job as Scrape Job
    participant SVC as scrape.Service
    participant Notifier as notify.Dispatcher
    participant Prefs as PreferenceResolver
    participant InApp as inAppChannel
    participant Email as emailChannel
    participant DB as PostgreSQL
    participant TX as DB Transaction

    Job->>SVC: scrapeEnvironment(env)
    SVC->>DB: GetEnvironmentChecks (old checks)
    SVC->>SVC: handleStatusTransition(oldChecks, newChecks)
    SVC->>DB: GetEnvironmentByID (current status)
    SVC->>SVC: computeStatusReasons()
    SVC->>Notifier: Dispatch(event, recipients)
    loop per recipient
        Notifier->>Prefs: Channels(recipient, event, defaults)
        Prefs->>DB: ListNotificationPreferences(userId)
        Prefs-->>Notifier: resolved channel list
        Notifier->>InApp: Send() → UpsertNotification (committed immediately)
        Notifier->>Email: Send() (if dedup lock acquired)
    end
    Notifier-->>SVC: transition struct
    SVC->>TX: persistScrapeResult (begin transaction)
    TX->>DB: UpdateEnvironmentStatus
    TX->>DB: InsertEnvironmentCheck (×N)
    TX->>DB: InsertEnvironmentStatusEvent
    TX-->>SVC: commit / rollback
    Note over Notifier,TX: Notifications committed before transaction —<br/>timeline entry absent if transaction rolls back
Loading

Reviews (3): Last reviewed commit: "fix: align notify subscriptions with cap..." | Re-trigger Greptile

Comment thread api/internal/handler/notification_preference.go

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cd053b51f6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread api/internal/jobs/environment_scrape_notify.go Outdated
Comment thread frontend/src/views/environment/detail/DetailStatusHistory.vue Outdated
shyim and others added 11 commits July 16, 2026 14:44
…eferences

A complete rework of how alerts and notifications work:

- notify domain package (typed events, registry, pluggable channels,
  dispatcher) with a shared i18n catalog; alerts render per recipient
  locale and emails localize via user.locale.
- Checks and notifications are stored as translation keys + params and
  rendered in the viewer's language; locale persists to the server.
- Normalized notification_preference table replaces the user.notifications
  JSONB array, with a most-specific-first resolver
  (global -> environment, event-specific > wildcard) and a
  useNotificationPreferences composable shared by the settings page and
  the environment notification modal.
- Per-channel, per-event-type and per-environment preferences, event
  types served from an endpoint, watched environments grouped by shop.
- Status-change visibility: check diffing, environment_status_event
  timeline, recovery alerts, and reasons surfaced in notifications,
  emails and a status-history view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2GLoyem7aStcR2K5hx5jq
…moval

Three lifecycle gaps around notification data:

1. user_notification was the only user-referencing table without
   ON DELETE cascade, so deleting a user who had any notification failed
   on the foreign key. Add the cascade (migration 000020).

2. Removing a user from an organization (RemoveMember) or leaving it
   (LeaveOrganization) left their environment-scoped preferences and
   inbox notifications for that org's environments behind — the
   subscriber query already gates on membership so no future alerts
   leak, but the stale rows kept the environments showing in the user's
   watched list. Both flows now purge that data in the same transaction
   as the member removal.

3. Deleting an environment left its environment-scoped preferences and
   linked notifications orphaned (they reference the environment by text
   scope_id / link param, not a foreign key). DeleteEnvironment now
   removes them within its existing transaction.

Adds integration tests covering the cascade and both cleanup paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2GLoyem7aStcR2K5hx5jq
Replaces the two separate global notification controls ("Delivery
channels" on/off-all-events and "Event types" on/off-all-channels) with
a single matrix: rows are event types, columns are channels, and each
cell toggles whether that event is delivered on that channel.

This was already supported by the model and resolver
(notification_preference is keyed by event_type x channel, and
channelEnabled resolves per event+channel); only the UI exposed the
coarser two-axis controls. Cells are shown only for an event's default
channels (e.g. data-fetch errors are in-app only), using the
defaultChannels from the event-types endpoint.

useNotificationPreferences gains eventChannelEnabled/setEventChannel and
drops the now-redundant global channel/event booleans.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2GLoyem7aStcR2K5hx5jq
Per-environment notification settings were two separate axes (a channel
list + an event-type list, each inherit/on/off), while the global
settings were an event x channel matrix. That was inconsistent and
actually less expressive than global — you couldn't override a single
event+channel for an environment.

Unify both scopes on one matrix:
- Extract a reusable <NotificationMatrix> component. Global cells are
  on/off switches; per-environment cells are inherit/on/off, where
  "inherit" falls back to the global cell for that exact event+channel
  (environment + event + channel is the most specific resolver scope).
- Settings (global + per-environment expandable) and the environment
  notification modal all render the same matrix.
- Composable: replace the coarse env channel/event helpers
  (envChannelState/setEnvChannel/envEventState/setEnvEvent) with
  envEventChannelState/setEnvEventChannel.

No backend change; the resolver already supported this granularity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2GLoyem7aStcR2K5hx5jq
The matrix overflowed the environment modal: DialogContent is a CSS
grid, whose item defaults to min-width:auto and so refused to shrink
below the table's content width, defeating the inner overflow-x-auto and
spilling the table outside the dialog.

- Add min-w-0 to the modal body (and the matrix scroll container) so the
  grid item can shrink and horizontal scroll engages when needed.
- Use short channel column headers ("In-app" / "Email") and let the
  event-label column wrap, so the matrix fits the dialog width without
  scrolling in the common case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2GLoyem7aStcR2K5hx5jq
…ests

- Enable email for data_fetch_error: add the email channel to its
  registry defaults and gate it behind the same one-hour dedup lock as
  the other emailing events, so a persistently failing fetch emails at
  most once an hour. The matrix surfaces the email cell automatically
  (it's driven by the event-types endpoint's defaultChannels).
- Convert the notify package tests to testify (assert/require) to match
  the rest of the codebase, and update the data-fetch dispatch test to
  reflect that it now emails (deduped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2GLoyem7aStcR2K5hx5jq
The status-history route and view existed, but the environment detail
tabs are a hardcoded list, so without an entry there was no way to reach
it from the UI. Add a "Status history" tab (after Checks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2GLoyem7aStcR2K5hx5jq
@shyim
shyim force-pushed the claude/alerts-notifications-shop-status-6rzds7 branch from 0e83d05 to c3266a4 Compare July 16, 2026 13:04
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