@donotdev/functions 0.0.10 → 0.0.11

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 (77) hide show
  1. package/package.json +31 -7
  2. package/src/firebase/auth/setCustomClaims.ts +26 -4
  3. package/src/firebase/baseFunction.ts +43 -20
  4. package/src/firebase/billing/cancelSubscription.ts +9 -1
  5. package/src/firebase/billing/changePlan.ts +8 -2
  6. package/src/firebase/billing/createCustomerPortal.ts +16 -2
  7. package/src/firebase/billing/refreshSubscriptionStatus.ts +3 -1
  8. package/src/firebase/billing/webhookHandler.ts +13 -1
  9. package/src/firebase/crud/aggregate.ts +20 -5
  10. package/src/firebase/crud/create.ts +31 -7
  11. package/src/firebase/crud/list.ts +7 -22
  12. package/src/firebase/crud/update.ts +29 -7
  13. package/src/firebase/oauth/exchangeToken.ts +30 -4
  14. package/src/firebase/oauth/githubAccess.ts +8 -3
  15. package/src/firebase/registerCrudFunctions.ts +2 -2
  16. package/src/firebase/scheduled/checkExpiredSubscriptions.ts +20 -3
  17. package/src/shared/__tests__/detectFirestore.test.ts +52 -0
  18. package/src/shared/__tests__/errorHandling.test.ts +144 -0
  19. package/src/shared/__tests__/idempotency.test.ts +95 -0
  20. package/src/shared/__tests__/rateLimiter.test.ts +142 -0
  21. package/src/shared/__tests__/validation.test.ts +172 -0
  22. package/src/shared/billing/__tests__/createCheckout.test.ts +393 -0
  23. package/src/shared/billing/__tests__/webhookHandler.test.ts +1091 -0
  24. package/src/shared/billing/webhookHandler.ts +16 -7
  25. package/src/shared/errorHandling.ts +16 -54
  26. package/src/shared/firebase.ts +1 -25
  27. package/src/shared/oauth/__tests__/exchangeToken.test.ts +116 -0
  28. package/src/shared/oauth/__tests__/grantAccess.test.ts +237 -0
  29. package/src/shared/utils/__tests__/functionWrapper.test.ts +122 -0
  30. package/src/shared/utils/external/subscription.ts +10 -0
  31. package/src/shared/utils/internal/auth.ts +140 -16
  32. package/src/shared/utils/internal/rateLimiter.ts +101 -90
  33. package/src/shared/utils/internal/validation.ts +47 -3
  34. package/src/shared/utils.ts +154 -39
  35. package/src/supabase/auth/deleteAccount.ts +59 -0
  36. package/src/supabase/auth/getCustomClaims.ts +56 -0
  37. package/src/supabase/auth/getUserAuthStatus.ts +64 -0
  38. package/src/supabase/auth/removeCustomClaims.ts +75 -0
  39. package/src/supabase/auth/setCustomClaims.ts +73 -0
  40. package/src/supabase/baseFunction.ts +302 -0
  41. package/src/supabase/billing/cancelSubscription.ts +57 -0
  42. package/src/supabase/billing/changePlan.ts +62 -0
  43. package/src/supabase/billing/createCheckoutSession.ts +82 -0
  44. package/src/supabase/billing/createCustomerPortal.ts +58 -0
  45. package/src/supabase/billing/refreshSubscriptionStatus.ts +89 -0
  46. package/src/supabase/crud/aggregate.ts +169 -0
  47. package/src/supabase/crud/create.ts +225 -0
  48. package/src/supabase/crud/delete.ts +154 -0
  49. package/src/supabase/crud/get.ts +89 -0
  50. package/src/supabase/crud/index.ts +24 -0
  51. package/src/supabase/crud/list.ts +357 -0
  52. package/src/supabase/crud/update.ts +199 -0
  53. package/src/supabase/helpers/authProvider.ts +45 -0
  54. package/src/supabase/index.ts +73 -0
  55. package/src/supabase/registerCrudFunctions.ts +180 -0
  56. package/src/supabase/utils/idempotency.ts +141 -0
  57. package/src/supabase/utils/monitoring.ts +187 -0
  58. package/src/supabase/utils/rateLimiter.ts +216 -0
  59. package/src/vercel/api/auth/get-custom-claims.ts +3 -2
  60. package/src/vercel/api/auth/get-user-auth-status.ts +3 -2
  61. package/src/vercel/api/auth/remove-custom-claims.ts +3 -2
  62. package/src/vercel/api/auth/set-custom-claims.ts +5 -2
  63. package/src/vercel/api/billing/cancel.ts +2 -1
  64. package/src/vercel/api/billing/change-plan.ts +3 -1
  65. package/src/vercel/api/billing/customer-portal.ts +4 -1
  66. package/src/vercel/api/billing/refresh-subscription-status.ts +3 -1
  67. package/src/vercel/api/billing/webhook-handler.ts +24 -4
  68. package/src/vercel/api/crud/create.ts +14 -8
  69. package/src/vercel/api/crud/delete.ts +15 -6
  70. package/src/vercel/api/crud/get.ts +16 -8
  71. package/src/vercel/api/crud/list.ts +22 -10
  72. package/src/vercel/api/crud/update.ts +16 -10
  73. package/src/vercel/api/oauth/check-github-access.ts +2 -5
  74. package/src/vercel/api/oauth/grant-github-access.ts +1 -5
  75. package/src/vercel/api/oauth/revoke-github-access.ts +7 -8
  76. package/src/vercel/api/utils/cors.ts +13 -2
  77. package/src/vercel/baseFunction.ts +40 -25
@@ -0,0 +1,357 @@
1
+ // packages/functions/src/supabase/crud/list.ts
2
+
3
+ /**
4
+ * @fileoverview Generic handler to list entities from Supabase with pagination, filtering, and sorting.
5
+ * @description Provides a reusable implementation for listing documents from PostgreSQL with advanced query capabilities.
6
+ *
7
+ * @version 0.0.1
8
+ * @since 0.5.0
9
+ * @author AMBROISE PARK Consulting
10
+ */
11
+
12
+ import * as v from 'valibot';
13
+
14
+ import {
15
+ filterVisibleFields,
16
+ hasRoleAccess,
17
+ HIDDEN_STATUSES,
18
+ } from '@donotdev/core/server';
19
+ import type {
20
+ EntityOwnershipConfig,
21
+ EntityOwnershipPublicCondition,
22
+ UserRole,
23
+ } from '@donotdev/core/server';
24
+
25
+ import { defaultFieldMapper } from '@donotdev/supabase';
26
+
27
+ import { DoNotDevError } from '../../shared/utils.js';
28
+ import { createSupabaseHandler } from '../baseFunction.js';
29
+
30
+ /** Field mapper: app (camelCase) ↔ backend (snake_case). Single boundary for list handler. */
31
+ const mapper = defaultFieldMapper;
32
+
33
+ /** Ensure we only pass strings to the mapper (entity listFields/ownership can be mis-typed at runtime). */
34
+ function toBackendColumn(field: unknown): string {
35
+ return mapper.toBackendField(typeof field === 'string' ? field : String(field));
36
+ }
37
+
38
+ export interface ListEntityRequest {
39
+ where?: Array<[string, any, any]>;
40
+ orderBy?: Array<[string, 'asc' | 'desc']>;
41
+ limit?: number;
42
+ startAfterId?: string; // Offset-based pagination (legacy)
43
+ startAfterCursor?: string; // Keyset pagination (cursor-based)
44
+ search?: {
45
+ field: string;
46
+ query: string;
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Encode cursor for keyset pagination
52
+ */
53
+ function encodeCursor(id: string, orderBy: Record<string, any>): string {
54
+ const cursor = { id, orderBy };
55
+ return Buffer.from(JSON.stringify(cursor)).toString('base64');
56
+ }
57
+
58
+ /**
59
+ * Decode cursor for keyset pagination
60
+ */
61
+ function decodeCursor(cursor: string): { id: string; orderBy: Record<string, any> } {
62
+ try {
63
+ return JSON.parse(Buffer.from(cursor, 'base64').toString());
64
+ } catch (error) {
65
+ throw new DoNotDevError('Invalid cursor format', 'invalid-argument');
66
+ }
67
+ }
68
+
69
+ /** Apply operator; column must already be backend (snake_case) name. */
70
+ function applyOperator(
71
+ query: any,
72
+ column: string,
73
+ operator: string,
74
+ value: any
75
+ ): any {
76
+ switch (operator) {
77
+ case '==':
78
+ return query.eq(column, value);
79
+ case '!=':
80
+ return query.neq(column, value);
81
+ case '>':
82
+ return query.gt(column, value);
83
+ case '>=':
84
+ return query.gte(column, value);
85
+ case '<':
86
+ return query.lt(column, value);
87
+ case '<=':
88
+ return query.lte(column, value);
89
+ case 'in':
90
+ return query.in(column, Array.isArray(value) ? value : [value]);
91
+ case 'not-in':
92
+ return query.not(column, 'in', Array.isArray(value) ? value : [value]);
93
+ case 'array-contains':
94
+ return query.contains(column, [value]);
95
+ case 'array-contains-any':
96
+ return query.contains(column, Array.isArray(value) ? value : [value]);
97
+ default:
98
+ return query.eq(column, value);
99
+ }
100
+ }
101
+
102
+ function applyPublicCondition(
103
+ query: any,
104
+ publicCondition: EntityOwnershipPublicCondition[]
105
+ ): any {
106
+ let q = query;
107
+ for (const c of publicCondition) {
108
+ q = applyOperator(q, toBackendColumn(c.field), c.op, c.value);
109
+ }
110
+ return q;
111
+ }
112
+
113
+ /**
114
+ * Create a Supabase Edge Function handler for listing entities.
115
+ *
116
+ * @param collection - The Supabase table name
117
+ * @param documentSchema - The Valibot schema for document validation
118
+ * @param requiredRole - Minimum role required for this operation
119
+ * @param listFields - Optional array of field names to include (plus id)
120
+ * @param ownership - Optional ownership config for list constraints and visibility: 'owner' masking
121
+ * @param isListCard - When true and ownership is set, applies public condition; when false, applies "mine" filter
122
+ * @returns A `(req: Request) => Promise<Response>` handler for Deno.serve
123
+ */
124
+ export function createSupabaseListEntities(
125
+ collection: string,
126
+ documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
127
+ requiredRole: UserRole,
128
+ listFields?: string[],
129
+ ownership?: EntityOwnershipConfig,
130
+ isListCard?: boolean
131
+ ) {
132
+ const requestSchema = v.object({
133
+ where: v.optional(v.array(v.tuple([v.string(), v.any(), v.any()]))),
134
+ orderBy: v.optional(
135
+ v.array(v.tuple([v.pipe(v.string(), v.minLength(1)), v.picklist(['asc', 'desc'])]))
136
+ ),
137
+ limit: v.optional(v.pipe(v.number(), v.minValue(1))),
138
+ startAfterId: v.optional(v.string()), // Offset-based (legacy)
139
+ startAfterCursor: v.optional(v.string()), // Keyset pagination
140
+ search: v.optional(
141
+ v.object({
142
+ field: v.string(),
143
+ query: v.string(),
144
+ })
145
+ ),
146
+ });
147
+
148
+ return createSupabaseHandler(
149
+ isListCard ? `listCard_${collection}` : `list_${collection}`,
150
+ requestSchema,
151
+ async (data: ListEntityRequest, ctx) => {
152
+ const { where = [], orderBy = [], limit = 50, startAfterId, startAfterCursor, search } = data;
153
+ const { userRole, uid, supabaseAdmin } = ctx;
154
+
155
+ const isAdmin = hasRoleAccess(userRole, 'admin');
156
+
157
+ // Build query - select fields (listFields + id + status, or *). Only use string entries (entity may be mis-typed).
158
+ const safeListFields = listFields?.filter((f): f is string => typeof f === 'string');
159
+ const selectFields = safeListFields && safeListFields.length > 0
160
+ ? safeListFields.map((f) => toBackendColumn(f)).join(', ') + ', id, status'
161
+ : '*';
162
+
163
+ let query = supabaseAdmin.from(collection).select(selectFields, { count: 'exact' });
164
+
165
+ // Filter out hidden statuses for non-admin users
166
+ if (!isAdmin) {
167
+ query = query.not('status', 'in', [...HIDDEN_STATUSES]);
168
+ }
169
+
170
+ // Ownership: when set and not admin, listCard = public condition, list = mine filter
171
+ if (ownership && !isAdmin) {
172
+ if (
173
+ isListCard &&
174
+ ownership.publicCondition &&
175
+ ownership.publicCondition.length > 0
176
+ ) {
177
+ query = applyPublicCondition(query, ownership.publicCondition);
178
+ } else if (!isListCard && ownership.ownerFields.length > 0) {
179
+ const firstOwnerField = ownership.ownerFields[0];
180
+ query = query.eq(toBackendColumn(firstOwnerField), uid);
181
+ }
182
+ }
183
+
184
+ if (search) {
185
+ query = query.ilike(toBackendColumn(search.field), `%${search.query}%`);
186
+ }
187
+
188
+ for (const [field, operator, value] of where) {
189
+ query = applyOperator(query, toBackendColumn(field), operator, value);
190
+ }
191
+
192
+ const hasIdInOrderBy = orderBy.some(([field]) => toBackendColumn(field) === 'id');
193
+ for (const [field, direction] of orderBy) {
194
+ query = query.order(toBackendColumn(field), { ascending: direction === 'asc' });
195
+ }
196
+ // Add id as tiebreaker if not already in orderBy
197
+ if (!hasIdInOrderBy && orderBy.length > 0) {
198
+ query = query.order('id', { ascending: true });
199
+ }
200
+
201
+ // Pagination: keyset (cursor-based) or offset-based (legacy)
202
+ let useKeysetPagination = false;
203
+ if (startAfterCursor) {
204
+ // Keyset pagination (preferred)
205
+ useKeysetPagination = true;
206
+ try {
207
+ const cursor = decodeCursor(startAfterCursor);
208
+
209
+ if (orderBy.length === 0) {
210
+ query = query.gt('id', cursor.id);
211
+ } else {
212
+ const firstOrderFieldApp = orderBy[0][0];
213
+ const firstOrderDirection = orderBy[0][1];
214
+ const orderColumnBackend = toBackendColumn(firstOrderFieldApp);
215
+ const cursorValue = cursor.orderBy[firstOrderFieldApp];
216
+ if (firstOrderDirection === 'asc') {
217
+ query = query.gte(orderColumnBackend, cursorValue);
218
+ } else {
219
+ query = query.lte(orderColumnBackend, cursorValue);
220
+ }
221
+ }
222
+ } catch (error) {
223
+ throw new DoNotDevError('Invalid cursor format', 'invalid-argument');
224
+ }
225
+ // Apply limit (will filter cursor item after fetch)
226
+ query = query.limit(limit + 1); // Fetch one extra to check hasMore
227
+ } else if (startAfterId) {
228
+ // Offset-based pagination (legacy)
229
+ const offset = parseInt(startAfterId, 10);
230
+ if (isNaN(offset)) {
231
+ throw new DoNotDevError('Invalid startAfterId', 'invalid-argument');
232
+ }
233
+ query = query.range(offset, offset + limit - 1);
234
+ } else {
235
+ // First page
236
+ query = query.range(0, limit - 1);
237
+ }
238
+
239
+ // Execute query
240
+ const { data: rows, error, count } = await query;
241
+
242
+ if (error) {
243
+ throw new DoNotDevError(`Failed to list entities: ${error.message}`, 'internal');
244
+ }
245
+
246
+ let items = (rows || []) as Record<string, any>[];
247
+
248
+ // Filter out cursor item for keyset pagination
249
+ if (useKeysetPagination && startAfterCursor && items.length > 0) {
250
+ try {
251
+ const cursor = decodeCursor(startAfterCursor);
252
+ items = items.filter((item) => {
253
+ if (orderBy.length === 0) {
254
+ return item.id !== cursor.id;
255
+ }
256
+ const firstOrderFieldApp = orderBy[0][0];
257
+ const cursorValue = cursor.orderBy[firstOrderFieldApp];
258
+ const itemValue = item[mapper.toBackendField(firstOrderFieldApp)];
259
+ return !(item.id === cursor.id && itemValue === cursorValue);
260
+ });
261
+ } catch {
262
+ // If cursor decode fails, continue with all items
263
+ }
264
+ }
265
+
266
+ // Helper: Check if value is a Picture object
267
+ const isPictureObject = (value: any): boolean => {
268
+ return (
269
+ typeof value === 'object' &&
270
+ value !== null &&
271
+ 'thumbUrl' in value &&
272
+ 'fullUrl' in value
273
+ );
274
+ };
275
+
276
+ // Helper: Optimize picture fields for listCard (only return first picture's thumbUrl)
277
+ const optimizePictureField = (value: any): any => {
278
+ if (Array.isArray(value) && value.length > 0) {
279
+ const firstPicture = value[0];
280
+ if (isPictureObject(firstPicture)) {
281
+ return firstPicture.thumbUrl || firstPicture.fullUrl || null;
282
+ }
283
+ if (typeof firstPicture === 'string') {
284
+ return firstPicture;
285
+ }
286
+ } else if (isPictureObject(value)) {
287
+ return value.thumbUrl || value.fullUrl || null;
288
+ }
289
+ return value;
290
+ };
291
+
292
+ const visibilityOptions = ownership && uid ? { uid, ownership } : undefined;
293
+
294
+ // Filter document fields based on visibility and user role
295
+ const filteredItems = items.map((row) => {
296
+ const camelRow = mapper.fromBackendRow(row) as Record<string, any>;
297
+ const visibleData = filterVisibleFields(
298
+ camelRow,
299
+ documentSchema,
300
+ userRole,
301
+ visibilityOptions
302
+ ? { ...visibilityOptions, documentData: camelRow }
303
+ : undefined
304
+ );
305
+
306
+ // If listFields specified, filter to only those fields (plus id always)
307
+ if (safeListFields && safeListFields.length > 0) {
308
+ const filtered: Record<string, any> = { id: camelRow.id };
309
+ for (const field of safeListFields) {
310
+ if (field in visibleData) {
311
+ const value = visibleData[field];
312
+ filtered[field] = optimizePictureField(value);
313
+ }
314
+ }
315
+ return filtered;
316
+ }
317
+
318
+ // No listFields restriction, return all visible fields (optimize pictures)
319
+ const optimizedData: Record<string, any> = { id: camelRow.id };
320
+ for (const [key, value] of Object.entries(visibleData)) {
321
+ optimizedData[key] = optimizePictureField(value);
322
+ }
323
+ return optimizedData;
324
+ });
325
+
326
+ const hasMore = items.length === limit;
327
+
328
+ // Generate cursor for keyset pagination or offset for legacy
329
+ let lastVisible: string | null = null;
330
+ if (hasMore && filteredItems.length > 0) {
331
+ const lastItem = filteredItems[filteredItems.length - 1];
332
+ if (useKeysetPagination && orderBy.length > 0) {
333
+ // Create cursor from last item's orderBy field + id
334
+ const firstOrderField = orderBy[0][0];
335
+ const orderByValue: Record<string, any> = {};
336
+ orderByValue[firstOrderField] = lastItem[firstOrderField];
337
+ lastVisible = encodeCursor(lastItem.id, orderByValue);
338
+ } else if (useKeysetPagination) {
339
+ // No orderBy: just use id
340
+ lastVisible = encodeCursor(lastItem.id, {});
341
+ } else {
342
+ // Offset-based: return offset + count
343
+ const offset = startAfterId ? parseInt(startAfterId, 10) : 0;
344
+ lastVisible = String(offset + items.length);
345
+ }
346
+ }
347
+
348
+ return {
349
+ items: filteredItems,
350
+ lastVisible,
351
+ count: count ?? undefined,
352
+ hasMore,
353
+ };
354
+ },
355
+ requiredRole
356
+ );
357
+ }
@@ -0,0 +1,199 @@
1
+ // packages/functions/src/supabase/crud/update.ts
2
+
3
+ /**
4
+ * @fileoverview Generic handler to update an entity in Supabase.
5
+ * @description Provides a reusable implementation for updating documents in PostgreSQL with validation.
6
+ *
7
+ * @version 0.0.1
8
+ * @since 0.5.0
9
+ * @author AMBROISE PARK Consulting
10
+ */
11
+
12
+ import * as v from 'valibot';
13
+
14
+ import type { UserRole, UniqueKeyDefinition } from '@donotdev/core/server';
15
+
16
+ import { defaultFieldMapper } from '@donotdev/supabase';
17
+
18
+ import { updateMetadata } from '../../shared/index.js';
19
+ import { DoNotDevError, validateDocument } from '../../shared/utils.js';
20
+ import { createSupabaseHandler } from '../baseFunction.js';
21
+ import {
22
+ checkIdempotency,
23
+ storeIdempotency,
24
+ } from '../utils/idempotency.js';
25
+
26
+ const mapper = defaultFieldMapper;
27
+
28
+ export type UpdateEntityRequest = {
29
+ id: string;
30
+ payload: Record<string, any>;
31
+ idempotencyKey?: string;
32
+ };
33
+
34
+ /**
35
+ * Normalize a value for case-insensitive comparison
36
+ */
37
+ function normalizeValue(value: unknown): unknown {
38
+ if (typeof value === 'string') {
39
+ return value.toLowerCase();
40
+ }
41
+ return value;
42
+ }
43
+
44
+ /**
45
+ * Check unique key constraints for modified fields only
46
+ */
47
+ async function checkUniqueKeys(
48
+ collection: string,
49
+ id: string,
50
+ payload: Record<string, any>,
51
+ uniqueKeys: UniqueKeyDefinition[],
52
+ isDraft: boolean,
53
+ supabaseAdmin: any
54
+ ): Promise<void> {
55
+ for (const uniqueKey of uniqueKeys) {
56
+ if (isDraft && uniqueKey.skipForDrafts === true) continue;
57
+
58
+ // Only check fields that are being modified
59
+ const modifiedFields = uniqueKey.fields.filter((field) => field in payload);
60
+ if (modifiedFields.length === 0) continue;
61
+
62
+ // Check if all fields in the unique key have values
63
+ const allFieldsHaveValues = uniqueKey.fields.every(
64
+ (field) => payload[field] != null && payload[field] !== ''
65
+ );
66
+ if (!allFieldsHaveValues) continue;
67
+
68
+ // Build query excluding current document
69
+ let query = supabaseAdmin.from(collection).select('*');
70
+ for (const field of uniqueKey.fields) {
71
+ query = query.eq(mapper.toBackendField(field), normalizeValue(payload[field]));
72
+ }
73
+ query = query.neq('id', id);
74
+
75
+ const { data: existing, error } = await query.limit(1);
76
+
77
+ if (!error && existing && existing.length > 0) {
78
+ const fieldNames = uniqueKey.fields.join(' + ');
79
+ throw new DoNotDevError(
80
+ uniqueKey.errorMessage || `Duplicate ${fieldNames}`,
81
+ 'already-exists',
82
+ {
83
+ details: {
84
+ fields: uniqueKey.fields,
85
+ existingId: existing[0].id,
86
+ },
87
+ }
88
+ );
89
+ }
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Create a Supabase Edge Function handler for updating an entity.
95
+ *
96
+ * @param collection - The Supabase table name
97
+ * @param documentSchema - The Valibot schema for document validation
98
+ * @param requiredRole - Minimum role required for this operation
99
+ * @returns A `(req: Request) => Promise<Response>` handler for Deno.serve
100
+ */
101
+ export function createSupabaseUpdateEntity(
102
+ collection: string,
103
+ documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
104
+ requiredRole: UserRole
105
+ ) {
106
+ const requestSchema = v.object({
107
+ id: v.pipe(v.string(), v.minLength(1)),
108
+ payload: v.record(v.string(), v.any()),
109
+ idempotencyKey: v.optional(v.string()),
110
+ });
111
+
112
+ return createSupabaseHandler(
113
+ `update_${collection}`,
114
+ requestSchema,
115
+ async (data: UpdateEntityRequest, ctx) => {
116
+ const { id, payload, idempotencyKey } = data;
117
+ const { uid, supabaseAdmin } = ctx;
118
+
119
+ // Idempotency check if key provided
120
+ if (idempotencyKey) {
121
+ const cachedResult = await checkIdempotency<any>(
122
+ supabaseAdmin,
123
+ idempotencyKey,
124
+ 'update'
125
+ );
126
+ if (cachedResult) {
127
+ return cachedResult;
128
+ }
129
+ }
130
+
131
+ // Fetch existing document
132
+ const { data: existing, error: fetchError } = await supabaseAdmin
133
+ .from(collection)
134
+ .select('*')
135
+ .eq('id', id)
136
+ .single();
137
+
138
+ if (fetchError || !existing) {
139
+ throw new DoNotDevError('Entity not found', 'not-found');
140
+ }
141
+
142
+ const merged = { ...(mapper.fromBackendRow(existing) as Record<string, any>), ...payload };
143
+ const status = merged.status ?? existing.status;
144
+ const isDraft = status === 'draft';
145
+
146
+ // Check unique keys if schema has metadata with uniqueKeys
147
+ const schemaWithMeta = documentSchema as {
148
+ metadata?: { uniqueKeys?: UniqueKeyDefinition[] };
149
+ };
150
+ const uniqueKeys = schemaWithMeta.metadata?.uniqueKeys;
151
+
152
+ if (uniqueKeys && uniqueKeys.length > 0) {
153
+ await checkUniqueKeys(collection, id, merged, uniqueKeys, isDraft, supabaseAdmin);
154
+ }
155
+
156
+ // Validate merged document (skip for drafts)
157
+ if (!isDraft) {
158
+ validateDocument(merged, documentSchema);
159
+ }
160
+
161
+ const metadata = updateMetadata(uid);
162
+ const snakeMetadata = mapper.toBackendKeys(metadata as Record<string, unknown>);
163
+
164
+ const { createdAt, updatedAt, created_at, updated_at, id: _id, ...payloadWithoutTimestamps } = payload;
165
+ const snakePayload = mapper.toBackendKeys(payloadWithoutTimestamps);
166
+
167
+ // Update document (DB sets updated_at via trigger)
168
+ const { data: updated, error } = await supabaseAdmin
169
+ .from(collection)
170
+ .update({
171
+ ...snakePayload,
172
+ ...snakeMetadata,
173
+ })
174
+ .eq('id', id)
175
+ .select()
176
+ .single();
177
+
178
+ if (error) {
179
+ throw new DoNotDevError(`Failed to update entity: ${error.message}`, 'internal');
180
+ }
181
+
182
+ const result = mapper.fromBackendRow(updated) as Record<string, any>;
183
+
184
+ // Store result for idempotency if key provided
185
+ if (idempotencyKey) {
186
+ await storeIdempotency(
187
+ supabaseAdmin,
188
+ idempotencyKey,
189
+ 'update',
190
+ result,
191
+ uid
192
+ );
193
+ }
194
+
195
+ return result;
196
+ },
197
+ requiredRole
198
+ );
199
+ }
@@ -0,0 +1,45 @@
1
+ // packages/functions/src/supabase/helpers/authProvider.ts
2
+
3
+ /**
4
+ * @fileoverview Supabase Auth Provider for shared billing logic
5
+ * @description Maps Supabase Admin auth to the shared `AuthProvider` interface,
6
+ * bridging `app_metadata` ↔ `customClaims`.
7
+ *
8
+ * @version 0.0.1
9
+ * @since 0.5.0
10
+ * @author AMBROISE PARK Consulting
11
+ */
12
+
13
+ import type { AuthProvider } from '../../shared/billing/helpers/updateUserSubscription.js';
14
+
15
+ import type { SupabaseClient } from '@supabase/supabase-js';
16
+
17
+ // =============================================================================
18
+ // Factory
19
+ // =============================================================================
20
+
21
+ /**
22
+ * Create an AuthProvider backed by Supabase Admin.
23
+ * `app_metadata` is used as the equivalent of Firebase custom claims.
24
+ *
25
+ * @param supabaseAdmin - Supabase client created with the service role key
26
+ * @returns AuthProvider compatible with shared billing helpers
27
+ *
28
+ * @version 0.0.1
29
+ * @since 0.5.0
30
+ */
31
+ export function createSupabaseAuthProvider(supabaseAdmin: SupabaseClient): AuthProvider {
32
+ return {
33
+ async getUser(userId: string) {
34
+ const { data, error } = await supabaseAdmin.auth.admin.getUserById(userId);
35
+ if (error) throw error;
36
+ return { customClaims: data.user?.app_metadata ?? {} };
37
+ },
38
+ async setCustomUserClaims(userId: string, claims: Record<string, unknown>) {
39
+ const { error } = await supabaseAdmin.auth.admin.updateUserById(userId, {
40
+ app_metadata: claims,
41
+ });
42
+ if (error) throw error;
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,73 @@
1
+ // packages/functions/src/supabase/index.ts
2
+
3
+ /**
4
+ * @fileoverview Supabase Edge Functions barrel exports
5
+ * @description All Supabase Edge Function handlers for auth and billing.
6
+ *
7
+ * @version 0.0.1
8
+ * @since 0.5.0
9
+ * @author AMBROISE PARK Consulting
10
+ */
11
+
12
+ // Base
13
+ export { createSupabaseHandler } from './baseFunction.js';
14
+ export type { SupabaseHandlerContext } from './baseFunction.js';
15
+
16
+ // Helpers
17
+ export { createSupabaseAuthProvider } from './helpers/authProvider.js';
18
+
19
+ // Auth
20
+ export { createDeleteAccount } from './auth/deleteAccount.js';
21
+ export { createSetCustomClaims } from './auth/setCustomClaims.js';
22
+ export { createGetCustomClaims } from './auth/getCustomClaims.js';
23
+ export { createRemoveCustomClaims } from './auth/removeCustomClaims.js';
24
+ export { createGetUserAuthStatus } from './auth/getUserAuthStatus.js';
25
+
26
+ // Billing
27
+ export { createCheckoutSession } from './billing/createCheckoutSession.js';
28
+ export { createCancelSubscription } from './billing/cancelSubscription.js';
29
+ export { createChangePlan } from './billing/changePlan.js';
30
+ export { createCustomerPortal } from './billing/createCustomerPortal.js';
31
+ export { createRefreshSubscriptionStatus } from './billing/refreshSubscriptionStatus.js';
32
+
33
+ // CRUD
34
+ export {
35
+ createSupabaseGetEntity,
36
+ createSupabaseCreateEntity,
37
+ createSupabaseUpdateEntity,
38
+ createSupabaseDeleteEntity,
39
+ createSupabaseListEntities,
40
+ createSupabaseAggregateEntities,
41
+ } from './crud/index.js';
42
+ export { createSupabaseCrudFunctions } from './registerCrudFunctions.js';
43
+ export type {
44
+ GetEntityRequest,
45
+ CreateEntityRequest,
46
+ UpdateEntityRequest,
47
+ DeleteEntityRequest,
48
+ ListEntityRequest,
49
+ AggregateEntityRequest,
50
+ ReferenceMetadata,
51
+ } from './crud/index.js';
52
+
53
+ // Utils
54
+ export {
55
+ checkIdempotency,
56
+ storeIdempotency,
57
+ cleanupExpiredIdempotency,
58
+ } from './utils/idempotency.js';
59
+ export {
60
+ checkRateLimitWithPostgres,
61
+ DEFAULT_RATE_LIMITS,
62
+ } from './utils/rateLimiter.js';
63
+ export type {
64
+ RateLimitConfig,
65
+ RateLimitResult,
66
+ } from './utils/rateLimiter.js';
67
+ export {
68
+ recordOperationMetrics,
69
+ getFailureRate,
70
+ getOperationCounts,
71
+ getSlowOperations,
72
+ } from './utils/monitoring.js';
73
+ export type { OperationMetrics } from './utils/monitoring.js';