@donotdev/functions 0.0.10 → 0.0.12

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 (81) hide show
  1. package/README.md +1 -1
  2. package/package.json +32 -8
  3. package/src/firebase/auth/setCustomClaims.ts +26 -4
  4. package/src/firebase/baseFunction.ts +43 -20
  5. package/src/firebase/billing/cancelSubscription.ts +9 -1
  6. package/src/firebase/billing/changePlan.ts +8 -2
  7. package/src/firebase/billing/createCheckoutSession.ts +3 -1
  8. package/src/firebase/billing/createCustomerPortal.ts +16 -2
  9. package/src/firebase/billing/refreshSubscriptionStatus.ts +3 -1
  10. package/src/firebase/billing/webhookHandler.ts +13 -1
  11. package/src/firebase/crud/aggregate.ts +20 -5
  12. package/src/firebase/crud/create.ts +31 -7
  13. package/src/firebase/crud/list.ts +36 -24
  14. package/src/firebase/crud/update.ts +29 -7
  15. package/src/firebase/oauth/exchangeToken.ts +30 -4
  16. package/src/firebase/oauth/githubAccess.ts +8 -3
  17. package/src/firebase/registerCrudFunctions.ts +2 -2
  18. package/src/firebase/scheduled/checkExpiredSubscriptions.ts +20 -3
  19. package/src/shared/__tests__/detectFirestore.test.ts +52 -0
  20. package/src/shared/__tests__/errorHandling.test.ts +144 -0
  21. package/src/shared/__tests__/idempotency.test.ts +95 -0
  22. package/src/shared/__tests__/rateLimiter.test.ts +142 -0
  23. package/src/shared/__tests__/validation.test.ts +172 -0
  24. package/src/shared/billing/__tests__/createCheckout.test.ts +393 -0
  25. package/src/shared/billing/__tests__/webhookHandler.test.ts +1091 -0
  26. package/src/shared/billing/webhookHandler.ts +16 -7
  27. package/src/shared/errorHandling.ts +22 -60
  28. package/src/shared/firebase.ts +1 -25
  29. package/src/shared/index.ts +2 -1
  30. package/src/shared/logger.ts +3 -7
  31. package/src/shared/oauth/__tests__/exchangeToken.test.ts +116 -0
  32. package/src/shared/oauth/__tests__/grantAccess.test.ts +237 -0
  33. package/src/shared/utils/__tests__/functionWrapper.test.ts +122 -0
  34. package/src/shared/utils/external/subscription.ts +12 -2
  35. package/src/shared/utils/internal/auth.ts +140 -16
  36. package/src/shared/utils/internal/rateLimiter.ts +101 -90
  37. package/src/shared/utils/internal/validation.ts +47 -3
  38. package/src/shared/utils.ts +170 -66
  39. package/src/supabase/auth/deleteAccount.ts +52 -0
  40. package/src/supabase/auth/getCustomClaims.ts +56 -0
  41. package/src/supabase/auth/getUserAuthStatus.ts +64 -0
  42. package/src/supabase/auth/removeCustomClaims.ts +75 -0
  43. package/src/supabase/auth/setCustomClaims.ts +73 -0
  44. package/src/supabase/baseFunction.ts +306 -0
  45. package/src/supabase/billing/cancelSubscription.ts +57 -0
  46. package/src/supabase/billing/changePlan.ts +62 -0
  47. package/src/supabase/billing/createCheckoutSession.ts +82 -0
  48. package/src/supabase/billing/createCustomerPortal.ts +58 -0
  49. package/src/supabase/billing/refreshSubscriptionStatus.ts +89 -0
  50. package/src/supabase/crud/aggregate.ts +169 -0
  51. package/src/supabase/crud/create.ts +225 -0
  52. package/src/supabase/crud/delete.ts +154 -0
  53. package/src/supabase/crud/get.ts +89 -0
  54. package/src/supabase/crud/index.ts +24 -0
  55. package/src/supabase/crud/list.ts +388 -0
  56. package/src/supabase/crud/update.ts +199 -0
  57. package/src/supabase/helpers/authProvider.ts +45 -0
  58. package/src/supabase/index.ts +73 -0
  59. package/src/supabase/registerCrudFunctions.ts +180 -0
  60. package/src/supabase/utils/idempotency.ts +141 -0
  61. package/src/supabase/utils/monitoring.ts +187 -0
  62. package/src/supabase/utils/rateLimiter.ts +216 -0
  63. package/src/vercel/api/auth/get-custom-claims.ts +3 -2
  64. package/src/vercel/api/auth/get-user-auth-status.ts +3 -2
  65. package/src/vercel/api/auth/remove-custom-claims.ts +3 -2
  66. package/src/vercel/api/auth/set-custom-claims.ts +5 -2
  67. package/src/vercel/api/billing/cancel.ts +2 -1
  68. package/src/vercel/api/billing/change-plan.ts +3 -1
  69. package/src/vercel/api/billing/customer-portal.ts +4 -1
  70. package/src/vercel/api/billing/refresh-subscription-status.ts +3 -1
  71. package/src/vercel/api/billing/webhook-handler.ts +24 -4
  72. package/src/vercel/api/crud/create.ts +14 -8
  73. package/src/vercel/api/crud/delete.ts +15 -6
  74. package/src/vercel/api/crud/get.ts +16 -8
  75. package/src/vercel/api/crud/list.ts +22 -10
  76. package/src/vercel/api/crud/update.ts +16 -10
  77. package/src/vercel/api/oauth/check-github-access.ts +2 -5
  78. package/src/vercel/api/oauth/grant-github-access.ts +1 -5
  79. package/src/vercel/api/oauth/revoke-github-access.ts +7 -8
  80. package/src/vercel/api/utils/cors.ts +13 -2
  81. package/src/vercel/baseFunction.ts +40 -25
@@ -0,0 +1,169 @@
1
+ // packages/functions/src/supabase/crud/aggregate.ts
2
+
3
+ /**
4
+ * @fileoverview Generic handler to aggregate entities in Supabase.
5
+ * @description Provides aggregate operations (COUNT, SUM, AVG, MIN, MAX, GROUP BY) for PostgreSQL.
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 } from '@donotdev/core/server';
15
+
16
+ import { defaultFieldMapper } from '@donotdev/supabase';
17
+
18
+ import { DoNotDevError } from '../../shared/utils.js';
19
+ import { createSupabaseHandler } from '../baseFunction.js';
20
+
21
+ const mapper = defaultFieldMapper;
22
+
23
+ export type AggregateEntityRequest = {
24
+ where?: Array<[string, any, any]>;
25
+ aggregate: {
26
+ field: string;
27
+ operation: 'count' | 'sum' | 'avg' | 'min' | 'max';
28
+ };
29
+ groupBy?: string[];
30
+ };
31
+
32
+ function applyOperator(
33
+ query: any,
34
+ column: string,
35
+ operator: string,
36
+ value: any
37
+ ): any {
38
+ switch (operator) {
39
+ case '==':
40
+ return query.eq(column, value);
41
+ case '!=':
42
+ return query.neq(column, value);
43
+ case '>':
44
+ return query.gt(column, value);
45
+ case '>=':
46
+ return query.gte(column, value);
47
+ case '<':
48
+ return query.lt(column, value);
49
+ case '<=':
50
+ return query.lte(column, value);
51
+ case 'in':
52
+ return query.in(column, Array.isArray(value) ? value : [value]);
53
+ case 'not-in':
54
+ return query.not(column, 'in', Array.isArray(value) ? value : [value]);
55
+ case 'array-contains':
56
+ return query.contains(column, [value]);
57
+ default:
58
+ return query.eq(column, value);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Create a Supabase Edge Function handler for aggregating entities.
64
+ *
65
+ * @param collection - The Supabase table name
66
+ * @param documentSchema - The Valibot schema for document validation
67
+ * @param requiredRole - Minimum role required for this operation
68
+ * @returns A `(req: Request) => Promise<Response>` handler for Deno.serve
69
+ */
70
+ export function createSupabaseAggregateEntities(
71
+ collection: string,
72
+ documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
73
+ requiredRole: UserRole
74
+ ) {
75
+ const requestSchema = v.object({
76
+ where: v.optional(v.array(v.tuple([v.string(), v.any(), v.any()]))),
77
+ aggregate: v.object({
78
+ field: v.string(),
79
+ operation: v.picklist(['count', 'sum', 'avg', 'min', 'max']),
80
+ }),
81
+ groupBy: v.optional(v.array(v.string())),
82
+ });
83
+
84
+ return createSupabaseHandler(
85
+ `aggregate_${collection}`,
86
+ requestSchema,
87
+ async (data: AggregateEntityRequest, ctx) => {
88
+ const { where = [], aggregate, groupBy } = data;
89
+ const { supabaseAdmin } = ctx;
90
+
91
+ const aggregateColumn = mapper.toBackendField(aggregate.field);
92
+ let query = supabaseAdmin.from(collection);
93
+
94
+ for (const [field, operator, value] of where) {
95
+ query = applyOperator(query, mapper.toBackendField(field), operator, value);
96
+ }
97
+
98
+ if (groupBy && groupBy.length > 0) {
99
+ const groupByColumns = groupBy.map((f) => mapper.toBackendField(f)).join(', ');
100
+ const selectFields = `${groupByColumns}, ${aggregate.operation}(${aggregateColumn})`;
101
+ query = query.select(selectFields);
102
+ for (const field of groupBy) {
103
+ query = query.group(mapper.toBackendField(field));
104
+ }
105
+ } else {
106
+ if (aggregate.operation === 'count') {
107
+ query = query.select('*', { count: 'exact', head: true });
108
+ } else {
109
+ query = query.select(aggregateColumn);
110
+ }
111
+ }
112
+
113
+ // Execute query
114
+ const { data: rows, error, count } = await query;
115
+
116
+ if (error) {
117
+ throw new DoNotDevError(`Failed to aggregate entities: ${error.message}`, 'internal');
118
+ }
119
+
120
+ // Process results
121
+ if (groupBy && groupBy.length > 0) {
122
+ const grouped: Record<string, number> = {};
123
+ for (const row of (rows || []) as any[]) {
124
+ const groupKey = groupBy
125
+ .map((f) => row[mapper.toBackendField(f)])
126
+ .join('|');
127
+ grouped[groupKey] = row[`${aggregate.operation}`] || 0;
128
+ }
129
+ return { value: grouped };
130
+ } else {
131
+ // Single aggregate value
132
+ if (aggregate.operation === 'count') {
133
+ return { value: count ?? 0 };
134
+ } else {
135
+ // Calculate aggregate from rows
136
+ const values = (rows || [] as any[])
137
+ .map((row) => row[aggregateColumn])
138
+ .filter((v) => v != null && !isNaN(Number(v)))
139
+ .map((v) => Number(v));
140
+
141
+ if (values.length === 0) {
142
+ return { value: 0 };
143
+ }
144
+
145
+ let result: number;
146
+ switch (aggregate.operation) {
147
+ case 'sum':
148
+ result = values.reduce((a, b) => a + b, 0);
149
+ break;
150
+ case 'avg':
151
+ result = values.reduce((a, b) => a + b, 0) / values.length;
152
+ break;
153
+ case 'min':
154
+ result = Math.min(...values);
155
+ break;
156
+ case 'max':
157
+ result = Math.max(...values);
158
+ break;
159
+ default:
160
+ result = 0;
161
+ }
162
+
163
+ return { value: result };
164
+ }
165
+ }
166
+ },
167
+ requiredRole
168
+ );
169
+ }
@@ -0,0 +1,225 @@
1
+ // packages/functions/src/supabase/crud/create.ts
2
+
3
+ /**
4
+ * @fileoverview Generic handler to create an entity in Supabase.
5
+ * @description Provides a reusable implementation for creating 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 { DEFAULT_STATUS_VALUE } from '@donotdev/core/server';
15
+ import type { UserRole, UniqueKeyDefinition } from '@donotdev/core/server';
16
+
17
+ import { defaultFieldMapper } from '@donotdev/supabase';
18
+
19
+ import { createMetadata } from '../../shared/index.js';
20
+ import { DoNotDevError, validateDocument } from '../../shared/utils.js';
21
+ import { createSupabaseHandler } from '../baseFunction.js';
22
+ import {
23
+ checkIdempotency,
24
+ storeIdempotency,
25
+ } from '../utils/idempotency.js';
26
+
27
+ const mapper = defaultFieldMapper;
28
+
29
+ export type CreateEntityRequest = {
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
+ * Normalize unique key fields in payload to lowercase (for strings)
46
+ */
47
+ function normalizePayloadForUniqueKeys(
48
+ payload: Record<string, any>,
49
+ uniqueKeys: UniqueKeyDefinition[]
50
+ ): Record<string, any> {
51
+ const normalized = { ...payload };
52
+ for (const uniqueKey of uniqueKeys) {
53
+ for (const field of uniqueKey.fields) {
54
+ if (typeof normalized[field] === 'string') {
55
+ normalized[field] = normalized[field].toLowerCase();
56
+ }
57
+ }
58
+ }
59
+ return normalized;
60
+ }
61
+
62
+ /**
63
+ * Check unique key constraints against existing documents
64
+ */
65
+ async function checkUniqueKeys(
66
+ collection: string,
67
+ payload: Record<string, any>,
68
+ uniqueKeys: UniqueKeyDefinition[],
69
+ isDraft: boolean,
70
+ supabaseAdmin: any
71
+ ): Promise<{ found: false } | { found: true; existingDoc: Record<string, any>; findOrCreate: boolean }> {
72
+ for (const uniqueKey of uniqueKeys) {
73
+ // Skip validation for drafts only if explicitly opted in
74
+ if (isDraft && uniqueKey.skipForDrafts === true) continue;
75
+
76
+ // Check if all fields in the unique key have values
77
+ const allFieldsHaveValues = uniqueKey.fields.every(
78
+ (field) => payload[field] != null && payload[field] !== ''
79
+ );
80
+ if (!allFieldsHaveValues) continue;
81
+
82
+ let query = supabaseAdmin.from(collection).select('*');
83
+ for (const field of uniqueKey.fields) {
84
+ query = query.eq(mapper.toBackendField(field), normalizeValue(payload[field]));
85
+ }
86
+
87
+ const { data: existing, error } = await query.limit(1);
88
+
89
+ if (!error && existing && existing.length > 0) {
90
+ const existingDoc = mapper.fromBackendRow(existing[0]) as Record<string, any>;
91
+
92
+ if (uniqueKey.findOrCreate) {
93
+ return { found: true, existingDoc, findOrCreate: true };
94
+ }
95
+
96
+ // Throw duplicate error
97
+ const fieldNames = uniqueKey.fields.join(' + ');
98
+ throw new DoNotDevError(
99
+ uniqueKey.errorMessage || `Duplicate ${fieldNames}`,
100
+ 'already-exists',
101
+ {
102
+ details: {
103
+ fields: uniqueKey.fields,
104
+ existingId: existingDoc.id,
105
+ },
106
+ }
107
+ );
108
+ }
109
+ }
110
+
111
+ return { found: false };
112
+ }
113
+
114
+ /**
115
+ * Create a Supabase Edge Function handler for creating an entity.
116
+ *
117
+ * @param collection - The Supabase table name
118
+ * @param documentSchema - The Valibot schema for document validation
119
+ * @param requiredRole - Minimum role required for this operation
120
+ * @returns A `(req: Request) => Promise<Response>` handler for Deno.serve
121
+ */
122
+ export function createSupabaseCreateEntity(
123
+ collection: string,
124
+ documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
125
+ requiredRole: UserRole
126
+ ) {
127
+ const requestSchema = v.object({
128
+ payload: v.record(v.string(), v.any()),
129
+ idempotencyKey: v.optional(v.string()),
130
+ });
131
+
132
+ return createSupabaseHandler(
133
+ `create_${collection}`,
134
+ requestSchema,
135
+ async (data: CreateEntityRequest, ctx) => {
136
+ const { payload, idempotencyKey } = data;
137
+ const { uid, supabaseAdmin } = ctx;
138
+
139
+ // Idempotency check if key provided
140
+ if (idempotencyKey) {
141
+ const cachedResult = await checkIdempotency<any>(
142
+ supabaseAdmin,
143
+ idempotencyKey,
144
+ 'create'
145
+ );
146
+ if (cachedResult) {
147
+ return cachedResult;
148
+ }
149
+ }
150
+
151
+ // Determine status (default to available if not provided)
152
+ const status = payload.status ?? DEFAULT_STATUS_VALUE;
153
+ const isDraft = status === 'draft';
154
+
155
+ // Check unique keys if schema has metadata with uniqueKeys
156
+ const schemaWithMeta = documentSchema as {
157
+ metadata?: { uniqueKeys?: UniqueKeyDefinition[] };
158
+ };
159
+ const uniqueKeys = schemaWithMeta.metadata?.uniqueKeys;
160
+
161
+ if (uniqueKeys && uniqueKeys.length > 0) {
162
+ const checkResult = await checkUniqueKeys(
163
+ collection,
164
+ payload,
165
+ uniqueKeys,
166
+ isDraft,
167
+ supabaseAdmin
168
+ );
169
+
170
+ if (checkResult.found && checkResult.findOrCreate) {
171
+ return checkResult.existingDoc;
172
+ }
173
+ }
174
+
175
+ // Normalize unique key fields to lowercase for case-insensitive storage
176
+ const normalizedPayload =
177
+ uniqueKeys && uniqueKeys.length > 0
178
+ ? normalizePayloadForUniqueKeys(payload, uniqueKeys)
179
+ : payload;
180
+
181
+ // Validate the document against the schema
182
+ // Skip validation for drafts - required fields can be incomplete
183
+ if (!isDraft) {
184
+ validateDocument(normalizedPayload, documentSchema);
185
+ }
186
+
187
+ const metadata = createMetadata(uid);
188
+ const snakeMetadata = mapper.toBackendKeys(metadata as Record<string, unknown>);
189
+
190
+ const { createdAt, updatedAt, created_at, updated_at, ...payloadWithoutTimestamps } = normalizedPayload;
191
+ const snakePayload = mapper.toBackendKeys(payloadWithoutTimestamps);
192
+
193
+ // Insert document (DB sets created_at/updated_at via triggers)
194
+ const { data: inserted, error } = await supabaseAdmin
195
+ .from(collection)
196
+ .insert({
197
+ ...snakePayload,
198
+ status,
199
+ ...snakeMetadata,
200
+ })
201
+ .select()
202
+ .single();
203
+
204
+ if (error) {
205
+ throw new DoNotDevError(`Failed to create entity: ${error.message}`, 'internal');
206
+ }
207
+
208
+ const result = mapper.fromBackendRow(inserted as Record<string, unknown>) as Record<string, any>;
209
+
210
+ // Store result for idempotency if key provided
211
+ if (idempotencyKey) {
212
+ await storeIdempotency(
213
+ supabaseAdmin,
214
+ idempotencyKey,
215
+ 'create',
216
+ result,
217
+ uid
218
+ );
219
+ }
220
+
221
+ return result;
222
+ },
223
+ requiredRole
224
+ );
225
+ }
@@ -0,0 +1,154 @@
1
+ // packages/functions/src/supabase/crud/delete.ts
2
+
3
+ /**
4
+ * @fileoverview Generic handler to delete an entity from Supabase.
5
+ * @description Provides a reusable implementation for deleting documents from PostgreSQL.
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 } from '@donotdev/core/server';
15
+
16
+ import { DoNotDevError } from '../../shared/utils.js';
17
+ import { createSupabaseHandler } from '../baseFunction.js';
18
+
19
+ export type DeleteEntityRequest = { id: string };
20
+
21
+ /**
22
+ * Reference metadata for entity deletion checking
23
+ */
24
+ export interface ReferenceMetadata {
25
+ /** Fields in THIS entity that reference OTHER entities */
26
+ outgoing?: Array<{
27
+ field: string;
28
+ targetCollection: string;
29
+ required?: boolean;
30
+ }>;
31
+ /** Fields in OTHER entities that reference THIS entity */
32
+ incoming?: Array<{
33
+ sourceCollection: string;
34
+ sourceField: string;
35
+ required?: boolean;
36
+ }>;
37
+ }
38
+
39
+ /**
40
+ * Find references to a document
41
+ *
42
+ * @param supabaseAdmin - Supabase admin client
43
+ * @param collection - Collection name
44
+ * @param docId - Document ID
45
+ * @param referenceMetadata - Reference metadata from entity
46
+ * @returns Array of reference information
47
+ */
48
+ async function findReferences(
49
+ supabaseAdmin: any,
50
+ collection: string,
51
+ docId: string,
52
+ referenceMetadata?: ReferenceMetadata
53
+ ): Promise<Array<{ collection: string; field: string; count: number }>> {
54
+ const references: Array<{ collection: string; field: string; count: number }> = [];
55
+
56
+ if (!referenceMetadata) {
57
+ return references;
58
+ }
59
+
60
+ // Check incoming references (other entities reference this one)
61
+ if (referenceMetadata.incoming) {
62
+ for (const ref of referenceMetadata.incoming) {
63
+ const { count, error } = await supabaseAdmin
64
+ .from(ref.sourceCollection)
65
+ .select('*', { count: 'exact', head: true })
66
+ .eq(ref.sourceField, docId);
67
+
68
+ if (!error && count && count > 0) {
69
+ references.push({
70
+ collection: ref.sourceCollection,
71
+ field: ref.sourceField,
72
+ count,
73
+ });
74
+ }
75
+ }
76
+ }
77
+
78
+ // Check outgoing references (this entity references others)
79
+ // Note: Outgoing references don't prevent deletion, but we track them for info
80
+ if (referenceMetadata.outgoing) {
81
+ // For now, we only check incoming references as they prevent deletion
82
+ // Outgoing references are informational only
83
+ }
84
+
85
+ return references;
86
+ }
87
+
88
+ /**
89
+ * Create a Supabase Edge Function handler for deleting an entity.
90
+ *
91
+ * @param collection - The Supabase table name
92
+ * @param requiredRole - Minimum role required for this operation
93
+ * @param referenceMetadata - Optional reference metadata for deletion checking
94
+ * @returns A `(req: Request) => Promise<Response>` handler for Deno.serve
95
+ */
96
+ export function createSupabaseDeleteEntity(
97
+ collection: string,
98
+ requiredRole: UserRole,
99
+ referenceMetadata?: ReferenceMetadata
100
+ ) {
101
+ const requestSchema = v.object({
102
+ id: v.pipe(v.string(), v.minLength(1)),
103
+ });
104
+
105
+ return createSupabaseHandler(
106
+ `delete_${collection}`,
107
+ requestSchema,
108
+ async (data: DeleteEntityRequest, ctx) => {
109
+ const { id } = data;
110
+ const { supabaseAdmin } = ctx;
111
+
112
+ // Check for references to this document
113
+ const references = await findReferences(
114
+ supabaseAdmin,
115
+ collection,
116
+ id,
117
+ referenceMetadata
118
+ );
119
+
120
+ // Prevent deletion if required references exist
121
+ const requiredReferences = references.filter((ref) => {
122
+ const metadata = referenceMetadata?.incoming?.find(
123
+ (r) => r.sourceCollection === ref.collection && r.sourceField === ref.field
124
+ );
125
+ return metadata?.required === true;
126
+ });
127
+
128
+ if (requiredReferences.length > 0) {
129
+ throw new DoNotDevError(
130
+ 'Cannot delete: item is referenced by other entities',
131
+ 'permission-denied',
132
+ {
133
+ details: {
134
+ references: requiredReferences,
135
+ },
136
+ }
137
+ );
138
+ }
139
+
140
+ // Delete document
141
+ const { error } = await supabaseAdmin
142
+ .from(collection)
143
+ .delete()
144
+ .eq('id', id);
145
+
146
+ if (error) {
147
+ throw new DoNotDevError(`Failed to delete entity: ${error.message}`, 'internal');
148
+ }
149
+
150
+ return { success: true };
151
+ },
152
+ requiredRole
153
+ );
154
+ }
@@ -0,0 +1,89 @@
1
+ // packages/functions/src/supabase/crud/get.ts
2
+
3
+ /**
4
+ * @fileoverview Generic handler to retrieve a single entity from Supabase.
5
+ * @description Provides a reusable implementation for retrieving documents from PostgreSQL with visibility filtering.
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 { EntityOwnershipConfig, UserRole } from '@donotdev/core/server';
20
+
21
+ import { DoNotDevError } from '../../shared/utils.js';
22
+ import { createSupabaseHandler } from '../baseFunction.js';
23
+
24
+ export type GetEntityRequest = { id: string };
25
+
26
+ /**
27
+ * Create a Supabase Edge Function handler for getting a single entity.
28
+ *
29
+ * @param collection - The Supabase table name
30
+ * @param documentSchema - The Valibot schema for document validation
31
+ * @param requiredRole - Minimum role required for this operation
32
+ * @param ownership - Optional ownership config for visibility: 'owner' field masking
33
+ * @returns A `(req: Request) => Promise<Response>` handler for Deno.serve
34
+ */
35
+ export function createSupabaseGetEntity(
36
+ collection: string,
37
+ documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
38
+ requiredRole: UserRole,
39
+ ownership?: EntityOwnershipConfig
40
+ ) {
41
+ const requestSchema = v.object({
42
+ id: v.pipe(v.string(), v.minLength(1)),
43
+ });
44
+
45
+ return createSupabaseHandler(
46
+ `get_${collection}`,
47
+ requestSchema,
48
+ async (data: GetEntityRequest, ctx) => {
49
+ const { id } = data;
50
+ const { userRole, uid, supabaseAdmin } = ctx;
51
+
52
+ // Query the document
53
+ const { data: row, error } = await supabaseAdmin
54
+ .from(collection)
55
+ .select('*')
56
+ .eq('id', id)
57
+ .single();
58
+
59
+ if (error || !row) {
60
+ throw new DoNotDevError('Entity not found', 'not-found');
61
+ }
62
+
63
+ const isAdmin = hasRoleAccess(userRole, 'admin');
64
+
65
+ // Hide drafts/deleted from non-admin users (security: hidden statuses never reach public)
66
+ if (!isAdmin && (HIDDEN_STATUSES as readonly string[]).includes(row.status)) {
67
+ throw new DoNotDevError('Entity not found', 'not-found');
68
+ }
69
+
70
+ const visibilityOptions =
71
+ ownership && uid ? { documentData: row, uid, ownership } : undefined;
72
+
73
+ // Filter fields based on visibility and user role (and ownership for visibility: 'owner')
74
+ const filteredData = filterVisibleFields(
75
+ row,
76
+ documentSchema,
77
+ userRole,
78
+ visibilityOptions
79
+ );
80
+
81
+ // Supabase returns plain JSON (no Firestore Timestamp conversion needed)
82
+ return {
83
+ id: row.id,
84
+ ...filteredData,
85
+ };
86
+ },
87
+ requiredRole
88
+ );
89
+ }
@@ -0,0 +1,24 @@
1
+ // packages/functions/src/supabase/crud/index.ts
2
+
3
+ /**
4
+ * @fileoverview Supabase CRUD handlers barrel export
5
+ * @description Exports all Supabase CRUD handler factories
6
+ *
7
+ * @version 0.0.1
8
+ * @since 0.5.0
9
+ * @author AMBROISE PARK Consulting
10
+ */
11
+
12
+ export { createSupabaseGetEntity } from './get.js';
13
+ export { createSupabaseCreateEntity } from './create.js';
14
+ export { createSupabaseUpdateEntity } from './update.js';
15
+ export { createSupabaseDeleteEntity } from './delete.js';
16
+ export { createSupabaseListEntities } from './list.js';
17
+ export { createSupabaseAggregateEntities } from './aggregate.js';
18
+
19
+ export type { GetEntityRequest } from './get.js';
20
+ export type { CreateEntityRequest } from './create.js';
21
+ export type { UpdateEntityRequest } from './update.js';
22
+ export type { DeleteEntityRequest, ReferenceMetadata } from './delete.js';
23
+ export type { ListEntityRequest } from './list.js';
24
+ export type { AggregateEntityRequest } from './aggregate.js';