@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.
- 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/crud/aggregate.ts +20 -5
- package/src/firebase/crud/create.ts +31 -7
- package/src/firebase/crud/list.ts +7 -22
- 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 +2 -2
- 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/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,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';
|