@intranefr/superbackend 1.4.3

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.
Files changed (188) hide show
  1. package/.commiat +4 -0
  2. package/.env.example +47 -0
  3. package/README.md +110 -0
  4. package/index.js +94 -0
  5. package/package.json +67 -0
  6. package/public/css/styles.css +139 -0
  7. package/public/js/animations.js +41 -0
  8. package/sdk/error-tracking/browser/package.json +16 -0
  9. package/sdk/error-tracking/browser/src/core.js +270 -0
  10. package/sdk/error-tracking/browser/src/embed.js +18 -0
  11. package/sdk/error-tracking/browser/src/index.js +1 -0
  12. package/server.js +5 -0
  13. package/src/admin/endpointRegistry.js +300 -0
  14. package/src/controllers/admin.controller.js +321 -0
  15. package/src/controllers/adminAssets.controller.js +530 -0
  16. package/src/controllers/adminAssetsStorage.controller.js +260 -0
  17. package/src/controllers/adminEjsVirtual.controller.js +354 -0
  18. package/src/controllers/adminFeatureFlags.controller.js +155 -0
  19. package/src/controllers/adminHeadless.controller.js +1071 -0
  20. package/src/controllers/adminI18n.controller.js +604 -0
  21. package/src/controllers/adminJsonConfigs.controller.js +97 -0
  22. package/src/controllers/adminLlm.controller.js +273 -0
  23. package/src/controllers/adminMigration.controller.js +257 -0
  24. package/src/controllers/adminSeoConfig.controller.js +515 -0
  25. package/src/controllers/adminStats.controller.js +121 -0
  26. package/src/controllers/adminUploadNamespaces.controller.js +208 -0
  27. package/src/controllers/assets.controller.js +248 -0
  28. package/src/controllers/auth.controller.js +93 -0
  29. package/src/controllers/billing.controller.js +223 -0
  30. package/src/controllers/featureFlags.controller.js +35 -0
  31. package/src/controllers/forms.controller.js +217 -0
  32. package/src/controllers/globalSettings.controller.js +252 -0
  33. package/src/controllers/headlessCrud.controller.js +126 -0
  34. package/src/controllers/i18n.controller.js +12 -0
  35. package/src/controllers/invite.controller.js +249 -0
  36. package/src/controllers/jsonConfigs.controller.js +19 -0
  37. package/src/controllers/metrics.controller.js +149 -0
  38. package/src/controllers/notificationAdmin.controller.js +264 -0
  39. package/src/controllers/notifications.controller.js +131 -0
  40. package/src/controllers/org.controller.js +357 -0
  41. package/src/controllers/orgAdmin.controller.js +491 -0
  42. package/src/controllers/stripeAdmin.controller.js +410 -0
  43. package/src/controllers/user.controller.js +361 -0
  44. package/src/controllers/userAdmin.controller.js +277 -0
  45. package/src/controllers/waitingList.controller.js +167 -0
  46. package/src/controllers/webhook.controller.js +200 -0
  47. package/src/middleware/auth.js +66 -0
  48. package/src/middleware/errorCapture.js +170 -0
  49. package/src/middleware/headlessApiTokenAuth.js +57 -0
  50. package/src/middleware/org.js +108 -0
  51. package/src/middleware.js +901 -0
  52. package/src/models/ActionEvent.js +31 -0
  53. package/src/models/ActivityLog.js +41 -0
  54. package/src/models/Asset.js +84 -0
  55. package/src/models/AuditEvent.js +93 -0
  56. package/src/models/EmailLog.js +28 -0
  57. package/src/models/ErrorAggregate.js +72 -0
  58. package/src/models/FormSubmission.js +41 -0
  59. package/src/models/GlobalSetting.js +38 -0
  60. package/src/models/HeadlessApiToken.js +24 -0
  61. package/src/models/HeadlessModelDefinition.js +41 -0
  62. package/src/models/I18nEntry.js +77 -0
  63. package/src/models/I18nLocale.js +33 -0
  64. package/src/models/Invite.js +70 -0
  65. package/src/models/JsonConfig.js +46 -0
  66. package/src/models/Notification.js +60 -0
  67. package/src/models/Organization.js +57 -0
  68. package/src/models/OrganizationMember.js +43 -0
  69. package/src/models/StripeCatalogItem.js +77 -0
  70. package/src/models/StripeWebhookEvent.js +57 -0
  71. package/src/models/User.js +89 -0
  72. package/src/models/VirtualEjsFile.js +60 -0
  73. package/src/models/VirtualEjsFileVersion.js +43 -0
  74. package/src/models/VirtualEjsGroupChange.js +32 -0
  75. package/src/models/WaitingList.js +41 -0
  76. package/src/models/Webhook.js +63 -0
  77. package/src/models/Workflow.js +29 -0
  78. package/src/models/WorkflowExecution.js +12 -0
  79. package/src/routes/admin.routes.js +26 -0
  80. package/src/routes/adminAssets.routes.js +28 -0
  81. package/src/routes/adminAssetsStorage.routes.js +13 -0
  82. package/src/routes/adminAudit.routes.js +196 -0
  83. package/src/routes/adminEjsVirtual.routes.js +17 -0
  84. package/src/routes/adminErrors.routes.js +164 -0
  85. package/src/routes/adminFeatureFlags.routes.js +12 -0
  86. package/src/routes/adminHeadless.routes.js +38 -0
  87. package/src/routes/adminI18n.routes.js +22 -0
  88. package/src/routes/adminJsonConfigs.routes.js +15 -0
  89. package/src/routes/adminLlm.routes.js +12 -0
  90. package/src/routes/adminMigration.routes.js +81 -0
  91. package/src/routes/adminSeoConfig.routes.js +20 -0
  92. package/src/routes/adminUploadNamespaces.routes.js +13 -0
  93. package/src/routes/assets.routes.js +21 -0
  94. package/src/routes/auth.routes.js +12 -0
  95. package/src/routes/billing.routes.js +11 -0
  96. package/src/routes/errorTracking.routes.js +31 -0
  97. package/src/routes/featureFlags.routes.js +9 -0
  98. package/src/routes/forms.routes.js +9 -0
  99. package/src/routes/formsAdmin.routes.js +13 -0
  100. package/src/routes/globalSettings.routes.js +18 -0
  101. package/src/routes/headless.routes.js +15 -0
  102. package/src/routes/i18n.routes.js +8 -0
  103. package/src/routes/invite.routes.js +9 -0
  104. package/src/routes/jsonConfigs.routes.js +8 -0
  105. package/src/routes/log.routes.js +111 -0
  106. package/src/routes/metrics.routes.js +9 -0
  107. package/src/routes/notificationAdmin.routes.js +15 -0
  108. package/src/routes/notifications.routes.js +12 -0
  109. package/src/routes/org.routes.js +31 -0
  110. package/src/routes/orgAdmin.routes.js +20 -0
  111. package/src/routes/publicAssets.routes.js +7 -0
  112. package/src/routes/stripeAdmin.routes.js +20 -0
  113. package/src/routes/user.routes.js +22 -0
  114. package/src/routes/userAdmin.routes.js +15 -0
  115. package/src/routes/waitingList.routes.js +13 -0
  116. package/src/routes/waitingListAdmin.routes.js +9 -0
  117. package/src/routes/webhook.routes.js +32 -0
  118. package/src/routes/workflowWebhook.routes.js +54 -0
  119. package/src/routes/workflows.routes.js +110 -0
  120. package/src/services/assets.service.js +110 -0
  121. package/src/services/audit.service.js +62 -0
  122. package/src/services/auditLogger.js +165 -0
  123. package/src/services/ejsVirtual.service.js +614 -0
  124. package/src/services/email.service.js +351 -0
  125. package/src/services/errorLogger.js +221 -0
  126. package/src/services/featureFlags.service.js +202 -0
  127. package/src/services/forms.service.js +214 -0
  128. package/src/services/globalSettings.service.js +49 -0
  129. package/src/services/headlessApiTokens.service.js +158 -0
  130. package/src/services/headlessCrypto.service.js +31 -0
  131. package/src/services/headlessModels.service.js +356 -0
  132. package/src/services/i18n.service.js +314 -0
  133. package/src/services/i18nInferredKeys.service.js +337 -0
  134. package/src/services/jsonConfigs.service.js +392 -0
  135. package/src/services/llm.service.js +749 -0
  136. package/src/services/migration.service.js +581 -0
  137. package/src/services/migrationAssets/fsLocal.js +58 -0
  138. package/src/services/migrationAssets/index.js +134 -0
  139. package/src/services/migrationAssets/s3.js +75 -0
  140. package/src/services/migrationAssets/sftp.js +92 -0
  141. package/src/services/notification.service.js +212 -0
  142. package/src/services/objectStorage.service.js +514 -0
  143. package/src/services/seoConfig.service.js +402 -0
  144. package/src/services/storage.js +150 -0
  145. package/src/services/stripe.service.js +185 -0
  146. package/src/services/stripeHelper.service.js +264 -0
  147. package/src/services/uploadNamespaces.service.js +326 -0
  148. package/src/services/webhook.service.js +157 -0
  149. package/src/services/workflow.service.js +271 -0
  150. package/src/utils/asyncHandler.js +5 -0
  151. package/src/utils/encryption.js +80 -0
  152. package/src/utils/jwt.js +40 -0
  153. package/src/utils/orgRoles.js +156 -0
  154. package/src/utils/validation.js +26 -0
  155. package/src/utils/webhookRetry.js +93 -0
  156. package/views/admin-assets.ejs +444 -0
  157. package/views/admin-audit.ejs +283 -0
  158. package/views/admin-coolify-deploy.ejs +207 -0
  159. package/views/admin-dashboard-home.ejs +291 -0
  160. package/views/admin-dashboard.ejs +397 -0
  161. package/views/admin-ejs-virtual.ejs +280 -0
  162. package/views/admin-errors.ejs +368 -0
  163. package/views/admin-feature-flags.ejs +390 -0
  164. package/views/admin-forms.ejs +526 -0
  165. package/views/admin-global-settings.ejs +436 -0
  166. package/views/admin-headless.ejs +2020 -0
  167. package/views/admin-i18n-locales.ejs +221 -0
  168. package/views/admin-i18n.ejs +728 -0
  169. package/views/admin-json-configs.ejs +410 -0
  170. package/views/admin-llm.ejs +884 -0
  171. package/views/admin-metrics.ejs +274 -0
  172. package/views/admin-migration.ejs +814 -0
  173. package/views/admin-notifications.ejs +430 -0
  174. package/views/admin-organizations.ejs +984 -0
  175. package/views/admin-seo-config.ejs +673 -0
  176. package/views/admin-stripe-pricing.ejs +558 -0
  177. package/views/admin-test.ejs +342 -0
  178. package/views/admin-users.ejs +452 -0
  179. package/views/admin-waiting-list.ejs +547 -0
  180. package/views/admin-webhooks.ejs +329 -0
  181. package/views/admin-workflows.ejs +310 -0
  182. package/views/partials/admin-assets-script.ejs +2022 -0
  183. package/views/partials/admin-test-sidebar.ejs +14 -0
  184. package/views/partials/dashboard/nav-items.ejs +66 -0
  185. package/views/partials/dashboard/palette.ejs +63 -0
  186. package/views/partials/dashboard/sidebar.ejs +21 -0
  187. package/views/partials/dashboard/tab-bar.ejs +26 -0
  188. package/views/partials/footer.ejs +3 -0
@@ -0,0 +1,356 @@
1
+ const mongoose = require('mongoose');
2
+ const crypto = require('crypto');
3
+
4
+ const HeadlessModelDefinition = require('../models/HeadlessModelDefinition');
5
+
6
+ const MODEL_COLLECTION_PREFIX = 'headless_';
7
+
8
+ function normalizeCodeIdentifier(codeIdentifier) {
9
+ const normalized = String(codeIdentifier || '').trim();
10
+ if (!normalized) {
11
+ const err = new Error('codeIdentifier is required');
12
+ err.code = 'VALIDATION';
13
+ throw err;
14
+ }
15
+ if (!/^[a-z][a-z0-9_]*$/.test(normalized)) {
16
+ const err = new Error('codeIdentifier must match /^[a-z][a-z0-9_]*$/');
17
+ err.code = 'VALIDATION';
18
+ throw err;
19
+ }
20
+ return normalized;
21
+ }
22
+
23
+ function computeSchemaHash({ fields, indexes }) {
24
+ return crypto
25
+ .createHash('sha256')
26
+ .update(JSON.stringify({ fields: fields || [], indexes: indexes || [] }))
27
+ .digest('hex');
28
+ }
29
+
30
+ function toMongooseField(field) {
31
+ const type = String(field.type || '').toLowerCase();
32
+ const base = {};
33
+
34
+ if (field.required) base.required = true;
35
+ if (field.unique) base.unique = true;
36
+ if (field.default !== undefined) base.default = field.default;
37
+
38
+ if (field.validation && typeof field.validation === 'object') {
39
+ if (field.validation.min !== undefined) base.min = field.validation.min;
40
+ if (field.validation.max !== undefined) base.max = field.validation.max;
41
+ if (field.validation.minLength !== undefined) base.minlength = field.validation.minLength;
42
+ if (field.validation.maxLength !== undefined) base.maxlength = field.validation.maxLength;
43
+ if (field.validation.enum !== undefined) base.enum = field.validation.enum;
44
+ if (field.validation.match !== undefined) base.match = field.validation.match;
45
+ }
46
+
47
+ if (type === 'string') return { ...base, type: String };
48
+ if (type === 'number') return { ...base, type: Number };
49
+ if (type === 'boolean') return { ...base, type: Boolean };
50
+ if (type === 'date') return { ...base, type: Date };
51
+
52
+ if (type === 'object') return { ...base, type: mongoose.Schema.Types.Mixed };
53
+ if (type === 'array') return { ...base, type: [mongoose.Schema.Types.Mixed] };
54
+
55
+ if (type === 'ref' || type === 'reference') {
56
+ const refModelCode = String(field.refModelCode || '').trim();
57
+ if (!refModelCode) {
58
+ const err = new Error(`Field ${field.name} is reference type but refModelCode is missing`);
59
+ err.code = 'VALIDATION';
60
+ throw err;
61
+ }
62
+ const refModelName = getMongooseModelName(refModelCode);
63
+ return { ...base, type: mongoose.Schema.Types.ObjectId, ref: refModelName };
64
+ }
65
+
66
+ if (type === 'ref[]' || type === 'ref_array' || type === 'refarray') {
67
+ const refModelCode = String(field.refModelCode || '').trim();
68
+ if (!refModelCode) {
69
+ const err = new Error(`Field ${field.name} is reference array type but refModelCode is missing`);
70
+ err.code = 'VALIDATION';
71
+ throw err;
72
+ }
73
+ const refModelName = getMongooseModelName(refModelCode);
74
+ return [{ ...base, type: mongoose.Schema.Types.ObjectId, ref: refModelName }];
75
+ }
76
+
77
+ const err = new Error(`Unsupported field type: ${field.type}`);
78
+ err.code = 'VALIDATION';
79
+ throw err;
80
+ }
81
+
82
+ function getMongooseModelName(codeIdentifier) {
83
+ const code = normalizeCodeIdentifier(codeIdentifier);
84
+ return `Headless_${code}`;
85
+ }
86
+
87
+ function getMongoCollectionName(codeIdentifier) {
88
+ const code = normalizeCodeIdentifier(codeIdentifier);
89
+ return `${MODEL_COLLECTION_PREFIX}${code}`;
90
+ }
91
+
92
+ function buildSchemaFromDefinition(def) {
93
+ const schemaShape = {};
94
+ for (const field of def.fields || []) {
95
+ const fieldName = String(field.name || '').trim();
96
+ if (!fieldName) continue;
97
+ if (fieldName === '_id') continue;
98
+
99
+ schemaShape[fieldName] = toMongooseField(field);
100
+ }
101
+
102
+ schemaShape._headlessModelCode = { type: String, default: def.codeIdentifier, index: true };
103
+ schemaShape._headlessSchemaVersion = { type: Number, default: def.version, index: true };
104
+
105
+ const schema = new mongoose.Schema(schemaShape, {
106
+ timestamps: true,
107
+ collection: getMongoCollectionName(def.codeIdentifier),
108
+ strict: false,
109
+ });
110
+
111
+ const indexes = Array.isArray(def.indexes) ? def.indexes : [];
112
+ for (const idx of indexes) {
113
+ if (!idx || typeof idx !== 'object') continue;
114
+ const fields = idx.fields;
115
+ if (!fields) continue;
116
+ const options = idx.options && typeof idx.options === 'object' ? idx.options : {};
117
+ schema.index(fields, options);
118
+ }
119
+
120
+ return schema;
121
+ }
122
+
123
+ async function ensureIndexesBestEffort(Model) {
124
+ try {
125
+ if (!Model || !Model.collection || typeof Model.collection.createIndex !== 'function') return;
126
+ const declared = Array.isArray(Model.schema && Model.schema.indexes)
127
+ ? Model.schema.indexes()
128
+ : [];
129
+ for (const idx of declared) {
130
+ const fields = idx && idx[0];
131
+ const options = idx && idx[1];
132
+ if (!fields) continue;
133
+ try {
134
+ await Model.collection.createIndex(fields, options || {});
135
+ } catch (e) {
136
+ // best-effort
137
+ }
138
+ }
139
+ } catch (e) {
140
+ // best-effort
141
+ }
142
+ }
143
+
144
+ async function listModelDefinitions() {
145
+ return HeadlessModelDefinition.find({ isActive: true }).sort({ updatedAt: -1 }).lean();
146
+ }
147
+
148
+ async function getModelDefinitionByCode(codeIdentifier) {
149
+ const code = normalizeCodeIdentifier(codeIdentifier);
150
+ return HeadlessModelDefinition.findOne({ codeIdentifier: code, isActive: true }).lean();
151
+ }
152
+
153
+ async function createModelDefinition(payload) {
154
+ const codeIdentifier = normalizeCodeIdentifier(payload.codeIdentifier);
155
+ const displayName = String(payload.displayName || codeIdentifier).trim();
156
+ if (!displayName) {
157
+ const err = new Error('displayName is required');
158
+ err.code = 'VALIDATION';
159
+ throw err;
160
+ }
161
+
162
+ const fields = Array.isArray(payload.fields) ? payload.fields : [];
163
+ const indexes = Array.isArray(payload.indexes) ? payload.indexes : [];
164
+ const fieldsHash = computeSchemaHash({ fields, indexes });
165
+
166
+ const existing = await HeadlessModelDefinition.findOne({ codeIdentifier }).lean();
167
+ if (existing) {
168
+ const err = new Error('Model already exists');
169
+ err.code = 'CONFLICT';
170
+ throw err;
171
+ }
172
+
173
+ const doc = await HeadlessModelDefinition.create({
174
+ codeIdentifier,
175
+ displayName,
176
+ description: String(payload.description || ''),
177
+ fields,
178
+ indexes,
179
+ fieldsHash,
180
+ version: 1,
181
+ previousFields: [],
182
+ previousIndexes: [],
183
+ isActive: true,
184
+ });
185
+
186
+ return doc.toObject();
187
+ }
188
+
189
+ async function updateModelDefinition(codeIdentifier, updates) {
190
+ const code = normalizeCodeIdentifier(codeIdentifier);
191
+ const doc = await HeadlessModelDefinition.findOne({ codeIdentifier: code, isActive: true });
192
+ if (!doc) {
193
+ const err = new Error('Model not found');
194
+ err.code = 'NOT_FOUND';
195
+ throw err;
196
+ }
197
+
198
+ if (updates.displayName !== undefined) {
199
+ const displayName = String(updates.displayName || '').trim();
200
+ if (!displayName) {
201
+ const err = new Error('displayName is required');
202
+ err.code = 'VALIDATION';
203
+ throw err;
204
+ }
205
+ doc.displayName = displayName;
206
+ }
207
+
208
+ if (updates.description !== undefined) {
209
+ doc.description = String(updates.description || '');
210
+ }
211
+
212
+ if (updates.fields !== undefined) {
213
+ const fields = Array.isArray(updates.fields) ? updates.fields : [];
214
+ const indexes = Array.isArray(doc.indexes) ? doc.indexes : [];
215
+ const newHash = computeSchemaHash({ fields, indexes });
216
+ if (newHash !== doc.fieldsHash) {
217
+ doc.previousFields = doc.fields;
218
+ doc.fields = fields;
219
+ doc.fieldsHash = newHash;
220
+ doc.version = Number(doc.version || 1) + 1;
221
+ }
222
+ }
223
+
224
+ if (updates.indexes !== undefined) {
225
+ const nextIndexes = Array.isArray(updates.indexes) ? updates.indexes : [];
226
+ const fields = Array.isArray(doc.fields) ? doc.fields : [];
227
+ const newHash = computeSchemaHash({ fields, indexes: nextIndexes });
228
+ if (newHash !== doc.fieldsHash) {
229
+ doc.previousIndexes = doc.indexes;
230
+ doc.indexes = nextIndexes;
231
+ doc.fieldsHash = newHash;
232
+ doc.version = Number(doc.version || 1) + 1;
233
+ }
234
+ }
235
+
236
+ await doc.save();
237
+ return doc.toObject();
238
+ }
239
+
240
+ async function disableModelDefinition(codeIdentifier) {
241
+ const code = normalizeCodeIdentifier(codeIdentifier);
242
+ const doc = await HeadlessModelDefinition.findOne({ codeIdentifier: code, isActive: true });
243
+ if (!doc) {
244
+ const err = new Error('Model not found');
245
+ err.code = 'NOT_FOUND';
246
+ throw err;
247
+ }
248
+
249
+ doc.isActive = false;
250
+ await doc.save();
251
+ return doc.toObject();
252
+ }
253
+
254
+ const modelCache = new Map();
255
+
256
+ async function ensureAutoMigration(modelDef) {
257
+ const collectionName = getMongoCollectionName(modelDef.codeIdentifier);
258
+ const coll = mongoose.connection.collection(collectionName);
259
+
260
+ const currentFields = Array.isArray(modelDef.fields) ? modelDef.fields : [];
261
+ const previousFields = Array.isArray(modelDef.previousFields) ? modelDef.previousFields : [];
262
+
263
+ const currentFieldNames = new Set(
264
+ currentFields
265
+ .map((f) => String(f?.name || '').trim())
266
+ .filter((n) => n && n !== '_id'),
267
+ );
268
+ const previousFieldNames = new Set(
269
+ previousFields
270
+ .map((f) => String(f?.name || '').trim())
271
+ .filter((n) => n && n !== '_id'),
272
+ );
273
+
274
+ const addedFields = currentFields.filter((f) => {
275
+ const n = String(f?.name || '').trim();
276
+ return n && !previousFieldNames.has(n);
277
+ });
278
+
279
+ const removedFieldNames = Array.from(previousFieldNames).filter((n) => !currentFieldNames.has(n));
280
+
281
+ const setOps = { _headlessSchemaVersion: modelDef.version };
282
+ const unsetOps = {};
283
+
284
+ for (const f of addedFields) {
285
+ const name = String(f?.name || '').trim();
286
+ if (!name) continue;
287
+ if (f.default !== undefined) {
288
+ setOps[name] = f.default;
289
+ }
290
+ }
291
+
292
+ for (const name of removedFieldNames) {
293
+ unsetOps[name] = '';
294
+ }
295
+
296
+ const update = { $set: setOps };
297
+ if (Object.keys(unsetOps).length > 0) update.$unset = unsetOps;
298
+
299
+ const filter = { _headlessSchemaVersion: { $ne: modelDef.version } };
300
+ if (addedFields.length > 0) {
301
+ filter.$or = [
302
+ { _headlessSchemaVersion: { $ne: modelDef.version } },
303
+ ...addedFields
304
+ .map((f) => String(f?.name || '').trim())
305
+ .filter((n) => n)
306
+ .map((name) => ({ [name]: { $exists: false } })),
307
+ ];
308
+ }
309
+
310
+ await coll.updateMany(filter, update);
311
+ }
312
+
313
+ async function getDynamicModel(codeIdentifier) {
314
+ const def = await getModelDefinitionByCode(codeIdentifier);
315
+ if (!def) {
316
+ const err = new Error('Model not found');
317
+ err.code = 'NOT_FOUND';
318
+ throw err;
319
+ }
320
+
321
+ const cacheKey = `${def.codeIdentifier}:${def.version}:${def.fieldsHash}`;
322
+ const cached = modelCache.get(cacheKey);
323
+ if (cached) return cached;
324
+
325
+ const modelName = getMongooseModelName(def.codeIdentifier);
326
+
327
+ if (mongoose.models[modelName]) {
328
+ delete mongoose.models[modelName];
329
+ }
330
+ if (mongoose.modelSchemas && mongoose.modelSchemas[modelName]) {
331
+ delete mongoose.modelSchemas[modelName];
332
+ }
333
+
334
+ const schema = buildSchemaFromDefinition(def);
335
+ const Model = mongoose.model(modelName, schema);
336
+
337
+ await ensureAutoMigration(def);
338
+ await ensureIndexesBestEffort(Model);
339
+
340
+ modelCache.set(cacheKey, Model);
341
+ return Model;
342
+ }
343
+
344
+ module.exports = {
345
+ MODEL_COLLECTION_PREFIX,
346
+ normalizeCodeIdentifier,
347
+ getMongooseModelName,
348
+ getMongoCollectionName,
349
+ computeSchemaHash,
350
+ listModelDefinitions,
351
+ getModelDefinitionByCode,
352
+ createModelDefinition,
353
+ updateModelDefinition,
354
+ disableModelDefinition,
355
+ getDynamicModel,
356
+ };
@@ -0,0 +1,314 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const crypto = require('crypto');
4
+
5
+ const I18nLocale = require('../models/I18nLocale');
6
+ const I18nEntry = require('../models/I18nEntry');
7
+ const { getSettingValue } = require('./globalSettings.service');
8
+ const { createAuditEvent } = require('./audit.service');
9
+
10
+ const cache = new Map();
11
+ const CACHE_TTL = 30000;
12
+
13
+ function stableStringify(value) {
14
+ if (value === null || value === undefined) return '';
15
+ if (typeof value === 'string') return value;
16
+ return JSON.stringify(value);
17
+ }
18
+
19
+ function sha256Base64(value) {
20
+ return crypto.createHash('sha256').update(String(value), 'utf8').digest('base64');
21
+ }
22
+
23
+ function flattenJson(obj, prefix = '', out = {}) {
24
+ if (obj === null || obj === undefined) {
25
+ return out;
26
+ }
27
+
28
+ if (typeof obj !== 'object' || Array.isArray(obj)) {
29
+ out[prefix] = stableStringify(obj);
30
+ return out;
31
+ }
32
+
33
+ for (const [k, v] of Object.entries(obj)) {
34
+ const nextPrefix = prefix ? `${prefix}.${k}` : k;
35
+ if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
36
+ flattenJson(v, nextPrefix, out);
37
+ } else {
38
+ out[nextPrefix] = stableStringify(v);
39
+ }
40
+ }
41
+
42
+ return out;
43
+ }
44
+
45
+ function interpolate(template, vars = {}) {
46
+ const str = String(template || '');
47
+ return str.replace(/\{([a-zA-Z0-9_\-\.]+)\}/g, (match, key) => {
48
+ if (Object.prototype.hasOwnProperty.call(vars, key)) {
49
+ return String(vars[key]);
50
+ }
51
+ return match;
52
+ });
53
+ }
54
+
55
+ async function getDefaultLocaleCode() {
56
+ const envDefault = process.env.I18N_DEFAULT_LOCALE;
57
+ if (envDefault) return envDefault;
58
+
59
+ const fromSettings = await getSettingValue('i18n.defaultLocale', null);
60
+ if (fromSettings) return fromSettings;
61
+
62
+ const localeDoc = await I18nLocale.findOne({ isDefault: true }).lean();
63
+ if (localeDoc?.code) return localeDoc.code;
64
+
65
+ const anyEnabled = await I18nLocale.findOne({ enabled: true }).sort({ code: 1 }).lean();
66
+ if (anyEnabled?.code) return anyEnabled.code;
67
+
68
+ return 'en';
69
+ }
70
+
71
+ function resolveLocaleFromRequest(req) {
72
+ const cookieHeader = req.headers?.cookie;
73
+ if (typeof cookieHeader === 'string' && cookieHeader.length > 0) {
74
+ const parts = cookieHeader.split(';').map((p) => p.trim());
75
+ for (const part of parts) {
76
+ const idx = part.indexOf('=');
77
+ if (idx === -1) continue;
78
+ const k = part.slice(0, idx).trim();
79
+ const v = part.slice(idx + 1).trim();
80
+ if (k === 'lang' && v) {
81
+ try {
82
+ return decodeURIComponent(v);
83
+ } catch (e) {
84
+ return v;
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ const queryLocale = req.query?.lang;
91
+ if (queryLocale) return queryLocale;
92
+
93
+ const header = req.headers['accept-language'];
94
+ if (typeof header === 'string' && header.length > 0) {
95
+ const primary = header.split(',')[0].trim();
96
+ if (primary) return primary.split(';')[0].trim();
97
+ }
98
+
99
+ return null;
100
+ }
101
+
102
+ async function getBundle(locale) {
103
+ const effectiveLocale = locale || (await getDefaultLocaleCode());
104
+
105
+ const cached = cache.get(`bundle:${effectiveLocale}`);
106
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
107
+ return cached.value;
108
+ }
109
+
110
+ const entries = await I18nEntry.find({ locale: effectiveLocale }).select('key value').lean();
111
+ const map = {};
112
+ for (const e of entries) {
113
+ map[e.key] = e.value;
114
+ }
115
+
116
+ const value = {
117
+ locale: effectiveLocale,
118
+ defaultLocale: await getDefaultLocaleCode(),
119
+ entries: map,
120
+ };
121
+
122
+ cache.set(`bundle:${effectiveLocale}`, { value, timestamp: Date.now() });
123
+ return value;
124
+ }
125
+
126
+ async function t({ key, locale, vars, html, defaultValue }) {
127
+ const effectiveLocale = locale || (await getDefaultLocaleCode());
128
+ const defaultLocale = await getDefaultLocaleCode();
129
+
130
+ const cacheKey = `entry:${effectiveLocale}:${key}`;
131
+ const cached = cache.get(cacheKey);
132
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
133
+ const value = cached.value;
134
+ const text = interpolate(value?.value ?? defaultValue ?? key, vars);
135
+ return { text, html: html === true || value?.valueFormat === 'html' };
136
+ }
137
+
138
+ let entry = await I18nEntry.findOne({ key, locale: effectiveLocale }).lean();
139
+ if (!entry && defaultLocale && defaultLocale !== effectiveLocale) {
140
+ entry = await I18nEntry.findOne({ key, locale: defaultLocale }).lean();
141
+ }
142
+
143
+ cache.set(cacheKey, { value: entry || null, timestamp: Date.now() });
144
+ const text = interpolate(entry?.value ?? defaultValue ?? key, vars);
145
+ return { text, html: html === true || entry?.valueFormat === 'html' };
146
+ }
147
+
148
+ function clearI18nCache() {
149
+ cache.clear();
150
+ }
151
+
152
+ async function ensureLocalesExist(locales) {
153
+ const existing = await I18nLocale.find({ code: { $in: locales } }).lean();
154
+ const existingSet = new Set(existing.map((l) => l.code));
155
+
156
+ const toCreate = locales
157
+ .filter((c) => !existingSet.has(c))
158
+ .map((code) => ({ code, name: code.toUpperCase(), enabled: true, isDefault: false }));
159
+
160
+ if (toCreate.length > 0) {
161
+ await I18nLocale.insertMany(toCreate);
162
+ }
163
+
164
+ const hasDefault = await I18nLocale.findOne({ isDefault: true }).lean();
165
+ if (!hasDefault) {
166
+ const first = locales[0];
167
+ if (first) {
168
+ await I18nLocale.updateOne({ code: first }, { $set: { isDefault: true } });
169
+ await I18nLocale.updateMany({ code: { $ne: first } }, { $set: { isDefault: false } });
170
+ }
171
+ }
172
+ }
173
+
174
+ async function seedFromJsonFiles({
175
+ baseDir,
176
+ locales,
177
+ seedVersion,
178
+ actorType = 'system',
179
+ actorId = null,
180
+ }) {
181
+ if (!baseDir) {
182
+ throw new Error('baseDir is required');
183
+ }
184
+ if (!Array.isArray(locales) || locales.length === 0) {
185
+ throw new Error('locales is required');
186
+ }
187
+
188
+ await ensureLocalesExist(locales);
189
+
190
+ const summary = {
191
+ inserted: 0,
192
+ updated: 0,
193
+ skippedEdited: 0,
194
+ skippedExists: 0,
195
+ locales: {},
196
+ };
197
+
198
+ for (const locale of locales) {
199
+ const filePath = path.join(baseDir, `${locale}.json`);
200
+ const raw = fs.readFileSync(filePath, 'utf8');
201
+ const json = JSON.parse(raw);
202
+ const flat = flattenJson(json);
203
+
204
+ summary.locales[locale] = { inserted: 0, updated: 0, skippedEdited: 0, skippedExists: 0 };
205
+
206
+ for (const [key, value] of Object.entries(flat)) {
207
+ const nextSeedHash = sha256Base64(value);
208
+ const existing = await I18nEntry.findOne({ key, locale });
209
+
210
+ if (!existing) {
211
+ await I18nEntry.create({
212
+ key,
213
+ locale,
214
+ value,
215
+ valueFormat: 'text',
216
+ source: 'seed',
217
+ seeded: true,
218
+ seedHash: nextSeedHash,
219
+ seedVersion: seedVersion || null,
220
+ edited: false,
221
+ });
222
+ summary.inserted += 1;
223
+ summary.locales[locale].inserted += 1;
224
+ continue;
225
+ }
226
+
227
+ if (existing.edited === true) {
228
+ summary.skippedEdited += 1;
229
+ summary.locales[locale].skippedEdited += 1;
230
+ continue;
231
+ }
232
+
233
+ if (existing.seeded === true) {
234
+ existing.value = value;
235
+ existing.seedHash = nextSeedHash;
236
+ existing.seedVersion = seedVersion || existing.seedVersion || null;
237
+ existing.source = 'seed';
238
+ await existing.save();
239
+ summary.updated += 1;
240
+ summary.locales[locale].updated += 1;
241
+ continue;
242
+ }
243
+
244
+ summary.skippedExists += 1;
245
+ summary.locales[locale].skippedExists += 1;
246
+ }
247
+
248
+ clearI18nCache();
249
+ }
250
+
251
+ await createAuditEvent({
252
+ actorType,
253
+ actorId,
254
+ action: 'i18n.seed',
255
+ entityType: 'I18nEntry',
256
+ entityId: null,
257
+ before: null,
258
+ after: summary,
259
+ meta: { locales, seedVersion: seedVersion || null },
260
+ });
261
+
262
+ return summary;
263
+ }
264
+
265
+ function createI18nMiddleware() {
266
+ return async (req, res, next) => {
267
+ try {
268
+ const resolved = resolveLocaleFromRequest(req);
269
+ const defaultLocale = await getDefaultLocaleCode();
270
+ const locale = resolved || defaultLocale;
271
+
272
+ res.locals.locale = locale;
273
+ res.locals.defaultLocale = defaultLocale;
274
+
275
+ const bundle = await getBundle(locale);
276
+ const defaultBundle =
277
+ defaultLocale && defaultLocale !== locale
278
+ ? await getBundle(defaultLocale)
279
+ : bundle;
280
+
281
+ res.locals.t = (key, vars, opts) => {
282
+ const useLocale = opts?.locale || locale;
283
+ const useBundle = useLocale === locale ? bundle : null;
284
+ const useDefaultBundle =
285
+ defaultLocale && useLocale !== defaultLocale ? defaultBundle : defaultBundle;
286
+
287
+ const raw =
288
+ useBundle?.entries?.[key] ??
289
+ useDefaultBundle?.entries?.[key] ??
290
+ opts?.defaultValue ??
291
+ key;
292
+
293
+ return interpolate(raw, vars || {});
294
+ };
295
+
296
+ next();
297
+ } catch (e) {
298
+ next(e);
299
+ }
300
+ };
301
+ }
302
+
303
+ module.exports = {
304
+ getDefaultLocaleCode,
305
+ resolveLocaleFromRequest,
306
+ getBundle,
307
+ t,
308
+ clearI18nCache,
309
+ seedFromJsonFiles,
310
+ createI18nMiddleware,
311
+ interpolate,
312
+ flattenJson,
313
+ sha256Base64,
314
+ };