@donotdev/functions 0.0.3 → 0.0.5
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/lib/firebase/index.js +30 -45
- package/lib/shared/index.js +5 -8
- package/lib/vercel/api/index.js +7 -11
- package/package.json +5 -5
- package/src/firebase/auth/getCustomClaims.ts +13 -4
- package/src/firebase/auth/getUserAuthStatus.ts +21 -4
- package/src/firebase/auth/removeCustomClaims.ts +18 -4
- package/src/firebase/auth/setCustomClaims.ts +18 -4
- package/src/firebase/billing/createCheckoutSession.ts +15 -2
- package/src/firebase/billing/createCustomerPortal.ts +34 -11
- package/src/firebase/billing/webhookHandler.ts +17 -5
- package/src/firebase/config/constants.ts +7 -1
- package/src/firebase/config/secrets.ts +32 -0
- package/src/firebase/crud/aggregate.ts +361 -0
- package/src/firebase/crud/create.ts +22 -4
- package/src/firebase/crud/delete.ts +8 -3
- package/src/firebase/crud/get.ts +16 -4
- package/src/firebase/crud/index.ts +1 -0
- package/src/firebase/crud/list.ts +14 -6
- package/src/firebase/crud/update.ts +21 -4
- package/src/firebase/oauth/githubAccess.ts +27 -6
- package/src/shared/schema.ts +45 -21
- package/src/shared/utils.ts +26 -13
- package/src/vercel/api/crud/create.ts +11 -1
- package/src/vercel/api/crud/get.ts +8 -1
- package/src/vercel/api/crud/list.ts +4 -0
- package/src/vercel/api/crud/update.ts +23 -2
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// packages/functions/src/firebase/crud/aggregate.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Generic function to aggregate entities with metrics and grouping.
|
|
5
|
+
* @description Provides a reusable implementation for computing aggregations on Firestore collections.
|
|
6
|
+
* Returns only computed metrics, never raw data - optimized for analytics dashboards.
|
|
7
|
+
*
|
|
8
|
+
* @version 0.0.1
|
|
9
|
+
* @since 0.0.1
|
|
10
|
+
* @author AMBROISE PARK Consulting
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { CallableRequest } from 'firebase-functions/v2/https';
|
|
14
|
+
|
|
15
|
+
import * as v from 'valibot';
|
|
16
|
+
|
|
17
|
+
import { getFirebaseAdminFirestore } from '@donotdev/firebase/server';
|
|
18
|
+
|
|
19
|
+
import { isFieldVisible } from '../../shared/schema.js';
|
|
20
|
+
import { DoNotDevError } from '../../shared/utils.js';
|
|
21
|
+
import { createBaseFunction } from '../baseFunction.js';
|
|
22
|
+
import { CRUD_CONFIG } from '../config/constants.js';
|
|
23
|
+
|
|
24
|
+
/** Supported aggregation operations */
|
|
25
|
+
export type AggregateOperation = 'count' | 'sum' | 'avg' | 'min' | 'max';
|
|
26
|
+
|
|
27
|
+
/** Single metric definition */
|
|
28
|
+
export interface MetricDefinition {
|
|
29
|
+
/** Field to aggregate ('*' for count all) */
|
|
30
|
+
field: string;
|
|
31
|
+
/** Aggregation operation */
|
|
32
|
+
operation: AggregateOperation;
|
|
33
|
+
/** Output name for this metric */
|
|
34
|
+
as: string;
|
|
35
|
+
/** Optional filter for this metric */
|
|
36
|
+
filter?: {
|
|
37
|
+
field: string;
|
|
38
|
+
operator: '==' | '!=' | '>' | '<' | '>=' | '<=';
|
|
39
|
+
value: any;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Group by definition */
|
|
44
|
+
export interface GroupByDefinition {
|
|
45
|
+
/** Field to group by */
|
|
46
|
+
field: string;
|
|
47
|
+
/** Metrics to compute per group */
|
|
48
|
+
metrics: MetricDefinition[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Aggregation configuration */
|
|
52
|
+
export interface AggregateConfig {
|
|
53
|
+
/** Top-level metrics (computed on entire collection) */
|
|
54
|
+
metrics?: MetricDefinition[];
|
|
55
|
+
/** Group by configurations */
|
|
56
|
+
groupBy?: GroupByDefinition[];
|
|
57
|
+
/** Optional global filters */
|
|
58
|
+
where?: Array<[string, any, any]>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Request schema for aggregate function */
|
|
62
|
+
interface AggregateRequest {
|
|
63
|
+
/** Optional runtime filters (merged with config filters) */
|
|
64
|
+
where?: Array<[string, any, any]>;
|
|
65
|
+
/** Optional date range filter */
|
|
66
|
+
dateRange?: {
|
|
67
|
+
field: string;
|
|
68
|
+
start?: string;
|
|
69
|
+
end?: string;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Extracts visibility from a Valibot field schema
|
|
75
|
+
*/
|
|
76
|
+
function getFieldVisibilityFromSchema(
|
|
77
|
+
schema: any,
|
|
78
|
+
fieldName: string
|
|
79
|
+
): string | undefined {
|
|
80
|
+
if (!schema?.entries?.[fieldName]) return undefined;
|
|
81
|
+
const field = schema.entries[fieldName];
|
|
82
|
+
return field?.visibility;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Checks if user can access a field for aggregation
|
|
87
|
+
*/
|
|
88
|
+
function canAggregateField(
|
|
89
|
+
fieldName: string,
|
|
90
|
+
schema: any,
|
|
91
|
+
isAdmin: boolean
|
|
92
|
+
): boolean {
|
|
93
|
+
// '*' is always allowed (count all)
|
|
94
|
+
if (fieldName === '*') return true;
|
|
95
|
+
|
|
96
|
+
const visibility = getFieldVisibilityFromSchema(schema, fieldName);
|
|
97
|
+
return isFieldVisible(fieldName, visibility as any, isAdmin);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Computes a single metric on a dataset
|
|
102
|
+
*/
|
|
103
|
+
function computeMetric(docs: any[], metric: MetricDefinition): number | null {
|
|
104
|
+
// Apply metric-level filter if specified
|
|
105
|
+
let filtered = docs;
|
|
106
|
+
if (metric.filter) {
|
|
107
|
+
filtered = docs.filter((doc) => {
|
|
108
|
+
const value = doc[metric.filter!.field];
|
|
109
|
+
switch (metric.filter!.operator) {
|
|
110
|
+
case '==':
|
|
111
|
+
return value === metric.filter!.value;
|
|
112
|
+
case '!=':
|
|
113
|
+
return value !== metric.filter!.value;
|
|
114
|
+
case '>':
|
|
115
|
+
return value > metric.filter!.value;
|
|
116
|
+
case '<':
|
|
117
|
+
return value < metric.filter!.value;
|
|
118
|
+
case '>=':
|
|
119
|
+
return value >= metric.filter!.value;
|
|
120
|
+
case '<=':
|
|
121
|
+
return value <= metric.filter!.value;
|
|
122
|
+
default:
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
switch (metric.operation) {
|
|
129
|
+
case 'count':
|
|
130
|
+
return filtered.length;
|
|
131
|
+
|
|
132
|
+
case 'sum': {
|
|
133
|
+
return filtered.reduce((sum, doc) => {
|
|
134
|
+
const val = Number(doc[metric.field]) || 0;
|
|
135
|
+
return sum + val;
|
|
136
|
+
}, 0);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
case 'avg': {
|
|
140
|
+
if (filtered.length === 0) return null;
|
|
141
|
+
const sum = filtered.reduce((s, doc) => {
|
|
142
|
+
const val = Number(doc[metric.field]) || 0;
|
|
143
|
+
return s + val;
|
|
144
|
+
}, 0);
|
|
145
|
+
return sum / filtered.length;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
case 'min': {
|
|
149
|
+
if (filtered.length === 0) return null;
|
|
150
|
+
return filtered.reduce(
|
|
151
|
+
(min, doc) => {
|
|
152
|
+
const val = Number(doc[metric.field]);
|
|
153
|
+
if (isNaN(val)) return min;
|
|
154
|
+
return min === null ? val : Math.min(min, val);
|
|
155
|
+
},
|
|
156
|
+
null as number | null
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
case 'max': {
|
|
161
|
+
if (filtered.length === 0) return null;
|
|
162
|
+
return filtered.reduce(
|
|
163
|
+
(max, doc) => {
|
|
164
|
+
const val = Number(doc[metric.field]);
|
|
165
|
+
if (isNaN(val)) return max;
|
|
166
|
+
return max === null ? val : Math.max(max, val);
|
|
167
|
+
},
|
|
168
|
+
null as number | null
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
default:
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Generic business logic for aggregating entities
|
|
179
|
+
*/
|
|
180
|
+
function aggregateEntitiesLogicFactory(
|
|
181
|
+
collection: string,
|
|
182
|
+
documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
|
|
183
|
+
config: AggregateConfig
|
|
184
|
+
) {
|
|
185
|
+
return async function aggregateEntitiesLogic(
|
|
186
|
+
data: AggregateRequest,
|
|
187
|
+
context: { uid: string; request: CallableRequest<AggregateRequest> }
|
|
188
|
+
) {
|
|
189
|
+
const db = getFirebaseAdminFirestore();
|
|
190
|
+
const user = context.request.auth;
|
|
191
|
+
const isAdmin = user?.token?.isAdmin === true;
|
|
192
|
+
|
|
193
|
+
// Validate that user can access the fields being aggregated
|
|
194
|
+
const allFields = new Set<string>();
|
|
195
|
+
|
|
196
|
+
config.metrics?.forEach((m) => allFields.add(m.field));
|
|
197
|
+
config.groupBy?.forEach((g) => {
|
|
198
|
+
allFields.add(g.field);
|
|
199
|
+
g.metrics.forEach((m) => allFields.add(m.field));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
for (const field of allFields) {
|
|
203
|
+
if (!canAggregateField(field, documentSchema, isAdmin)) {
|
|
204
|
+
throw new DoNotDevError(
|
|
205
|
+
`Access denied: cannot aggregate field '${field}'`,
|
|
206
|
+
'permission-denied'
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Build query with filters
|
|
212
|
+
let query: FirebaseFirestore.Query = db.collection(collection);
|
|
213
|
+
|
|
214
|
+
// Apply config-level filters
|
|
215
|
+
const whereFilters = [...(config.where || []), ...(data.where || [])];
|
|
216
|
+
for (const [field, operator, value] of whereFilters) {
|
|
217
|
+
query = query.where(field, operator, value);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Apply date range filter if specified
|
|
221
|
+
if (data.dateRange) {
|
|
222
|
+
const { field, start, end } = data.dateRange;
|
|
223
|
+
if (start) {
|
|
224
|
+
query = query.where(field, '>=', new Date(start));
|
|
225
|
+
}
|
|
226
|
+
if (end) {
|
|
227
|
+
query = query.where(field, '<=', new Date(end));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Fetch all matching documents
|
|
232
|
+
const snapshot = await query.get();
|
|
233
|
+
const docs: Record<string, any>[] = snapshot.docs.map((doc) => ({
|
|
234
|
+
id: doc.id,
|
|
235
|
+
...doc.data(),
|
|
236
|
+
}));
|
|
237
|
+
|
|
238
|
+
// Compute top-level metrics
|
|
239
|
+
const metrics: Record<string, number | null> = {};
|
|
240
|
+
if (config.metrics) {
|
|
241
|
+
for (const metric of config.metrics) {
|
|
242
|
+
metrics[metric.as] = computeMetric(docs, metric);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Compute grouped metrics
|
|
247
|
+
const groups: Record<
|
|
248
|
+
string,
|
|
249
|
+
Record<string, Record<string, number | null>>
|
|
250
|
+
> = {};
|
|
251
|
+
if (config.groupBy) {
|
|
252
|
+
for (const groupConfig of config.groupBy) {
|
|
253
|
+
const groupedDocs = new Map<string, any[]>();
|
|
254
|
+
|
|
255
|
+
// Group documents by field value
|
|
256
|
+
for (const doc of docs) {
|
|
257
|
+
const groupValue = String(doc[groupConfig.field] ?? 'null');
|
|
258
|
+
if (!groupedDocs.has(groupValue)) {
|
|
259
|
+
groupedDocs.set(groupValue, []);
|
|
260
|
+
}
|
|
261
|
+
groupedDocs.get(groupValue)!.push(doc);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Compute metrics for each group
|
|
265
|
+
const groupResults: Record<string, Record<string, number | null>> = {};
|
|
266
|
+
for (const [groupValue, groupDocs] of groupedDocs) {
|
|
267
|
+
groupResults[groupValue] = {};
|
|
268
|
+
for (const metric of groupConfig.metrics) {
|
|
269
|
+
groupResults[groupValue][metric.as] = computeMetric(
|
|
270
|
+
groupDocs,
|
|
271
|
+
metric
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
groups[groupConfig.field] = groupResults;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
metrics,
|
|
282
|
+
groups,
|
|
283
|
+
meta: {
|
|
284
|
+
collection,
|
|
285
|
+
totalDocs: docs.length,
|
|
286
|
+
computedAt: new Date().toISOString(),
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Generic function to aggregate entities from any Firestore collection.
|
|
294
|
+
* Returns only computed metrics, never raw data.
|
|
295
|
+
*
|
|
296
|
+
* @param collection - The Firestore collection name
|
|
297
|
+
* @param documentSchema - The Valibot schema (used for visibility checks)
|
|
298
|
+
* @param config - Aggregation configuration (metrics, groupBy, filters)
|
|
299
|
+
* @returns Firebase callable function
|
|
300
|
+
*
|
|
301
|
+
* @example
|
|
302
|
+
* ```typescript
|
|
303
|
+
* // Define analytics for cars collection
|
|
304
|
+
* export const getCarsAnalytics = aggregateEntities('cars', carSchema, {
|
|
305
|
+
* metrics: [
|
|
306
|
+
* { field: '*', operation: 'count', as: 'total' },
|
|
307
|
+
* { field: 'price', operation: 'sum', as: 'totalValue' },
|
|
308
|
+
* { field: 'price', operation: 'avg', as: 'avgPrice' },
|
|
309
|
+
* ],
|
|
310
|
+
* groupBy: [
|
|
311
|
+
* {
|
|
312
|
+
* field: 'status',
|
|
313
|
+
* metrics: [
|
|
314
|
+
* { field: '*', operation: 'count', as: 'count' },
|
|
315
|
+
* { field: 'price', operation: 'sum', as: 'value' },
|
|
316
|
+
* ],
|
|
317
|
+
* },
|
|
318
|
+
* ],
|
|
319
|
+
* });
|
|
320
|
+
*
|
|
321
|
+
* // Returns:
|
|
322
|
+
* // {
|
|
323
|
+
* // metrics: { total: 150, totalValue: 4500000, avgPrice: 30000 },
|
|
324
|
+
* // groups: {
|
|
325
|
+
* // status: {
|
|
326
|
+
* // Available: { count: 80, value: 2400000 },
|
|
327
|
+
* // Reserved: { count: 20, value: 600000 },
|
|
328
|
+
* // Sold: { count: 50, value: 1500000 },
|
|
329
|
+
* // }
|
|
330
|
+
* // },
|
|
331
|
+
* // meta: { collection: 'cars', totalDocs: 150, computedAt: '...' }
|
|
332
|
+
* // }
|
|
333
|
+
* ```
|
|
334
|
+
*
|
|
335
|
+
* @version 0.0.1
|
|
336
|
+
* @since 0.0.1
|
|
337
|
+
* @author AMBROISE PARK Consulting
|
|
338
|
+
*/
|
|
339
|
+
export const aggregateEntities = (
|
|
340
|
+
collection: string,
|
|
341
|
+
documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
|
|
342
|
+
config: AggregateConfig
|
|
343
|
+
) => {
|
|
344
|
+
const requestSchema = v.object({
|
|
345
|
+
where: v.optional(v.array(v.tuple([v.string(), v.any(), v.any()]))),
|
|
346
|
+
dateRange: v.optional(
|
|
347
|
+
v.object({
|
|
348
|
+
field: v.string(),
|
|
349
|
+
start: v.optional(v.string()),
|
|
350
|
+
end: v.optional(v.string()),
|
|
351
|
+
})
|
|
352
|
+
),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
return createBaseFunction(
|
|
356
|
+
CRUD_CONFIG,
|
|
357
|
+
requestSchema,
|
|
358
|
+
'aggregate_entities',
|
|
359
|
+
aggregateEntitiesLogicFactory(collection, documentSchema, config)
|
|
360
|
+
);
|
|
361
|
+
};
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import * as v from 'valibot';
|
|
13
13
|
|
|
14
14
|
import { getFirebaseAdminFirestore } from '@donotdev/firebase/server';
|
|
15
|
+
import { DEFAULT_STATUS_VALUE } from '@donotdev/core/server';
|
|
15
16
|
|
|
16
17
|
import {
|
|
17
18
|
prepareForFirestore,
|
|
@@ -22,7 +23,15 @@ import { assertAdmin, validateDocument } from '../../shared/utils.js';
|
|
|
22
23
|
import { createBaseFunction } from '../baseFunction.js';
|
|
23
24
|
import { CRUD_CONFIG } from '../config/constants.js';
|
|
24
25
|
|
|
25
|
-
import type {
|
|
26
|
+
import type {
|
|
27
|
+
CallableFunction,
|
|
28
|
+
CallableRequest,
|
|
29
|
+
} from 'firebase-functions/v2/https';
|
|
30
|
+
|
|
31
|
+
export type CreateEntityRequest = {
|
|
32
|
+
payload: Record<string, any>;
|
|
33
|
+
idempotencyKey?: string;
|
|
34
|
+
};
|
|
26
35
|
|
|
27
36
|
/**
|
|
28
37
|
* Generic business logic for creating entities
|
|
@@ -37,7 +46,7 @@ function createEntityLogicFactory(
|
|
|
37
46
|
documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>
|
|
38
47
|
) {
|
|
39
48
|
return async function createEntityLogic(
|
|
40
|
-
data:
|
|
49
|
+
data: CreateEntityRequest,
|
|
41
50
|
context: { uid: string; request: CallableRequest<any> }
|
|
42
51
|
) {
|
|
43
52
|
const { payload, idempotencyKey } = data;
|
|
@@ -57,12 +66,21 @@ function createEntityLogicFactory(
|
|
|
57
66
|
}
|
|
58
67
|
}
|
|
59
68
|
|
|
69
|
+
// Determine status (default to draft if not provided)
|
|
70
|
+
const status = payload.status ?? DEFAULT_STATUS_VALUE;
|
|
71
|
+
const isDraft = status === 'draft';
|
|
72
|
+
|
|
60
73
|
// Validate the document against the schema
|
|
61
|
-
|
|
74
|
+
// Skip validation for drafts - required fields can be incomplete
|
|
75
|
+
if (!isDraft) {
|
|
76
|
+
validateDocument(payload, documentSchema);
|
|
77
|
+
}
|
|
62
78
|
|
|
63
79
|
// Prepare the document for Firestore and add metadata
|
|
80
|
+
// Always ensure status is set
|
|
64
81
|
const documentData = {
|
|
65
82
|
...prepareForFirestore(payload),
|
|
83
|
+
status, // Ensure status is always present
|
|
66
84
|
...createMetadata(uid),
|
|
67
85
|
};
|
|
68
86
|
|
|
@@ -110,7 +128,7 @@ export const createEntity = (
|
|
|
110
128
|
collection: string,
|
|
111
129
|
documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
|
|
112
130
|
customSchema?: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>
|
|
113
|
-
) => {
|
|
131
|
+
): CallableFunction<CreateEntityRequest, Promise<any>> => {
|
|
114
132
|
const requestSchema =
|
|
115
133
|
customSchema ||
|
|
116
134
|
v.object({
|
|
@@ -20,7 +20,12 @@ import {
|
|
|
20
20
|
import { createBaseFunction } from '../baseFunction.js';
|
|
21
21
|
import { CRUD_CONFIG } from '../config/constants.js';
|
|
22
22
|
|
|
23
|
-
import type {
|
|
23
|
+
import type {
|
|
24
|
+
CallableFunction,
|
|
25
|
+
CallableRequest,
|
|
26
|
+
} from 'firebase-functions/v2/https';
|
|
27
|
+
|
|
28
|
+
export type DeleteEntityRequest = { id: string };
|
|
24
29
|
|
|
25
30
|
/**
|
|
26
31
|
* Generic business logic for deleting entities
|
|
@@ -32,7 +37,7 @@ import type { CallableRequest } from 'firebase-functions/v2/https';
|
|
|
32
37
|
*/
|
|
33
38
|
function deleteEntityLogicFactory(collection: string) {
|
|
34
39
|
return async function deleteEntityLogic(
|
|
35
|
-
data:
|
|
40
|
+
data: DeleteEntityRequest,
|
|
36
41
|
context: { uid: string; request: CallableRequest<any> }
|
|
37
42
|
) {
|
|
38
43
|
const { id } = data;
|
|
@@ -73,7 +78,7 @@ function deleteEntityLogicFactory(collection: string) {
|
|
|
73
78
|
export const deleteEntity = (
|
|
74
79
|
collection: string,
|
|
75
80
|
customSchema?: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>
|
|
76
|
-
) => {
|
|
81
|
+
): CallableFunction<DeleteEntityRequest, Promise<{ success: boolean }>> => {
|
|
77
82
|
const requestSchema =
|
|
78
83
|
customSchema ||
|
|
79
84
|
v.object({
|
package/src/firebase/crud/get.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import * as v from 'valibot';
|
|
13
|
+
import { HIDDEN_STATUSES } from '@donotdev/core/server';
|
|
13
14
|
|
|
14
15
|
import { transformFirestoreData } from '../../shared/index.js';
|
|
15
16
|
import { filterVisibleFields } from '../../shared/index.js';
|
|
@@ -19,7 +20,12 @@ import { DoNotDevError } from '../../shared/utils.js';
|
|
|
19
20
|
import { createBaseFunction } from '../baseFunction.js';
|
|
20
21
|
import { CRUD_CONFIG } from '../config/constants.js';
|
|
21
22
|
|
|
22
|
-
import type {
|
|
23
|
+
import type {
|
|
24
|
+
CallableFunction,
|
|
25
|
+
CallableRequest,
|
|
26
|
+
} from 'firebase-functions/v2/https';
|
|
27
|
+
|
|
28
|
+
export type GetEntityRequest = { id: string };
|
|
23
29
|
|
|
24
30
|
/**
|
|
25
31
|
* Generic business logic for getting entities
|
|
@@ -30,7 +36,7 @@ function getEntityLogicFactory(
|
|
|
30
36
|
documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>
|
|
31
37
|
) {
|
|
32
38
|
return async function getEntityLogic(
|
|
33
|
-
data:
|
|
39
|
+
data: GetEntityRequest,
|
|
34
40
|
context: { uid: string; request: CallableRequest<any> }
|
|
35
41
|
) {
|
|
36
42
|
const { id } = data;
|
|
@@ -50,9 +56,15 @@ function getEntityLogicFactory(
|
|
|
50
56
|
// Check if the user is an admin
|
|
51
57
|
const isAdmin = context.request.auth?.token?.isAdmin === true;
|
|
52
58
|
|
|
59
|
+
// Hide drafts/deleted from non-admin users (security: hidden statuses never reach public)
|
|
60
|
+
const docData = doc.data();
|
|
61
|
+
if (!isAdmin && (HIDDEN_STATUSES as readonly string[]).includes(docData?.status)) {
|
|
62
|
+
throw new DoNotDevError('Entity not found', 'not-found');
|
|
63
|
+
}
|
|
64
|
+
|
|
53
65
|
// Filter fields based on visibility and user role
|
|
54
66
|
const filteredData = filterVisibleFields(
|
|
55
|
-
|
|
67
|
+
docData || {},
|
|
56
68
|
documentSchema,
|
|
57
69
|
isAdmin
|
|
58
70
|
);
|
|
@@ -76,7 +88,7 @@ export const getEntity = (
|
|
|
76
88
|
collection: string,
|
|
77
89
|
documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
|
|
78
90
|
customSchema?: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>
|
|
79
|
-
) => {
|
|
91
|
+
): CallableFunction<GetEntityRequest, Promise<any>> => {
|
|
80
92
|
const requestSchema =
|
|
81
93
|
customSchema ||
|
|
82
94
|
v.object({
|
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { Query } from '@donotdev/firebase/server';
|
|
13
|
+
import { HIDDEN_STATUSES } from '@donotdev/core/server';
|
|
13
14
|
|
|
14
15
|
import type { CallableRequest } from 'firebase-functions/v2/https';
|
|
15
16
|
|
|
16
17
|
import * as v from 'valibot';
|
|
17
18
|
|
|
18
|
-
interface ListEntityRequest {
|
|
19
|
+
export interface ListEntityRequest {
|
|
19
20
|
where?: Array<[string, any, any]>;
|
|
20
21
|
orderBy?: Array<[string, 'asc' | 'desc']>;
|
|
21
22
|
limit?: number;
|
|
@@ -33,6 +34,8 @@ import { DoNotDevError } from '../../shared/utils.js';
|
|
|
33
34
|
import { createBaseFunction } from '../baseFunction.js';
|
|
34
35
|
import { CRUD_CONFIG } from '../config/constants.js';
|
|
35
36
|
|
|
37
|
+
import type { CallableFunction } from 'firebase-functions/v2/https';
|
|
38
|
+
|
|
36
39
|
/**
|
|
37
40
|
* Generic business logic for listing entities
|
|
38
41
|
* Base function handles: validation, auth, rate limiting, monitoring
|
|
@@ -47,10 +50,19 @@ function listEntitiesLogicFactory(
|
|
|
47
50
|
) {
|
|
48
51
|
const { where = [], orderBy = [], limit = 50, startAfterId, search } = data;
|
|
49
52
|
|
|
53
|
+
// Check if the user is an admin (needed early for draft filtering)
|
|
54
|
+
const user = context.request.auth;
|
|
55
|
+
const isAdmin = user?.token?.isAdmin === true;
|
|
56
|
+
|
|
50
57
|
// Start with a Query (not a CollectionReference)
|
|
51
58
|
const db = getFirebaseAdminFirestore();
|
|
52
59
|
let query: Query = db.collection(collection);
|
|
53
60
|
|
|
61
|
+
// Filter out hidden statuses for non-admin users (security: drafts/deleted never reach public)
|
|
62
|
+
if (!isAdmin) {
|
|
63
|
+
query = query.where('status', 'not-in', [...HIDDEN_STATUSES]);
|
|
64
|
+
}
|
|
65
|
+
|
|
54
66
|
// Apply search if provided
|
|
55
67
|
if (search) {
|
|
56
68
|
const { field, query: searchQuery } = search;
|
|
@@ -89,10 +101,6 @@ function listEntitiesLogicFactory(
|
|
|
89
101
|
// Execute the query
|
|
90
102
|
const snapshot = await query.get();
|
|
91
103
|
|
|
92
|
-
// Check if the user is an admin
|
|
93
|
-
const user = context.request.auth;
|
|
94
|
-
const isAdmin = user?.token?.isAdmin === true;
|
|
95
|
-
|
|
96
104
|
// Filter document fields based on visibility and user role
|
|
97
105
|
const docs = snapshot.docs.map((doc: any) => ({
|
|
98
106
|
id: doc.id,
|
|
@@ -120,7 +128,7 @@ export const listEntities = (
|
|
|
120
128
|
collection: string,
|
|
121
129
|
documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
|
|
122
130
|
customSchema?: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>
|
|
123
|
-
) => {
|
|
131
|
+
): CallableFunction<ListEntityRequest, Promise<any>> => {
|
|
124
132
|
const requestSchema =
|
|
125
133
|
customSchema ||
|
|
126
134
|
v.object({
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import * as v from 'valibot';
|
|
13
13
|
|
|
14
14
|
import { getFirebaseAdminFirestore } from '@donotdev/firebase/server';
|
|
15
|
+
import { DEFAULT_STATUS_VALUE } from '@donotdev/core/server';
|
|
15
16
|
|
|
16
17
|
import {
|
|
17
18
|
prepareForFirestore,
|
|
@@ -26,7 +27,16 @@ import {
|
|
|
26
27
|
import { createBaseFunction } from '../baseFunction.js';
|
|
27
28
|
import { CRUD_CONFIG } from '../config/constants.js';
|
|
28
29
|
|
|
29
|
-
import type {
|
|
30
|
+
import type {
|
|
31
|
+
CallableFunction,
|
|
32
|
+
CallableRequest,
|
|
33
|
+
} from 'firebase-functions/v2/https';
|
|
34
|
+
|
|
35
|
+
export type UpdateEntityRequest = {
|
|
36
|
+
id: string;
|
|
37
|
+
payload: Record<string, any>;
|
|
38
|
+
idempotencyKey?: string;
|
|
39
|
+
};
|
|
30
40
|
|
|
31
41
|
/**
|
|
32
42
|
* Generic business logic for updating entities
|
|
@@ -37,7 +47,7 @@ function updateEntityLogicFactory(
|
|
|
37
47
|
documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>
|
|
38
48
|
) {
|
|
39
49
|
return async function updateEntityLogic(
|
|
40
|
-
data:
|
|
50
|
+
data: UpdateEntityRequest,
|
|
41
51
|
context: { uid: string; request: CallableRequest<any> }
|
|
42
52
|
) {
|
|
43
53
|
const { id, payload, idempotencyKey } = data;
|
|
@@ -71,8 +81,15 @@ function updateEntityLogicFactory(
|
|
|
71
81
|
const currentData = transformFirestoreData(doc.data());
|
|
72
82
|
const mergedData = { ...currentData, ...payload };
|
|
73
83
|
|
|
84
|
+
// Determine resulting status (default to draft if not set)
|
|
85
|
+
const resultingStatus = mergedData.status ?? DEFAULT_STATUS_VALUE;
|
|
86
|
+
const isDraft = resultingStatus === 'draft';
|
|
87
|
+
|
|
74
88
|
// Validate the merged document against the schema
|
|
75
|
-
|
|
89
|
+
// Skip validation for drafts - required fields can be incomplete
|
|
90
|
+
if (!isDraft) {
|
|
91
|
+
validateDocument(mergedData, documentSchema);
|
|
92
|
+
}
|
|
76
93
|
|
|
77
94
|
// Prepare the update data for Firestore and add metadata
|
|
78
95
|
const updateData = {
|
|
@@ -119,7 +136,7 @@ export const updateEntity = (
|
|
|
119
136
|
collection: string,
|
|
120
137
|
documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
|
|
121
138
|
customSchema?: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>
|
|
122
|
-
) => {
|
|
139
|
+
): CallableFunction<UpdateEntityRequest, Promise<any>> => {
|
|
123
140
|
const requestSchema =
|
|
124
141
|
customSchema ||
|
|
125
142
|
v.object({
|