@checkstack/notification-frontend 0.1.0 → 0.2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,78 @@
1
1
  # @checkstack/notification-frontend
2
2
 
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [4eed42d]
8
+ - @checkstack/frontend-api@0.3.0
9
+ - @checkstack/auth-frontend@0.3.1
10
+ - @checkstack/ui@0.2.2
11
+
12
+ ## 0.2.0
13
+
14
+ ### Minor Changes
15
+
16
+ - 7a23261: ## TanStack Query Integration
17
+
18
+ Migrated all frontend components to use `usePluginClient` hook with TanStack Query integration, replacing the legacy `forPlugin()` pattern.
19
+
20
+ ### New Features
21
+
22
+ - **`usePluginClient` hook**: Provides type-safe access to plugin APIs with `.useQuery()` and `.useMutation()` methods
23
+ - **Automatic request deduplication**: Multiple components requesting the same data share a single network request
24
+ - **Built-in caching**: Configurable stale time and cache duration per query
25
+ - **Loading/error states**: TanStack Query provides `isLoading`, `error`, `isRefetching` states automatically
26
+ - **Background refetching**: Stale data is automatically refreshed when components mount
27
+
28
+ ### Contract Changes
29
+
30
+ All RPC contracts now require `operationType: "query"` or `operationType: "mutation"` metadata:
31
+
32
+ ```typescript
33
+ const getItems = proc()
34
+ .meta({ operationType: "query", access: [access.read] })
35
+ .output(z.array(itemSchema))
36
+ .query();
37
+
38
+ const createItem = proc()
39
+ .meta({ operationType: "mutation", access: [access.manage] })
40
+ .input(createItemSchema)
41
+ .output(itemSchema)
42
+ .mutation();
43
+ ```
44
+
45
+ ### Migration
46
+
47
+ ```typescript
48
+ // Before (forPlugin pattern)
49
+ const api = useApi(myPluginApiRef);
50
+ const [items, setItems] = useState<Item[]>([]);
51
+ useEffect(() => {
52
+ api.getItems().then(setItems);
53
+ }, [api]);
54
+
55
+ // After (usePluginClient pattern)
56
+ const client = usePluginClient(MyPluginApi);
57
+ const { data: items, isLoading } = client.getItems.useQuery({});
58
+ ```
59
+
60
+ ### Bug Fixes
61
+
62
+ - Fixed `rpc.test.ts` test setup for middleware type inference
63
+ - Fixed `SearchDialog` to use `setQuery` instead of deprecated `search` method
64
+ - Fixed null→undefined warnings in notification and queue frontends
65
+
66
+ ### Patch Changes
67
+
68
+ - Updated dependencies [7a23261]
69
+ - @checkstack/frontend-api@0.2.0
70
+ - @checkstack/common@0.3.0
71
+ - @checkstack/auth-frontend@0.3.0
72
+ - @checkstack/notification-common@0.2.0
73
+ - @checkstack/ui@0.2.1
74
+ - @checkstack/signal-frontend@0.0.7
75
+
3
76
  ## 0.1.0
4
77
 
5
78
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/notification-frontend",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "main": "src/index.tsx",
6
6
  "checkstack": {
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback } from "react";
1
+ import { useState, useCallback } from "react";
2
2
  import { Link } from "react-router-dom";
3
3
  import { Bell, CheckCheck } from "lucide-react";
4
4
  import {
@@ -11,7 +11,7 @@ import {
11
11
  Button,
12
12
  stripMarkdown,
13
13
  } from "@checkstack/ui";
14
- import { useApi, rpcApiRef } from "@checkstack/frontend-api";
14
+ import { useApi, usePluginClient } from "@checkstack/frontend-api";
15
15
  import { useSignal } from "@checkstack/signal-frontend";
16
16
  import { resolveRoute } from "@checkstack/common";
17
17
  import type { Notification } from "@checkstack/notification-common";
@@ -27,54 +27,42 @@ import { authApiRef } from "@checkstack/auth-frontend/api";
27
27
  export const NotificationBell = () => {
28
28
  const authApi = useApi(authApiRef);
29
29
  const { data: session, isPending: isAuthLoading } = authApi.useSession();
30
- const rpcApi = useApi(rpcApiRef);
31
- const notificationClient = rpcApi.forPlugin(NotificationApi);
30
+ const notificationClient = usePluginClient(NotificationApi);
32
31
 
33
- const [unreadCount, setUnreadCount] = useState(0);
34
- const [recentNotifications, setRecentNotifications] = useState<
35
- Notification[]
36
- >([]);
37
32
  const [isOpen, setIsOpen] = useState(false);
38
- const [loading, setLoading] = useState(true);
39
33
 
40
- const fetchUnreadCount = useCallback(async () => {
41
- // Skip fetch if not authenticated
42
- if (!session) return;
43
- try {
44
- const { count } = await notificationClient.getUnreadCount();
45
- setUnreadCount(count);
46
- } catch (error) {
47
- console.error("Failed to fetch unread count:", error);
48
- }
49
- }, [notificationClient, session]);
34
+ // State for real-time updates
35
+ const [signalUnreadCount, setSignalUnreadCount] = useState<
36
+ number | undefined
37
+ >();
38
+ const [signalNotifications, setSignalNotifications] = useState<
39
+ Notification[] | undefined
40
+ >();
50
41
 
51
- const fetchRecentNotifications = useCallback(async () => {
52
- // Skip fetch if not authenticated
53
- if (!session) return;
54
- try {
55
- const { notifications } = await notificationClient.getNotifications({
56
- limit: 5,
57
- offset: 0,
58
- unreadOnly: true, // Only show unread notifications in the dropdown
59
- });
60
- setRecentNotifications(notifications);
61
- } catch (error) {
62
- console.error("Failed to fetch notifications:", error);
63
- }
64
- }, [notificationClient, session]);
42
+ // Fetch unread count via useQuery
43
+ const { data: unreadData, isLoading: unreadLoading } =
44
+ notificationClient.getUnreadCount.useQuery(undefined, {
45
+ enabled: !!session,
46
+ staleTime: 30_000,
47
+ });
65
48
 
66
- // Initial fetch
67
- useEffect(() => {
68
- if (!session) {
69
- setLoading(false);
70
- return;
71
- }
72
- const init = async () => {
73
- await Promise.all([fetchUnreadCount(), fetchRecentNotifications()]);
74
- setLoading(false);
75
- };
76
- void init();
77
- }, [fetchUnreadCount, fetchRecentNotifications, session]);
49
+ // Fetch recent notifications via useQuery
50
+ const { data: notificationsData, isLoading: notificationsLoading } =
51
+ notificationClient.getNotifications.useQuery(
52
+ { limit: 5, offset: 0, unreadOnly: true },
53
+ {
54
+ enabled: !!session && isOpen,
55
+ staleTime: 30_000,
56
+ }
57
+ );
58
+
59
+ // Mark all as read mutation
60
+ const markAsReadMutation = notificationClient.markAsRead.useMutation();
61
+
62
+ // Use signal data if available, otherwise use query data
63
+ const unreadCount = signalUnreadCount ?? unreadData?.count ?? 0;
64
+ const recentNotifications =
65
+ signalNotifications ?? notificationsData?.notifications ?? [];
78
66
 
79
67
  // ==========================================================================
80
68
  // REALTIME SIGNAL SUBSCRIPTIONS (replaces polling)
@@ -85,10 +73,10 @@ export const NotificationBell = () => {
85
73
  NOTIFICATION_RECEIVED,
86
74
  useCallback((payload) => {
87
75
  // Increment unread count
88
- setUnreadCount((prev) => prev + 1);
76
+ setSignalUnreadCount((prev) => (prev ?? 0) + 1);
89
77
 
90
78
  // Add to recent notifications if dropdown is open
91
- setRecentNotifications((prev) => [
79
+ setSignalNotifications((prev) => [
92
80
  {
93
81
  id: payload.id,
94
82
  title: payload.title,
@@ -98,7 +86,7 @@ export const NotificationBell = () => {
98
86
  isRead: false,
99
87
  createdAt: new Date(),
100
88
  },
101
- ...prev.slice(0, 4), // Keep only 5 items
89
+ ...(prev ?? []).slice(0, 4), // Keep only 5 items
102
90
  ]);
103
91
  }, [])
104
92
  );
@@ -107,7 +95,7 @@ export const NotificationBell = () => {
107
95
  useSignal(
108
96
  NOTIFICATION_COUNT_CHANGED,
109
97
  useCallback((payload) => {
110
- setUnreadCount(payload.unreadCount);
98
+ setSignalUnreadCount(payload.unreadCount);
111
99
  }, [])
112
100
  );
113
101
 
@@ -117,32 +105,25 @@ export const NotificationBell = () => {
117
105
  useCallback((payload) => {
118
106
  if (payload.notificationId) {
119
107
  // Single notification marked as read - remove from list
120
- setRecentNotifications((prev) =>
121
- prev.filter((n) => n.id !== payload.notificationId)
108
+ setSignalNotifications((prev) =>
109
+ (prev ?? []).filter((n) => n.id !== payload.notificationId)
122
110
  );
123
- setUnreadCount((prev) => Math.max(0, prev - 1));
111
+ setSignalUnreadCount((prev) => Math.max(0, (prev ?? 1) - 1));
124
112
  } else {
125
113
  // All marked as read - clear the list
126
- setRecentNotifications([]);
127
- setUnreadCount(0);
114
+ setSignalNotifications([]);
115
+ setSignalUnreadCount(0);
128
116
  }
129
117
  }, [])
130
118
  );
131
119
 
132
120
  // ==========================================================================
133
121
 
134
- // Fetch notifications when dropdown opens
135
- useEffect(() => {
136
- if (isOpen) {
137
- void fetchRecentNotifications();
138
- }
139
- }, [isOpen, fetchRecentNotifications]);
140
-
141
122
  const handleMarkAllAsRead = async () => {
142
123
  try {
143
- await notificationClient.markAsRead({});
144
- setUnreadCount(0);
145
- setRecentNotifications([]);
124
+ await markAsReadMutation.mutateAsync({});
125
+ setSignalUnreadCount(0);
126
+ setSignalNotifications([]);
146
127
  } catch (error) {
147
128
  console.error("Failed to mark all as read:", error);
148
129
  }
@@ -157,6 +138,8 @@ export const NotificationBell = () => {
157
138
  return;
158
139
  }
159
140
 
141
+ const loading = unreadLoading || notificationsLoading;
142
+
160
143
  if (loading) {
161
144
  return (
162
145
  <Button variant="ghost" size="icon" className="relative" disabled>
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback } from "react";
1
+ import { useState, useEffect } from "react";
2
2
  import { Bell, Clock, Zap, Send } from "lucide-react";
3
3
  import {
4
4
  PageLayout,
@@ -8,7 +8,11 @@ import {
8
8
  SectionHeader,
9
9
  DynamicForm,
10
10
  } from "@checkstack/ui";
11
- import { useApi, rpcApiRef, accessApiRef } from "@checkstack/frontend-api";
11
+ import {
12
+ usePluginClient,
13
+ useApi,
14
+ accessApiRef,
15
+ } from "@checkstack/frontend-api";
12
16
  import type { EnrichedSubscription } from "@checkstack/notification-common";
13
17
  import {
14
18
  NotificationApi,
@@ -24,287 +28,218 @@ import {
24
28
  } from "../components/UserChannelCard";
25
29
 
26
30
  export const NotificationSettingsPage = () => {
27
- const rpcApi = useApi(rpcApiRef);
31
+ const notificationClient = usePluginClient(NotificationApi);
28
32
  const accessApi = useApi(accessApiRef);
29
- const notificationClient = rpcApi.forPlugin(NotificationApi);
30
33
  const toast = useToast();
31
34
 
32
35
  // Check if user has admin access
33
- const { allowed: isAdmin } = accessApi.useAccess(
34
- notificationAccess.admin
35
- );
36
+ const { allowed: isAdmin } = accessApi.useAccess(notificationAccess.admin);
36
37
 
37
- // Retention settings state
38
- const [retentionSchema, setRetentionSchema] = useState<
39
- Record<string, unknown> | undefined
40
- >();
38
+ // Local state for editing
41
39
  const [retentionSettings, setRetentionSettings] = useState<
42
40
  Record<string, unknown>
43
41
  >({
44
42
  retentionDays: 30,
45
43
  enabled: false,
46
44
  });
47
- const [retentionLoading, setRetentionLoading] = useState(true);
48
- const [retentionSaving, setRetentionSaving] = useState(false);
49
45
  const [retentionValid, setRetentionValid] = useState(true);
50
-
51
- // Subscription state - now uses enriched subscriptions only
52
- const [subscriptions, setSubscriptions] = useState<EnrichedSubscription[]>(
53
- []
54
- );
55
- const [subsLoading, setSubsLoading] = useState(true);
56
-
57
- // Delivery strategies state (admin only)
58
- const [strategies, setStrategies] = useState<DeliveryStrategy[]>([]);
59
- const [strategiesLoading, setStrategiesLoading] = useState(true);
60
- const [strategySaving, setStrategySaving] = useState<string | undefined>();
61
-
62
- // User channels state
63
- const [userChannels, setUserChannels] = useState<UserDeliveryChannel[]>([]);
64
- const [channelsLoading, setChannelsLoading] = useState(true);
65
46
  const [channelSaving, setChannelSaving] = useState<string | undefined>();
66
47
  const [channelConnecting, setChannelConnecting] = useState<
67
48
  string | undefined
68
49
  >();
69
50
  const [channelTesting, setChannelTesting] = useState<string | undefined>();
51
+ const [strategySaving, setStrategySaving] = useState<string | undefined>();
70
52
 
71
- // Fetch retention settings and schema (admin only)
72
- const fetchRetentionData = useCallback(async () => {
73
- if (!isAdmin) {
74
- setRetentionLoading(false);
75
- return;
76
- }
77
- try {
78
- const [schema, settings] = await Promise.all([
79
- notificationClient.getRetentionSchema(),
80
- notificationClient.getRetentionSettings(),
81
- ]);
82
- setRetentionSchema(schema as Record<string, unknown>);
83
- setRetentionSettings(settings);
84
- } catch (error) {
85
- const message =
86
- error instanceof Error
87
- ? error.message
88
- : "Failed to load retention settings";
89
- toast.error(message);
90
- } finally {
91
- setRetentionLoading(false);
92
- }
93
- }, [notificationClient, isAdmin, toast]);
53
+ // Query: Retention schema (admin only)
54
+ const { data: retentionSchema } =
55
+ notificationClient.getRetentionSchema.useQuery({}, { enabled: isAdmin });
94
56
 
95
- // Fetch subscriptions only (no groups needed)
96
- const fetchSubscriptionData = useCallback(async () => {
97
- try {
98
- const subsData = await notificationClient.getSubscriptions();
99
- setSubscriptions(subsData);
100
- } catch (error) {
101
- const message =
102
- error instanceof Error
103
- ? error.message
104
- : "Failed to fetch subscriptions";
105
- toast.error(message);
106
- } finally {
107
- setSubsLoading(false);
108
- }
109
- }, [notificationClient, toast]);
57
+ // Query: Retention settings (admin only)
58
+ const { data: fetchedRetentionSettings, isLoading: retentionLoading } =
59
+ notificationClient.getRetentionSettings.useQuery({}, { enabled: isAdmin });
110
60
 
111
- // Fetch delivery strategies (admin only)
112
- const fetchStrategies = useCallback(async () => {
113
- if (!isAdmin) {
114
- setStrategiesLoading(false);
115
- return;
116
- }
117
- try {
118
- const data = await notificationClient.getDeliveryStrategies();
119
- setStrategies(data as DeliveryStrategy[]);
120
- } catch (error) {
121
- const message =
122
- error instanceof Error
123
- ? error.message
124
- : "Failed to load delivery channels";
125
- toast.error(message);
126
- } finally {
127
- setStrategiesLoading(false);
128
- }
129
- }, [notificationClient, isAdmin, toast]);
61
+ // Query: Subscriptions
62
+ const {
63
+ data: subscriptions = [],
64
+ isLoading: subsLoading,
65
+ refetch: refetchSubscriptions,
66
+ } = notificationClient.getSubscriptions.useQuery({});
130
67
 
131
- // Fetch user delivery channels
132
- const fetchUserChannels = useCallback(async () => {
133
- try {
134
- const data = await notificationClient.getUserDeliveryChannels();
135
- setUserChannels(data as UserDeliveryChannel[]);
136
- } catch (error) {
137
- const message =
138
- error instanceof Error ? error.message : "Failed to load your channels";
139
- toast.error(message);
140
- } finally {
141
- setChannelsLoading(false);
142
- }
143
- }, [notificationClient, toast]);
68
+ // Query: Delivery strategies (admin only)
69
+ const {
70
+ data: strategies = [],
71
+ isLoading: strategiesLoading,
72
+ refetch: refetchStrategies,
73
+ } = notificationClient.getDeliveryStrategies.useQuery(
74
+ {},
75
+ { enabled: isAdmin }
76
+ );
144
77
 
78
+ // Query: User delivery channels
79
+ const {
80
+ data: userChannels = [],
81
+ isLoading: channelsLoading,
82
+ refetch: refetchChannels,
83
+ } = notificationClient.getUserDeliveryChannels.useQuery({});
84
+
85
+ // Sync fetched retention settings to local state
145
86
  useEffect(() => {
146
- void fetchRetentionData();
147
- void fetchSubscriptionData();
148
- void fetchStrategies();
149
- void fetchUserChannels();
150
- }, [
151
- fetchRetentionData,
152
- fetchSubscriptionData,
153
- fetchStrategies,
154
- fetchUserChannels,
155
- ]);
87
+ if (fetchedRetentionSettings) {
88
+ setRetentionSettings(fetchedRetentionSettings);
89
+ }
90
+ }, [fetchedRetentionSettings]);
91
+
92
+ // Mutations
93
+ const setRetentionMutation =
94
+ notificationClient.setRetentionSettings.useMutation({
95
+ onSuccess: () => {
96
+ toast.success("Retention settings saved");
97
+ },
98
+ onError: (error) => {
99
+ toast.error(
100
+ error instanceof Error ? error.message : "Failed to save settings"
101
+ );
102
+ },
103
+ });
156
104
 
157
- const handleSaveRetention = async () => {
158
- try {
159
- setRetentionSaving(true);
160
- await notificationClient.setRetentionSettings(
161
- retentionSettings as { enabled: boolean; retentionDays: number }
105
+ const unsubscribeMutation = notificationClient.unsubscribe.useMutation({
106
+ onSuccess: () => {
107
+ toast.success("Unsubscribed successfully");
108
+ void refetchSubscriptions();
109
+ },
110
+ onError: (error) => {
111
+ toast.error(
112
+ error instanceof Error ? error.message : "Failed to unsubscribe"
162
113
  );
163
- toast.success("Retention settings saved");
164
- } catch (error) {
165
- const message =
166
- error instanceof Error ? error.message : "Failed to save settings";
167
- toast.error(message);
168
- } finally {
169
- setRetentionSaving(false);
170
- }
114
+ },
115
+ });
116
+
117
+ const updateStrategyMutation =
118
+ notificationClient.updateDeliveryStrategy.useMutation({
119
+ onSuccess: () => {
120
+ toast.success("Updated delivery channel");
121
+ void refetchStrategies();
122
+ setStrategySaving(undefined);
123
+ },
124
+ onError: (error) => {
125
+ toast.error(
126
+ error instanceof Error ? error.message : "Failed to update channel"
127
+ );
128
+ setStrategySaving(undefined);
129
+ },
130
+ });
131
+
132
+ const setUserPreferenceMutation =
133
+ notificationClient.setUserDeliveryPreference.useMutation({
134
+ onSuccess: () => {
135
+ toast.success("Updated notification channel");
136
+ void refetchChannels();
137
+ setChannelSaving(undefined);
138
+ },
139
+ onError: (error) => {
140
+ toast.error(
141
+ error instanceof Error ? error.message : "Failed to update preference"
142
+ );
143
+ setChannelSaving(undefined);
144
+ },
145
+ });
146
+
147
+ const unlinkChannelMutation =
148
+ notificationClient.unlinkDeliveryChannel.useMutation({
149
+ onSuccess: () => {
150
+ toast.success("Disconnected notification channel");
151
+ void refetchChannels();
152
+ setChannelSaving(undefined);
153
+ },
154
+ onError: (error) => {
155
+ toast.error(
156
+ error instanceof Error ? error.message : "Failed to disconnect"
157
+ );
158
+ setChannelSaving(undefined);
159
+ },
160
+ });
161
+
162
+ const getOAuthUrlMutation =
163
+ notificationClient.getDeliveryOAuthUrl.useMutation({
164
+ onSuccess: (data) => {
165
+ globalThis.location.href = data.authUrl;
166
+ },
167
+ onError: (error) => {
168
+ toast.error(
169
+ error instanceof Error ? error.message : "Failed to start OAuth flow"
170
+ );
171
+ setChannelConnecting(undefined);
172
+ },
173
+ });
174
+
175
+ const sendTestMutation = notificationClient.sendTestNotification.useMutation({
176
+ onSettled: () => {
177
+ setChannelTesting(undefined);
178
+ },
179
+ });
180
+
181
+ const handleSaveRetention = () => {
182
+ setRetentionMutation.mutate(
183
+ retentionSettings as { enabled: boolean; retentionDays: number }
184
+ );
171
185
  };
172
186
 
173
- const handleUnsubscribe = async (groupId: string) => {
174
- try {
175
- await notificationClient.unsubscribe({ groupId });
176
- setSubscriptions((prev) => prev.filter((s) => s.groupId !== groupId));
177
- toast.success("Unsubscribed successfully");
178
- } catch (error) {
179
- const message =
180
- error instanceof Error ? error.message : "Failed to unsubscribe";
181
- toast.error(message);
182
- }
187
+ const handleUnsubscribe = (groupId: string) => {
188
+ unsubscribeMutation.mutate({ groupId });
183
189
  };
184
190
 
185
- // Handle strategy update (enabled state and config)
186
191
  const handleStrategyUpdate = async (
187
192
  strategyId: string,
188
193
  enabled: boolean,
189
194
  config?: Record<string, unknown>,
190
195
  layoutConfig?: Record<string, unknown>
191
196
  ) => {
192
- try {
193
- setStrategySaving(strategyId);
194
- await notificationClient.updateDeliveryStrategy({
195
- strategyId,
196
- enabled,
197
- config,
198
- layoutConfig,
199
- });
200
- // Update local state
201
- setStrategies((prev) =>
202
- prev.map((s) =>
203
- s.qualifiedId === strategyId
204
- ? { ...s, enabled, config, layoutConfig }
205
- : s
206
- )
207
- );
208
- toast.success(`${enabled ? "Enabled" : "Disabled"} delivery channel`);
209
- } catch (error) {
210
- const message =
211
- error instanceof Error ? error.message : "Failed to update channel";
212
- toast.error(message);
213
- } finally {
214
- setStrategySaving(undefined);
215
- }
197
+ setStrategySaving(strategyId);
198
+ await updateStrategyMutation.mutateAsync({
199
+ strategyId,
200
+ enabled,
201
+ config,
202
+ layoutConfig,
203
+ });
216
204
  };
217
205
 
218
- // Handle user channel toggle
219
206
  const handleChannelToggle = async (strategyId: string, enabled: boolean) => {
220
- try {
221
- setChannelSaving(strategyId);
222
- await notificationClient.setUserDeliveryPreference({
223
- strategyId,
224
- enabled,
225
- });
226
- setUserChannels((prev) =>
227
- prev.map((c) => (c.strategyId === strategyId ? { ...c, enabled } : c))
228
- );
229
- toast.success(`${enabled ? "Enabled" : "Disabled"} notification channel`);
230
- } catch (error) {
231
- const message =
232
- error instanceof Error ? error.message : "Failed to update preference";
233
- toast.error(message);
234
- } finally {
235
- setChannelSaving(undefined);
236
- }
207
+ setChannelSaving(strategyId);
208
+ await setUserPreferenceMutation.mutateAsync({
209
+ strategyId,
210
+ enabled,
211
+ });
237
212
  };
238
213
 
239
- // Handle OAuth connect
240
214
  const handleChannelConnect = async (strategyId: string) => {
241
- try {
242
- setChannelConnecting(strategyId);
243
- const { authUrl } = await notificationClient.getDeliveryOAuthUrl({
244
- strategyId,
245
- returnUrl: globalThis.location.pathname,
246
- });
247
- // Redirect to OAuth provider
248
- globalThis.location.href = authUrl;
249
- } catch (error) {
250
- const message =
251
- error instanceof Error ? error.message : "Failed to start OAuth flow";
252
- toast.error(message);
253
- setChannelConnecting(undefined);
254
- }
215
+ setChannelConnecting(strategyId);
216
+ await getOAuthUrlMutation.mutateAsync({
217
+ strategyId,
218
+ returnUrl: globalThis.location.pathname,
219
+ });
255
220
  };
256
221
 
257
- // Handle OAuth disconnect
258
222
  const handleChannelDisconnect = async (strategyId: string) => {
259
- try {
260
- setChannelSaving(strategyId);
261
- await notificationClient.unlinkDeliveryChannel({ strategyId });
262
- setUserChannels((prev) =>
263
- prev.map((c) =>
264
- c.strategyId === strategyId
265
- ? { ...c, linkedAt: undefined, enabled: false, isConfigured: false }
266
- : c
267
- )
268
- );
269
- toast.success("Disconnected notification channel");
270
- } catch (error) {
271
- const message =
272
- error instanceof Error ? error.message : "Failed to disconnect";
273
- toast.error(message);
274
- } finally {
275
- setChannelSaving(undefined);
276
- }
223
+ setChannelSaving(strategyId);
224
+ await unlinkChannelMutation.mutateAsync({ strategyId });
277
225
  };
278
226
 
279
- // Handle user config save
280
227
  const handleChannelConfigSave = async (
281
228
  strategyId: string,
282
229
  userConfig: Record<string, unknown>
283
230
  ) => {
284
- try {
285
- setChannelSaving(strategyId);
286
- await notificationClient.setUserDeliveryPreference({
287
- strategyId,
288
- enabled:
289
- userChannels.find((c) => c.strategyId === strategyId)?.enabled ??
290
- false,
291
- userConfig,
292
- });
293
- setUserChannels((prev) =>
294
- prev.map((c) =>
295
- c.strategyId === strategyId
296
- ? { ...c, userConfig, isConfigured: true }
297
- : c
298
- )
299
- );
300
- toast.success("Saved channel settings");
301
- } catch (error) {
302
- const message =
303
- error instanceof Error ? error.message : "Failed to save settings";
304
- toast.error(message);
305
- } finally {
306
- setChannelSaving(undefined);
307
- }
231
+ setChannelSaving(strategyId);
232
+ const channel = userChannels.find((c) => c.strategyId === strategyId);
233
+ await setUserPreferenceMutation.mutateAsync({
234
+ strategyId,
235
+ enabled: channel?.enabled ?? false,
236
+ userConfig,
237
+ });
238
+ };
239
+
240
+ const handleTest = async (strategyId: string) => {
241
+ setChannelTesting(strategyId);
242
+ return sendTestMutation.mutateAsync({ strategyId });
308
243
  };
309
244
 
310
245
  return (
@@ -323,7 +258,7 @@ export const NotificationSettingsPage = () => {
323
258
  Loading your channels...
324
259
  </div>
325
260
  </Card>
326
- ) : userChannels.length === 0 ? (
261
+ ) : (userChannels as UserDeliveryChannel[]).length === 0 ? (
327
262
  <Card className="p-4">
328
263
  <div className="text-center py-4 text-muted-foreground">
329
264
  No notification channels available. Contact your administrator
@@ -332,7 +267,7 @@ export const NotificationSettingsPage = () => {
332
267
  </Card>
333
268
  ) : (
334
269
  <div className="space-y-3">
335
- {userChannels.map((channel) => (
270
+ {(userChannels as UserDeliveryChannel[]).map((channel) => (
336
271
  <UserChannelCard
337
272
  key={channel.strategyId}
338
273
  channel={channel}
@@ -340,21 +275,7 @@ export const NotificationSettingsPage = () => {
340
275
  onConnect={handleChannelConnect}
341
276
  onDisconnect={handleChannelDisconnect}
342
277
  onSaveConfig={handleChannelConfigSave}
343
- onTest={async (strategyId) => {
344
- setChannelTesting(strategyId);
345
- try {
346
- const result =
347
- await notificationClient.sendTestNotification({
348
- strategyId,
349
- });
350
- if (!result.success) {
351
- alert(`Test failed: ${result.error}`);
352
- }
353
- return result;
354
- } finally {
355
- setChannelTesting(undefined);
356
- }
357
- }}
278
+ onTest={handleTest}
358
279
  saving={channelSaving === channel.strategyId}
359
280
  connecting={channelConnecting === channel.strategyId}
360
281
  testing={channelTesting === channel.strategyId}
@@ -372,13 +293,13 @@ export const NotificationSettingsPage = () => {
372
293
  icon={<Bell className="h-5 w-5" />}
373
294
  />
374
295
  <Card className="p-4">
375
- {subscriptions.length === 0 ? (
296
+ {(subscriptions as EnrichedSubscription[]).length === 0 ? (
376
297
  <div className="text-center py-4 text-muted-foreground">
377
298
  No active subscriptions
378
299
  </div>
379
300
  ) : (
380
301
  <div className="space-y-3">
381
- {subscriptions.map((sub) => (
302
+ {(subscriptions as EnrichedSubscription[]).map((sub) => (
382
303
  <div
383
304
  key={sub.groupId}
384
305
  className="flex items-center justify-between py-2 border-b last:border-0"
@@ -395,7 +316,8 @@ export const NotificationSettingsPage = () => {
395
316
  <Button
396
317
  variant="outline"
397
318
  size="sm"
398
- onClick={() => void handleUnsubscribe(sub.groupId)}
319
+ onClick={() => handleUnsubscribe(sub.groupId)}
320
+ disabled={unsubscribeMutation.isPending}
399
321
  >
400
322
  Unsubscribe
401
323
  </Button>
@@ -435,7 +357,7 @@ export const NotificationSettingsPage = () => {
435
357
  Loading delivery channels...
436
358
  </div>
437
359
  </Card>
438
- ) : strategies.length === 0 ? (
360
+ ) : (strategies as DeliveryStrategy[]).length === 0 ? (
439
361
  <Card className="p-4">
440
362
  <div className="text-center py-4 text-muted-foreground">
441
363
  No delivery channels registered. Plugins can register delivery
@@ -444,7 +366,7 @@ export const NotificationSettingsPage = () => {
444
366
  </Card>
445
367
  ) : (
446
368
  <div className="space-y-3">
447
- {strategies.map((strategy) => (
369
+ {(strategies as DeliveryStrategy[]).map((strategy) => (
448
370
  <StrategyCard
449
371
  key={strategy.qualifiedId}
450
372
  strategy={strategy}
@@ -473,18 +395,18 @@ export const NotificationSettingsPage = () => {
473
395
  ) : (
474
396
  <div className="space-y-4">
475
397
  <DynamicForm
476
- schema={retentionSchema}
398
+ schema={retentionSchema as Record<string, unknown>}
477
399
  value={retentionSettings}
478
400
  onChange={setRetentionSettings}
479
401
  onValidChange={setRetentionValid}
480
402
  />
481
403
  <Button
482
- onClick={() => {
483
- void handleSaveRetention();
484
- }}
485
- disabled={retentionSaving || !retentionValid}
404
+ onClick={handleSaveRetention}
405
+ disabled={setRetentionMutation.isPending || !retentionValid}
486
406
  >
487
- {retentionSaving ? "Saving..." : "Save Settings"}
407
+ {setRetentionMutation.isPending
408
+ ? "Saving..."
409
+ : "Save Settings"}
488
410
  </Button>
489
411
  </div>
490
412
  )}
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback } from "react";
1
+ import { useState } from "react";
2
2
  import { Link } from "react-router-dom";
3
3
  import { Bell, Check, Trash2, ChevronDown } from "lucide-react";
4
4
  import {
@@ -13,88 +13,82 @@ import {
13
13
  DropdownMenuTrigger,
14
14
  Markdown,
15
15
  } from "@checkstack/ui";
16
- import { useApi, rpcApiRef } from "@checkstack/frontend-api";
16
+ import { usePluginClient } from "@checkstack/frontend-api";
17
17
  import type { Notification } from "@checkstack/notification-common";
18
18
  import { NotificationApi } from "@checkstack/notification-common";
19
19
 
20
20
  export const NotificationsPage = () => {
21
- const rpcApi = useApi(rpcApiRef);
22
- const notificationClient = rpcApi.forPlugin(NotificationApi);
21
+ const notificationClient = usePluginClient(NotificationApi);
23
22
  const toast = useToast();
24
23
 
25
- const [notifications, setNotifications] = useState<Notification[]>([]);
26
- const [total, setTotal] = useState(0);
27
- const [loading, setLoading] = useState(true);
28
24
  const [filter, setFilter] = useState<"all" | "unread">("all");
29
25
  const [page, setPage] = useState(0);
30
26
  const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
31
27
  const pageSize = 20;
32
28
 
33
- const fetchNotifications = useCallback(async () => {
34
- try {
35
- setLoading(true);
36
- const { notifications: data, total: totalCount } =
37
- await notificationClient.getNotifications({
38
- limit: pageSize,
39
- offset: page * pageSize,
40
- unreadOnly: filter === "unread",
41
- });
42
- setNotifications(data);
43
- setTotal(totalCount);
44
- } catch (error) {
45
- const message =
46
- error instanceof Error
47
- ? error.message
48
- : "Failed to fetch notifications";
49
- toast.error(message);
50
- } finally {
51
- setLoading(false);
52
- }
53
- }, [notificationClient, page, filter, toast]);
29
+ // Query: Fetch notifications
30
+ const {
31
+ data: notificationsData,
32
+ isLoading: loading,
33
+ refetch,
34
+ } = notificationClient.getNotifications.useQuery({
35
+ limit: pageSize,
36
+ offset: page * pageSize,
37
+ unreadOnly: filter === "unread",
38
+ });
54
39
 
55
- useEffect(() => {
56
- void fetchNotifications();
57
- }, [fetchNotifications]);
40
+ const notifications = notificationsData?.notifications ?? [];
41
+ const total = notificationsData?.total ?? 0;
58
42
 
59
- const handleMarkAsRead = async (notificationId: string) => {
60
- try {
61
- await notificationClient.markAsRead({ notificationId });
62
- setNotifications((prev) =>
63
- prev.map((n) => (n.id === notificationId ? { ...n, isRead: true } : n))
64
- );
43
+ // Mutation: Mark as read
44
+ const markAsReadMutation = notificationClient.markAsRead.useMutation({
45
+ onSuccess: () => {
46
+ void refetch();
65
47
  toast.success("Notification marked as read");
66
- } catch (error) {
67
- const message =
68
- error instanceof Error ? error.message : "Failed to mark as read";
69
- toast.error(message);
70
- }
71
- };
48
+ },
49
+ onError: (error) => {
50
+ toast.error(
51
+ error instanceof Error ? error.message : "Failed to mark as read"
52
+ );
53
+ },
54
+ });
72
55
 
73
- const handleDelete = async (notificationId: string) => {
74
- try {
75
- await notificationClient.deleteNotification({ notificationId });
76
- setNotifications((prev) => prev.filter((n) => n.id !== notificationId));
77
- setTotal((prev) => prev - 1);
56
+ // Mutation: Delete notification
57
+ const deleteMutation = notificationClient.deleteNotification.useMutation({
58
+ onSuccess: () => {
59
+ void refetch();
78
60
  toast.success("Notification deleted");
79
- } catch (error) {
80
- const message =
81
- error instanceof Error
82
- ? error.message
83
- : "Failed to delete notification";
84
- toast.error(message);
85
- }
86
- };
61
+ },
62
+ onError: (error) => {
63
+ toast.error(
64
+ error instanceof Error ? error.message : "Failed to delete notification"
65
+ );
66
+ },
67
+ });
87
68
 
88
- const handleMarkAllAsRead = async () => {
89
- try {
90
- await notificationClient.markAsRead({});
91
- setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
69
+ // Mutation: Mark all as read
70
+ const markAllAsReadMutation = notificationClient.markAsRead.useMutation({
71
+ onSuccess: () => {
72
+ void refetch();
92
73
  toast.success("All notifications marked as read");
93
- } catch (error) {
94
- const message =
95
- error instanceof Error ? error.message : "Failed to mark all as read";
96
- toast.error(message);
97
- }
74
+ },
75
+ onError: (error) => {
76
+ toast.error(
77
+ error instanceof Error ? error.message : "Failed to mark all as read"
78
+ );
79
+ },
80
+ });
81
+
82
+ const handleMarkAsRead = (notificationId: string) => {
83
+ markAsReadMutation.mutate({ notificationId });
84
+ };
85
+
86
+ const handleDelete = (notificationId: string) => {
87
+ deleteMutation.mutate({ notificationId });
88
+ };
89
+
90
+ const handleMarkAllAsRead = () => {
91
+ markAllAsReadMutation.mutate({});
98
92
  };
99
93
 
100
94
  const getImportanceBadge = (importance: Notification["importance"]) => {
@@ -182,9 +176,8 @@ export const NotificationsPage = () => {
182
176
  <Button
183
177
  variant="outline"
184
178
  size="sm"
185
- onClick={() => {
186
- void handleMarkAllAsRead();
187
- }}
179
+ onClick={handleMarkAllAsRead}
180
+ disabled={markAllAsReadMutation.isPending}
188
181
  >
189
182
  <Check className="h-4 w-4 mr-1" /> Mark all read
190
183
  </Button>
@@ -241,9 +234,8 @@ export const NotificationsPage = () => {
241
234
  <Button
242
235
  variant="ghost"
243
236
  size="icon"
244
- onClick={() => {
245
- void handleMarkAsRead(notification.id);
246
- }}
237
+ onClick={() => handleMarkAsRead(notification.id)}
238
+ disabled={markAsReadMutation.isPending}
247
239
  title="Mark as read"
248
240
  >
249
241
  <Check className="h-4 w-4" />
@@ -252,9 +244,8 @@ export const NotificationsPage = () => {
252
244
  <Button
253
245
  variant="ghost"
254
246
  size="icon"
255
- onClick={() => {
256
- void handleDelete(notification.id);
257
- }}
247
+ onClick={() => handleDelete(notification.id)}
248
+ disabled={deleteMutation.isPending}
258
249
  title="Delete"
259
250
  >
260
251
  <Trash2 className="h-4 w-4 text-destructive" />