@react-spa-scaffold/mcp 2.1.1 → 2.3.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.
Files changed (168) hide show
  1. package/README.md +2 -1
  2. package/dist/constants.d.ts +4 -0
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +4 -0
  5. package/dist/constants.js.map +1 -1
  6. package/dist/features/definitions/auth.d.ts +3 -0
  7. package/dist/features/definitions/auth.d.ts.map +1 -0
  8. package/dist/features/definitions/auth.js +17 -0
  9. package/dist/features/definitions/auth.js.map +1 -0
  10. package/dist/features/definitions/core.d.ts.map +1 -1
  11. package/dist/features/definitions/core.js +16 -1
  12. package/dist/features/definitions/core.js.map +1 -1
  13. package/dist/features/definitions/database.d.ts +3 -0
  14. package/dist/features/definitions/database.d.ts.map +1 -0
  15. package/dist/features/definitions/database.js +45 -0
  16. package/dist/features/definitions/database.js.map +1 -0
  17. package/dist/features/definitions/deployment.d.ts +3 -0
  18. package/dist/features/definitions/deployment.d.ts.map +1 -0
  19. package/dist/features/definitions/deployment.js +14 -0
  20. package/dist/features/definitions/deployment.js.map +1 -0
  21. package/dist/features/definitions/forms.d.ts.map +1 -1
  22. package/dist/features/definitions/forms.js +4 -0
  23. package/dist/features/definitions/forms.js.map +1 -1
  24. package/dist/features/definitions/index.d.ts +3 -0
  25. package/dist/features/definitions/index.d.ts.map +1 -1
  26. package/dist/features/definitions/index.js +3 -0
  27. package/dist/features/definitions/index.js.map +1 -1
  28. package/dist/features/definitions/mobile.d.ts.map +1 -1
  29. package/dist/features/definitions/mobile.js +11 -2
  30. package/dist/features/definitions/mobile.js.map +1 -1
  31. package/dist/features/definitions/observability.js +1 -1
  32. package/dist/features/definitions/observability.js.map +1 -1
  33. package/dist/features/definitions/routing.d.ts.map +1 -1
  34. package/dist/features/definitions/routing.js +2 -1
  35. package/dist/features/definitions/routing.js.map +1 -1
  36. package/dist/features/definitions/state.d.ts.map +1 -1
  37. package/dist/features/definitions/state.js +9 -2
  38. package/dist/features/definitions/state.js.map +1 -1
  39. package/dist/features/definitions/testing.d.ts.map +1 -1
  40. package/dist/features/definitions/testing.js +4 -2
  41. package/dist/features/definitions/testing.js.map +1 -1
  42. package/dist/features/registry.d.ts.map +1 -1
  43. package/dist/features/registry.js +4 -1
  44. package/dist/features/registry.js.map +1 -1
  45. package/dist/features/types.test.js +6 -2
  46. package/dist/features/types.test.js.map +1 -1
  47. package/dist/resources/docs.d.ts.map +1 -1
  48. package/dist/resources/docs.js +5 -0
  49. package/dist/resources/docs.js.map +1 -1
  50. package/dist/tools/add-features.js +1 -1
  51. package/dist/tools/add-features.js.map +1 -1
  52. package/dist/utils/docs.d.ts.map +1 -1
  53. package/dist/utils/docs.js +2 -0
  54. package/dist/utils/docs.js.map +1 -1
  55. package/dist/utils/scaffold/claude-md/index.d.ts.map +1 -1
  56. package/dist/utils/scaffold/claude-md/index.js +3 -1
  57. package/dist/utils/scaffold/claude-md/index.js.map +1 -1
  58. package/dist/utils/scaffold/claude-md/sections.d.ts +2 -0
  59. package/dist/utils/scaffold/claude-md/sections.d.ts.map +1 -1
  60. package/dist/utils/scaffold/claude-md/sections.js +132 -2
  61. package/dist/utils/scaffold/claude-md/sections.js.map +1 -1
  62. package/dist/utils/scaffold/compute.js +1 -1
  63. package/dist/utils/scaffold/compute.js.map +1 -1
  64. package/dist/utils/scaffold/generators.d.ts +2 -2
  65. package/dist/utils/scaffold/generators.d.ts.map +1 -1
  66. package/dist/utils/scaffold/generators.js +64 -22
  67. package/dist/utils/scaffold/generators.js.map +1 -1
  68. package/package.json +1 -1
  69. package/templates/.env.example +44 -10
  70. package/templates/.github/workflows/ci.yml +12 -4
  71. package/templates/.github/workflows/deploy.yml +59 -0
  72. package/templates/CLAUDE.md +251 -2
  73. package/templates/docs/ARCHITECTURE.md +13 -12
  74. package/templates/docs/AUTHENTICATION.md +325 -0
  75. package/templates/docs/CODING_STANDARDS.md +65 -0
  76. package/templates/docs/DEPLOYMENT.md +268 -0
  77. package/templates/docs/E2E_TESTING.md +133 -11
  78. package/templates/docs/SUPABASE_INTEGRATION.md +310 -0
  79. package/templates/docs/TESTING.md +195 -77
  80. package/templates/e2e/auth/auth.setup.ts +60 -0
  81. package/templates/e2e/fixtures/index.ts +24 -2
  82. package/templates/e2e/tests/profile.auth.spec.ts +103 -0
  83. package/templates/e2e/tests/profile.spec.ts +64 -0
  84. package/templates/e2e/tests/register-form.spec.ts +38 -0
  85. package/templates/gitignore +5 -0
  86. package/templates/package.json +15 -3
  87. package/templates/playwright.config.ts +39 -4
  88. package/templates/src/App.tsx +32 -19
  89. package/templates/src/components/layout/Header.test.tsx +17 -1
  90. package/templates/src/components/layout/Header.tsx +13 -1
  91. package/templates/src/components/shared/AccountButton/AccountButton.test.tsx +30 -0
  92. package/templates/src/components/shared/AccountButton/AccountButton.tsx +38 -0
  93. package/templates/src/components/shared/AccountButton/index.ts +1 -0
  94. package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.test.tsx +4 -4
  95. package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.tsx +55 -53
  96. package/templates/src/components/shared/ProfileSync/ProfileSync.test.tsx +44 -0
  97. package/templates/src/components/shared/ProfileSync/ProfileSync.tsx +104 -0
  98. package/templates/src/components/shared/ProfileSync/index.ts +1 -0
  99. package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.test.tsx +43 -0
  100. package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.tsx +35 -0
  101. package/templates/src/components/shared/ProtectedRoute/index.ts +1 -0
  102. package/templates/src/components/shared/index.ts +5 -2
  103. package/templates/src/contexts/clerkContext.tsx +45 -0
  104. package/templates/src/contexts/performanceContext.tsx +3 -3
  105. package/templates/src/contexts/supabaseContext.test.tsx +59 -0
  106. package/templates/src/contexts/supabaseContext.tsx +87 -0
  107. package/templates/src/hooks/index.ts +40 -2
  108. package/templates/src/hooks/supabase/index.ts +12 -0
  109. package/templates/src/hooks/supabase/useProfiles.test.tsx +207 -0
  110. package/templates/src/hooks/supabase/useProfiles.ts +213 -0
  111. package/templates/src/hooks/supabase/useSupabaseQuery.test.tsx +150 -0
  112. package/templates/src/hooks/supabase/useSupabaseQuery.ts +91 -0
  113. package/templates/src/hooks/useCopyFeedback.test.ts +129 -0
  114. package/templates/src/hooks/useCopyFeedback.ts +41 -0
  115. package/templates/src/hooks/useDebouncedCallback.test.ts +164 -0
  116. package/templates/src/hooks/useDebouncedCallback.ts +47 -0
  117. package/templates/src/hooks/useDocumentTitle.test.ts +59 -0
  118. package/templates/src/hooks/useDocumentTitle.ts +31 -0
  119. package/templates/src/hooks/useIOSViewportReset.test.ts +58 -0
  120. package/templates/src/hooks/useIOSViewportReset.ts +18 -0
  121. package/templates/src/hooks/useKeyboardShortcut.test.ts +86 -0
  122. package/templates/src/hooks/useKeyboardShortcuts.ts +44 -0
  123. package/templates/src/hooks/useLocalStorage.test.ts +111 -0
  124. package/templates/src/hooks/useLocalStorage.ts +77 -0
  125. package/templates/src/hooks/useSyncedFormData.test.ts +75 -0
  126. package/templates/src/hooks/useSyncedFormData.ts +21 -0
  127. package/templates/src/hooks/useSyncedState.test.ts +119 -0
  128. package/templates/src/hooks/useSyncedState.ts +30 -0
  129. package/templates/src/index.css +1 -0
  130. package/templates/src/lib/api.test.ts +30 -38
  131. package/templates/src/lib/api.ts +1 -7
  132. package/templates/src/lib/config.ts +54 -4
  133. package/templates/src/lib/constants.ts +10 -0
  134. package/templates/src/lib/createSelectors.test.ts +136 -0
  135. package/templates/src/lib/createSelectors.ts +31 -0
  136. package/templates/src/lib/env.ts +36 -14
  137. package/templates/src/lib/index.ts +5 -2
  138. package/templates/src/lib/routes.ts +1 -0
  139. package/templates/src/lib/sentry.ts +58 -0
  140. package/templates/src/lib/storage.ts +6 -2
  141. package/templates/src/lib/supabase/client.ts +58 -0
  142. package/templates/src/lib/supabase/index.ts +5 -0
  143. package/templates/src/main.tsx +19 -31
  144. package/templates/src/mocks/constants.ts +31 -0
  145. package/templates/src/mocks/fixtures/index.ts +3 -1
  146. package/templates/src/mocks/fixtures/profiles.ts +55 -0
  147. package/templates/src/mocks/fixtures/users.ts +91 -0
  148. package/templates/src/mocks/handlers/index.ts +2 -1
  149. package/templates/src/mocks/handlers/supabase.ts +64 -0
  150. package/templates/src/mocks/handlers/todos.ts +1 -1
  151. package/templates/src/mocks/index.ts +6 -0
  152. package/templates/src/pages/Profile.test.tsx +263 -0
  153. package/templates/src/pages/Profile.tsx +171 -0
  154. package/templates/src/pages/index.ts +1 -0
  155. package/templates/src/stores/preferencesStore.ts +35 -9
  156. package/templates/src/test/clerkMock.tsx +137 -0
  157. package/templates/src/test/fetchMock.ts +58 -0
  158. package/templates/src/test/index.ts +51 -2
  159. package/templates/src/test/mocks.ts +128 -1
  160. package/templates/src/test/providers.tsx +10 -4
  161. package/templates/src/test/supabaseMock.ts +112 -0
  162. package/templates/src/test-setup.ts +42 -2
  163. package/templates/src/types/database.ts +46 -0
  164. package/templates/src/types/index.ts +1 -0
  165. package/templates/src/types/supabase.ts +167 -0
  166. package/templates/src/vite-env.d.ts +6 -0
  167. package/templates/supabase/migrations/20260104000000_create_profiles_table.sql +67 -0
  168. package/templates/vitest.config.ts +9 -1
@@ -0,0 +1,207 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query';
2
+ import { renderHook, waitFor } from '@testing-library/react';
3
+ import type { ReactNode } from 'react';
4
+ import { describe, it, expect, beforeEach } from 'vitest';
5
+
6
+ import {
7
+ createTestQueryClient,
8
+ setMockClerkSignedIn,
9
+ setMockClerkUser,
10
+ resetClerkMocks,
11
+ createProfile,
12
+ setMockSupabaseData,
13
+ resetSupabaseMocks,
14
+ } from '@/test';
15
+
16
+ import { useCurrentProfile, useProfile, useUpsertProfile, useUpdateProfile, useDeleteProfile } from './useProfiles';
17
+
18
+ function createWrapper() {
19
+ const queryClient = createTestQueryClient();
20
+ return function Wrapper({ children }: { children: ReactNode }) {
21
+ return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
22
+ };
23
+ }
24
+
25
+ describe('useCurrentProfile', () => {
26
+ beforeEach(() => {
27
+ resetClerkMocks();
28
+ resetSupabaseMocks();
29
+ });
30
+
31
+ it('fetches profile when user is signed in', async () => {
32
+ const profile = createProfile({ id: 'user-123', full_name: 'Test User' });
33
+ setMockSupabaseData([profile]);
34
+ setMockClerkUser({ id: 'user-123' });
35
+
36
+ const { result } = renderHook(() => useCurrentProfile(), {
37
+ wrapper: createWrapper(),
38
+ });
39
+
40
+ await waitFor(() => {
41
+ expect(result.current.isSuccess).toBe(true);
42
+ });
43
+
44
+ expect(result.current.data).toHaveLength(1);
45
+ expect(result.current.data?.[0].full_name).toBe('Test User');
46
+ });
47
+
48
+ it('does not fetch when user is not signed in', async () => {
49
+ setMockClerkSignedIn(false);
50
+
51
+ const { result } = renderHook(() => useCurrentProfile(), {
52
+ wrapper: createWrapper(),
53
+ });
54
+
55
+ // Query should be disabled
56
+ expect(result.current.isLoading).toBe(false);
57
+ expect(result.current.data).toBeUndefined();
58
+ });
59
+ });
60
+
61
+ describe('useProfile', () => {
62
+ beforeEach(() => {
63
+ resetClerkMocks();
64
+ resetSupabaseMocks();
65
+ });
66
+
67
+ it('returns profile object and exists flag', async () => {
68
+ const profile = createProfile({ id: 'user-123', full_name: 'Test User' });
69
+ setMockSupabaseData([profile]);
70
+ setMockClerkUser({ id: 'user-123' });
71
+
72
+ const { result } = renderHook(() => useProfile(), {
73
+ wrapper: createWrapper(),
74
+ });
75
+
76
+ await waitFor(() => {
77
+ expect(result.current.isLoading).toBe(false);
78
+ });
79
+
80
+ expect(result.current.profile?.full_name).toBe('Test User');
81
+ expect(result.current.exists).toBe(true);
82
+ });
83
+
84
+ it('returns null profile when not found', async () => {
85
+ setMockSupabaseData([]);
86
+ setMockClerkUser({ id: 'user-123' });
87
+
88
+ const { result } = renderHook(() => useProfile(), {
89
+ wrapper: createWrapper(),
90
+ });
91
+
92
+ await waitFor(() => {
93
+ expect(result.current.isLoading).toBe(false);
94
+ });
95
+
96
+ expect(result.current.profile).toBeNull();
97
+ expect(result.current.exists).toBe(false);
98
+ });
99
+
100
+ it('exposes isFetching and refetch', async () => {
101
+ const profile = createProfile({ id: 'user-123' });
102
+ setMockSupabaseData([profile]);
103
+ setMockClerkUser({ id: 'user-123' });
104
+
105
+ const { result } = renderHook(() => useProfile(), {
106
+ wrapper: createWrapper(),
107
+ });
108
+
109
+ await waitFor(() => {
110
+ expect(result.current.isLoading).toBe(false);
111
+ });
112
+
113
+ expect(typeof result.current.isFetching).toBe('boolean');
114
+ expect(typeof result.current.refetch).toBe('function');
115
+ });
116
+ });
117
+
118
+ describe('useUpsertProfile', () => {
119
+ beforeEach(() => {
120
+ resetClerkMocks();
121
+ resetSupabaseMocks();
122
+ });
123
+
124
+ it('returns mutation function', () => {
125
+ const { result } = renderHook(() => useUpsertProfile(), {
126
+ wrapper: createWrapper(),
127
+ });
128
+
129
+ expect(typeof result.current.mutate).toBe('function');
130
+ expect(typeof result.current.mutateAsync).toBe('function');
131
+ expect(result.current.isIdle).toBe(true);
132
+ });
133
+ });
134
+
135
+ describe('useUpdateProfile', () => {
136
+ beforeEach(() => {
137
+ resetClerkMocks();
138
+ resetSupabaseMocks();
139
+ });
140
+
141
+ it('returns mutation function', () => {
142
+ setMockClerkUser({ id: 'user-123' });
143
+
144
+ const { result } = renderHook(() => useUpdateProfile(), {
145
+ wrapper: createWrapper(),
146
+ });
147
+
148
+ expect(typeof result.current.mutate).toBe('function');
149
+ expect(typeof result.current.mutateAsync).toBe('function');
150
+ expect(result.current.isIdle).toBe(true);
151
+ });
152
+
153
+ it('throws when user is not authenticated', async () => {
154
+ setMockClerkSignedIn(false);
155
+
156
+ const { result } = renderHook(() => useUpdateProfile(), {
157
+ wrapper: createWrapper(),
158
+ });
159
+
160
+ let errorThrown = false;
161
+ try {
162
+ await result.current.mutateAsync({ full_name: 'Test' });
163
+ } catch (error) {
164
+ errorThrown = true;
165
+ expect((error as Error).message).toBe('No authenticated user');
166
+ }
167
+
168
+ expect(errorThrown).toBe(true);
169
+ });
170
+ });
171
+
172
+ describe('useDeleteProfile', () => {
173
+ beforeEach(() => {
174
+ resetClerkMocks();
175
+ resetSupabaseMocks();
176
+ });
177
+
178
+ it('returns mutation function', () => {
179
+ setMockClerkUser({ id: 'user-123' });
180
+
181
+ const { result } = renderHook(() => useDeleteProfile(), {
182
+ wrapper: createWrapper(),
183
+ });
184
+
185
+ expect(typeof result.current.mutate).toBe('function');
186
+ expect(typeof result.current.mutateAsync).toBe('function');
187
+ expect(result.current.isIdle).toBe(true);
188
+ });
189
+
190
+ it('throws when user is not authenticated', async () => {
191
+ setMockClerkSignedIn(false);
192
+
193
+ const { result } = renderHook(() => useDeleteProfile(), {
194
+ wrapper: createWrapper(),
195
+ });
196
+
197
+ let errorThrown = false;
198
+ try {
199
+ await result.current.mutateAsync();
200
+ } catch (error) {
201
+ errorThrown = true;
202
+ expect((error as Error).message).toBe('No authenticated user');
203
+ }
204
+
205
+ expect(errorThrown).toBe(true);
206
+ });
207
+ });
@@ -0,0 +1,213 @@
1
+ /**
2
+ * User profile hooks for Supabase with Clerk authentication.
3
+ *
4
+ * All operations are automatically scoped to the current user via RLS policies.
5
+ * The profile `id` field corresponds to the Clerk user_id (`auth.uid()`).
6
+ */
7
+
8
+ import { useUser } from '@clerk/react-router';
9
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
10
+ import type { PostgrestError } from '@supabase/supabase-js';
11
+
12
+ import { useSupabase } from '@/contexts/supabaseContext';
13
+ import type { Profile, ProfileInsert, ProfileUpdate } from '@/types/database';
14
+
15
+ import { useSupabaseQuery } from './useSupabaseQuery';
16
+
17
+ /**
18
+ * Hook to fetch the current user's profile (returns array).
19
+ *
20
+ * Uses Clerk's user ID to query the profile and automatically
21
+ * respects RLS policies. For convenience, consider using `useProfile()`
22
+ * which returns a single profile instead of an array.
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * function ProfilePage() {
27
+ * const { data: profiles, isLoading, error } = useCurrentProfile();
28
+ * const profile = profiles?.[0];
29
+ *
30
+ * if (isLoading) return <Spinner />;
31
+ * if (error) return <Error message={error.message} />;
32
+ * if (!profile) return <CreateProfileForm />;
33
+ *
34
+ * return <ProfileDisplay profile={profile} />;
35
+ * }
36
+ * ```
37
+ */
38
+ export function useCurrentProfile() {
39
+ const { user, isLoaded } = useUser();
40
+ const userId = user?.id;
41
+
42
+ return useSupabaseQuery<Profile>({
43
+ table: 'profiles',
44
+ filter: (query) => query.eq('id', userId ?? ''),
45
+ queryKey: ['current', userId ?? ''],
46
+ queryOptions: {
47
+ enabled: isLoaded && !!userId,
48
+ },
49
+ });
50
+ }
51
+
52
+ /**
53
+ * Convenience hook to fetch the current user's profile as a single object.
54
+ *
55
+ * This is a wrapper around `useCurrentProfile()` that extracts the first
56
+ * profile from the array and provides a cleaner API for common use cases.
57
+ *
58
+ * @example
59
+ * ```tsx
60
+ * function ProfilePage() {
61
+ * const { profile, isLoading, error, exists } = useProfile();
62
+ *
63
+ * if (isLoading) return <Spinner />;
64
+ * if (error) return <Error message={error.message} />;
65
+ * if (!exists) return <CreateProfileForm />;
66
+ *
67
+ * return <ProfileDisplay profile={profile} />;
68
+ * }
69
+ * ```
70
+ */
71
+ export function useProfile() {
72
+ const query = useCurrentProfile();
73
+ const profile = query.data?.[0] ?? null;
74
+
75
+ return {
76
+ /** The user's profile, or null if not found */
77
+ profile,
78
+ /** Whether a profile exists for the current user */
79
+ exists: profile !== null,
80
+ /** Whether the query is currently loading */
81
+ isLoading: query.isLoading,
82
+ /** Whether the query is fetching (includes background refetches) */
83
+ isFetching: query.isFetching,
84
+ /** Error from the query, if any */
85
+ error: query.error,
86
+ /** Refetch the profile data */
87
+ refetch: query.refetch,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Hook to create or update the current user's profile.
93
+ *
94
+ * Uses upsert to handle both creation and updates in a single operation.
95
+ * This is useful for syncing Clerk user data to Supabase on first login.
96
+ *
97
+ * @example
98
+ * ```tsx
99
+ * function ProfileSync() {
100
+ * const { user } = useUser();
101
+ * const upsertProfile = useUpsertProfile();
102
+ *
103
+ * useEffect(() => {
104
+ * if (user) {
105
+ * upsertProfile.mutate({
106
+ * id: user.id,
107
+ * email: user.primaryEmailAddress?.emailAddress ?? '',
108
+ * full_name: user.fullName,
109
+ * avatar_url: user.imageUrl,
110
+ * });
111
+ * }
112
+ * }, [user]);
113
+ * }
114
+ * ```
115
+ */
116
+ export function useUpsertProfile() {
117
+ const supabase = useSupabase();
118
+ const queryClient = useQueryClient();
119
+
120
+ return useMutation<Profile, PostgrestError, ProfileInsert>({
121
+ mutationFn: async (profile) => {
122
+ const { data, error } = await supabase.from('profiles').upsert(profile, { onConflict: 'id' }).select().single();
123
+
124
+ if (error) throw error;
125
+ return data as Profile;
126
+ },
127
+ onSuccess: () => {
128
+ // Invalidate profile queries to refetch fresh data
129
+ queryClient.invalidateQueries({ queryKey: ['supabase', 'profiles'] });
130
+ },
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Hook to update the current user's profile.
136
+ *
137
+ * Only updates the specified fields, leaving others unchanged.
138
+ *
139
+ * @example
140
+ * ```tsx
141
+ * function EditProfileForm() {
142
+ * const updateProfile = useUpdateProfile();
143
+ *
144
+ * const handleSubmit = (values: ProfileUpdate) => {
145
+ * updateProfile.mutate(values, {
146
+ * onSuccess: () => toast.success('Profile updated!'),
147
+ * onError: (error) => toast.error(error.message),
148
+ * });
149
+ * };
150
+ * }
151
+ * ```
152
+ */
153
+ export function useUpdateProfile() {
154
+ const { user } = useUser();
155
+ const supabase = useSupabase();
156
+ const queryClient = useQueryClient();
157
+
158
+ return useMutation<Profile, PostgrestError, ProfileUpdate>({
159
+ mutationFn: async (updates) => {
160
+ if (!user?.id) {
161
+ throw new Error('No authenticated user');
162
+ }
163
+
164
+ const { data, error } = await supabase.from('profiles').update(updates).eq('id', user.id).select().single();
165
+
166
+ if (error) throw error;
167
+ return data as Profile;
168
+ },
169
+ onSuccess: () => {
170
+ queryClient.invalidateQueries({ queryKey: ['supabase', 'profiles'] });
171
+ },
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Hook to delete the current user's profile.
177
+ *
178
+ * Use with caution - this permanently removes the user's profile data.
179
+ *
180
+ * @example
181
+ * ```tsx
182
+ * function DeleteAccountButton() {
183
+ * const deleteProfile = useDeleteProfile();
184
+ *
185
+ * const handleDelete = async () => {
186
+ * if (confirm('Are you sure? This cannot be undone.')) {
187
+ * await deleteProfile.mutateAsync();
188
+ * // User should be signed out after deletion
189
+ * }
190
+ * };
191
+ * }
192
+ * ```
193
+ */
194
+ export function useDeleteProfile() {
195
+ const { user } = useUser();
196
+ const supabase = useSupabase();
197
+ const queryClient = useQueryClient();
198
+
199
+ return useMutation<void, PostgrestError, void>({
200
+ mutationFn: async () => {
201
+ if (!user?.id) {
202
+ throw new Error('No authenticated user');
203
+ }
204
+
205
+ const { error } = await supabase.from('profiles').delete().eq('id', user.id);
206
+
207
+ if (error) throw error;
208
+ },
209
+ onSuccess: () => {
210
+ queryClient.removeQueries({ queryKey: ['supabase', 'profiles'] });
211
+ },
212
+ });
213
+ }
@@ -0,0 +1,150 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query';
2
+ import { renderHook, waitFor } from '@testing-library/react';
3
+ import type { ReactNode } from 'react';
4
+ import { describe, it, expect, beforeEach } from 'vitest';
5
+
6
+ import {
7
+ createTestQueryClient,
8
+ createProfile,
9
+ setMockSupabaseData,
10
+ setMockSupabaseError,
11
+ resetSupabaseMocks,
12
+ } from '@/test';
13
+ import type { Profile } from '@/types/database';
14
+
15
+ import { useSupabaseQuery } from './useSupabaseQuery';
16
+
17
+ function createWrapper() {
18
+ const queryClient = createTestQueryClient();
19
+ return function Wrapper({ children }: { children: ReactNode }) {
20
+ return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
21
+ };
22
+ }
23
+
24
+ describe('useSupabaseQuery', () => {
25
+ beforeEach(() => {
26
+ resetSupabaseMocks();
27
+ });
28
+
29
+ it('returns empty array when no data', async () => {
30
+ setMockSupabaseData([]);
31
+
32
+ const { result } = renderHook(
33
+ () =>
34
+ useSupabaseQuery<Profile>({
35
+ table: 'profiles',
36
+ queryKey: ['test'],
37
+ }),
38
+ { wrapper: createWrapper() },
39
+ );
40
+
41
+ await waitFor(() => {
42
+ expect(result.current.isSuccess).toBe(true);
43
+ });
44
+
45
+ expect(result.current.data).toEqual([]);
46
+ });
47
+
48
+ it('returns data when available', async () => {
49
+ const profile = createProfile({ id: 'user-1', full_name: 'Test User' });
50
+ setMockSupabaseData([profile]);
51
+
52
+ const { result } = renderHook(
53
+ () =>
54
+ useSupabaseQuery<Profile>({
55
+ table: 'profiles',
56
+ queryKey: ['test'],
57
+ }),
58
+ { wrapper: createWrapper() },
59
+ );
60
+
61
+ await waitFor(() => {
62
+ expect(result.current.isSuccess).toBe(true);
63
+ });
64
+
65
+ expect(result.current.data).toHaveLength(1);
66
+ expect(result.current.data?.[0].full_name).toBe('Test User');
67
+ });
68
+
69
+ it('returns error when query fails', async () => {
70
+ setMockSupabaseError({ message: 'Database error', code: 'DB_ERROR' });
71
+
72
+ const { result } = renderHook(
73
+ () =>
74
+ useSupabaseQuery<Profile>({
75
+ table: 'profiles',
76
+ queryKey: ['test-error'],
77
+ queryOptions: { retry: false },
78
+ }),
79
+ { wrapper: createWrapper() },
80
+ );
81
+
82
+ await waitFor(() => {
83
+ expect(result.current.isError).toBe(true);
84
+ });
85
+
86
+ expect(result.current.error?.message).toBe('Database error');
87
+ });
88
+
89
+ it('applies custom select', async () => {
90
+ const profile = createProfile({ id: 'user-1' });
91
+ setMockSupabaseData([profile]);
92
+
93
+ const { result } = renderHook(
94
+ () =>
95
+ useSupabaseQuery<Profile>({
96
+ table: 'profiles',
97
+ select: 'id, full_name',
98
+ queryKey: ['test-select'],
99
+ }),
100
+ { wrapper: createWrapper() },
101
+ );
102
+
103
+ await waitFor(() => {
104
+ expect(result.current.isSuccess).toBe(true);
105
+ });
106
+
107
+ expect(result.current.data).toBeDefined();
108
+ });
109
+
110
+ it('applies filter function', async () => {
111
+ const profile = createProfile({ id: 'user-1' });
112
+ setMockSupabaseData([profile]);
113
+
114
+ const { result } = renderHook(
115
+ () =>
116
+ useSupabaseQuery<Profile>({
117
+ table: 'profiles',
118
+ filter: (query) => query.eq('id', 'user-1'),
119
+ queryKey: ['test-filter'],
120
+ }),
121
+ { wrapper: createWrapper() },
122
+ );
123
+
124
+ await waitFor(() => {
125
+ expect(result.current.isSuccess).toBe(true);
126
+ });
127
+
128
+ expect(result.current.data).toHaveLength(1);
129
+ });
130
+
131
+ it('respects enabled option', async () => {
132
+ const profile = createProfile();
133
+ setMockSupabaseData([profile]);
134
+
135
+ const { result } = renderHook(
136
+ () =>
137
+ useSupabaseQuery<Profile>({
138
+ table: 'profiles',
139
+ queryKey: ['test-disabled'],
140
+ queryOptions: { enabled: false },
141
+ }),
142
+ { wrapper: createWrapper() },
143
+ );
144
+
145
+ // Query should not run when disabled
146
+ expect(result.current.isLoading).toBe(false);
147
+ expect(result.current.isFetching).toBe(false);
148
+ expect(result.current.data).toBeUndefined();
149
+ });
150
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Generic Supabase query hook with TanStack Query integration.
3
+ *
4
+ * Provides type-safe database queries with automatic Clerk authentication
5
+ * and RLS policy enforcement.
6
+ */
7
+
8
+ import { useQuery, type UseQueryOptions, type UseQueryResult } from '@tanstack/react-query';
9
+ import type { PostgrestError } from '@supabase/supabase-js';
10
+
11
+ import { useSupabase } from '@/contexts/supabaseContext';
12
+ import type { TableName } from '@/types/database';
13
+
14
+ /**
15
+ * Options for useSupabaseQuery hook.
16
+ */
17
+ export interface UseSupabaseQueryOptions<TData> {
18
+ /** The database table to query */
19
+ table: TableName;
20
+ /** Columns to select (defaults to '*') */
21
+ select?: string;
22
+ /** Optional filter function to apply conditions */
23
+ filter?: (query: ReturnType<ReturnType<typeof useSupabase>['from']>) => unknown;
24
+ /** Unique query key for caching (will be prefixed with ['supabase', table]) */
25
+ queryKey: string[];
26
+ /** Additional TanStack Query options */
27
+ queryOptions?: Omit<UseQueryOptions<TData[], PostgrestError>, 'queryKey' | 'queryFn'>;
28
+ }
29
+
30
+ /**
31
+ * Generic hook for Supabase SELECT queries with TanStack Query.
32
+ *
33
+ * Automatically applies Clerk authentication via the Supabase context,
34
+ * ensuring RLS policies are enforced based on the current user.
35
+ *
36
+ * @example
37
+ * ```tsx
38
+ * // Simple query - fetch all user's profiles
39
+ * const { data, isLoading } = useSupabaseQuery<Profile>({
40
+ * table: 'profiles',
41
+ * queryKey: ['all'],
42
+ * });
43
+ *
44
+ * // Query with filter
45
+ * const { data } = useSupabaseQuery<Profile>({
46
+ * table: 'profiles',
47
+ * select: 'id, full_name, avatar_url',
48
+ * filter: (query) => query.eq('id', userId),
49
+ * queryKey: ['single', userId],
50
+ * });
51
+ *
52
+ * // Query with custom options
53
+ * const { data } = useSupabaseQuery<Profile>({
54
+ * table: 'profiles',
55
+ * queryKey: ['current'],
56
+ * queryOptions: {
57
+ * staleTime: 1000 * 60 * 10, // 10 minutes
58
+ * enabled: !!userId,
59
+ * },
60
+ * });
61
+ * ```
62
+ */
63
+ export function useSupabaseQuery<TData>({
64
+ table,
65
+ select = '*',
66
+ filter,
67
+ queryKey,
68
+ queryOptions,
69
+ }: UseSupabaseQueryOptions<TData>): UseQueryResult<TData[], PostgrestError> {
70
+ const supabase = useSupabase();
71
+
72
+ return useQuery<TData[], PostgrestError>({
73
+ queryKey: ['supabase', table, ...queryKey],
74
+ queryFn: async () => {
75
+ let query = supabase.from(table).select(select);
76
+
77
+ if (filter) {
78
+ query = filter(query) as typeof query;
79
+ }
80
+
81
+ const { data, error } = await query;
82
+
83
+ if (error) {
84
+ throw error;
85
+ }
86
+
87
+ return (data ?? []) as TData[];
88
+ },
89
+ ...queryOptions,
90
+ });
91
+ }