@cat-factory/app 0.252.0 → 0.253.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -469,6 +469,7 @@ function selectTask() {
469
469
  variant="soft"
470
470
  size="xs"
471
471
  icon="i-lucide-scan-eye"
472
+ data-testid="task-review"
472
473
  @click.stop="review"
473
474
  >
474
475
  {{ t('board.task.review') }}
@@ -60,16 +60,67 @@ const contentBackendLabels = computed<Record<ContentStorageBackend, string>>(()
60
60
  s3: t('layout.accountDeployment.contentStorage.backends.s3'),
61
61
  r2: t('layout.accountDeployment.contentStorage.backends.r2'),
62
62
  db: t('layout.accountDeployment.contentStorage.backends.db'),
63
+ custom: t('layout.accountDeployment.contentStorage.backends.custom'),
63
64
  }))
64
65
  const storageCapability = computed(() => store.view?.contentStorageCapability ?? null)
65
66
  const storageSummary = computed(() => summary.value?.contentStorage ?? null)
66
- const backendItems = computed(() =>
67
- (storageCapability.value?.supportedBackends ?? []).map((b) => ({
68
- label: contentBackendLabels.value[b],
69
- value: b,
70
- })),
67
+ // A deployment-registered store is selected as `custom` PLUS an id, so one select carries both:
68
+ // each registered store is its own option, tagged so the save path can tell it from a built-in
69
+ // backend. A two-control version (backend, then store) would leave "custom" selectable with no
70
+ // store chosen, which is the one content-storage config that means nothing.
71
+ const CUSTOM_OPTION_PREFIX = 'custom:'
72
+ const customStores = computed(() => storageCapability.value?.customStores ?? [])
73
+ /**
74
+ * The store an account is configured with that this deployment does NOT register: its build no
75
+ * longer carries it, or it never did. Kept as its own selectable option rather than dropped, so
76
+ * the control shows what is actually stored; hiding it would render an empty select over a
77
+ * working-looking account whose artifacts are going nowhere.
78
+ */
79
+ const unregisteredStoreId = computed(() => {
80
+ const configured = store.view?.config?.contentStorage
81
+ if (configured?.backend !== 'custom') return null
82
+ const id = configured.custom?.storeId
83
+ if (!id || customStores.value.some((s) => s.id === id)) return null
84
+ return id
85
+ })
86
+ const backendItems = computed(() => [
87
+ ...(storageCapability.value?.supportedBackends ?? []).flatMap((b) =>
88
+ b === 'custom'
89
+ ? customStores.value.map((s) => ({
90
+ label: s.name,
91
+ value: `${CUSTOM_OPTION_PREFIX}${s.id}`,
92
+ }))
93
+ : [{ label: contentBackendLabels.value[b], value: b as string }],
94
+ ),
95
+ ...(unregisteredStoreId.value
96
+ ? [
97
+ {
98
+ label: t('layout.accountDeployment.contentStorage.unregisteredStore', {
99
+ store: unregisteredStoreId.value,
100
+ }),
101
+ value: `${CUSTOM_OPTION_PREFIX}${unregisteredStoreId.value}`,
102
+ },
103
+ ]
104
+ : []),
105
+ ])
106
+ /** The select's value: a backend id, or `custom:<storeId>` for a registered store. */
107
+ const csBackend = ref<string>('off')
108
+ /** The registered store the select currently names, for the note under it. */
109
+ const selectedCustomStore = computed(() =>
110
+ customStores.value.find((s) => `${CUSTOM_OPTION_PREFIX}${s.id}` === csBackend.value),
71
111
  )
72
- const csBackend = ref<ContentStorageBackend>('off')
112
+ /**
113
+ * What the status badge says. `custom` is the one backend whose own label names nothing an
114
+ * operator can act on, so it resolves to the STORE: its registered name, or the bare id when this
115
+ * build does not register it (which the warning below then explains).
116
+ */
117
+ const configuredStorageLabel = computed(() => {
118
+ const configured = storageSummary.value
119
+ if (!configured?.backend) return null
120
+ if (configured.backend !== 'custom') return contentBackendLabels.value[configured.backend]
121
+ const registered = customStores.value.find((s) => s.id === configured.customStoreId)
122
+ return registered?.name ?? configured.customStoreId ?? contentBackendLabels.value.custom
123
+ })
73
124
  const cs = reactive({
74
125
  basePath: '',
75
126
  region: '',
@@ -84,7 +135,10 @@ const savingStorage = ref(false)
84
135
 
85
136
  function hydrateStorage() {
86
137
  const cfg = store.view?.config?.contentStorage
87
- csBackend.value = cfg?.backend ?? storageCapability.value?.defaultBackend ?? 'off'
138
+ csBackend.value =
139
+ cfg?.backend === 'custom' && cfg.custom?.storeId
140
+ ? `${CUSTOM_OPTION_PREFIX}${cfg.custom.storeId}`
141
+ : (cfg?.backend ?? storageCapability.value?.defaultBackend ?? 'off')
88
142
  cs.basePath = cfg?.fs?.basePath ?? ''
89
143
  cs.region = cfg?.s3?.region ?? ''
90
144
  cs.bucket = cfg?.s3?.bucket ?? ''
@@ -113,8 +167,16 @@ onMounted(async () => {
113
167
  })
114
168
 
115
169
  async function saveStorage() {
116
- const backend = csBackend.value
117
- const config: ContentStorageConfig = { backend }
170
+ const selected = csBackend.value
171
+ const customStoreId = selected.startsWith(CUSTOM_OPTION_PREFIX)
172
+ ? selected.slice(CUSTOM_OPTION_PREFIX.length)
173
+ : null
174
+ const backend: ContentStorageBackend = customStoreId
175
+ ? 'custom'
176
+ : (selected as ContentStorageBackend)
177
+ const config: ContentStorageConfig = customStoreId
178
+ ? { backend, custom: { storeId: customStoreId } }
179
+ : { backend }
118
180
  if (backend === 'fs' && cs.basePath.trim()) {
119
181
  config.fs = { basePath: cs.basePath.trim() }
120
182
  }
@@ -677,11 +739,10 @@ async function clearWeb() {
677
739
  size="xs"
678
740
  >
679
741
  {{
680
- storageSummary?.backend
681
- ? contentBackendLabels[storageSummary.backend]
682
- : t('layout.accountDeployment.contentStorage.default', {
683
- backend: contentBackendLabels[storageCapability.defaultBackend],
684
- })
742
+ configuredStorageLabel ??
743
+ t('layout.accountDeployment.contentStorage.default', {
744
+ backend: contentBackendLabels[storageCapability.defaultBackend],
745
+ })
685
746
  }}
686
747
  </UBadge>
687
748
  </div>
@@ -691,6 +752,16 @@ async function clearWeb() {
691
752
  <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
692
753
  <USelect v-model="csBackend" :items="backendItems" value-key="value" size="sm" />
693
754
  </div>
755
+ <p v-if="selectedCustomStore?.summary" class="text-[11px] text-slate-400">
756
+ {{ selectedCustomStore.summary }}
757
+ </p>
758
+ <p v-if="unregisteredStoreId" class="text-[11px] text-amber-400">
759
+ {{
760
+ t('layout.accountDeployment.contentStorage.unregisteredStoreWarning', {
761
+ store: unregisteredStoreId,
762
+ })
763
+ }}
764
+ </p>
694
765
 
695
766
  <!-- Filesystem -->
696
767
  <div v-if="csBackend === 'fs'" class="grid grid-cols-1 gap-2">
@@ -72,6 +72,14 @@ const dynamicIntegrationCommands = computed<Command[]>(() => {
72
72
  run: () => ui.openSlack(),
73
73
  })
74
74
  }
75
+ list.push({
76
+ id: 'notification-settings',
77
+ label: t('layout.commandBar.cmd.notificationSettings'),
78
+ group: groupIntegrations,
79
+ icon: 'i-lucide-bell',
80
+ keywords: t('layout.commandBar.keywords.notificationSettings'),
81
+ run: () => ui.openNotificationSettings(),
82
+ })
75
83
  if (documents.available) {
76
84
  for (const src of documents.sources) {
77
85
  list.push({
@@ -150,6 +150,16 @@ const groups = computed<IntegrationGroup[]>(() => {
150
150
  onClick: () => go(ui.openSlack),
151
151
  })
152
152
  }
153
+ // The notification manager is always listed: it configures the channels every deployment has
154
+ // (the in-app inbox, and email once an account connects a sender), so unlike the integrations
155
+ // around it there is no connection to probe before it is useful.
156
+ comms.push({
157
+ key: 'notificationSettings',
158
+ icon: 'i-lucide-bell',
159
+ label: t('layout.integrationsHub.items.notificationSettings.label'),
160
+ description: t('layout.integrationsHub.items.notificationSettings.description'),
161
+ onClick: () => go(ui.openNotificationSettings),
162
+ })
153
163
  if (comms.length)
154
164
  out.push({ title: t('layout.integrationsHub.groups.communication'), items: comms })
155
165
 
@@ -0,0 +1,252 @@
1
+ <script setup lang="ts">
2
+ // The notification manager: which notification types this board delivers on which channel.
3
+ //
4
+ // One row per notification type, one switch per routed channel. The switches render the
5
+ // RESOLVED routing (a workspace's override, else the shipped default) through the same
6
+ // `isNotificationRouted` the delivery path uses, so what the toggle says is what the engine
7
+ // does. Saving writes only the cells that differ from their default, which is what makes
8
+ // "put this back the way it ships" expressible at all — and what lets a later change to a
9
+ // default reach every board that never opted out of it.
10
+ //
11
+ // Slack and the outbound webhooks are deliberately absent: each answers "which types" where
12
+ // its DESTINATION is declared (a Slack route's channel, a webhook endpoint's own filter), so
13
+ // a second switch here would be a place to look that does not decide. The footer says so and
14
+ // links to the Slack panel.
15
+ import { computed, reactive, ref, watch } from 'vue'
16
+ import {
17
+ NOTIFICATION_DELIVERY_CHANNELS,
18
+ defaultNotificationRoute,
19
+ isNotificationRouted,
20
+ notificationTypeSchema,
21
+ } from '@cat-factory/contracts'
22
+ import type {
23
+ NotificationDeliveryChannel,
24
+ NotificationRoutingMatrix,
25
+ NotificationType,
26
+ } from '~/types/notifications'
27
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
28
+
29
+ const ui = useUiStore()
30
+ const notifications = useNotificationsStore()
31
+ const slack = useSlackStore()
32
+ const toast = useToast()
33
+ const { t } = useI18n()
34
+
35
+ const open = computed({
36
+ get: () => ui.notificationSettingsOpen,
37
+ set: (v: boolean) => (v ? ui.openNotificationSettings() : ui.closeNotificationSettings()),
38
+ })
39
+ const back = useIntegrationBack(open)
40
+
41
+ /**
42
+ * Every notification type, in the picklist's own order, taken from the SCHEMA rather than a
43
+ * hand-kept list: a type added to the vocabulary appears here with its shipped default instead
44
+ * of being invisible to the only surface that can change it.
45
+ */
46
+ const TYPES = notificationTypeSchema.options as readonly NotificationType[]
47
+
48
+ /** The editable grid: `resolved[type][channel]` is the switch's state. */
49
+ const resolved = reactive(
50
+ Object.fromEntries(
51
+ TYPES.map((type) => [
52
+ type,
53
+ Object.fromEntries(
54
+ NOTIFICATION_DELIVERY_CHANNELS.map((channel) => [
55
+ channel,
56
+ defaultNotificationRoute(type, channel),
57
+ ]),
58
+ ) as Record<NotificationDeliveryChannel, boolean>,
59
+ ]),
60
+ ) as Record<NotificationType, Record<NotificationDeliveryChannel, boolean>>,
61
+ )
62
+
63
+ const busy = ref(false)
64
+
65
+ function applyMatrix(matrix: NotificationRoutingMatrix | null | undefined) {
66
+ for (const type of TYPES) {
67
+ for (const channel of NOTIFICATION_DELIVERY_CHANNELS) {
68
+ resolved[type][channel] = isNotificationRouted(matrix, type, channel)
69
+ }
70
+ }
71
+ }
72
+
73
+ function notifyError(title: string, e: unknown) {
74
+ toast.add({
75
+ title,
76
+ description: e instanceof Error ? e.message : String(e),
77
+ icon: 'i-lucide-triangle-alert',
78
+ color: 'error',
79
+ })
80
+ }
81
+
82
+ /**
83
+ * The grid is editable only once the board's OWN matrix is in hand.
84
+ *
85
+ * The switches start on the shipped defaults, which is right while loading and wrong as a
86
+ * statement about the board: on a failed read the panel used to render them as the current
87
+ * configuration, and save is a full replace, so one press would write that guess over whatever
88
+ * overrides were stored. `failed` therefore gets its own state with a retry and no save, kept
89
+ * distinct from the settled `unavailable`.
90
+ */
91
+ const editable = computed(() => notifications.settingsStatus === 'ready')
92
+
93
+ async function load() {
94
+ try {
95
+ await notifications.loadSettings()
96
+ applyMatrix(notifications.settings?.matrix)
97
+ } catch (e) {
98
+ notifyError(t('notificationSettings.error.load'), e)
99
+ }
100
+ }
101
+
102
+ watch(
103
+ () => open.value,
104
+ async (isOpen) => {
105
+ if (!isOpen) return
106
+ await load()
107
+ },
108
+ // Lazy v-if mount: the panel mounts with `open` already true, so load immediately.
109
+ { immediate: true },
110
+ )
111
+
112
+ async function save() {
113
+ // Store only what DIFFERS from the shipped default. A full grid would freeze today's
114
+ // defaults onto every board that saved once, so a later change to what "high impact" means
115
+ // would reach nobody who had ever opened this panel.
116
+ const matrix: NotificationRoutingMatrix = {}
117
+ for (const type of TYPES) {
118
+ const overrides: Partial<Record<NotificationDeliveryChannel, boolean>> = {}
119
+ for (const channel of NOTIFICATION_DELIVERY_CHANNELS) {
120
+ if (resolved[type][channel] !== defaultNotificationRoute(type, channel)) {
121
+ overrides[channel] = resolved[type][channel]
122
+ }
123
+ }
124
+ if (Object.keys(overrides).length > 0) matrix[type] = overrides
125
+ }
126
+ busy.value = true
127
+ try {
128
+ await notifications.updateSettings(matrix)
129
+ toast.add({
130
+ title: t('notificationSettings.toast.saved'),
131
+ icon: 'i-lucide-check',
132
+ color: 'success',
133
+ })
134
+ } catch (e) {
135
+ notifyError(t('notificationSettings.error.save'), e)
136
+ } finally {
137
+ busy.value = false
138
+ }
139
+ }
140
+
141
+ /** Restore every type to its shipped routing (the switches; nothing is written until save). */
142
+ function resetToDefaults() {
143
+ applyMatrix({})
144
+ }
145
+ </script>
146
+
147
+ <template>
148
+ <UModal
149
+ v-model:open="open"
150
+ :title="t('notificationSettings.panel.title')"
151
+ :ui="{ content: 'max-w-2xl' }"
152
+ >
153
+ <template #title>
154
+ <IntegrationBackTitle :title="t('notificationSettings.panel.title')" @back="back" />
155
+ </template>
156
+ <template #body>
157
+ <div class="space-y-4">
158
+ <p class="text-xs text-slate-400">{{ t('notificationSettings.panel.intro') }}</p>
159
+
160
+ <div
161
+ v-if="notifications.settingsStatus === 'unavailable'"
162
+ class="rounded-lg border border-slate-700 bg-slate-800/40 p-3 text-xs text-slate-400"
163
+ >
164
+ {{ t('notificationSettings.unavailable') }}
165
+ </div>
166
+
167
+ <div
168
+ v-else-if="notifications.settingsStatus === 'failed'"
169
+ class="space-y-3 rounded-lg border border-amber-700/60 bg-amber-950/30 p-3 text-xs text-amber-200"
170
+ >
171
+ <p>{{ t('notificationSettings.loadFailed') }}</p>
172
+ <UButton color="neutral" variant="soft" size="xs" icon="i-lucide-rotate-cw" @click="load">
173
+ {{ t('common.retry') }}
174
+ </UButton>
175
+ </div>
176
+
177
+ <div
178
+ v-else-if="!editable"
179
+ class="rounded-lg border border-slate-700 bg-slate-800/40 p-3 text-xs text-slate-400"
180
+ >
181
+ {{ t('common.loading') }}
182
+ </div>
183
+
184
+ <template v-else>
185
+ <div
186
+ class="flex items-center gap-3 px-2 text-[10px] uppercase tracking-wide text-slate-500"
187
+ >
188
+ <span class="flex-1">{{ t('notificationSettings.column.event') }}</span>
189
+ <span class="w-16 text-center">{{ t('notificationSettings.column.inApp') }}</span>
190
+ <span class="w-16 text-center">{{ t('notificationSettings.column.email') }}</span>
191
+ </div>
192
+
193
+ <div class="max-h-[50vh] space-y-1 overflow-y-auto pr-1">
194
+ <div
195
+ v-for="type in TYPES"
196
+ :key="type"
197
+ class="flex items-center gap-3 rounded-lg border border-slate-700 bg-slate-800/40 p-2"
198
+ >
199
+ <span class="flex-1 text-sm text-slate-300">
200
+ {{ t(`notificationSettings.type.${type}`) }}
201
+ </span>
202
+ <div class="flex w-16 justify-center">
203
+ <USwitch v-model="resolved[type]!.in_app" size="sm" />
204
+ </div>
205
+ <div class="flex w-16 justify-center">
206
+ <USwitch v-model="resolved[type]!.email" size="sm" />
207
+ </div>
208
+ </div>
209
+ </div>
210
+
211
+ <p class="text-[11px] text-slate-500">{{ t('notificationSettings.panel.inAppNote') }}</p>
212
+ <p class="text-[11px] text-slate-500">{{ t('notificationSettings.panel.emailNote') }}</p>
213
+ <p class="text-[11px] text-slate-500">
214
+ {{ t('notificationSettings.panel.otherChannelsNote') }}
215
+ <UButton
216
+ v-if="slack.available"
217
+ color="neutral"
218
+ variant="link"
219
+ size="xs"
220
+ class="px-1"
221
+ @click="ui.openSlack()"
222
+ >
223
+ {{ t('notificationSettings.panel.openSlack') }}
224
+ </UButton>
225
+ </p>
226
+
227
+ <div class="flex justify-between">
228
+ <UButton
229
+ color="neutral"
230
+ variant="ghost"
231
+ size="xs"
232
+ icon="i-lucide-rotate-ccw"
233
+ @click="resetToDefaults"
234
+ >
235
+ {{ t('notificationSettings.action.reset') }}
236
+ </UButton>
237
+ <UButton
238
+ color="primary"
239
+ variant="soft"
240
+ size="xs"
241
+ icon="i-lucide-save"
242
+ :loading="busy || notifications.savingSettings"
243
+ @click="save"
244
+ >
245
+ {{ t('notificationSettings.action.save') }}
246
+ </UButton>
247
+ </div>
248
+ </template>
249
+ </div>
250
+ </template>
251
+ </UModal>
252
+ </template>
@@ -1,12 +1,15 @@
1
1
  import {
2
2
  actNotificationContract,
3
3
  dismissNotificationContract,
4
+ getNotificationSettingsContract,
4
5
  listNotificationsContract,
6
+ updateNotificationSettingsContract,
5
7
  } from '@cat-factory/contracts'
6
8
  import type { ReviewEffort } from '~/types/merge'
9
+ import type { NotificationRoutingMatrix } from '~/types/notifications'
7
10
  import type { ApiContext } from './context'
8
11
 
9
- /** The human-actionable notification inbox (act / dismiss). */
12
+ /** The human-actionable notification inbox (act / dismiss) + the per-workspace manager. */
10
13
  export function notificationsApi({ send, ws }: ApiContext) {
11
14
  return {
12
15
  // ---- notifications (human-actionable board items) ---------------------
@@ -29,5 +32,14 @@ export function notificationsApi({ send, ws }: ApiContext) {
29
32
  pathPrefix: ws(workspaceId),
30
33
  pathParams: { notificationId: id },
31
34
  }),
35
+
36
+ // ---- the notification manager (per-workspace channel routing) ---------
37
+ // Which types this board delivers on which channel. The read is member-visible; the
38
+ // write is admin-tier (a 403 from the backend, which the panel surfaces).
39
+ getNotificationSettings: (workspaceId: string) =>
40
+ send(getNotificationSettingsContract, { pathPrefix: ws(workspaceId) }),
41
+
42
+ updateNotificationSettings: (workspaceId: string, matrix: NotificationRoutingMatrix) =>
43
+ send(updateNotificationSettingsContract, { pathPrefix: ws(workspaceId), body: { matrix } }),
32
44
  }
33
45
  }
@@ -335,6 +335,7 @@ const UNAVAILABLE_DESCRIPTION_KEYS: Record<UnavailableReason, string> = {
335
335
  'errors.unavailable.description.foundational_builtins_unreachable',
336
336
  connection_credentials_unreadable:
337
337
  'errors.unavailable.description.connection_credentials_unreadable',
338
+ vcs_capability_unsupported: 'errors.unavailable.description.vcs_capability_unsupported',
338
339
  }
339
340
 
340
341
  /**
@@ -66,6 +66,9 @@ const AddServiceFromRepoModal = defineAsyncComponent(
66
66
  )
67
67
  const GitHubPanel = defineAsyncComponent(() => import('~/components/github/GitHubPanel.vue'))
68
68
  const SlackPanel = defineAsyncComponent(() => import('~/components/slack/SlackPanel.vue'))
69
+ const NotificationSettingsPanel = defineAsyncComponent(
70
+ () => import('~/components/notifications/NotificationSettingsPanel.vue'),
71
+ )
69
72
  const FragmentLibraryPanel = defineAsyncComponent(
70
73
  () => import('~/components/fragments/FragmentLibraryPanel.vue'),
71
74
  )
@@ -480,6 +483,7 @@ watch(
480
483
  <AddServiceFromRepoModal v-if="ui.addServiceOpen" />
481
484
  <GitHubPanel v-if="ui.githubOpen" />
482
485
  <SlackPanel v-if="ui.slackOpen" />
486
+ <NotificationSettingsPanel v-if="ui.notificationSettingsOpen" />
483
487
  <FragmentLibraryPanel v-if="ui.fragmentLibraryOpen" />
484
488
  <FoundationalServicePanel v-if="ui.foundationalServicesOpen" />
485
489
  <PipelineHealthModal v-if="ui.pipelineHealthOpen" />
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { ApiError } from '~/composables/api/errors'
3
+ import { useNotificationsStore } from '~/stores/notifications'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+
6
+ // The notification manager's LOAD outcome, which decides whether the settings panel may offer a
7
+ // save at all. Its write is a full replace of the board's overrides, and the grid it saves is
8
+ // pre-filled with the shipped defaults, so a load that ended in anything but `ready` must be
9
+ // distinguishable: rendering an unknown configuration as the current one turns one press of Save
10
+ // into a silent wipe of every override the board had.
11
+
12
+ describe('notifications store: manager settings load outcome', () => {
13
+ beforeEach(() => {
14
+ useWorkspaceStore().workspaceId = 'ws1'
15
+ })
16
+
17
+ it('is `ready` with the board matrix once the read succeeds', async () => {
18
+ const settings = { matrix: { merge_review: { email: false } }, updatedAt: 7 }
19
+ vi.stubGlobal('useApi', () => ({
20
+ getNotificationSettings: () => Promise.resolve(settings),
21
+ }))
22
+
23
+ const store = useNotificationsStore()
24
+ await store.loadSettings()
25
+
26
+ expect(store.settingsStatus).toBe('ready')
27
+ expect(store.settings).toEqual(settings)
28
+ })
29
+
30
+ it('is `unavailable` (settled, not an error) when the deployment wired no routing store', async () => {
31
+ vi.stubGlobal('useApi', () => ({
32
+ getNotificationSettings: () =>
33
+ Promise.reject(
34
+ new ApiError(503, { error: { code: 'unavailable', message: 'no routing store' } }),
35
+ ),
36
+ }))
37
+
38
+ const store = useNotificationsStore()
39
+ // A 503 is the opt-in shape, so the caller is not asked to report it.
40
+ await expect(store.loadSettings()).resolves.toBeUndefined()
41
+
42
+ expect(store.settingsStatus).toBe('unavailable')
43
+ expect(store.settings).toBeNull()
44
+ })
45
+
46
+ it('is `failed`, NOT `unavailable`, on a transient read fault, and still surfaces it', async () => {
47
+ vi.stubGlobal('useApi', () => ({
48
+ getNotificationSettings: () =>
49
+ Promise.reject(
50
+ new ApiError(500, { error: { code: 'internal', message: 'upstream exploded' } }),
51
+ ),
52
+ }))
53
+
54
+ const store = useNotificationsStore()
55
+ await expect(store.loadSettings()).rejects.toThrow('upstream exploded')
56
+
57
+ // The distinction IS the fix: `unavailable` means the shipped defaults are the whole truth,
58
+ // while `failed` means the board's real configuration is unknown and must not be written over.
59
+ expect(store.settingsStatus).toBe('failed')
60
+ expect(store.settings).toBeNull()
61
+ })
62
+
63
+ it('leaves no stale matrix behind when a reload fails after a good load', async () => {
64
+ let fail = false
65
+ vi.stubGlobal('useApi', () => ({
66
+ getNotificationSettings: () =>
67
+ fail
68
+ ? Promise.reject(new ApiError(500, { error: { code: 'internal', message: 'gone' } }))
69
+ : Promise.resolve({ matrix: { ci_failed: { in_app: false } }, updatedAt: 1 }),
70
+ }))
71
+
72
+ const store = useNotificationsStore()
73
+ await store.loadSettings()
74
+ fail = true
75
+ await expect(store.loadSettings()).rejects.toThrow()
76
+
77
+ expect(store.settingsStatus).toBe('failed')
78
+ expect(store.settings).toBeNull()
79
+ })
80
+ })
@@ -1,6 +1,12 @@
1
1
  import { defineStore } from 'pinia'
2
- import { computed } from 'vue'
2
+ import { computed, ref } from 'vue'
3
3
  import type { Notification } from '~/types/domain'
4
+ import type {
5
+ NotificationRoutingMatrix,
6
+ NotificationSettings,
7
+ NotificationSettingsStatus,
8
+ } from '~/types/notifications'
9
+ import { ApiError } from '~/composables/api/errors'
4
10
  import type { ReviewEffort } from '~/types/merge'
5
11
  import { useUpsertList } from '~/composables/useUpsertList'
6
12
  import { useWorkspaceStore } from '~/stores/workspace'
@@ -109,5 +115,77 @@ export const useNotificationsStore = defineStore('notifications', () => {
109
115
  upsert(resolved)
110
116
  }
111
117
 
112
- return { open, hydrate, hydrateBaseline, upsert, byBlock, count, act, dismiss }
118
+ // ---- the notification manager (per-workspace channel routing) ------------
119
+ // Loaded on demand by the settings panel, never with the board: an inbox reader does not
120
+ // need the routing matrix, and the read 503s on a deployment with no routing store.
121
+
122
+ /** The workspace's routing settings, or null unless {@link settingsStatus} is `ready`. */
123
+ const settings = ref<NotificationSettings | null>(null)
124
+ /**
125
+ * How the last load ENDED, as four distinct states rather than a nullable boolean.
126
+ *
127
+ * `unavailable` and `failed` need different reactions and must not be collapsed: the first is
128
+ * settled ("this deployment wired no routing store"), the second is transient and leaves the
129
+ * board's real configuration UNKNOWN. A panel that cannot tell them apart renders the shipped
130
+ * defaults as though they were the board's own, and its save (a full replace) then writes
131
+ * that guess over whatever was stored.
132
+ */
133
+ const settingsStatus = ref<NotificationSettingsStatus>('unloaded')
134
+ const savingSettings = ref(false)
135
+
136
+ async function loadSettings() {
137
+ const ws = useWorkspaceStore()
138
+ settingsStatus.value = 'loading'
139
+ try {
140
+ settings.value = await api.getNotificationSettings(ws.requireId())
141
+ settingsStatus.value = 'ready'
142
+ } catch (error) {
143
+ settings.value = null
144
+ // A 503 is the opt-in shape (no routing store wired), not a failure to report: the panel
145
+ // renders the shipped defaults read-only. Anything else is a real error, which the panel
146
+ // states as such AND the caller still sees, so the server's own message reaches the toast.
147
+ if (isUnavailable(error)) {
148
+ settingsStatus.value = 'unavailable'
149
+ return
150
+ }
151
+ settingsStatus.value = 'failed'
152
+ throw error
153
+ }
154
+ }
155
+
156
+ /** Replace the routing overrides (a full replace; dropping a cell restores its default). */
157
+ async function updateSettings(matrix: NotificationRoutingMatrix) {
158
+ const ws = useWorkspaceStore()
159
+ savingSettings.value = true
160
+ try {
161
+ settings.value = await api.updateNotificationSettings(ws.requireId(), matrix)
162
+ } finally {
163
+ savingSettings.value = false
164
+ }
165
+ }
166
+
167
+ return {
168
+ open,
169
+ hydrate,
170
+ hydrateBaseline,
171
+ upsert,
172
+ byBlock,
173
+ count,
174
+ act,
175
+ dismiss,
176
+ settings,
177
+ settingsStatus,
178
+ savingSettings,
179
+ loadSettings,
180
+ updateSettings,
181
+ }
113
182
  })
183
+
184
+ /**
185
+ * Whether a failed load is the SETTLED "you can't have this" — the facade wired no routing store
186
+ * (503), rather than a transient fault. Only that resolves to `unavailable`; everything else
187
+ * becomes `failed` and propagates, so a later visit retries and the caller can report it.
188
+ */
189
+ function isUnavailable(error: unknown): boolean {
190
+ return error instanceof ApiError && error.statusCode === 503
191
+ }