@cat-factory/app 0.252.0 → 0.253.0
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.
- package/app/components/board/nodes/TaskCard.vue +1 -0
- package/app/components/layout/AccountDeploymentSettings.vue +85 -14
- package/app/components/layout/CommandBar.vue +8 -0
- package/app/components/layout/IntegrationsHub.vue +10 -0
- package/app/components/notifications/NotificationSettingsPanel.vue +252 -0
- package/app/composables/api/notifications.ts +13 -1
- package/app/composables/usePipelineErrorToast.ts +1 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/notifications.settings.spec.ts +80 -0
- package/app/stores/notifications.ts +80 -2
- package/app/stores/ui/modals.ts +14 -0
- package/app/types/notifications.ts +18 -0
- package/i18n/locales/de.json +66 -4
- package/i18n/locales/en.json +66 -4
- package/i18n/locales/es.json +66 -4
- package/i18n/locales/fr.json +66 -4
- package/i18n/locales/he.json +66 -4
- package/i18n/locales/it.json +66 -4
- package/i18n/locales/ja.json +66 -4
- package/i18n/locales/pl.json +66 -4
- package/i18n/locales/tr.json +66 -4
- package/i18n/locales/uk.json +66 -4
- package/package.json +2 -2
|
@@ -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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
117
|
-
const
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
:
|
|
683
|
-
|
|
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
|
/**
|
package/app/pages/index.vue
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|