@donotdev/functions 0.0.9 → 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.
- package/package.json +31 -7
- package/src/firebase/auth/setCustomClaims.ts +26 -4
- package/src/firebase/baseFunction.ts +43 -20
- package/src/firebase/billing/cancelSubscription.ts +9 -1
- package/src/firebase/billing/changePlan.ts +8 -2
- package/src/firebase/billing/createCustomerPortal.ts +16 -2
- package/src/firebase/billing/refreshSubscriptionStatus.ts +3 -1
- package/src/firebase/billing/webhookHandler.ts +13 -1
- package/src/firebase/config/constants.ts +0 -3
- package/src/firebase/crud/aggregate.ts +20 -5
- package/src/firebase/crud/create.ts +31 -7
- package/src/firebase/crud/get.ts +16 -8
- package/src/firebase/crud/list.ts +70 -29
- package/src/firebase/crud/update.ts +29 -7
- package/src/firebase/oauth/exchangeToken.ts +30 -4
- package/src/firebase/oauth/githubAccess.ts +8 -3
- package/src/firebase/registerCrudFunctions.ts +15 -4
- package/src/firebase/scheduled/checkExpiredSubscriptions.ts +20 -3
- package/src/shared/__tests__/detectFirestore.test.ts +52 -0
- package/src/shared/__tests__/errorHandling.test.ts +144 -0
- package/src/shared/__tests__/idempotency.test.ts +95 -0
- package/src/shared/__tests__/rateLimiter.test.ts +142 -0
- package/src/shared/__tests__/validation.test.ts +172 -0
- package/src/shared/billing/__tests__/createCheckout.test.ts +393 -0
- package/src/shared/billing/__tests__/webhookHandler.test.ts +1091 -0
- package/src/shared/billing/webhookHandler.ts +16 -7
- package/src/shared/errorHandling.ts +16 -54
- package/src/shared/firebase.ts +1 -25
- package/src/shared/oauth/__tests__/exchangeToken.test.ts +116 -0
- package/src/shared/oauth/__tests__/grantAccess.test.ts +237 -0
- package/src/shared/schema.ts +7 -1
- package/src/shared/utils/__tests__/functionWrapper.test.ts +122 -0
- package/src/shared/utils/external/subscription.ts +10 -0
- package/src/shared/utils/internal/auth.ts +140 -16
- package/src/shared/utils/internal/rateLimiter.ts +101 -90
- package/src/shared/utils/internal/validation.ts +47 -3
- package/src/shared/utils.ts +154 -39
- package/src/supabase/auth/deleteAccount.ts +59 -0
- package/src/supabase/auth/getCustomClaims.ts +56 -0
- package/src/supabase/auth/getUserAuthStatus.ts +64 -0
- package/src/supabase/auth/removeCustomClaims.ts +75 -0
- package/src/supabase/auth/setCustomClaims.ts +73 -0
- package/src/supabase/baseFunction.ts +302 -0
- package/src/supabase/billing/cancelSubscription.ts +57 -0
- package/src/supabase/billing/changePlan.ts +62 -0
- package/src/supabase/billing/createCheckoutSession.ts +82 -0
- package/src/supabase/billing/createCustomerPortal.ts +58 -0
- package/src/supabase/billing/refreshSubscriptionStatus.ts +89 -0
- package/src/supabase/crud/aggregate.ts +169 -0
- package/src/supabase/crud/create.ts +225 -0
- package/src/supabase/crud/delete.ts +154 -0
- package/src/supabase/crud/get.ts +89 -0
- package/src/supabase/crud/index.ts +24 -0
- package/src/supabase/crud/list.ts +357 -0
- package/src/supabase/crud/update.ts +199 -0
- package/src/supabase/helpers/authProvider.ts +45 -0
- package/src/supabase/index.ts +73 -0
- package/src/supabase/registerCrudFunctions.ts +180 -0
- package/src/supabase/utils/idempotency.ts +141 -0
- package/src/supabase/utils/monitoring.ts +187 -0
- package/src/supabase/utils/rateLimiter.ts +216 -0
- package/src/vercel/api/auth/get-custom-claims.ts +3 -2
- package/src/vercel/api/auth/get-user-auth-status.ts +3 -2
- package/src/vercel/api/auth/remove-custom-claims.ts +3 -2
- package/src/vercel/api/auth/set-custom-claims.ts +5 -2
- package/src/vercel/api/billing/cancel.ts +2 -1
- package/src/vercel/api/billing/change-plan.ts +3 -1
- package/src/vercel/api/billing/customer-portal.ts +4 -1
- package/src/vercel/api/billing/refresh-subscription-status.ts +3 -1
- package/src/vercel/api/billing/webhook-handler.ts +24 -4
- package/src/vercel/api/crud/create.ts +14 -8
- package/src/vercel/api/crud/delete.ts +15 -6
- package/src/vercel/api/crud/get.ts +16 -8
- package/src/vercel/api/crud/list.ts +22 -10
- package/src/vercel/api/crud/update.ts +16 -10
- package/src/vercel/api/oauth/check-github-access.ts +2 -5
- package/src/vercel/api/oauth/grant-github-access.ts +1 -5
- package/src/vercel/api/oauth/revoke-github-access.ts +7 -8
- package/src/vercel/api/utils/cors.ts +13 -2
- 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';
|