@checkstack/notification-frontend 0.0.5 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,149 @@
1
1
  # @checkstack/notification-frontend
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7a23261: ## TanStack Query Integration
8
+
9
+ Migrated all frontend components to use `usePluginClient` hook with TanStack Query integration, replacing the legacy `forPlugin()` pattern.
10
+
11
+ ### New Features
12
+
13
+ - **`usePluginClient` hook**: Provides type-safe access to plugin APIs with `.useQuery()` and `.useMutation()` methods
14
+ - **Automatic request deduplication**: Multiple components requesting the same data share a single network request
15
+ - **Built-in caching**: Configurable stale time and cache duration per query
16
+ - **Loading/error states**: TanStack Query provides `isLoading`, `error`, `isRefetching` states automatically
17
+ - **Background refetching**: Stale data is automatically refreshed when components mount
18
+
19
+ ### Contract Changes
20
+
21
+ All RPC contracts now require `operationType: "query"` or `operationType: "mutation"` metadata:
22
+
23
+ ```typescript
24
+ const getItems = proc()
25
+ .meta({ operationType: "query", access: [access.read] })
26
+ .output(z.array(itemSchema))
27
+ .query();
28
+
29
+ const createItem = proc()
30
+ .meta({ operationType: "mutation", access: [access.manage] })
31
+ .input(createItemSchema)
32
+ .output(itemSchema)
33
+ .mutation();
34
+ ```
35
+
36
+ ### Migration
37
+
38
+ ```typescript
39
+ // Before (forPlugin pattern)
40
+ const api = useApi(myPluginApiRef);
41
+ const [items, setItems] = useState<Item[]>([]);
42
+ useEffect(() => {
43
+ api.getItems().then(setItems);
44
+ }, [api]);
45
+
46
+ // After (usePluginClient pattern)
47
+ const client = usePluginClient(MyPluginApi);
48
+ const { data: items, isLoading } = client.getItems.useQuery({});
49
+ ```
50
+
51
+ ### Bug Fixes
52
+
53
+ - Fixed `rpc.test.ts` test setup for middleware type inference
54
+ - Fixed `SearchDialog` to use `setQuery` instead of deprecated `search` method
55
+ - Fixed null→undefined warnings in notification and queue frontends
56
+
57
+ ### Patch Changes
58
+
59
+ - Updated dependencies [7a23261]
60
+ - @checkstack/frontend-api@0.2.0
61
+ - @checkstack/common@0.3.0
62
+ - @checkstack/auth-frontend@0.3.0
63
+ - @checkstack/notification-common@0.2.0
64
+ - @checkstack/ui@0.2.1
65
+ - @checkstack/signal-frontend@0.0.7
66
+
67
+ ## 0.1.0
68
+
69
+ ### Minor Changes
70
+
71
+ - 9faec1f: # Unified AccessRule Terminology Refactoring
72
+
73
+ This release completes a comprehensive terminology refactoring from "permission" to "accessRule" across the entire codebase, establishing a consistent and modern access control vocabulary.
74
+
75
+ ## Changes
76
+
77
+ ### Core Infrastructure (`@checkstack/common`)
78
+
79
+ - Introduced `AccessRule` interface as the primary access control type
80
+ - Added `accessPair()` helper for creating read/manage access rule pairs
81
+ - Added `access()` builder for individual access rules
82
+ - Replaced `Permission` type with `AccessRule` throughout
83
+
84
+ ### API Changes
85
+
86
+ - `env.registerPermissions()` → `env.registerAccessRules()`
87
+ - `meta.permissions` → `meta.access` in RPC contracts
88
+ - `usePermission()` → `useAccess()` in frontend hooks
89
+ - Route `permission:` field → `accessRule:` field
90
+
91
+ ### UI Changes
92
+
93
+ - "Roles & Permissions" tab → "Roles & Access Rules"
94
+ - "You don't have permission..." → "You don't have access..."
95
+ - All permission-related UI text updated
96
+
97
+ ### Documentation & Templates
98
+
99
+ - Updated 18 documentation files with AccessRule terminology
100
+ - Updated 7 scaffolding templates with `accessPair()` pattern
101
+ - All code examples use new AccessRule API
102
+
103
+ ## Migration Guide
104
+
105
+ ### Backend Plugins
106
+
107
+ ```diff
108
+ - import { permissionList } from "./permissions";
109
+ - env.registerPermissions(permissionList);
110
+ + import { accessRules } from "./access";
111
+ + env.registerAccessRules(accessRules);
112
+ ```
113
+
114
+ ### RPC Contracts
115
+
116
+ ```diff
117
+ - .meta({ userType: "user", permissions: [permissions.read.id] })
118
+ + .meta({ userType: "user", access: [access.read] })
119
+ ```
120
+
121
+ ### Frontend Hooks
122
+
123
+ ```diff
124
+ - const canRead = accessApi.usePermission(permissions.read.id);
125
+ + const canRead = accessApi.useAccess(access.read);
126
+ ```
127
+
128
+ ### Routes
129
+
130
+ ```diff
131
+ - permission: permissions.entityRead.id,
132
+ + accessRule: access.read,
133
+ ```
134
+
135
+ ### Patch Changes
136
+
137
+ - Updated dependencies [9faec1f]
138
+ - Updated dependencies [95eeec7]
139
+ - Updated dependencies [f533141]
140
+ - @checkstack/auth-frontend@0.2.0
141
+ - @checkstack/common@0.2.0
142
+ - @checkstack/frontend-api@0.1.0
143
+ - @checkstack/notification-common@0.1.0
144
+ - @checkstack/ui@0.2.0
145
+ - @checkstack/signal-frontend@0.0.6
146
+
3
147
  ## 0.0.5
4
148
 
5
149
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/notification-frontend",
3
- "version": "0.0.5",
3
+ "version": "0.2.0",
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,
@@ -9,14 +9,14 @@ import {
9
9
  DynamicForm,
10
10
  } from "@checkstack/ui";
11
11
  import {
12
+ usePluginClient,
12
13
  useApi,
13
- rpcApiRef,
14
- permissionApiRef,
14
+ accessApiRef,
15
15
  } from "@checkstack/frontend-api";
16
16
  import type { EnrichedSubscription } from "@checkstack/notification-common";
17
17
  import {
18
18
  NotificationApi,
19
- permissions,
19
+ notificationAccess,
20
20
  } from "@checkstack/notification-common";
21
21
  import {
22
22
  StrategyCard,
@@ -28,287 +28,218 @@ import {
28
28
  } from "../components/UserChannelCard";
29
29
 
30
30
  export const NotificationSettingsPage = () => {
31
- const rpcApi = useApi(rpcApiRef);
32
- const permissionApi = useApi(permissionApiRef);
33
- const notificationClient = rpcApi.forPlugin(NotificationApi);
31
+ const notificationClient = usePluginClient(NotificationApi);
32
+ const accessApi = useApi(accessApiRef);
34
33
  const toast = useToast();
35
34
 
36
- // Check if user has admin permission
37
- const { allowed: isAdmin } = permissionApi.usePermission(
38
- permissions.notificationAdmin.id
39
- );
35
+ // Check if user has admin access
36
+ const { allowed: isAdmin } = accessApi.useAccess(notificationAccess.admin);
40
37
 
41
- // Retention settings state
42
- const [retentionSchema, setRetentionSchema] = useState<
43
- Record<string, unknown> | undefined
44
- >();
38
+ // Local state for editing
45
39
  const [retentionSettings, setRetentionSettings] = useState<
46
40
  Record<string, unknown>
47
41
  >({
48
42
  retentionDays: 30,
49
43
  enabled: false,
50
44
  });
51
- const [retentionLoading, setRetentionLoading] = useState(true);
52
- const [retentionSaving, setRetentionSaving] = useState(false);
53
45
  const [retentionValid, setRetentionValid] = useState(true);
54
-
55
- // Subscription state - now uses enriched subscriptions only
56
- const [subscriptions, setSubscriptions] = useState<EnrichedSubscription[]>(
57
- []
58
- );
59
- const [subsLoading, setSubsLoading] = useState(true);
60
-
61
- // Delivery strategies state (admin only)
62
- const [strategies, setStrategies] = useState<DeliveryStrategy[]>([]);
63
- const [strategiesLoading, setStrategiesLoading] = useState(true);
64
- const [strategySaving, setStrategySaving] = useState<string | undefined>();
65
-
66
- // User channels state
67
- const [userChannels, setUserChannels] = useState<UserDeliveryChannel[]>([]);
68
- const [channelsLoading, setChannelsLoading] = useState(true);
69
46
  const [channelSaving, setChannelSaving] = useState<string | undefined>();
70
47
  const [channelConnecting, setChannelConnecting] = useState<
71
48
  string | undefined
72
49
  >();
73
50
  const [channelTesting, setChannelTesting] = useState<string | undefined>();
51
+ const [strategySaving, setStrategySaving] = useState<string | undefined>();
74
52
 
75
- // Fetch retention settings and schema (admin only)
76
- const fetchRetentionData = useCallback(async () => {
77
- if (!isAdmin) {
78
- setRetentionLoading(false);
79
- return;
80
- }
81
- try {
82
- const [schema, settings] = await Promise.all([
83
- notificationClient.getRetentionSchema(),
84
- notificationClient.getRetentionSettings(),
85
- ]);
86
- setRetentionSchema(schema as Record<string, unknown>);
87
- setRetentionSettings(settings);
88
- } catch (error) {
89
- const message =
90
- error instanceof Error
91
- ? error.message
92
- : "Failed to load retention settings";
93
- toast.error(message);
94
- } finally {
95
- setRetentionLoading(false);
96
- }
97
- }, [notificationClient, isAdmin, toast]);
53
+ // Query: Retention schema (admin only)
54
+ const { data: retentionSchema } =
55
+ notificationClient.getRetentionSchema.useQuery({}, { enabled: isAdmin });
98
56
 
99
- // Fetch subscriptions only (no groups needed)
100
- const fetchSubscriptionData = useCallback(async () => {
101
- try {
102
- const subsData = await notificationClient.getSubscriptions();
103
- setSubscriptions(subsData);
104
- } catch (error) {
105
- const message =
106
- error instanceof Error
107
- ? error.message
108
- : "Failed to fetch subscriptions";
109
- toast.error(message);
110
- } finally {
111
- setSubsLoading(false);
112
- }
113
- }, [notificationClient, toast]);
57
+ // Query: Retention settings (admin only)
58
+ const { data: fetchedRetentionSettings, isLoading: retentionLoading } =
59
+ notificationClient.getRetentionSettings.useQuery({}, { enabled: isAdmin });
114
60
 
115
- // Fetch delivery strategies (admin only)
116
- const fetchStrategies = useCallback(async () => {
117
- if (!isAdmin) {
118
- setStrategiesLoading(false);
119
- return;
120
- }
121
- try {
122
- const data = await notificationClient.getDeliveryStrategies();
123
- setStrategies(data as DeliveryStrategy[]);
124
- } catch (error) {
125
- const message =
126
- error instanceof Error
127
- ? error.message
128
- : "Failed to load delivery channels";
129
- toast.error(message);
130
- } finally {
131
- setStrategiesLoading(false);
132
- }
133
- }, [notificationClient, isAdmin, toast]);
61
+ // Query: Subscriptions
62
+ const {
63
+ data: subscriptions = [],
64
+ isLoading: subsLoading,
65
+ refetch: refetchSubscriptions,
66
+ } = notificationClient.getSubscriptions.useQuery({});
134
67
 
135
- // Fetch user delivery channels
136
- const fetchUserChannels = useCallback(async () => {
137
- try {
138
- const data = await notificationClient.getUserDeliveryChannels();
139
- setUserChannels(data as UserDeliveryChannel[]);
140
- } catch (error) {
141
- const message =
142
- error instanceof Error ? error.message : "Failed to load your channels";
143
- toast.error(message);
144
- } finally {
145
- setChannelsLoading(false);
146
- }
147
- }, [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
+ );
77
+
78
+ // Query: User delivery channels
79
+ const {
80
+ data: userChannels = [],
81
+ isLoading: channelsLoading,
82
+ refetch: refetchChannels,
83
+ } = notificationClient.getUserDeliveryChannels.useQuery({});
148
84
 
85
+ // Sync fetched retention settings to local state
149
86
  useEffect(() => {
150
- void fetchRetentionData();
151
- void fetchSubscriptionData();
152
- void fetchStrategies();
153
- void fetchUserChannels();
154
- }, [
155
- fetchRetentionData,
156
- fetchSubscriptionData,
157
- fetchStrategies,
158
- fetchUserChannels,
159
- ]);
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
+ });
160
104
 
161
- const handleSaveRetention = async () => {
162
- try {
163
- setRetentionSaving(true);
164
- await notificationClient.setRetentionSettings(
165
- 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"
166
113
  );
167
- toast.success("Retention settings saved");
168
- } catch (error) {
169
- const message =
170
- error instanceof Error ? error.message : "Failed to save settings";
171
- toast.error(message);
172
- } finally {
173
- setRetentionSaving(false);
174
- }
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
+ );
175
185
  };
176
186
 
177
- const handleUnsubscribe = async (groupId: string) => {
178
- try {
179
- await notificationClient.unsubscribe({ groupId });
180
- setSubscriptions((prev) => prev.filter((s) => s.groupId !== groupId));
181
- toast.success("Unsubscribed successfully");
182
- } catch (error) {
183
- const message =
184
- error instanceof Error ? error.message : "Failed to unsubscribe";
185
- toast.error(message);
186
- }
187
+ const handleUnsubscribe = (groupId: string) => {
188
+ unsubscribeMutation.mutate({ groupId });
187
189
  };
188
190
 
189
- // Handle strategy update (enabled state and config)
190
191
  const handleStrategyUpdate = async (
191
192
  strategyId: string,
192
193
  enabled: boolean,
193
194
  config?: Record<string, unknown>,
194
195
  layoutConfig?: Record<string, unknown>
195
196
  ) => {
196
- try {
197
- setStrategySaving(strategyId);
198
- await notificationClient.updateDeliveryStrategy({
199
- strategyId,
200
- enabled,
201
- config,
202
- layoutConfig,
203
- });
204
- // Update local state
205
- setStrategies((prev) =>
206
- prev.map((s) =>
207
- s.qualifiedId === strategyId
208
- ? { ...s, enabled, config, layoutConfig }
209
- : s
210
- )
211
- );
212
- toast.success(`${enabled ? "Enabled" : "Disabled"} delivery channel`);
213
- } catch (error) {
214
- const message =
215
- error instanceof Error ? error.message : "Failed to update channel";
216
- toast.error(message);
217
- } finally {
218
- setStrategySaving(undefined);
219
- }
197
+ setStrategySaving(strategyId);
198
+ await updateStrategyMutation.mutateAsync({
199
+ strategyId,
200
+ enabled,
201
+ config,
202
+ layoutConfig,
203
+ });
220
204
  };
221
205
 
222
- // Handle user channel toggle
223
206
  const handleChannelToggle = async (strategyId: string, enabled: boolean) => {
224
- try {
225
- setChannelSaving(strategyId);
226
- await notificationClient.setUserDeliveryPreference({
227
- strategyId,
228
- enabled,
229
- });
230
- setUserChannels((prev) =>
231
- prev.map((c) => (c.strategyId === strategyId ? { ...c, enabled } : c))
232
- );
233
- toast.success(`${enabled ? "Enabled" : "Disabled"} notification channel`);
234
- } catch (error) {
235
- const message =
236
- error instanceof Error ? error.message : "Failed to update preference";
237
- toast.error(message);
238
- } finally {
239
- setChannelSaving(undefined);
240
- }
207
+ setChannelSaving(strategyId);
208
+ await setUserPreferenceMutation.mutateAsync({
209
+ strategyId,
210
+ enabled,
211
+ });
241
212
  };
242
213
 
243
- // Handle OAuth connect
244
214
  const handleChannelConnect = async (strategyId: string) => {
245
- try {
246
- setChannelConnecting(strategyId);
247
- const { authUrl } = await notificationClient.getDeliveryOAuthUrl({
248
- strategyId,
249
- returnUrl: globalThis.location.pathname,
250
- });
251
- // Redirect to OAuth provider
252
- globalThis.location.href = authUrl;
253
- } catch (error) {
254
- const message =
255
- error instanceof Error ? error.message : "Failed to start OAuth flow";
256
- toast.error(message);
257
- setChannelConnecting(undefined);
258
- }
215
+ setChannelConnecting(strategyId);
216
+ await getOAuthUrlMutation.mutateAsync({
217
+ strategyId,
218
+ returnUrl: globalThis.location.pathname,
219
+ });
259
220
  };
260
221
 
261
- // Handle OAuth disconnect
262
222
  const handleChannelDisconnect = async (strategyId: string) => {
263
- try {
264
- setChannelSaving(strategyId);
265
- await notificationClient.unlinkDeliveryChannel({ strategyId });
266
- setUserChannels((prev) =>
267
- prev.map((c) =>
268
- c.strategyId === strategyId
269
- ? { ...c, linkedAt: undefined, enabled: false, isConfigured: false }
270
- : c
271
- )
272
- );
273
- toast.success("Disconnected notification channel");
274
- } catch (error) {
275
- const message =
276
- error instanceof Error ? error.message : "Failed to disconnect";
277
- toast.error(message);
278
- } finally {
279
- setChannelSaving(undefined);
280
- }
223
+ setChannelSaving(strategyId);
224
+ await unlinkChannelMutation.mutateAsync({ strategyId });
281
225
  };
282
226
 
283
- // Handle user config save
284
227
  const handleChannelConfigSave = async (
285
228
  strategyId: string,
286
229
  userConfig: Record<string, unknown>
287
230
  ) => {
288
- try {
289
- setChannelSaving(strategyId);
290
- await notificationClient.setUserDeliveryPreference({
291
- strategyId,
292
- enabled:
293
- userChannels.find((c) => c.strategyId === strategyId)?.enabled ??
294
- false,
295
- userConfig,
296
- });
297
- setUserChannels((prev) =>
298
- prev.map((c) =>
299
- c.strategyId === strategyId
300
- ? { ...c, userConfig, isConfigured: true }
301
- : c
302
- )
303
- );
304
- toast.success("Saved channel settings");
305
- } catch (error) {
306
- const message =
307
- error instanceof Error ? error.message : "Failed to save settings";
308
- toast.error(message);
309
- } finally {
310
- setChannelSaving(undefined);
311
- }
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 });
312
243
  };
313
244
 
314
245
  return (
@@ -327,7 +258,7 @@ export const NotificationSettingsPage = () => {
327
258
  Loading your channels...
328
259
  </div>
329
260
  </Card>
330
- ) : userChannels.length === 0 ? (
261
+ ) : (userChannels as UserDeliveryChannel[]).length === 0 ? (
331
262
  <Card className="p-4">
332
263
  <div className="text-center py-4 text-muted-foreground">
333
264
  No notification channels available. Contact your administrator
@@ -336,7 +267,7 @@ export const NotificationSettingsPage = () => {
336
267
  </Card>
337
268
  ) : (
338
269
  <div className="space-y-3">
339
- {userChannels.map((channel) => (
270
+ {(userChannels as UserDeliveryChannel[]).map((channel) => (
340
271
  <UserChannelCard
341
272
  key={channel.strategyId}
342
273
  channel={channel}
@@ -344,21 +275,7 @@ export const NotificationSettingsPage = () => {
344
275
  onConnect={handleChannelConnect}
345
276
  onDisconnect={handleChannelDisconnect}
346
277
  onSaveConfig={handleChannelConfigSave}
347
- onTest={async (strategyId) => {
348
- setChannelTesting(strategyId);
349
- try {
350
- const result =
351
- await notificationClient.sendTestNotification({
352
- strategyId,
353
- });
354
- if (!result.success) {
355
- alert(`Test failed: ${result.error}`);
356
- }
357
- return result;
358
- } finally {
359
- setChannelTesting(undefined);
360
- }
361
- }}
278
+ onTest={handleTest}
362
279
  saving={channelSaving === channel.strategyId}
363
280
  connecting={channelConnecting === channel.strategyId}
364
281
  testing={channelTesting === channel.strategyId}
@@ -376,13 +293,13 @@ export const NotificationSettingsPage = () => {
376
293
  icon={<Bell className="h-5 w-5" />}
377
294
  />
378
295
  <Card className="p-4">
379
- {subscriptions.length === 0 ? (
296
+ {(subscriptions as EnrichedSubscription[]).length === 0 ? (
380
297
  <div className="text-center py-4 text-muted-foreground">
381
298
  No active subscriptions
382
299
  </div>
383
300
  ) : (
384
301
  <div className="space-y-3">
385
- {subscriptions.map((sub) => (
302
+ {(subscriptions as EnrichedSubscription[]).map((sub) => (
386
303
  <div
387
304
  key={sub.groupId}
388
305
  className="flex items-center justify-between py-2 border-b last:border-0"
@@ -399,7 +316,8 @@ export const NotificationSettingsPage = () => {
399
316
  <Button
400
317
  variant="outline"
401
318
  size="sm"
402
- onClick={() => void handleUnsubscribe(sub.groupId)}
319
+ onClick={() => handleUnsubscribe(sub.groupId)}
320
+ disabled={unsubscribeMutation.isPending}
403
321
  >
404
322
  Unsubscribe
405
323
  </Button>
@@ -439,7 +357,7 @@ export const NotificationSettingsPage = () => {
439
357
  Loading delivery channels...
440
358
  </div>
441
359
  </Card>
442
- ) : strategies.length === 0 ? (
360
+ ) : (strategies as DeliveryStrategy[]).length === 0 ? (
443
361
  <Card className="p-4">
444
362
  <div className="text-center py-4 text-muted-foreground">
445
363
  No delivery channels registered. Plugins can register delivery
@@ -448,7 +366,7 @@ export const NotificationSettingsPage = () => {
448
366
  </Card>
449
367
  ) : (
450
368
  <div className="space-y-3">
451
- {strategies.map((strategy) => (
369
+ {(strategies as DeliveryStrategy[]).map((strategy) => (
452
370
  <StrategyCard
453
371
  key={strategy.qualifiedId}
454
372
  strategy={strategy}
@@ -477,18 +395,18 @@ export const NotificationSettingsPage = () => {
477
395
  ) : (
478
396
  <div className="space-y-4">
479
397
  <DynamicForm
480
- schema={retentionSchema}
398
+ schema={retentionSchema as Record<string, unknown>}
481
399
  value={retentionSettings}
482
400
  onChange={setRetentionSettings}
483
401
  onValidChange={setRetentionValid}
484
402
  />
485
403
  <Button
486
- onClick={() => {
487
- void handleSaveRetention();
488
- }}
489
- disabled={retentionSaving || !retentionValid}
404
+ onClick={handleSaveRetention}
405
+ disabled={setRetentionMutation.isPending || !retentionValid}
490
406
  >
491
- {retentionSaving ? "Saving..." : "Save Settings"}
407
+ {setRetentionMutation.isPending
408
+ ? "Saving..."
409
+ : "Save Settings"}
492
410
  </Button>
493
411
  </div>
494
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" />