@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,604 @@
1
+ const I18nLocale = require('../models/I18nLocale');
2
+ const I18nEntry = require('../models/I18nEntry');
3
+
4
+ const { clearI18nCache } = require('../services/i18n.service');
5
+ const { getInferredI18nKeys, getInferredI18nEntries } = require('../services/i18nInferredKeys.service');
6
+ const { createAuditEvent, getBasicAuthActor } = require('../services/audit.service');
7
+ const { getSettingValue } = require('../services/globalSettings.service');
8
+
9
+ const OpenAI = require('openai');
10
+
11
+ async function ensureLocaleExists(code, actor) {
12
+ if (!code) return;
13
+ const normalized = String(code).trim();
14
+ if (!normalized) return;
15
+
16
+ const existing = await I18nLocale.findOne({ code: normalized }).lean();
17
+ if (existing) return;
18
+
19
+ const locale = await I18nLocale.create({
20
+ code: normalized,
21
+ name: normalized.toUpperCase(),
22
+ enabled: true,
23
+ isDefault: false,
24
+ });
25
+
26
+ await createAuditEvent({
27
+ ...(actor || { actorType: 'system', actorId: null }),
28
+ action: 'i18n.locale.auto_create',
29
+ entityType: 'I18nLocale',
30
+ entityId: String(locale._id),
31
+ before: null,
32
+ after: locale.toObject(),
33
+ meta: null,
34
+ });
35
+ }
36
+
37
+ async function setDefaultLocale(code) {
38
+ await I18nLocale.updateMany({ isDefault: true }, { $set: { isDefault: false } });
39
+ await I18nLocale.updateOne({ code }, { $set: { isDefault: true } });
40
+ await I18nLocale.updateOne({ code }, { $set: { enabled: true } });
41
+ }
42
+
43
+ async function ensureDefaultLocalesExist(actor) {
44
+ const defaults = ['en', 'fr', 'es'];
45
+ const existing = await I18nLocale.find({ code: { $in: defaults } }).select('code').lean();
46
+ const existingSet = new Set(existing.map((l) => l.code));
47
+
48
+ const toCreate = defaults
49
+ .filter((code) => !existingSet.has(code))
50
+ .map((code) => ({
51
+ code,
52
+ name: code.toUpperCase(),
53
+ enabled: true,
54
+ isDefault: false,
55
+ }));
56
+
57
+ if (toCreate.length > 0) {
58
+ const created = await I18nLocale.insertMany(toCreate);
59
+ for (const locale of created) {
60
+ await createAuditEvent({
61
+ ...(actor || { actorType: 'system', actorId: null }),
62
+ action: 'i18n.locale.auto_create',
63
+ entityType: 'I18nLocale',
64
+ entityId: String(locale._id),
65
+ before: null,
66
+ after: locale.toObject(),
67
+ meta: { reason: 'bootstrap_defaults' },
68
+ });
69
+ }
70
+ }
71
+
72
+ const hasDefault = await I18nLocale.findOne({ isDefault: true }).lean();
73
+ if (!hasDefault) {
74
+ await setDefaultLocale('en');
75
+ }
76
+ }
77
+
78
+ exports.listLocales = async (req, res) => {
79
+ try {
80
+ const actor = getBasicAuthActor(req);
81
+ const entryLocales = await I18nEntry.distinct('locale');
82
+ for (const code of entryLocales) {
83
+ await ensureLocaleExists(code, actor);
84
+ }
85
+
86
+ const anyLocale = await I18nLocale.findOne({}).select('_id').lean();
87
+ if (!anyLocale) {
88
+ await ensureDefaultLocalesExist(actor);
89
+ }
90
+
91
+ const locales = await I18nLocale.find().sort({ code: 1 }).lean();
92
+
93
+ const counts = await I18nEntry.aggregate([
94
+ { $group: { _id: '$locale', entryCount: { $sum: 1 } } },
95
+ ]);
96
+ const countByLocale = new Map(counts.map((c) => [c._id, c.entryCount]));
97
+
98
+ const enriched = locales.map((l) => ({
99
+ ...l,
100
+ entryCount: countByLocale.get(l.code) || 0,
101
+ }));
102
+
103
+ res.json({ locales: enriched });
104
+ } catch (error) {
105
+ console.error('Error listing locales:', error);
106
+ res.status(500).json({ error: 'Failed to list locales' });
107
+ }
108
+ };
109
+
110
+ exports.createLocale = async (req, res) => {
111
+ try {
112
+ const { code, name, enabled, isDefault } = req.body;
113
+ if (!code || !name) {
114
+ return res.status(400).json({ error: 'code and name are required' });
115
+ }
116
+
117
+ const existing = await I18nLocale.findOne({ code }).lean();
118
+ if (existing) {
119
+ return res.status(409).json({ error: 'Locale already exists' });
120
+ }
121
+
122
+ const locale = await I18nLocale.create({
123
+ code,
124
+ name,
125
+ enabled: enabled !== undefined ? Boolean(enabled) : true,
126
+ isDefault: Boolean(isDefault),
127
+ });
128
+
129
+ if (locale.isDefault) {
130
+ await setDefaultLocale(code);
131
+ }
132
+
133
+ await createAuditEvent({
134
+ ...getBasicAuthActor(req),
135
+ action: 'i18n.locale.create',
136
+ entityType: 'I18nLocale',
137
+ entityId: String(locale._id),
138
+ before: null,
139
+ after: locale.toObject(),
140
+ meta: null,
141
+ });
142
+
143
+ res.status(201).json({ locale: locale.toObject() });
144
+ } catch (error) {
145
+ console.error('Error creating locale:', error);
146
+ res.status(500).json({ error: 'Failed to create locale' });
147
+ }
148
+ };
149
+
150
+ exports.updateLocale = async (req, res) => {
151
+ try {
152
+ const { code } = req.params;
153
+ const { name, enabled, isDefault } = req.body;
154
+
155
+ const locale = await I18nLocale.findOne({ code });
156
+ if (!locale) {
157
+ return res.status(404).json({ error: 'Locale not found' });
158
+ }
159
+
160
+ const before = locale.toObject();
161
+
162
+ if (name !== undefined) locale.name = name;
163
+ if (enabled !== undefined) locale.enabled = Boolean(enabled);
164
+ if (isDefault === true) {
165
+ await setDefaultLocale(code);
166
+ locale.isDefault = true;
167
+ }
168
+
169
+ await locale.save();
170
+
171
+ await createAuditEvent({
172
+ ...getBasicAuthActor(req),
173
+ action: 'i18n.locale.update',
174
+ entityType: 'I18nLocale',
175
+ entityId: String(locale._id),
176
+ before,
177
+ after: locale.toObject(),
178
+ meta: null,
179
+ });
180
+
181
+ res.json({ locale: locale.toObject() });
182
+ } catch (error) {
183
+ console.error('Error updating locale:', error);
184
+ res.status(500).json({ error: 'Failed to update locale' });
185
+ }
186
+ };
187
+
188
+ exports.listEntries = async (req, res) => {
189
+ try {
190
+ const { locale, search, missing, includeInferred } = req.query;
191
+ if (!locale) {
192
+ return res.status(400).json({ error: 'locale is required' });
193
+ }
194
+
195
+ const wantsInferred = includeInferred === 'true' || includeInferred === '1';
196
+
197
+ const query = { locale };
198
+ if (search) {
199
+ query.key = { $regex: search, $options: 'i' };
200
+ }
201
+
202
+ const entries = await I18nEntry.find(query)
203
+ .sort({ key: 1 })
204
+ .limit(2000)
205
+ .lean();
206
+
207
+ const existingKeys = new Set(entries.map((e) => e.key));
208
+
209
+ const inferredKeys = wantsInferred ? getInferredI18nKeys() : [];
210
+ const inferredEntriesMap = wantsInferred ? getInferredI18nEntries() : {};
211
+ const filteredInferredKeys = search
212
+ ? inferredKeys.filter((k) => String(k).toLowerCase().includes(String(search).toLowerCase()))
213
+ : inferredKeys;
214
+
215
+ if (missing === 'true') {
216
+ const allDbKeys = await I18nEntry.distinct('key');
217
+ const keySet = new Set(allDbKeys);
218
+ for (const k of filteredInferredKeys) keySet.add(k);
219
+
220
+ const allKeys = Array.from(keySet).sort();
221
+ const missingKeys = allKeys.filter((k) => !existingKeys.has(k));
222
+
223
+ const missingEntries = missingKeys.map((k) => ({
224
+ ...(wantsInferred && inferredEntriesMap[k] ? inferredEntriesMap[k] : null),
225
+ _id: null,
226
+ key: k,
227
+ locale,
228
+ value: wantsInferred && inferredEntriesMap[k]?.value ? inferredEntriesMap[k].value : '',
229
+ valueFormat: wantsInferred && inferredEntriesMap[k]?.valueFormat ? inferredEntriesMap[k].valueFormat : 'text',
230
+ source: wantsInferred && filteredInferredKeys.includes(k) ? 'inferred' : 'admin',
231
+ seeded: false,
232
+ edited: false,
233
+ }));
234
+
235
+ return res.json({ entries: [...entries, ...missingEntries] });
236
+ }
237
+
238
+ if (!wantsInferred) {
239
+ return res.json({ entries });
240
+ }
241
+
242
+ const inferredMissingEntries = filteredInferredKeys
243
+ .filter((k) => !existingKeys.has(k))
244
+ .map((k) => ({
245
+ _id: null,
246
+ key: k,
247
+ locale,
248
+ value: inferredEntriesMap[k]?.value ? inferredEntriesMap[k].value : '',
249
+ valueFormat: inferredEntriesMap[k]?.valueFormat ? inferredEntriesMap[k].valueFormat : 'text',
250
+ source: 'inferred',
251
+ seeded: false,
252
+ edited: false,
253
+ }));
254
+
255
+ const merged = [...entries, ...inferredMissingEntries]
256
+ .sort((a, b) => String(a.key).localeCompare(String(b.key)));
257
+
258
+ res.json({ entries: merged });
259
+ } catch (error) {
260
+ console.error('Error listing entries:', error);
261
+ res.status(500).json({ error: 'Failed to list entries' });
262
+ }
263
+ };
264
+
265
+ exports.createEntry = async (req, res) => {
266
+ try {
267
+ const { key, locale, value, valueFormat } = req.body;
268
+ if (!key || !locale) {
269
+ return res.status(400).json({ error: 'key and locale are required' });
270
+ }
271
+
272
+ const existing = await I18nEntry.findOne({ key, locale }).lean();
273
+ if (existing) {
274
+ return res.status(409).json({ error: 'Entry already exists' });
275
+ }
276
+
277
+ const actor = getBasicAuthActor(req);
278
+
279
+ await ensureLocaleExists(locale, actor);
280
+
281
+ const entry = await I18nEntry.create({
282
+ key,
283
+ locale,
284
+ value: value || '',
285
+ valueFormat: valueFormat === 'html' ? 'html' : 'text',
286
+ source: 'admin',
287
+ seeded: false,
288
+ edited: true,
289
+ editedAt: new Date(),
290
+ editedBy: actor.actorId,
291
+ });
292
+
293
+ clearI18nCache();
294
+
295
+ await createAuditEvent({
296
+ ...actor,
297
+ action: 'i18n.entry.create',
298
+ entityType: 'I18nEntry',
299
+ entityId: String(entry._id),
300
+ before: null,
301
+ after: entry.toObject(),
302
+ meta: null,
303
+ });
304
+
305
+ res.status(201).json({ entry: entry.toObject() });
306
+ } catch (error) {
307
+ console.error('Error creating entry:', error);
308
+ res.status(500).json({ error: 'Failed to create entry' });
309
+ }
310
+ };
311
+
312
+ exports.updateEntry = async (req, res) => {
313
+ try {
314
+ const { id } = req.params;
315
+ const { value, valueFormat } = req.body;
316
+
317
+ const entry = await I18nEntry.findById(id);
318
+ if (!entry) {
319
+ return res.status(404).json({ error: 'Entry not found' });
320
+ }
321
+
322
+ const before = entry.toObject();
323
+ const actor = getBasicAuthActor(req);
324
+
325
+ if (value !== undefined) entry.value = value;
326
+ if (valueFormat !== undefined) entry.valueFormat = valueFormat === 'html' ? 'html' : 'text';
327
+
328
+ entry.edited = true;
329
+ entry.editedAt = new Date();
330
+ entry.editedBy = actor.actorId;
331
+ entry.source = 'admin';
332
+
333
+ await entry.save();
334
+ clearI18nCache();
335
+
336
+ await createAuditEvent({
337
+ ...actor,
338
+ action: 'i18n.entry.update',
339
+ entityType: 'I18nEntry',
340
+ entityId: String(entry._id),
341
+ before,
342
+ after: entry.toObject(),
343
+ meta: null,
344
+ });
345
+
346
+ res.json({ entry: entry.toObject() });
347
+ } catch (error) {
348
+ console.error('Error updating entry:', error);
349
+ res.status(500).json({ error: 'Failed to update entry' });
350
+ }
351
+ };
352
+
353
+ exports.deleteEntry = async (req, res) => {
354
+ try {
355
+ const { id } = req.params;
356
+ const entry = await I18nEntry.findById(id);
357
+ if (!entry) {
358
+ return res.status(404).json({ error: 'Entry not found' });
359
+ }
360
+
361
+ const before = entry.toObject();
362
+ await I18nEntry.deleteOne({ _id: id });
363
+ clearI18nCache();
364
+
365
+ await createAuditEvent({
366
+ ...getBasicAuthActor(req),
367
+ action: 'i18n.entry.delete',
368
+ entityType: 'I18nEntry',
369
+ entityId: String(id),
370
+ before,
371
+ after: null,
372
+ meta: null,
373
+ });
374
+
375
+ res.status(204).send();
376
+ } catch (error) {
377
+ console.error('Error deleting entry:', error);
378
+ res.status(500).json({ error: 'Failed to delete entry' });
379
+ }
380
+ };
381
+
382
+ async function buildOpenRouterClient() {
383
+ const apiKey = await getSettingValue('i18n.ai.openrouter.apiKey', await getSettingValue('ai.openrouter.apiKey', null));
384
+ if (!apiKey) {
385
+ throw new Error('Missing i18n.ai.openrouter.apiKey or ai.openrouter.apiKey');
386
+ }
387
+
388
+ return new OpenAI({
389
+ apiKey,
390
+ baseURL: 'https://openrouter.ai/api/v1',
391
+ });
392
+ }
393
+
394
+ function buildAiPrompt({ glossary, fromLocale, toLocale, key, fromValue }) {
395
+ const glossaryStr = glossary ? String(glossary) : '';
396
+
397
+ const parts = [
398
+ `Translate the following string from ${fromLocale} to ${toLocale}.`,
399
+ 'Return only the translated string, without quotes.',
400
+ 'The output may contain HTML and templating placeholders like {name}; keep placeholders unchanged.',
401
+ `Key: ${key}`,
402
+ `Text: ${fromValue}`,
403
+ ];
404
+
405
+ if (glossaryStr.trim()) {
406
+ parts.unshift(`Glossary (optional):\n${glossaryStr}`);
407
+ }
408
+
409
+ return parts.join('\n');
410
+ }
411
+
412
+ exports.aiPreview = async (req, res) => {
413
+ try {
414
+ const {
415
+ fromLocale,
416
+ toLocale,
417
+ keys,
418
+ missingOnly,
419
+ model,
420
+ } = req.body;
421
+
422
+ if (!fromLocale || !toLocale) {
423
+ return res.status(400).json({ error: 'fromLocale and toLocale are required' });
424
+ }
425
+
426
+ await ensureLocaleExists(toLocale, getBasicAuthActor(req));
427
+
428
+ const selectedKeys = Array.isArray(keys) ? keys.filter(Boolean) : [];
429
+
430
+ let targetKeys = selectedKeys;
431
+ if (targetKeys.length === 0) {
432
+ targetKeys = await I18nEntry.distinct('key');
433
+ }
434
+
435
+ if (missingOnly === true) {
436
+ const existingTo = await I18nEntry.find({ locale: toLocale, key: { $in: targetKeys } })
437
+ .select('key')
438
+ .lean();
439
+ const haveTo = new Set(existingTo.map((e) => e.key));
440
+ targetKeys = targetKeys.filter((k) => !haveTo.has(k));
441
+ }
442
+
443
+ const fromEntries = await I18nEntry.find({ locale: fromLocale, key: { $in: targetKeys } })
444
+ .select('key value valueFormat')
445
+ .lean();
446
+
447
+ const fromMap = new Map(fromEntries.map((e) => [e.key, e]));
448
+
449
+ const aiModel = model || (await getSettingValue('i18n.ai.model', 'google/gemini-2.5-flash-lite'));
450
+ const glossary = await getSettingValue('i18n.ai.glossary', '');
451
+
452
+ const client = await buildOpenRouterClient();
453
+
454
+ const results = [];
455
+ for (const key of targetKeys) {
456
+ const from = fromMap.get(key);
457
+ if (!from) {
458
+ continue;
459
+ }
460
+
461
+ const prompt = buildAiPrompt({ glossary, fromLocale, toLocale, key, fromValue: from.value });
462
+ const resp = await client.chat.completions.create({
463
+ model: aiModel,
464
+ messages: [{ role: 'user', content: prompt }],
465
+ });
466
+
467
+ const translated = resp.choices?.[0]?.message?.content?.trim() || '';
468
+
469
+ results.push({
470
+ key,
471
+ fromLocale,
472
+ toLocale,
473
+ fromValue: from.value,
474
+ proposedValue: translated,
475
+ valueFormat: from.valueFormat || 'text',
476
+ model: aiModel,
477
+ });
478
+ }
479
+
480
+ res.json({ results });
481
+ } catch (error) {
482
+ console.error('Error generating AI preview:', error);
483
+ res.status(500).json({ error: error.message || 'Failed to generate AI preview' });
484
+ }
485
+ };
486
+
487
+ exports.aiApply = async (req, res) => {
488
+ try {
489
+ const { toLocale, items } = req.body;
490
+ if (!toLocale) {
491
+ return res.status(400).json({ error: 'toLocale is required' });
492
+ }
493
+ if (!Array.isArray(items) || items.length === 0) {
494
+ return res.status(400).json({ error: 'items is required' });
495
+ }
496
+
497
+ const actor = getBasicAuthActor(req);
498
+
499
+ await ensureLocaleExists(toLocale, actor);
500
+
501
+ const applied = [];
502
+ for (const item of items) {
503
+ if (!item?.key) continue;
504
+
505
+ const existing = await I18nEntry.findOne({ key: item.key, locale: toLocale });
506
+ if (!existing) {
507
+ const entry = await I18nEntry.create({
508
+ key: item.key,
509
+ locale: toLocale,
510
+ value: item.value || '',
511
+ valueFormat: item.valueFormat === 'html' ? 'html' : 'text',
512
+ source: 'ai',
513
+ seeded: false,
514
+ edited: true,
515
+ editedAt: new Date(),
516
+ editedBy: actor.actorId,
517
+ lastAiProvider: 'openrouter',
518
+ lastAiModel: item.model || null,
519
+ });
520
+
521
+ applied.push(entry.toObject());
522
+
523
+ await createAuditEvent({
524
+ ...actor,
525
+ action: 'i18n.ai.apply.create',
526
+ entityType: 'I18nEntry',
527
+ entityId: String(entry._id),
528
+ before: null,
529
+ after: entry.toObject(),
530
+ meta: { toLocale },
531
+ });
532
+
533
+ continue;
534
+ }
535
+
536
+ const before = existing.toObject();
537
+ existing.value = item.value || '';
538
+ existing.valueFormat = item.valueFormat === 'html' ? 'html' : 'text';
539
+ existing.source = 'ai';
540
+ existing.edited = true;
541
+ existing.editedAt = new Date();
542
+ existing.editedBy = actor.actorId;
543
+ existing.lastAiProvider = 'openrouter';
544
+ existing.lastAiModel = item.model || null;
545
+ await existing.save();
546
+
547
+ applied.push(existing.toObject());
548
+
549
+ await createAuditEvent({
550
+ ...actor,
551
+ action: 'i18n.ai.apply.update',
552
+ entityType: 'I18nEntry',
553
+ entityId: String(existing._id),
554
+ before,
555
+ after: existing.toObject(),
556
+ meta: { toLocale },
557
+ });
558
+ }
559
+
560
+ clearI18nCache();
561
+
562
+ res.json({ appliedCount: applied.length, applied });
563
+ } catch (error) {
564
+ console.error('Error applying AI results:', error);
565
+ res.status(500).json({ error: error.message || 'Failed to apply AI results' });
566
+ }
567
+ };
568
+
569
+ exports.aiTranslateText = async (req, res) => {
570
+ try {
571
+ const { fromLocale, toLocale, text, model } = req.body;
572
+ if (!fromLocale || !toLocale) {
573
+ return res.status(400).json({ error: 'fromLocale and toLocale are required' });
574
+ }
575
+ if (typeof text !== 'string' || text.trim() === '') {
576
+ return res.status(400).json({ error: 'text is required' });
577
+ }
578
+
579
+ await ensureLocaleExists(toLocale, getBasicAuthActor(req));
580
+
581
+ const aiModel = model || (await getSettingValue('i18n.ai.model', 'google/gemini-2.5-flash-lite'));
582
+ const glossary = await getSettingValue('i18n.ai.glossary', '');
583
+
584
+ const client = await buildOpenRouterClient();
585
+ const prompt = buildAiPrompt({
586
+ glossary,
587
+ fromLocale,
588
+ toLocale,
589
+ key: '(admin.text)',
590
+ fromValue: text,
591
+ });
592
+
593
+ const resp = await client.chat.completions.create({
594
+ model: aiModel,
595
+ messages: [{ role: 'user', content: prompt }],
596
+ });
597
+
598
+ const translatedText = resp.choices?.[0]?.message?.content?.trim() || '';
599
+ res.json({ translatedText, model: aiModel });
600
+ } catch (error) {
601
+ console.error('Error translating text with AI:', error);
602
+ res.status(500).json({ error: error.message || 'Failed to translate text' });
603
+ }
604
+ };
@@ -0,0 +1,97 @@
1
+ const {
2
+ listJsonConfigs,
3
+ getJsonConfigById,
4
+ createJsonConfig,
5
+ updateJsonConfig,
6
+ regenerateJsonConfigSlug,
7
+ deleteJsonConfig,
8
+ clearJsonConfigCache,
9
+ } = require('../services/jsonConfigs.service');
10
+
11
+ function handleServiceError(res, error) {
12
+ const msg = error?.message || 'Operation failed';
13
+ const code = error?.code;
14
+
15
+ if (code === 'VALIDATION' || code === 'INVALID_JSON') {
16
+ return res.status(400).json({ error: msg });
17
+ }
18
+ if (code === 'NOT_FOUND') {
19
+ return res.status(404).json({ error: msg });
20
+ }
21
+
22
+ return res.status(500).json({ error: msg });
23
+ }
24
+
25
+ exports.list = async (req, res) => {
26
+ try {
27
+ const items = await listJsonConfigs();
28
+ return res.json({ items });
29
+ } catch (error) {
30
+ console.error('Error listing JSON configs:', error);
31
+ return handleServiceError(res, error);
32
+ }
33
+ };
34
+
35
+ exports.get = async (req, res) => {
36
+ try {
37
+ const item = await getJsonConfigById(req.params.id);
38
+ if (!item) return res.status(404).json({ error: 'JSON config not found' });
39
+ return res.json({ item });
40
+ } catch (error) {
41
+ console.error('Error fetching JSON config:', error);
42
+ return handleServiceError(res, error);
43
+ }
44
+ };
45
+
46
+ exports.create = async (req, res) => {
47
+ try {
48
+ const item = await createJsonConfig(req.body || {});
49
+ return res.status(201).json({ item });
50
+ } catch (error) {
51
+ console.error('Error creating JSON config:', error);
52
+ return handleServiceError(res, error);
53
+ }
54
+ };
55
+
56
+ exports.update = async (req, res) => {
57
+ try {
58
+ const item = await updateJsonConfig(req.params.id, req.body || {});
59
+ return res.json({ item });
60
+ } catch (error) {
61
+ console.error('Error updating JSON config:', error);
62
+ return handleServiceError(res, error);
63
+ }
64
+ };
65
+
66
+ exports.regenerateSlug = async (req, res) => {
67
+ try {
68
+ const item = await regenerateJsonConfigSlug(req.params.id);
69
+ return res.json({ item });
70
+ } catch (error) {
71
+ console.error('Error regenerating JSON config slug:', error);
72
+ return handleServiceError(res, error);
73
+ }
74
+ };
75
+
76
+ exports.clearCache = async (req, res) => {
77
+ try {
78
+ const item = await getJsonConfigById(req.params.id);
79
+ if (!item) return res.status(404).json({ error: 'JSON config not found' });
80
+
81
+ clearJsonConfigCache(item.slug);
82
+ return res.json({ success: true });
83
+ } catch (error) {
84
+ console.error('Error clearing JSON config cache:', error);
85
+ return handleServiceError(res, error);
86
+ }
87
+ };
88
+
89
+ exports.remove = async (req, res) => {
90
+ try {
91
+ const result = await deleteJsonConfig(req.params.id);
92
+ return res.json(result);
93
+ } catch (error) {
94
+ console.error('Error deleting JSON config:', error);
95
+ return handleServiceError(res, error);
96
+ }
97
+ };