@donotdev/functions 0.0.8 → 0.0.10

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donotdev/functions",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "private": false,
5
5
  "description": "Backend functions for DoNotDev Framework - Firebase, Vercel, and platform-agnostic implementations for auth, billing, CRUD, and OAuth",
6
6
  "main": "./lib/firebase/index.js",
@@ -42,15 +42,15 @@
42
42
  "serve": "firebase emulators:start --only functions"
43
43
  },
44
44
  "dependencies": {
45
- "@donotdev/core": "^0.0.18",
46
- "@donotdev/firebase": "^0.0.7"
45
+ "@donotdev/core": "^0.0.23",
46
+ "@donotdev/firebase": "^0.0.10"
47
47
  },
48
48
  "peerDependencies": {
49
- "@sentry/node": "^10.33.0",
49
+ "@sentry/node": "^10.38.0",
50
50
  "firebase-admin": "^13.6.0",
51
- "firebase-functions": "^7.0.1",
52
- "next": "^16.1.4",
53
- "stripe": "^20.1.0",
51
+ "firebase-functions": "^7.0.5",
52
+ "next": "^16.1.6",
53
+ "stripe": "^20.3.0",
54
54
  "valibot": "^1.2.0"
55
55
  },
56
56
  "repository": {
@@ -30,15 +30,12 @@ export const FUNCTION_CONFIG = {
30
30
  cors: true, // Enable CORS by default for all functions (required for web apps)
31
31
  } as const;
32
32
 
33
- import { stripeSecretKey, stripeWebhookSecret } from './secrets.js';
34
-
35
33
  /** Stripe/billing functions */
36
34
  export const STRIPE_CONFIG = {
37
35
  ...BASE_CONFIG,
38
36
  memory: '512MiB' as const,
39
37
  timeoutSeconds: 30,
40
38
  cors: true,
41
- secrets: [stripeSecretKey, stripeWebhookSecret],
42
39
  };
43
40
 
44
41
  /** Auth functions */
@@ -97,6 +97,34 @@ function canAggregateField(
97
97
  return isFieldVisible(fieldName, visibility as any, isAdmin);
98
98
  }
99
99
 
100
+ /**
101
+ * Returns the effective (discounted) amount for a price field.
102
+ * Matches display/formula: amount * (1 - discountPercent/100). Plain numbers unchanged.
103
+ */
104
+ function getPriceOrNumberValue(raw: unknown): number {
105
+ if (raw != null && typeof raw === 'object' && 'amount' in raw) {
106
+ const obj = raw as { amount?: unknown; discountPercent?: unknown };
107
+ const amount = Number(obj.amount);
108
+ if (!Number.isFinite(amount)) return NaN;
109
+ const discountPercent = Number(obj.discountPercent);
110
+ const pct = Number.isFinite(discountPercent)
111
+ ? Math.min(100, Math.max(0, discountPercent))
112
+ : 0;
113
+ return pct > 0 ? amount * (1 - pct / 100) : amount;
114
+ }
115
+ const n = Number(raw);
116
+ return Number.isFinite(n) ? n : NaN;
117
+ }
118
+
119
+ /**
120
+ * Returns the numeric value of a field for aggregation.
121
+ * For structured price fields uses effective (discounted) price (expected revenue).
122
+ * Plain numbers are used as-is.
123
+ */
124
+ function getNumericValue(doc: Record<string, any>, field: string): number {
125
+ return getPriceOrNumberValue(doc[field]);
126
+ }
127
+
100
128
  /**
101
129
  * Computes a single metric on a dataset
102
130
  */
@@ -106,19 +134,25 @@ function computeMetric(docs: any[], metric: MetricDefinition): number | null {
106
134
  if (metric.filter) {
107
135
  filtered = docs.filter((doc) => {
108
136
  const value = doc[metric.filter!.field];
137
+ const comparable =
138
+ value != null && typeof value === 'object' && 'amount' in value
139
+ ? getPriceOrNumberValue(value)
140
+ : typeof value === 'number' || value == null
141
+ ? value
142
+ : Number(value);
109
143
  switch (metric.filter!.operator) {
110
144
  case '==':
111
145
  return value === metric.filter!.value;
112
146
  case '!=':
113
147
  return value !== metric.filter!.value;
114
148
  case '>':
115
- return value > metric.filter!.value;
149
+ return comparable > metric.filter!.value;
116
150
  case '<':
117
- return value < metric.filter!.value;
151
+ return comparable < metric.filter!.value;
118
152
  case '>=':
119
- return value >= metric.filter!.value;
153
+ return comparable >= metric.filter!.value;
120
154
  case '<=':
121
- return value <= metric.filter!.value;
155
+ return comparable <= metric.filter!.value;
122
156
  default:
123
157
  return true;
124
158
  }
@@ -131,16 +165,16 @@ function computeMetric(docs: any[], metric: MetricDefinition): number | null {
131
165
 
132
166
  case 'sum': {
133
167
  return filtered.reduce((sum, doc) => {
134
- const val = Number(doc[metric.field]) || 0;
135
- return sum + val;
168
+ const val = getNumericValue(doc, metric.field);
169
+ return sum + (Number.isFinite(val) ? val : 0);
136
170
  }, 0);
137
171
  }
138
172
 
139
173
  case 'avg': {
140
174
  if (filtered.length === 0) return null;
141
175
  const sum = filtered.reduce((s, doc) => {
142
- const val = Number(doc[metric.field]) || 0;
143
- return s + val;
176
+ const val = getNumericValue(doc, metric.field);
177
+ return s + (Number.isFinite(val) ? val : 0);
144
178
  }, 0);
145
179
  return sum / filtered.length;
146
180
  }
@@ -149,8 +183,8 @@ function computeMetric(docs: any[], metric: MetricDefinition): number | null {
149
183
  if (filtered.length === 0) return null;
150
184
  return filtered.reduce(
151
185
  (min, doc) => {
152
- const val = Number(doc[metric.field]);
153
- if (isNaN(val)) return min;
186
+ const val = getNumericValue(doc, metric.field);
187
+ if (!Number.isFinite(val)) return min;
154
188
  return min === null ? val : Math.min(min, val);
155
189
  },
156
190
  null as number | null
@@ -161,8 +195,8 @@ function computeMetric(docs: any[], metric: MetricDefinition): number | null {
161
195
  if (filtered.length === 0) return null;
162
196
  return filtered.reduce(
163
197
  (max, doc) => {
164
- const val = Number(doc[metric.field]);
165
- if (isNaN(val)) return max;
198
+ const val = getNumericValue(doc, metric.field);
199
+ if (!Number.isFinite(val)) return max;
166
200
  return max === null ? val : Math.max(max, val);
167
201
  },
168
202
  null as number | null
@@ -12,7 +12,7 @@
12
12
  import * as v from 'valibot';
13
13
 
14
14
  import { DEFAULT_STATUS_VALUE } from '@donotdev/core/server';
15
- import type { UserRole } from '@donotdev/core/server';
15
+ import type { UserRole, UniqueKeyDefinition } from '@donotdev/core/server';
16
16
  import { getFirebaseAdminFirestore } from '@donotdev/firebase/server';
17
17
 
18
18
  import {
@@ -20,7 +20,7 @@ import {
20
20
  transformFirestoreData,
21
21
  } from '../../shared/index.js';
22
22
  import { createMetadata } from '../../shared/index.js';
23
- import { validateDocument } from '../../shared/utils.js';
23
+ import { DoNotDevError, validateDocument } from '../../shared/utils.js';
24
24
  import { createBaseFunction } from '../baseFunction.js';
25
25
  import { CRUD_CONFIG } from '../config/constants.js';
26
26
 
@@ -28,17 +28,123 @@ import type {
28
28
  CallableFunction,
29
29
  CallableRequest,
30
30
  } from 'firebase-functions/v2/https';
31
+ import type { Query, DocumentData } from 'firebase-admin/firestore';
31
32
 
32
33
  export type CreateEntityRequest = {
33
34
  payload: Record<string, any>;
34
35
  idempotencyKey?: string;
35
36
  };
36
37
 
38
+ /**
39
+ * Result of unique key check
40
+ */
41
+ type UniqueKeyCheckResult =
42
+ | { found: false }
43
+ | { found: true; existingDoc: Record<string, any>; findOrCreate: boolean };
44
+
45
+ /**
46
+ * Checks unique key constraints against existing documents
47
+ * Returns the first matching document if findOrCreate is enabled
48
+ *
49
+ * @param collection - Firestore collection name
50
+ * @param payload - Document data to check
51
+ * @param uniqueKeys - Unique key definitions from entity
52
+ * @param isDraft - Whether the document is a draft
53
+ * @returns Check result with existing document if found
54
+ */
55
+ /**
56
+ * Normalize a value for case-insensitive comparison
57
+ * Lowercases strings, leaves other types unchanged
58
+ */
59
+ function normalizeValue(value: unknown): unknown {
60
+ if (typeof value === 'string') {
61
+ return value.toLowerCase();
62
+ }
63
+ return value;
64
+ }
65
+
66
+ /**
67
+ * Normalize unique key fields in payload to lowercase (for strings)
68
+ * Returns a new object with normalized values for unique key fields
69
+ */
70
+ function normalizePayloadForUniqueKeys(
71
+ payload: Record<string, any>,
72
+ uniqueKeys: UniqueKeyDefinition[]
73
+ ): Record<string, any> {
74
+ const normalized = { ...payload };
75
+ for (const uniqueKey of uniqueKeys) {
76
+ for (const field of uniqueKey.fields) {
77
+ if (typeof normalized[field] === 'string') {
78
+ normalized[field] = normalized[field].toLowerCase();
79
+ }
80
+ }
81
+ }
82
+ return normalized;
83
+ }
84
+
85
+ async function checkUniqueKeys(
86
+ collection: string,
87
+ payload: Record<string, any>,
88
+ uniqueKeys: UniqueKeyDefinition[],
89
+ isDraft: boolean
90
+ ): Promise<UniqueKeyCheckResult> {
91
+ const db = getFirebaseAdminFirestore();
92
+
93
+ for (const uniqueKey of uniqueKeys) {
94
+ // Skip validation for drafts if configured (default: true)
95
+ const skipForDrafts = uniqueKey.skipForDrafts !== false;
96
+ if (isDraft && skipForDrafts) continue;
97
+
98
+ // Check if all fields in the unique key have values
99
+ const allFieldsHaveValues = uniqueKey.fields.every(
100
+ (field) => payload[field] != null && payload[field] !== ''
101
+ );
102
+ if (!allFieldsHaveValues) continue;
103
+
104
+ // Build query for all fields in this unique key
105
+ // Normalize values to lowercase for case-insensitive matching
106
+ let query: Query<DocumentData> = db.collection(collection);
107
+ for (const field of uniqueKey.fields) {
108
+ query = query.where(field, '==', normalizeValue(payload[field]));
109
+ }
110
+
111
+ const existing = await query.limit(1).get();
112
+
113
+ if (!existing.empty) {
114
+ const firstDoc = existing.docs[0]!;
115
+ const existingDoc = transformFirestoreData({
116
+ id: firstDoc.id,
117
+ ...firstDoc.data(),
118
+ });
119
+
120
+ if (uniqueKey.findOrCreate) {
121
+ // Return existing document for findOrCreate behavior
122
+ return { found: true, existingDoc, findOrCreate: true };
123
+ }
124
+
125
+ // Throw duplicate error
126
+ const fieldNames = uniqueKey.fields.join(' + ');
127
+ throw new DoNotDevError(
128
+ uniqueKey.errorMessage || `Duplicate ${fieldNames}`,
129
+ 'already-exists',
130
+ {
131
+ details: {
132
+ fields: uniqueKey.fields,
133
+ existingId: firstDoc.id,
134
+ },
135
+ }
136
+ );
137
+ }
138
+ }
139
+
140
+ return { found: false };
141
+ }
142
+
37
143
  /**
38
144
  * Generic business logic for creating entities
39
145
  * Base function handles: validation, auth, rate limiting, monitoring
40
146
  *
41
- * @version 0.0.1
147
+ * @version 0.0.2
42
148
  * @since 0.0.1
43
149
  * @author AMBROISE PARK Consulting
44
150
  */
@@ -69,16 +175,44 @@ function createEntityLogicFactory(
69
175
  const status = payload.status ?? DEFAULT_STATUS_VALUE;
70
176
  const isDraft = status === 'draft';
71
177
 
178
+ // Check unique keys if schema has metadata with uniqueKeys
179
+ // This handles findOrCreate behavior and duplicate prevention
180
+ const schemaWithMeta = documentSchema as {
181
+ metadata?: { uniqueKeys?: UniqueKeyDefinition[] };
182
+ };
183
+ const uniqueKeys = schemaWithMeta.metadata?.uniqueKeys;
184
+
185
+ if (uniqueKeys && uniqueKeys.length > 0) {
186
+ const checkResult = await checkUniqueKeys(
187
+ collection,
188
+ payload,
189
+ uniqueKeys,
190
+ isDraft
191
+ );
192
+
193
+ if (checkResult.found && checkResult.findOrCreate) {
194
+ // Return existing document instead of creating new one
195
+ return checkResult.existingDoc;
196
+ }
197
+ // If found but not findOrCreate, checkUniqueKeys already threw an error
198
+ }
199
+
200
+ // Normalize unique key fields to lowercase for case-insensitive storage
201
+ const normalizedPayload =
202
+ uniqueKeys && uniqueKeys.length > 0
203
+ ? normalizePayloadForUniqueKeys(payload, uniqueKeys)
204
+ : payload;
205
+
72
206
  // Validate the document against the schema
73
207
  // Skip validation for drafts - required fields can be incomplete
74
208
  if (!isDraft) {
75
- validateDocument(payload, documentSchema);
209
+ validateDocument(normalizedPayload, documentSchema);
76
210
  }
77
211
 
78
212
  // Prepare the document for Firestore and add metadata
79
213
  // Always ensure status is set
80
214
  const documentData = {
81
- ...prepareForFirestore(payload),
215
+ ...prepareForFirestore(normalizedPayload),
82
216
  status, // Ensure status is always present
83
217
  ...createMetadata(uid),
84
218
  };
@@ -16,7 +16,7 @@ import {
16
16
  hasRoleAccess,
17
17
  HIDDEN_STATUSES,
18
18
  } from '@donotdev/core/server';
19
- import type { UserRole } from '@donotdev/core/server';
19
+ import type { EntityOwnershipConfig, UserRole } from '@donotdev/core/server';
20
20
  import { getFirebaseAdminFirestore } from '@donotdev/firebase/server';
21
21
 
22
22
  import { transformFirestoreData } from '../../shared/index.js';
@@ -37,14 +37,15 @@ export type GetEntityRequest = { id: string };
37
37
  */
38
38
  function getEntityLogicFactory(
39
39
  collection: string,
40
- documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>
40
+ documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
41
+ ownership?: EntityOwnershipConfig
41
42
  ) {
42
43
  return async function getEntityLogic(
43
44
  data: GetEntityRequest,
44
45
  context: { uid: string; userRole: UserRole; request: CallableRequest<any> }
45
46
  ) {
46
47
  const { id } = data;
47
- const { userRole } = context;
48
+ const { userRole, uid } = context;
48
49
 
49
50
  // Get the document reference
50
51
  const db = getFirebaseAdminFirestore();
@@ -69,11 +70,16 @@ function getEntityLogicFactory(
69
70
  throw new DoNotDevError('Entity not found', 'not-found');
70
71
  }
71
72
 
72
- // Filter fields based on visibility and user role
73
+ const rawData = docData || {};
74
+ const visibilityOptions =
75
+ ownership && uid ? { documentData: rawData, uid, ownership } : undefined;
76
+
77
+ // Filter fields based on visibility and user role (and ownership for visibility: 'owner')
73
78
  const filteredData = filterVisibleFields(
74
- docData || {},
79
+ rawData,
75
80
  documentSchema,
76
- userRole
81
+ userRole,
82
+ visibilityOptions
77
83
  );
78
84
 
79
85
  // Transform the document data back to the application format
@@ -90,13 +96,15 @@ function getEntityLogicFactory(
90
96
  * @param documentSchema - The Valibot schema for document validation
91
97
  * @param requiredRole - Minimum role required for this operation
92
98
  * @param customSchema - Optional custom request schema
99
+ * @param ownership - Optional ownership config for visibility: 'owner' field masking
93
100
  * @returns Firebase callable function
94
101
  */
95
102
  export const getEntity = (
96
103
  collection: string,
97
104
  documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
98
105
  requiredRole: UserRole,
99
- customSchema?: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>
106
+ customSchema?: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
107
+ ownership?: EntityOwnershipConfig
100
108
  ): CallableFunction<GetEntityRequest, Promise<any>> => {
101
109
  const requestSchema =
102
110
  customSchema ||
@@ -108,7 +116,7 @@ export const getEntity = (
108
116
  CRUD_READ_CONFIG,
109
117
  requestSchema,
110
118
  'get_entity',
111
- getEntityLogicFactory(collection, documentSchema),
119
+ getEntityLogicFactory(collection, documentSchema, ownership),
112
120
  requiredRole
113
121
  );
114
122
  };
@@ -16,7 +16,11 @@ import {
16
16
  hasRoleAccess,
17
17
  HIDDEN_STATUSES,
18
18
  } from '@donotdev/core/server';
19
- import type { UserRole } from '@donotdev/core/server';
19
+ import type {
20
+ EntityOwnershipConfig,
21
+ EntityOwnershipPublicCondition,
22
+ UserRole,
23
+ } from '@donotdev/core/server';
20
24
  import { getFirebaseAdminFirestore, Query } from '@donotdev/firebase/server';
21
25
 
22
26
  import { transformFirestoreData } from '../../shared/index.js';
@@ -40,6 +44,20 @@ export interface ListEntityRequest {
40
44
  };
41
45
  }
42
46
 
47
+ /**
48
+ * Apply public-condition where clauses to a query (for listCard when ownership is set).
49
+ */
50
+ function applyPublicCondition(
51
+ query: Query,
52
+ publicCondition: EntityOwnershipPublicCondition[]
53
+ ): Query {
54
+ let q = query;
55
+ for (const c of publicCondition) {
56
+ q = q.where(c.field, c.op as any, c.value);
57
+ }
58
+ return q;
59
+ }
60
+
43
61
  /**
44
62
  * Generic business logic for listing entities
45
63
  * Base function handles: validation, auth, rate limiting, monitoring
@@ -47,7 +65,9 @@ export interface ListEntityRequest {
47
65
  function listEntitiesLogicFactory(
48
66
  collection: string,
49
67
  documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
50
- listFields?: string[]
68
+ listFields?: string[],
69
+ ownership?: EntityOwnershipConfig,
70
+ isListCard?: boolean
51
71
  ) {
52
72
  return async function listEntitiesLogic(
53
73
  data: ListEntityRequest,
@@ -58,7 +78,8 @@ function listEntitiesLogicFactory(
58
78
  }
59
79
  ) {
60
80
  const { where = [], orderBy = [], limit, startAfterId, search } = data;
61
- const { userRole } = context;
81
+ // Extract uid and userRole once (reused for all document masking)
82
+ const { userRole, uid } = context;
62
83
 
63
84
  const isAdmin = hasRoleAccess(userRole, 'admin'); // Uses role hierarchy
64
85
 
@@ -71,6 +92,26 @@ function listEntitiesLogicFactory(
71
92
  query = query.where('status', 'not-in', [...HIDDEN_STATUSES]);
72
93
  }
73
94
 
95
+ // Ownership: when set and not admin, listCard = public condition, list = mine filter
96
+ if (ownership && !isAdmin) {
97
+ if (
98
+ isListCard &&
99
+ ownership.publicCondition &&
100
+ ownership.publicCondition.length > 0
101
+ ) {
102
+ query = applyPublicCondition(query, ownership.publicCondition);
103
+ } else if (!isListCard && ownership.ownerFields.length > 0) {
104
+ // "Mine" filter: Firestore does not support OR across different fields in one query.
105
+ // Use first owner field only for single query; recommend ownerIds array-contains for multiple.
106
+ const firstOwnerField = ownership.ownerFields[0];
107
+ query = query.where(firstOwnerField, '==', uid);
108
+ if (ownership.ownerFields.length > 1) {
109
+ // Optional: run second query and merge (complex pagination). For now, single field only.
110
+ // Document ownerIds pattern for multiple stakeholders.
111
+ }
112
+ }
113
+ }
114
+
74
115
  // Apply search if provided
75
116
  if (search) {
76
117
  const { field, query: searchQuery } = search;
@@ -128,12 +169,48 @@ function listEntitiesLogicFactory(
128
169
  const schemaHasEntries = !!(documentSchema as any)?.entries;
129
170
  console.log(`[listEntities] Schema has entries: ${schemaHasEntries}`);
130
171
 
131
- // Filter document fields based on visibility and user role
172
+ // Helper: Check if value is a Picture object (has thumbUrl and fullUrl)
173
+ const isPictureObject = (value: any): boolean => {
174
+ return (
175
+ typeof value === 'object' &&
176
+ value !== null &&
177
+ 'thumbUrl' in value &&
178
+ 'fullUrl' in value
179
+ );
180
+ };
181
+
182
+ // Helper: Optimize picture fields for listCard (only return first picture's thumbUrl)
183
+ const optimizePictureField = (value: any): any => {
184
+ if (Array.isArray(value) && value.length > 0) {
185
+ // Array of pictures - return just the first picture's thumbUrl
186
+ const firstPicture = value[0];
187
+ if (isPictureObject(firstPicture)) {
188
+ return firstPicture.thumbUrl || firstPicture.fullUrl || null;
189
+ }
190
+ // If first item is a string, return it as-is
191
+ if (typeof firstPicture === 'string') {
192
+ return firstPicture;
193
+ }
194
+ } else if (isPictureObject(value)) {
195
+ // Single picture object - return thumbUrl
196
+ return value.thumbUrl || value.fullUrl || null;
197
+ }
198
+ // Not a picture, return as-is
199
+ return value;
200
+ };
201
+
202
+ const visibilityOptions = ownership && uid ? { uid, ownership } : undefined;
203
+
204
+ // Filter document fields based on visibility and user role (uid/userRole from context, once)
132
205
  const docs = snapshot.docs.map((doc: any) => {
206
+ const rawData = doc.data() || {};
133
207
  const visibleData = filterVisibleFields(
134
- doc.data() || {},
208
+ rawData,
135
209
  documentSchema,
136
- userRole
210
+ userRole,
211
+ visibilityOptions
212
+ ? { ...visibilityOptions, documentData: rawData }
213
+ : undefined
137
214
  );
138
215
 
139
216
  // If listFields specified, filter to only those fields (plus id always)
@@ -141,24 +218,23 @@ function listEntitiesLogicFactory(
141
218
  const filtered: Record<string, any> = { id: doc.id };
142
219
  for (const field of listFields) {
143
220
  if (field in visibleData) {
144
- filtered[field] = visibleData[field];
221
+ const value = visibleData[field];
222
+ // Optimize picture fields for list views (only need first thumbUrl)
223
+ filtered[field] = optimizePictureField(value);
145
224
  }
146
225
  }
147
226
  return filtered;
148
227
  }
149
228
 
150
229
  // No listFields restriction, return all visible fields
151
- return {
152
- id: doc.id,
153
- ...visibleData,
154
- };
230
+ // Still optimize picture fields for all list queries (datatables only need thumbUrl)
231
+ const optimizedData: Record<string, any> = { id: doc.id };
232
+ for (const [key, value] of Object.entries(visibleData)) {
233
+ optimizedData[key] = optimizePictureField(value);
234
+ }
235
+ return optimizedData;
155
236
  });
156
237
 
157
- // DEBUG: Log filtered docs
158
- console.log(
159
- `[listEntities] Filtered docs count: ${docs.length}, first doc keys: ${docs[0] ? Object.keys(docs[0]).join(', ') : 'N/A'}`
160
- );
161
-
162
238
  // Return the paginated result with metadata
163
239
  return {
164
240
  items: transformFirestoreData(docs),
@@ -176,6 +252,8 @@ function listEntitiesLogicFactory(
176
252
  * @param requiredRole - Minimum role required for this operation
177
253
  * @param customSchema - Optional custom request schema
178
254
  * @param listFields - Optional array of field names to include (plus id). If not provided, all visible fields are returned.
255
+ * @param ownership - Optional ownership config for list constraints and visibility: 'owner' masking
256
+ * @param isListCard - When true and ownership is set, applies public condition; when false, applies "mine" filter
179
257
  * @returns Firebase callable function
180
258
  */
181
259
  export const listEntities = (
@@ -183,7 +261,9 @@ export const listEntities = (
183
261
  documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
184
262
  requiredRole: UserRole,
185
263
  customSchema?: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
186
- listFields?: string[]
264
+ listFields?: string[],
265
+ ownership?: EntityOwnershipConfig,
266
+ isListCard?: boolean
187
267
  ): CallableFunction<ListEntityRequest, Promise<any>> => {
188
268
  const requestSchema =
189
269
  customSchema ||
@@ -206,7 +286,13 @@ export const listEntities = (
206
286
  CRUD_READ_CONFIG,
207
287
  requestSchema,
208
288
  'list_entities',
209
- listEntitiesLogicFactory(collection, documentSchema, listFields),
289
+ listEntitiesLogicFactory(
290
+ collection,
291
+ documentSchema,
292
+ listFields,
293
+ ownership,
294
+ isListCard
295
+ ),
210
296
  requiredRole
211
297
  );
212
298
  };
@@ -12,7 +12,7 @@
12
12
  import * as v from 'valibot';
13
13
 
14
14
  import { DEFAULT_STATUS_VALUE } from '@donotdev/core/server';
15
- import type { UserRole } from '@donotdev/core/server';
15
+ import type { UserRole, UniqueKeyDefinition } from '@donotdev/core/server';
16
16
  import { getFirebaseAdminFirestore } from '@donotdev/firebase/server';
17
17
 
18
18
  import {
@@ -28,6 +28,7 @@ import type {
28
28
  CallableFunction,
29
29
  CallableRequest,
30
30
  } from 'firebase-functions/v2/https';
31
+ import type { Query, DocumentData } from 'firebase-admin/firestore';
31
32
 
32
33
  export type UpdateEntityRequest = {
33
34
  id: string;
@@ -35,9 +36,110 @@ export type UpdateEntityRequest = {
35
36
  idempotencyKey?: string;
36
37
  };
37
38
 
39
+ /**
40
+ * Normalize a value for case-insensitive comparison
41
+ * Lowercases strings, leaves other types unchanged
42
+ */
43
+ function normalizeValue(value: unknown): unknown {
44
+ if (typeof value === 'string') {
45
+ return value.toLowerCase();
46
+ }
47
+ return value;
48
+ }
49
+
50
+ /**
51
+ * Normalize unique key fields in payload to lowercase (for strings)
52
+ * Returns a new object with normalized values for unique key fields
53
+ */
54
+ function normalizePayloadForUniqueKeys(
55
+ payload: Record<string, any>,
56
+ uniqueKeys: UniqueKeyDefinition[]
57
+ ): Record<string, any> {
58
+ const normalized = { ...payload };
59
+ for (const uniqueKey of uniqueKeys) {
60
+ for (const field of uniqueKey.fields) {
61
+ if (typeof normalized[field] === 'string') {
62
+ normalized[field] = normalized[field].toLowerCase();
63
+ }
64
+ }
65
+ }
66
+ return normalized;
67
+ }
68
+
69
+ /**
70
+ * Checks unique key constraints for updates
71
+ * Only checks if unique key fields are being modified
72
+ * Excludes the current document from conflict check
73
+ *
74
+ * @param collection - Firestore collection name
75
+ * @param currentDocId - ID of document being updated (excluded from check)
76
+ * @param mergedData - Merged document data (current + payload)
77
+ * @param payload - Update payload (only fields being changed)
78
+ * @param uniqueKeys - Unique key definitions from entity
79
+ * @param isDraft - Whether the resulting document is a draft
80
+ */
81
+ async function checkUniqueKeysForUpdate(
82
+ collection: string,
83
+ currentDocId: string,
84
+ mergedData: Record<string, any>,
85
+ payload: Record<string, any>,
86
+ uniqueKeys: UniqueKeyDefinition[],
87
+ isDraft: boolean
88
+ ): Promise<void> {
89
+ const db = getFirebaseAdminFirestore();
90
+
91
+ for (const uniqueKey of uniqueKeys) {
92
+ // Skip validation for drafts if configured (default: true)
93
+ const skipForDrafts = uniqueKey.skipForDrafts !== false;
94
+ if (isDraft && skipForDrafts) continue;
95
+
96
+ // Check if any of the unique key fields are being updated
97
+ const isUpdatingUniqueKeyField = uniqueKey.fields.some(
98
+ (field) => field in payload
99
+ );
100
+ if (!isUpdatingUniqueKeyField) continue;
101
+
102
+ // Check if all fields in the unique key have values in merged data
103
+ const allFieldsHaveValues = uniqueKey.fields.every(
104
+ (field) => mergedData[field] != null && mergedData[field] !== ''
105
+ );
106
+ if (!allFieldsHaveValues) continue;
107
+
108
+ // Build query for all fields in this unique key
109
+ // Normalize values to lowercase for case-insensitive matching
110
+ let query: Query<DocumentData> = db.collection(collection);
111
+ for (const field of uniqueKey.fields) {
112
+ query = query.where(field, '==', normalizeValue(mergedData[field]));
113
+ }
114
+
115
+ const existing = await query.limit(2).get(); // Get 2 to check if another doc exists
116
+
117
+ // Check if any matching document is NOT the current document
118
+ const conflictingDoc = existing.docs.find((doc) => doc.id !== currentDocId);
119
+
120
+ if (conflictingDoc) {
121
+ const fieldNames = uniqueKey.fields.join(' + ');
122
+ throw new DoNotDevError(
123
+ uniqueKey.errorMessage || `Duplicate ${fieldNames}`,
124
+ 'already-exists',
125
+ {
126
+ details: {
127
+ fields: uniqueKey.fields,
128
+ existingId: conflictingDoc.id,
129
+ },
130
+ }
131
+ );
132
+ }
133
+ }
134
+ }
135
+
38
136
  /**
39
137
  * Generic business logic for updating entities
40
138
  * Base function handles: validation, auth, rate limiting, monitoring
139
+ *
140
+ * @version 0.0.2
141
+ * @since 0.0.1
142
+ * @author AMBROISE PARK Consulting
41
143
  */
42
144
  function updateEntityLogicFactory(
43
145
  collection: string,
@@ -80,15 +182,39 @@ function updateEntityLogicFactory(
80
182
  const resultingStatus = mergedData.status ?? DEFAULT_STATUS_VALUE;
81
183
  const isDraft = resultingStatus === 'draft';
82
184
 
185
+ // Check unique keys if schema has metadata with uniqueKeys
186
+ // Only checks fields that are being updated
187
+ const schemaWithMeta = documentSchema as {
188
+ metadata?: { uniqueKeys?: UniqueKeyDefinition[] };
189
+ };
190
+ const uniqueKeys = schemaWithMeta.metadata?.uniqueKeys;
191
+
192
+ if (uniqueKeys && uniqueKeys.length > 0) {
193
+ await checkUniqueKeysForUpdate(
194
+ collection,
195
+ id,
196
+ mergedData,
197
+ payload,
198
+ uniqueKeys,
199
+ isDraft
200
+ );
201
+ }
202
+
83
203
  // Validate the merged document against the schema
84
204
  // Skip validation for drafts - required fields can be incomplete
85
205
  if (!isDraft) {
86
206
  validateDocument(mergedData, documentSchema);
87
207
  }
88
208
 
209
+ // Normalize unique key fields to lowercase for case-insensitive storage
210
+ const normalizedPayload =
211
+ uniqueKeys && uniqueKeys.length > 0
212
+ ? normalizePayloadForUniqueKeys(payload, uniqueKeys)
213
+ : payload;
214
+
89
215
  // Prepare the update data for Firestore and add metadata
90
216
  const updateData = {
91
- ...prepareForFirestore(payload),
217
+ ...prepareForFirestore(normalizedPayload),
92
218
  ...updateMetadata(uid),
93
219
  };
94
220
 
@@ -67,14 +67,23 @@ export function createCrudFunctions(
67
67
  schemas.create,
68
68
  access.create
69
69
  );
70
- functions[`${prefix}get_${col}`] = getEntity(col, schemas.get, access.read);
70
+ functions[`${prefix}get_${col}`] = getEntity(
71
+ col,
72
+ schemas.get,
73
+ access.read,
74
+ undefined,
75
+ entity.ownership
76
+ );
71
77
  // Use schemas.get for visibility filtering, entity.listFields for field selection
78
+ // When ownership is set: list = "mine" filter, listCard = public condition
72
79
  functions[`${prefix}list_${col}`] = listEntities(
73
80
  col,
74
81
  schemas.get,
75
82
  access.read,
76
83
  undefined,
77
- entity.listFields
84
+ entity.listFields,
85
+ entity.ownership,
86
+ false
78
87
  );
79
88
  // Always create listCard - uses same schemas.get, field selection via listCardFields ?? listFields ?? undefined
80
89
  functions[`${prefix}listCard_${col}`] = listEntities(
@@ -82,7 +91,9 @@ export function createCrudFunctions(
82
91
  schemas.get,
83
92
  access.read,
84
93
  undefined,
85
- entity.listCardFields ?? entity.listFields ?? undefined
94
+ entity.listCardFields ?? entity.listFields ?? undefined,
95
+ entity.ownership,
96
+ true
86
97
  );
87
98
  functions[`${prefix}update_${col}`] = updateEntity(
88
99
  col,
@@ -158,7 +158,8 @@ export function transformFirestoreData<T = any>(
158
158
  }
159
159
 
160
160
  /**
161
- * Recursively prepares data for Firestore by converting ISO strings or Date objects to Timestamps.
161
+ * Recursively prepares data for Firestore by converting Date objects to ISO strings.
162
+ * ISO strings are kept as-is (no conversion needed).
162
163
  * @param data - The data to prepare
163
164
  * @param removeFields - Optional array of field names to remove
164
165
  * @returns The prepared data
@@ -189,14 +190,11 @@ export function prepareForFirestore<T = any>(
189
190
  continue;
190
191
  }
191
192
 
192
- // Handle Date objects
193
+ // Handle Date objects - convert to ISO string
193
194
  if (value instanceof Date) {
194
- prepared[key] = toTimestamp(value);
195
- }
196
- // Handle ISO strings that look like dates
197
- else if (typeof value === 'string' && isISODateString(value)) {
198
- prepared[key] = toTimestamp(value);
195
+ prepared[key] = value.toISOString();
199
196
  }
197
+ // ISO strings stay as strings - no conversion needed
200
198
  // Handle nested objects and arrays
201
199
  else {
202
200
  prepared[key] = prepareForFirestore(value, removeFields);
@@ -205,15 +203,12 @@ export function prepareForFirestore<T = any>(
205
203
  return prepared as T;
206
204
  }
207
205
 
208
- // Handle Date objects at root level
206
+ // Handle Date objects at root level - convert to ISO string
209
207
  if (data instanceof Date) {
210
- return toTimestamp(data) as unknown as T;
208
+ return data.toISOString() as unknown as T;
211
209
  }
212
210
 
213
- // Handle ISO strings at root level
214
- if (typeof data === 'string' && isISODateString(data)) {
215
- return toTimestamp(data) as unknown as T;
216
- }
211
+ // ISO strings stay as strings - no conversion needed
217
212
 
218
213
  // Return primitive values unchanged
219
214
  return data;
@@ -12,7 +12,7 @@
12
12
  import * as v from 'valibot';
13
13
 
14
14
  /** Visibility levels matching @donotdev/core */
15
- type Visibility = 'guest' | 'user' | 'admin' | 'technical' | 'hidden';
15
+ type Visibility = 'guest' | 'user' | 'admin' | 'technical' | 'hidden' | 'owner';
16
16
 
17
17
  // Define a type for the custom visibility property we might add to Valibot schemas
18
18
  interface ValibotSchemaWithVisibility extends v.BaseSchema<
@@ -52,6 +52,7 @@ function getFieldVisibility(field: any): Visibility | undefined {
52
52
  * - 'admin': Visible only to admins
53
53
  * - 'technical': Visible to admins only (shown as read-only in edit forms)
54
54
  * - 'hidden': Never visible (passwords, tokens, API keys - only in DB)
55
+ * - 'owner': Visible only when uid matches one of entity.ownership.ownerFields (requires document context; here treated as not visible for aggregate)
55
56
  * - undefined: Defaults to 'user' behavior (visible to authenticated users)
56
57
  *
57
58
  * @param key - The name (key) of the field.
@@ -90,6 +91,11 @@ export function isFieldVisible(
90
91
  return isAdmin;
91
92
  }
92
93
 
94
+ // Owner: requires per-document check (documentData, uid, ownership); not available in aggregate context
95
+ if (visibility === 'owner') {
96
+ return false;
97
+ }
98
+
93
99
  // User fields (or undefined/default) are visible to authenticated users
94
100
  if (visibility === 'user' || visibility === undefined) {
95
101
  return isAuthenticated;