@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 +7 -7
- package/src/firebase/config/constants.ts +0 -3
- package/src/firebase/crud/aggregate.ts +46 -12
- package/src/firebase/crud/create.ts +139 -5
- package/src/firebase/crud/get.ts +16 -8
- package/src/firebase/crud/list.ts +104 -18
- package/src/firebase/crud/update.ts +128 -2
- package/src/firebase/registerCrudFunctions.ts +14 -3
- package/src/shared/firebase.ts +8 -13
- package/src/shared/schema.ts +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@donotdev/functions",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
46
|
-
"@donotdev/firebase": "^0.0.
|
|
45
|
+
"@donotdev/core": "^0.0.23",
|
|
46
|
+
"@donotdev/firebase": "^0.0.10"
|
|
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": {
|
|
@@ -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
|
|
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
|
};
|
package/src/firebase/crud/get.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
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;
|
package/src/shared/schema.ts
CHANGED
|
@@ -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;
|