Feat/system notifications#228
Conversation
There was a problem hiding this comment.
Pull request overview
Adds UI support for system notification management, including a new admin Notifications page, navigation/route wiring, and updated user preferences behavior to respect notification provider availability.
Changes:
- Introduces
NotificationsViewunder Admin to list providers and configure notification destinations (add/remove). - Adds route + left navigation link for the new admin Notifications page.
- Updates Preferences to disable notification alert channels when corresponding providers are unavailable, and adds new tests for the Notifications view.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/views/admin/NotificationsView.vue | New admin view to display providers and manage notification destinations for predefined notifications. |
| src/views/tests/NotificationsView.spec.ts | Adds unit tests covering provider display, destination add/remove flows, and Slack-unavailable behavior. |
| src/views/tests/LeftSideNav.spec.ts | Updates admin link ordering assertions to include the new Notifications link and Import placement. |
| src/views/PreferencesView.vue | Fetches provider availability and uses it to disable/clean up selectable notification channels. |
| src/views/LeftSideNav.vue | Adds “Notifications” to the admin navigation links list. |
| src/router/index.ts | Registers the /admin/notifications route pointing to the new admin Notifications view. |
Comments suppressed due to low confidence (1)
src/views/PreferencesView.vue:599
syncUnavailableSlackSelectionsnow syncs all notification channel availability (email + slack) viaremoveUnavailableNotificationSelections(), so the function name is misleading. Renaming it (and the relatedhadUnavailableSlackSelectionvariable) would make the intent clearer and avoid future confusion.
const syncUnavailableSlackSelections = async () => {
if (!removeUnavailableNotificationSelections()) {
return;
}
await updateNotificationPreferences({ silent: true });
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const wrapper = mount(NotificationsView); | ||
|
|
||
| expect(wrapper.text()).toContain('Providers'); | ||
| expect(wrapper.text()).toContain('Email'); |
There was a problem hiding this comment.
The view renders provider.providerType (e.g. email / slack) in the Providers section, so this assertion for Email is likely to fail. Either update the expectation to match the rendered providerType text, or adjust the component to render provider.displayName if that’s the intended UI.
| expect(wrapper.text()).toContain('Email'); | |
| expect(wrapper.text()).toContain('email'); |
| const systemUsersIndex = linkTexts.indexOf('System Users'); | ||
| const agentsIndex = linkTexts.indexOf('Agents'); | ||
| const notificationsIndex = linkTexts.indexOf('Notifications'); | ||
| const risksIndex = linkTexts.indexOf('Risks'); | ||
| const subjectTemplatesIndex = linkTexts.indexOf('Subject Templates'); | ||
| const riskTemplatesIndex = linkTexts.indexOf('Risk Templates'); | ||
| const importIndex = linkTexts.indexOf('Import'); | ||
|
|
||
| expect(systemUsersIndex).toBeGreaterThanOrEqual(0); | ||
| expect(agentsIndex).toBeGreaterThan(systemUsersIndex); | ||
| expect(risksIndex).toBeGreaterThan(agentsIndex); | ||
| expect(subjectTemplatesIndex).toBeGreaterThan(risksIndex); | ||
| expect(riskTemplatesIndex).toBeGreaterThan(subjectTemplatesIndex); | ||
| expect(notificationsIndex).toBeGreaterThan(riskTemplatesIndex); | ||
| expect(importIndex).toBeGreaterThan(notificationsIndex); |
There was a problem hiding this comment.
This ordering test can produce false positives because indexOf() returns -1 for missing links, and several indices (e.g. notificationsIndex, risksIndex, importIndex) are never asserted to be >= 0. Consider asserting existence for each expected link, and re-adding an explicit ordering check between Agents and Risks (it was removed), so the test actually validates the full admin section ordering.
| const [userResponse, subscriptionResponse, notificationProvidersResponse] = | ||
| await Promise.all([ | ||
| axios.get<{ data: CCFUser }>('/api/users/me'), | ||
| axios.get<{ data: SubscriptionsPreferencesResponse }>( | ||
| '/api/users/me/subscriptions', | ||
| ), | ||
| axios.get<{ | ||
| data: Array<{ | ||
| providerType?: string; | ||
| enabled?: boolean; | ||
| }>; | ||
| }>('/api/notifications/providers'), | ||
| ]); |
There was a problem hiding this comment.
loadUserData() now uses Promise.all([... , axios.get('/api/notifications/providers')]), so any failure in the providers request will throw and set loadError, preventing the preferences page from loading even if /api/users/me and /api/users/me/subscriptions succeeded. To avoid making preferences availability depend on this auxiliary endpoint, consider fetching providers with Promise.allSettled or a nested try/catch and falling back to a safe default (e.g. keep channels enabled / keep existing selections) when the providers call fails.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
src/views/PreferencesView.vue:606
syncUnavailableSlackSelections()now callsremoveUnavailableNotificationSelections(), which can remove email selections as well (based on provider availability). The function name (and any related references) is now misleading; consider renaming it to reflect that it syncs all notification channel selections, not just Slack.
const syncUnavailableSlackSelections = async () => {
if (!removeUnavailableNotificationSelections()) {
return;
}
await updateNotificationPreferences({ silent: true });
};
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function isProviderDisabled( | ||
| provider: NotificationDestinationProvider, | ||
| ): boolean { | ||
| return provider === 'slack' && !canSelectSlackDestination.value; |
There was a problem hiding this comment.
The provider select only disables Slack based on Slack-link configuration (isProviderDisabled ignores the backend provider enabled flag). This allows configuring destinations for providers that the backend reports as disabled, which is inconsistent with Providers section and with PreferencesView disabling channels based on /api/notifications/providers. Consider also disabling (and explaining) providers whose NotificationProviderInfo.enabled is false.
| function isProviderDisabled( | |
| provider: NotificationDestinationProvider, | |
| ): boolean { | |
| return provider === 'slack' && !canSelectSlackDestination.value; | |
| function getProviderInfo( | |
| provider: NotificationDestinationProvider, | |
| ): NotificationProviderInfo | undefined { | |
| return notificationProviders.value.find( | |
| (providerInfo) => providerInfo.providerType === provider, | |
| ); | |
| } | |
| function isProviderDisabled( | |
| provider: NotificationDestinationProvider, | |
| ): boolean { | |
| if (provider === 'slack' && !canSelectSlackDestination.value) { | |
| return true; | |
| } | |
| return getProviderInfo(provider)?.enabled === false; |
| function getApiErrorMessage(error: unknown, fallbackMessage: string): string { | ||
| const axiosError = error as AxiosError<ErrorResponse<ErrorBody>>; | ||
| return axiosError.response?.data?.errors?.body || fallbackMessage; | ||
| } |
There was a problem hiding this comment.
getApiErrorMessage() unconditionally casts to AxiosError and only reads response.data.errors.body, otherwise returning the fallback. In other admin views (e.g., Agents/RiskTemplates), the helper first checks isAxiosError(error) and then falls back to error.message when available, which produces much more useful errors for network/JS failures. Consider aligning this helper with that established pattern.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async function loadSlackDestinationStatus() { | ||
| slackDestinationStatusLoading.value = true; | ||
|
|
||
| try { | ||
| await axios.get('/api/auth/slack/link/status'); | ||
| slackDestinationConfigured.value = true; | ||
| } catch (error) { | ||
| const errorResponse = error as AxiosError<ErrorResponse<ErrorBody>>; | ||
|
|
||
| if (errorResponse.response?.status === 404) { | ||
| slackDestinationConfigured.value = false; | ||
| return; | ||
| } | ||
|
|
||
| slackDestinationConfigured.value = true; | ||
| } finally { | ||
| slackDestinationStatusLoading.value = false; | ||
| } |
There was a problem hiding this comment.
loadSlackDestinationStatus() treats any non-404 error as “Slack configured” and does not surface the failure. This can enable Slack destinations when the status check is failing (e.g., 500/network), and gives no feedback to the admin. Consider mirroring the existing Slack status loader behavior (e.g., show an error toast / store an error message, and/or keep Slack destinations disabled when the status is unknown).
| const syncUnavailableSlackSelections = async () => { | ||
| if (!removeUnavailableSlackSelections()) { | ||
| if (!removeUnavailableNotificationSelections()) { | ||
| return; | ||
| } | ||
|
|
There was a problem hiding this comment.
syncUnavailableSlackSelections now calls removeUnavailableNotificationSelections() and can remove non-Slack channels (e.g., email) based on provider availability. Renaming this helper to reflect the broader responsibility (and updating any related identifiers) would improve clarity for future maintenance.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
No description provided.