data-primals-engine 1.4.1 → 1.4.2
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/README.md +23 -19
- package/client/src/AssistantChat.jsx +48 -5
- package/client/src/AssistantChat.scss +0 -1
- package/client/src/ChartConfigModal.jsx +34 -9
- package/client/src/Dashboard.jsx +396 -349
- package/client/src/DashboardChart.jsx +91 -12
- package/client/src/DataTable.jsx +817 -807
- package/client/src/Field.jsx +1784 -1782
- package/client/src/contexts/UIContext.jsx +5 -2
- package/client/src/translations.js +16818 -16750
- package/package.json +10 -2
- package/src/config.js +2 -2
- package/src/core.js +21 -3
- package/src/engine.js +2 -1
- package/src/events.js +4 -3
- package/src/middlewares/middleware-mongodb.js +3 -1
- package/src/modules/assistant/assistant.js +131 -42
- package/src/modules/bucket.js +3 -2
- package/src/modules/data/data.backup.js +374 -0
- package/src/modules/data/data.history.js +11 -8
- package/src/modules/data/data.js +190 -4551
- package/src/modules/data/data.operations.js +2790 -0
- package/src/modules/data/data.relations.js +687 -0
- package/src/modules/data/data.routes.js +132 -38
- package/src/modules/data/data.scheduling.js +274 -0
- package/src/modules/data/data.validation.js +245 -0
- package/src/modules/data/index.js +29 -1
- package/src/modules/user.js +2 -1
- package/src/modules/workflow.js +3 -2
- package/src/services/stripe.js +3 -2
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import {Event} from "../../events.js";
|
|
2
|
+
import i18n from "../../i18n.js";
|
|
3
|
+
import {allowedFields, maxFileSize, maxModelNameLength, maxStringLength} from "../../constants.js";
|
|
4
|
+
import {getDefaultForType} from "../../data.js";
|
|
5
|
+
|
|
6
|
+
import {dataTypes} from "./data.operations.js";
|
|
7
|
+
import {Logger} from "../../gameObject.js";
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
let engine, logger;
|
|
11
|
+
export function onInit(defaultEngine) {
|
|
12
|
+
engine = defaultEngine;
|
|
13
|
+
logger = engine.getComponent(Logger);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function validateModelStructure(modelStructure) {
|
|
17
|
+
return await Event.Trigger("OnValidateModelStructure", "event", "system", modelStructure);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const validateField = (field) => {
|
|
21
|
+
|
|
22
|
+
const allowedFieldTest = (fields) => {
|
|
23
|
+
// Check for unknown fields
|
|
24
|
+
const unknownFields = Object.keys(field).filter(f => ![...allowedFields, ...fields].includes(f));
|
|
25
|
+
|
|
26
|
+
if (unknownFields.length > 0) {
|
|
27
|
+
throw new Error(i18n.t('api.validate.unknowField', `Propriété(s) non reconnue(s): '{{0}}' pour le champ '{{1}}'`, [unknownFields.join(', '), field.name]));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const fieldInvalid = Object.keys(fields).find(f => JSON.stringify(field[f] || '').length > maxStringLength);
|
|
31
|
+
if (fieldInvalid) {
|
|
32
|
+
throw new Error(i18n.t('api.validate.invalidField', `Champ(s) non valide(s): '{{0}}'`, [fieldInvalid.name]));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check for required fields
|
|
37
|
+
if (!field.name || typeof field.name !== 'string') {
|
|
38
|
+
throw new Error(i18n.t('api.validate.requiredFieldString', "Le champ '{{0}}' est requis et doit être une chaîne de caractères.", ["name"]));
|
|
39
|
+
}
|
|
40
|
+
if (!field.type || typeof field.type !== 'string') {
|
|
41
|
+
throw new Error(i18n.t('api.validate.requiredFieldString', "Le champ '{{0}}' est requis et doit être une chaîne de caractères.", ["type"]));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check for specific field types
|
|
45
|
+
switch (field.type) {
|
|
46
|
+
case 'relation':
|
|
47
|
+
allowedFieldTest(['relation', 'multiple', 'relationFilter']);
|
|
48
|
+
if (!field.relation || typeof field.relation !== 'string' || field.relation.length > maxModelNameLength) {
|
|
49
|
+
throw new Error(i18n.t('api.validate.requiredFieldString', "Le champ '{{0}}' est requis et doit être une chaîne de caractères.", ["relation"]));
|
|
50
|
+
}
|
|
51
|
+
if (field.multiple !== undefined && typeof field.multiple !== 'boolean') {
|
|
52
|
+
throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["multiple"]));
|
|
53
|
+
}
|
|
54
|
+
if (field.relationFilter && typeof field.relationFilter !== 'object') {
|
|
55
|
+
throw new Error(i18n.t('api.validate.fieldObject', "L'attribut '{{0}}' doit être un objet.", ["relationFilter"]));
|
|
56
|
+
}
|
|
57
|
+
break;
|
|
58
|
+
case 'enum': {
|
|
59
|
+
allowedFieldTest(['items']);
|
|
60
|
+
if (!field.items || !Array.isArray(field.items) || field.items.length === 0) {
|
|
61
|
+
throw new Error(i18n.t('api.validate.fieldStringArray', "L'attribut '{{0}}' doit être un tableau de chaines de caractères.", ["items"]));
|
|
62
|
+
}
|
|
63
|
+
let id = field.items.findIndex(item => typeof item !== 'string');
|
|
64
|
+
if (id !== -1) {
|
|
65
|
+
throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["items[" + id + "]"]));
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case 'number':
|
|
70
|
+
allowedFieldTest(['min', 'max', 'step', 'unit', 'delay', 'gauge', 'percent']);
|
|
71
|
+
if (field.min !== undefined && typeof field.min !== 'number') {
|
|
72
|
+
throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["min"]));
|
|
73
|
+
}
|
|
74
|
+
if (field.max !== undefined && typeof field.max !== 'number') {
|
|
75
|
+
throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["max"]));
|
|
76
|
+
}
|
|
77
|
+
if (field.max < field.min) {
|
|
78
|
+
throw new Error(i18n.t('api.validate.inferiorTo', "L'attribut '{{0}}' doit être inférieur à l'attribut '{{1}}'.", ["min", "max"]));
|
|
79
|
+
}
|
|
80
|
+
if (field.step !== undefined && typeof field.step !== 'number') {
|
|
81
|
+
throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["step"]));
|
|
82
|
+
}
|
|
83
|
+
if (field.unit !== undefined && typeof field.unit !== 'string') {
|
|
84
|
+
throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["unit"]));
|
|
85
|
+
}
|
|
86
|
+
if (field.delay !== undefined && typeof field.delay !== 'boolean') {
|
|
87
|
+
throw new Error(i18n.t('api.validate.fieldBoolean', "Le champ '{{0}}' doit être un booléen.", ["unit"]));
|
|
88
|
+
}
|
|
89
|
+
if (field.gauge !== undefined && typeof field.gauge !== 'boolean') {
|
|
90
|
+
throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["gauge"]));
|
|
91
|
+
}
|
|
92
|
+
if (field.percent !== undefined && typeof field.percent !== 'boolean') {
|
|
93
|
+
throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["percent"]));
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
case 'string':
|
|
97
|
+
case 'string_t':
|
|
98
|
+
case 'richtext':
|
|
99
|
+
case 'richtext_t':
|
|
100
|
+
case 'url':
|
|
101
|
+
case 'email':
|
|
102
|
+
case 'phone':
|
|
103
|
+
case 'password':
|
|
104
|
+
case 'code':
|
|
105
|
+
if (field.type === 'code')
|
|
106
|
+
allowedFieldTest(['maxlength', 'language', 'conditionBuilder', 'targetModel']);
|
|
107
|
+
else if (['string_t', 'string'].includes(field.type))
|
|
108
|
+
allowedFieldTest(['maxlength', 'multiline']);
|
|
109
|
+
else
|
|
110
|
+
allowedFieldTest(['maxlength']);
|
|
111
|
+
if (field.maxlength !== undefined && typeof field.maxlength !== 'number') {
|
|
112
|
+
throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["maxlength"]));
|
|
113
|
+
}
|
|
114
|
+
break;
|
|
115
|
+
case 'model':
|
|
116
|
+
case 'modelField':
|
|
117
|
+
allowedFieldTest([]);
|
|
118
|
+
break;
|
|
119
|
+
case 'object':
|
|
120
|
+
allowedFieldTest([]);
|
|
121
|
+
break;
|
|
122
|
+
case 'boolean':
|
|
123
|
+
allowedFieldTest([]);
|
|
124
|
+
break;
|
|
125
|
+
case 'date':
|
|
126
|
+
case 'datetime': {
|
|
127
|
+
allowedFieldTest(['min', 'max']);
|
|
128
|
+
if (field.min !== undefined && typeof field.min !== 'string') {
|
|
129
|
+
throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["min"]));
|
|
130
|
+
}
|
|
131
|
+
if (field.max !== undefined && typeof field.max !== 'string') {
|
|
132
|
+
throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["max"]));
|
|
133
|
+
}
|
|
134
|
+
const dtMin = field.min ? new Date(field.min) : null;
|
|
135
|
+
const dtMax = field.max ? new Date(field.max) : null;
|
|
136
|
+
if (dtMin && dtMax && dtMin > dtMax) {
|
|
137
|
+
throw new Error(i18n.t('api.validate.inferiorTo', "L'attribut '{{0}}' doit être inférieur à l'attribut '{{1}}'.", ["min", "max"]));
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case 'image':
|
|
142
|
+
case 'file': {
|
|
143
|
+
allowedFieldTest(['mimeTypes', 'maxSize']);
|
|
144
|
+
if (field.mimeTypes !== undefined && !Array.isArray(field.mimeTypes)) {
|
|
145
|
+
throw new Error(i18n.t('api.validate.fieldStringArray', "L'attribut '{{0}}' doit être un tableau de chaines de caractères.", ["mimeTypes"]));
|
|
146
|
+
}
|
|
147
|
+
let id;
|
|
148
|
+
if (field.mimeTypes !== undefined && (id = field.mimeTypes.findIndex(item => typeof item !== 'string')) !== -1) {
|
|
149
|
+
throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["mimeTypes[" + id + "]"]));
|
|
150
|
+
}
|
|
151
|
+
if (field.maxSize !== undefined && typeof field.maxSize !== 'number') {
|
|
152
|
+
throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["maxSize"]));
|
|
153
|
+
}
|
|
154
|
+
if (field.maxSize !== undefined && field.maxSize > maxFileSize) {
|
|
155
|
+
throw new Error(i18n.t('api.validate.fileSize', `L'attribut 'maxSize' ne doit pas dépasser {{0}} octets.`, [maxFileSize]));
|
|
156
|
+
}
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case 'color':
|
|
160
|
+
allowedFieldTest([]);
|
|
161
|
+
return true;
|
|
162
|
+
case 'cronSchedule':
|
|
163
|
+
allowedFieldTest(['cronMask']);
|
|
164
|
+
return true;
|
|
165
|
+
case 'calculated':
|
|
166
|
+
allowedFieldTest(['calculation']);
|
|
167
|
+
return true;
|
|
168
|
+
case 'array':
|
|
169
|
+
if (!field.itemsType || typeof field.itemsType !== 'string') {
|
|
170
|
+
throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["itemsType"]));
|
|
171
|
+
}
|
|
172
|
+
if (!dataTypes[field.itemsType]) {
|
|
173
|
+
throw new Error(i18n.t('api.validate.invalidField', `Champ(s) non valide(s): '{{0}}'`, ["itemsType"]));
|
|
174
|
+
}
|
|
175
|
+
if (field.minItems !== undefined && typeof field.minItems !== 'number') {
|
|
176
|
+
throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["minItems"]));
|
|
177
|
+
}
|
|
178
|
+
if (field.maxItems !== undefined && typeof field.maxItems !== 'number') {
|
|
179
|
+
throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["maxItems"]));
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
default:
|
|
183
|
+
throw new Error(i18n.t('api.validate.unknowType', `Le type '{{0}}' n'est pas reconnu.`, [field.type]));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Check for optional fields
|
|
187
|
+
if (field.required !== undefined && typeof field.required !== 'boolean') {
|
|
188
|
+
throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["required"]));
|
|
189
|
+
}
|
|
190
|
+
if (field.hint !== undefined && typeof field.hint !== 'string') {
|
|
191
|
+
throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["hint"]));
|
|
192
|
+
}
|
|
193
|
+
if (field.default !== undefined && field.default !== null && typeof field.default !== typeof getDefaultForType(field) && typeof field.default !== 'function') {
|
|
194
|
+
throw new Error(i18n.t('api.validate.sameType', `L'attribut '{{0}}' doit être du même type que l'attribut '{{0}}' (${field.type}).`, ['default', 'type']));
|
|
195
|
+
}
|
|
196
|
+
if (field.validate !== undefined && typeof field.validate !== 'function') {
|
|
197
|
+
throw new Error(i18n.t('api.validate.fieldFunction', "L'attribut '{{0}}' doit être une fonction.", ['validate']));
|
|
198
|
+
}
|
|
199
|
+
if (field.unique !== undefined && typeof field.unique !== 'boolean') {
|
|
200
|
+
throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["unique"]));
|
|
201
|
+
}
|
|
202
|
+
if (field.placeholder !== undefined && typeof field.placeholder !== 'string') {
|
|
203
|
+
throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["placeholder"]));
|
|
204
|
+
}
|
|
205
|
+
if (field.asMain !== undefined && typeof field.asMain !== 'boolean') {
|
|
206
|
+
throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["asMain"]));
|
|
207
|
+
}
|
|
208
|
+
if (field.unit !== undefined && typeof field.unit !== 'string') {
|
|
209
|
+
throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["unit"]));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return true;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Valide la structure et le contenu du document selon le modèle
|
|
217
|
+
*/
|
|
218
|
+
export async function validateModelData(doc, model, isPatch = false) {
|
|
219
|
+
if (!isPatch) {
|
|
220
|
+
model.fields.forEach(field => {
|
|
221
|
+
const value = doc[field.name];
|
|
222
|
+
if (field.required) {
|
|
223
|
+
if (value === undefined && !('default' in field)) {
|
|
224
|
+
throw new Error(i18n.t('api.field.missingRequired', {field: field.name + " (" + model.name + ")"}));
|
|
225
|
+
}
|
|
226
|
+
if (value === '' || value === null) {
|
|
227
|
+
throw new Error(i18n.t('api.field.requiredCannotBeEmpty', {field: field.name}));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 2. Validation des types de champs (toujours exécutée pour les champs fournis)
|
|
234
|
+
for (const [fieldName, value] of Object.entries(doc)) {
|
|
235
|
+
const fieldDef = model.fields.find(f => f.name === fieldName);
|
|
236
|
+
if (!fieldDef) continue; // On ignore les champs supplémentaires
|
|
237
|
+
|
|
238
|
+
const validator = dataTypes[fieldDef.type]?.validate;
|
|
239
|
+
const valid = validator && validator(value, fieldDef);
|
|
240
|
+
const realValidation = await Event.Trigger('OnDataValidate', "event", "system", value, fieldDef, doc);
|
|
241
|
+
if (!(valid || realValidation)) {
|
|
242
|
+
throw new Error(i18n.t('api.field.validationFailed', {field: fieldName, value}));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -1 +1,29 @@
|
|
|
1
|
-
export * from './data.js';
|
|
1
|
+
export * from './data.js';
|
|
2
|
+
export {validateModelData} from "./data.validation.js";
|
|
3
|
+
export {validateField} from "./data.validation.js";
|
|
4
|
+
export {validateModelStructure} from "./data.validation.js";
|
|
5
|
+
export {scheduleAlerts} from "./data.scheduling.js";
|
|
6
|
+
export {runStatefulAlertJob} from "./data.scheduling.js";
|
|
7
|
+
export {cancelAlerts} from "./data.scheduling.js";
|
|
8
|
+
export {installAllPacks} from "./data.operations.js";
|
|
9
|
+
export {installPack} from "./data.operations.js";
|
|
10
|
+
export {exportData} from "./data.operations.js";
|
|
11
|
+
export {importData} from "./data.operations.js";
|
|
12
|
+
export {searchData} from "./data.operations.js";
|
|
13
|
+
export {deleteData} from "./data.operations.js";
|
|
14
|
+
export {editData} from "./data.operations.js";
|
|
15
|
+
export {patchData} from "./data.operations.js";
|
|
16
|
+
export {pushDataUnsecure} from "./data.operations.js";
|
|
17
|
+
export {insertData} from "./data.operations.js";
|
|
18
|
+
export {getModels} from "./data.operations.js";
|
|
19
|
+
export {getModel} from "./data.operations.js";
|
|
20
|
+
export {deleteModels} from "./data.operations.js";
|
|
21
|
+
export {createModel} from "./data.operations.js";
|
|
22
|
+
export {editModel} from "./data.operations.js";
|
|
23
|
+
export {dataTypes} from "./data.operations.js";
|
|
24
|
+
export {dumpUserData} from "./data.backup.js";
|
|
25
|
+
export {loadFromDump} from "./data.backup.js";
|
|
26
|
+
export {validateRestoreRequest} from "./data.backup.js";
|
|
27
|
+
export {jobDumpUserData} from "./data.backup.js";
|
|
28
|
+
export {handleCustomEndpointRequest} from "./data.routes.js";
|
|
29
|
+
export {middlewareEndpointAuthenticator} from "./data.routes.js";
|
package/src/modules/user.js
CHANGED
|
@@ -14,6 +14,7 @@ import {Logger} from "../gameObject.js";
|
|
|
14
14
|
import rateLimit from "express-rate-limit";
|
|
15
15
|
import ivm from "isolated-vm";
|
|
16
16
|
import {emailDefaultConfig} from "../constants.js";
|
|
17
|
+
import {safeAssignObject} from "../core.js";
|
|
17
18
|
|
|
18
19
|
export const userInitiator = async (req, res, next) => {
|
|
19
20
|
|
|
@@ -269,7 +270,7 @@ export async function calculateTotalUserStorageUsage(user) {
|
|
|
269
270
|
export async function getEnv(user){
|
|
270
271
|
const result = await searchData({ model: 'env' }, user);
|
|
271
272
|
const envObject = result.data.reduce((acc, v) => {
|
|
272
|
-
acc
|
|
273
|
+
safeAssignObject(acc, v.name, v.value);
|
|
273
274
|
return acc;
|
|
274
275
|
}, {});
|
|
275
276
|
return envObject;
|
package/src/modules/workflow.js
CHANGED
|
@@ -23,7 +23,7 @@ import {getHost} from "../constants.js";
|
|
|
23
23
|
import {providers} from "./assistant/constants.js";
|
|
24
24
|
import {ChatAnthropic} from "@langchain/anthropic";
|
|
25
25
|
import {getAIProvider} from "./assistant/assistant.js";
|
|
26
|
-
import {escapeRegex} from "../core.js";
|
|
26
|
+
import {escapeRegex, safeAssignObject} from "../core.js";
|
|
27
27
|
|
|
28
28
|
let logger = null;
|
|
29
29
|
export async function onInit(defaultEngine) {
|
|
@@ -1143,7 +1143,8 @@ export async function substituteVariables(template, contextData, user) {
|
|
|
1143
1143
|
const newObj = {};
|
|
1144
1144
|
for (const key in template) {
|
|
1145
1145
|
if (Object.prototype.hasOwnProperty.call(template, key)) {
|
|
1146
|
-
|
|
1146
|
+
const val = await substituteVariables(template[key], contextData, user);
|
|
1147
|
+
safeAssignObject(newObj, key, val);
|
|
1147
1148
|
}
|
|
1148
1149
|
}
|
|
1149
1150
|
return newObj;
|
package/src/services/stripe.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Stripe from 'stripe';
|
|
2
2
|
import {editData, insertData, searchData} from '../modules/data/index.js';
|
|
3
3
|
import {getEnv} from "../modules/user.js";
|
|
4
|
+
import {safeAssignObject} from "../core.js";
|
|
4
5
|
|
|
5
6
|
const stripeInstances = {};
|
|
6
7
|
|
|
@@ -15,10 +16,10 @@ async function getStripeClient(user) {
|
|
|
15
16
|
if (!stripeInstances[userId]) {
|
|
16
17
|
const env = await getEnv(user);
|
|
17
18
|
if (env.STRIPE_SECRET_KEY) {
|
|
18
|
-
stripeInstances
|
|
19
|
+
safeAssignObject(stripeInstances, userId, {
|
|
19
20
|
client: new Stripe(env.STRIPE_SECRET_KEY),
|
|
20
21
|
webhookSecret: env.STRIPE_WEBHOOK_SECRET
|
|
21
|
-
};
|
|
22
|
+
});
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
return stripeInstances[userId] || null;
|