@donotdev/functions 0.0.8 → 0.0.9
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 +7 -7
- package/src/firebase/crud/aggregate.ts +46 -12
- package/src/firebase/crud/create.ts +139 -5
- package/src/firebase/crud/list.ts +41 -11
- package/src/firebase/crud/update.ts +128 -2
- package/src/shared/firebase.ts +8 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@donotdev/functions",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
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.
|
|
46
|
-
"@donotdev/firebase": "^0.0.
|
|
45
|
+
"@donotdev/core": "^0.0.19",
|
|
46
|
+
"@donotdev/firebase": "^0.0.8"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@sentry/node": "^10.
|
|
49
|
+
"@sentry/node": "^10.38.0",
|
|
50
50
|
"firebase-admin": "^13.6.0",
|
|
51
|
-
"firebase-functions": "^7.0.
|
|
52
|
-
"next": "^16.1.
|
|
53
|
-
"stripe": "^20.
|
|
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": {
|
|
@@ -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
|
|
149
|
+
return comparable > metric.filter!.value;
|
|
116
150
|
case '<':
|
|
117
|
-
return
|
|
151
|
+
return comparable < metric.filter!.value;
|
|
118
152
|
case '>=':
|
|
119
|
-
return
|
|
153
|
+
return comparable >= metric.filter!.value;
|
|
120
154
|
case '<=':
|
|
121
|
-
return
|
|
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 =
|
|
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 =
|
|
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 =
|
|
153
|
-
if (
|
|
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 =
|
|
165
|
-
if (
|
|
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.
|
|
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(
|
|
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(
|
|
215
|
+
...prepareForFirestore(normalizedPayload),
|
|
82
216
|
status, // Ensure status is always present
|
|
83
217
|
...createMetadata(uid),
|
|
84
218
|
};
|
|
@@ -128,10 +128,41 @@ function listEntitiesLogicFactory(
|
|
|
128
128
|
const schemaHasEntries = !!(documentSchema as any)?.entries;
|
|
129
129
|
console.log(`[listEntities] Schema has entries: ${schemaHasEntries}`);
|
|
130
130
|
|
|
131
|
+
// Helper: Check if value is a Picture object (has thumbUrl and fullUrl)
|
|
132
|
+
const isPictureObject = (value: any): boolean => {
|
|
133
|
+
return (
|
|
134
|
+
typeof value === 'object' &&
|
|
135
|
+
value !== null &&
|
|
136
|
+
'thumbUrl' in value &&
|
|
137
|
+
'fullUrl' in value
|
|
138
|
+
);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Helper: Optimize picture fields for listCard (only return first picture's thumbUrl)
|
|
142
|
+
const optimizePictureField = (value: any): any => {
|
|
143
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
144
|
+
// Array of pictures - return just the first picture's thumbUrl
|
|
145
|
+
const firstPicture = value[0];
|
|
146
|
+
if (isPictureObject(firstPicture)) {
|
|
147
|
+
return firstPicture.thumbUrl || firstPicture.fullUrl || null;
|
|
148
|
+
}
|
|
149
|
+
// If first item is a string, return it as-is
|
|
150
|
+
if (typeof firstPicture === 'string') {
|
|
151
|
+
return firstPicture;
|
|
152
|
+
}
|
|
153
|
+
} else if (isPictureObject(value)) {
|
|
154
|
+
// Single picture object - return thumbUrl
|
|
155
|
+
return value.thumbUrl || value.fullUrl || null;
|
|
156
|
+
}
|
|
157
|
+
// Not a picture, return as-is
|
|
158
|
+
return value;
|
|
159
|
+
};
|
|
160
|
+
|
|
131
161
|
// Filter document fields based on visibility and user role
|
|
132
162
|
const docs = snapshot.docs.map((doc: any) => {
|
|
163
|
+
const rawData = doc.data() || {};
|
|
133
164
|
const visibleData = filterVisibleFields(
|
|
134
|
-
|
|
165
|
+
rawData,
|
|
135
166
|
documentSchema,
|
|
136
167
|
userRole
|
|
137
168
|
);
|
|
@@ -141,24 +172,23 @@ function listEntitiesLogicFactory(
|
|
|
141
172
|
const filtered: Record<string, any> = { id: doc.id };
|
|
142
173
|
for (const field of listFields) {
|
|
143
174
|
if (field in visibleData) {
|
|
144
|
-
|
|
175
|
+
const value = visibleData[field];
|
|
176
|
+
// Optimize picture fields for list views (only need first thumbUrl)
|
|
177
|
+
filtered[field] = optimizePictureField(value);
|
|
145
178
|
}
|
|
146
179
|
}
|
|
147
180
|
return filtered;
|
|
148
181
|
}
|
|
149
182
|
|
|
150
183
|
// No listFields restriction, return all visible fields
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
184
|
+
// Still optimize picture fields for all list queries (datatables only need thumbUrl)
|
|
185
|
+
const optimizedData: Record<string, any> = { id: doc.id };
|
|
186
|
+
for (const [key, value] of Object.entries(visibleData)) {
|
|
187
|
+
optimizedData[key] = optimizePictureField(value);
|
|
188
|
+
}
|
|
189
|
+
return optimizedData;
|
|
155
190
|
});
|
|
156
191
|
|
|
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
192
|
// Return the paginated result with metadata
|
|
163
193
|
return {
|
|
164
194
|
items: transformFirestoreData(docs),
|
|
@@ -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(
|
|
217
|
+
...prepareForFirestore(normalizedPayload),
|
|
92
218
|
...updateMetadata(uid),
|
|
93
219
|
};
|
|
94
220
|
|
package/src/shared/firebase.ts
CHANGED
|
@@ -158,7 +158,8 @@ export function transformFirestoreData<T = any>(
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
/**
|
|
161
|
-
* Recursively prepares data for Firestore by converting
|
|
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] =
|
|
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
|
|
208
|
+
return data.toISOString() as unknown as T;
|
|
211
209
|
}
|
|
212
210
|
|
|
213
|
-
//
|
|
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;
|