@intranefr/superbackend 1.7.8 → 1.7.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.
@@ -0,0 +1,465 @@
1
+ const crypto = require('crypto');
2
+ const bcrypt = require('bcryptjs');
3
+ const {
4
+ getJsonConfigValueBySlug,
5
+ updateJsonConfigValueBySlug,
6
+ clearJsonConfigCacheByPattern,
7
+ isJsonConfigCached,
8
+ getJsonConfigCacheInfo
9
+ } = require('./jsonConfigs.service');
10
+ const { logAudit } = require('./auditLogger');
11
+
12
+ const PUBLIC_EXPORTS_KEY = 'waiting-list-public-exports';
13
+
14
+ // Adjective-animal combinations for auto-generated names
15
+ const ADJECTIVES = [
16
+ 'black', 'white', 'golden', 'silver', 'red', 'blue', 'green', 'purple',
17
+ 'silent', 'loud', 'fast', 'slow', 'big', 'small', 'tall', 'short',
18
+ 'brave', 'shy', 'wise', 'clever', 'strong', 'gentle', 'wild', 'calm',
19
+ 'happy', 'sad', 'angry', 'peaceful', 'bright', 'dark', 'light', 'heavy'
20
+ ];
21
+
22
+ const ANIMALS = [
23
+ 'bear', 'eagle', 'wolf', 'lion', 'tiger', 'elephant', 'giraffe', 'zebra',
24
+ 'monkey', 'dolphin', 'whale', 'shark', 'eagle', 'hawk', 'owl', 'falcon',
25
+ 'fox', 'deer', 'rabbit', 'squirrel', 'mouse', 'rat', 'cat', 'dog',
26
+ 'horse', 'cow', 'pig', 'sheep', 'goat', 'chicken', 'duck', 'goose'
27
+ ];
28
+
29
+ /**
30
+ * Waiting List Public Exports Service
31
+ * Manages public export configurations using JSON Configs system
32
+ */
33
+
34
+ function generateId() {
35
+ return crypto.randomBytes(16).toString('hex');
36
+ }
37
+
38
+ function generateName() {
39
+ const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
40
+ const animal = ANIMALS[Math.floor(Math.random() * ANIMALS.length)];
41
+ return `${adjective}-${animal}`;
42
+ }
43
+
44
+ async function generateUniqueName(existingNames = []) {
45
+ let attempts = 0;
46
+ const maxAttempts = 100;
47
+
48
+ while (attempts < maxAttempts) {
49
+ const name = generateName();
50
+ if (!existingNames.includes(name)) {
51
+ return name;
52
+ }
53
+ attempts++;
54
+ }
55
+
56
+ throw new Error('Failed to generate unique name after maximum attempts');
57
+ }
58
+
59
+ function validateExportConfig(config) {
60
+ if (!config || typeof config !== 'object') {
61
+ const err = new Error('Export configuration must be an object');
62
+ err.code = 'VALIDATION';
63
+ throw err;
64
+ }
65
+
66
+ if (!config.name || typeof config.name !== 'string') {
67
+ const err = new Error('Name is required and must be a string');
68
+ err.code = 'VALIDATION';
69
+ throw err;
70
+ }
71
+
72
+ if (!config.type || typeof config.type !== 'string') {
73
+ const err = new Error('Type is required and must be a string');
74
+ err.code = 'VALIDATION';
75
+ throw err;
76
+ }
77
+
78
+ const normalizedConfig = {
79
+ id: config.id || generateId(),
80
+ name: String(config.name).trim(),
81
+ type: String(config.type).trim(),
82
+ password: config.password || null,
83
+ format: config.format || 'csv',
84
+ createdAt: config.createdAt || new Date().toISOString(),
85
+ createdBy: config.createdBy || 'system',
86
+ accessCount: config.accessCount || 0,
87
+ lastAccessed: config.lastAccessed || null,
88
+ updatedAt: new Date().toISOString()
89
+ };
90
+
91
+ // Validate format
92
+ if (!['csv', 'json'].includes(normalizedConfig.format)) {
93
+ const err = new Error('Format must be either "csv" or "json"');
94
+ err.code = 'VALIDATION';
95
+ throw err;
96
+ }
97
+
98
+ return normalizedConfig;
99
+ }
100
+
101
+ /**
102
+ * Get all public export configurations
103
+ */
104
+ async function getPublicExports(options = {}) {
105
+ try {
106
+ const data = await getJsonConfigValueBySlug(PUBLIC_EXPORTS_KEY, {
107
+ bypassCache: options.bypassCache
108
+ });
109
+
110
+ return {
111
+ exports: Array.isArray(data.exports) ? data.exports : [],
112
+ lastUpdated: data.lastUpdated || null
113
+ };
114
+ } catch (error) {
115
+ if (error.code === 'NOT_FOUND') {
116
+ return { exports: [], lastUpdated: null };
117
+ }
118
+ throw error;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Create new public export configuration
124
+ */
125
+ async function createPublicExport(configData, adminUser) {
126
+ const validatedConfig = validateExportConfig(configData);
127
+
128
+ try {
129
+ const result = await updateJsonConfigValueBySlug(PUBLIC_EXPORTS_KEY, (currentData) => {
130
+ const data = currentData || { exports: [] };
131
+ const exports = Array.isArray(data.exports) ? data.exports : [];
132
+
133
+ // Check for duplicate name
134
+ const existingExport = exports.find(e =>
135
+ e.name.toLowerCase() === validatedConfig.name.toLowerCase()
136
+ );
137
+
138
+ if (existingExport) {
139
+ const err = new Error('An export with this name already exists');
140
+ err.code = 'DUPLICATE_NAME';
141
+ throw err;
142
+ }
143
+
144
+ // Add new export
145
+ exports.push(validatedConfig);
146
+
147
+ return {
148
+ ...data,
149
+ exports,
150
+ lastUpdated: new Date().toISOString()
151
+ };
152
+ }, { invalidateCache: true });
153
+
154
+ // Log audit event
155
+ await logAudit({
156
+ action: 'public.waiting_list.export.create',
157
+ entityType: 'WaitingListPublicExport',
158
+ entityId: validatedConfig.id,
159
+ actor: { actorType: 'admin', actorId: adminUser },
160
+ details: {
161
+ exportName: validatedConfig.name,
162
+ exportType: validatedConfig.type,
163
+ hasPassword: !!validatedConfig.password,
164
+ format: validatedConfig.format
165
+ }
166
+ });
167
+
168
+ return validatedConfig;
169
+ } catch (error) {
170
+ // If the config doesn't exist, initialize it first
171
+ if (error.code === 'NOT_FOUND') {
172
+ await initializePublicExportsData();
173
+
174
+ // Retry the operation after initialization
175
+ try {
176
+ return await createPublicExport(configData, adminUser);
177
+ } catch (retryError) {
178
+ if (retryError.code === 'NOT_FOUND') {
179
+ const err = new Error('Failed to initialize public exports data structure');
180
+ err.code = 'INITIALIZATION_FAILED';
181
+ throw err;
182
+ }
183
+ throw retryError;
184
+ }
185
+ }
186
+ throw error;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Update public export configuration
192
+ */
193
+ async function updatePublicExport(exportId, updates, adminUser) {
194
+ if (!exportId) {
195
+ const err = new Error('Export ID is required');
196
+ err.code = 'VALIDATION';
197
+ throw err;
198
+ }
199
+
200
+ const result = await updateJsonConfigValueBySlug(PUBLIC_EXPORTS_KEY, (currentData) => {
201
+ const data = currentData || { exports: [] };
202
+ const exports = Array.isArray(data.exports) ? data.exports : [];
203
+
204
+ const exportIndex = exports.findIndex(e => e.id === exportId);
205
+ if (exportIndex === -1) {
206
+ const err = new Error('Public export not found');
207
+ err.code = 'NOT_FOUND';
208
+ throw err;
209
+ }
210
+
211
+ // Update export with validation
212
+ const updatedExport = validateExportConfig({
213
+ ...exports[exportIndex],
214
+ ...updates,
215
+ id: exportId, // Preserve original ID
216
+ createdAt: exports[exportIndex].createdAt, // Preserve creation time
217
+ createdBy: exports[exportIndex].createdBy, // Preserve creator
218
+ updatedAt: new Date().toISOString()
219
+ });
220
+
221
+ exports[exportIndex] = updatedExport;
222
+
223
+ return {
224
+ ...data,
225
+ exports,
226
+ lastUpdated: new Date().toISOString()
227
+ };
228
+ }, { invalidateCache: true });
229
+
230
+ // Log audit event
231
+ await logAudit({
232
+ action: 'public.waiting_list.export.update',
233
+ entityType: 'WaitingListPublicExport',
234
+ entityId: exportId,
235
+ actor: { actorType: 'admin', actorId: adminUser },
236
+ details: {
237
+ exportName: result.exports.find(e => e.id === exportId)?.name,
238
+ updates: Object.keys(updates)
239
+ }
240
+ });
241
+
242
+ return result;
243
+ }
244
+
245
+ /**
246
+ * Delete public export configuration
247
+ */
248
+ async function deletePublicExport(exportId, adminUser) {
249
+ if (!exportId) {
250
+ const err = new Error('Export ID is required');
251
+ err.code = 'VALIDATION';
252
+ throw err;
253
+ }
254
+
255
+ // Get the export info before deletion for audit logging
256
+ const { exports } = await getPublicExports();
257
+ const exportToDelete = exports.find(e => e.id === exportId);
258
+
259
+ if (!exportToDelete) {
260
+ const err = new Error('Public export not found');
261
+ err.code = 'NOT_FOUND';
262
+ throw err;
263
+ }
264
+
265
+ const deletedExportName = exportToDelete.name;
266
+
267
+ const result = await updateJsonConfigValueBySlug(PUBLIC_EXPORTS_KEY, (currentData) => {
268
+ const data = currentData || { exports: [] };
269
+ const exports = Array.isArray(data.exports) ? data.exports : [];
270
+
271
+ const exportIndex = exports.findIndex(e => e.id === exportId);
272
+ if (exportIndex === -1) {
273
+ const err = new Error('Public export not found');
274
+ err.code = 'NOT_FOUND';
275
+ throw err;
276
+ }
277
+
278
+ const deletedExport = exports[exportIndex];
279
+
280
+ // Remove export
281
+ exports.splice(exportIndex, 1);
282
+
283
+ return {
284
+ ...data,
285
+ exports,
286
+ lastUpdated: new Date().toISOString()
287
+ };
288
+ }, { invalidateCache: true });
289
+
290
+ // Log audit event
291
+ await logAudit({
292
+ action: 'public.waiting_list.export.delete',
293
+ entityType: 'WaitingListPublicExport',
294
+ entityId: exportId,
295
+ actor: { actorType: 'admin', actorId: adminUser },
296
+ details: {
297
+ exportName: deletedExportName
298
+ }
299
+ });
300
+
301
+ return result;
302
+ }
303
+
304
+ /**
305
+ * Get public export by name
306
+ */
307
+ async function getPublicExportByName(name, options = {}) {
308
+ const { exports } = await getPublicExports(options);
309
+ return exports.find(e => e.name === name);
310
+ }
311
+
312
+ /**
313
+ * Validate password for protected export
314
+ */
315
+ async function validateExportPassword(exportConfig, password) {
316
+ if (!exportConfig.password) {
317
+ return true; // No password required
318
+ }
319
+
320
+ if (!password) {
321
+ return false; // Password required but not provided
322
+ }
323
+
324
+ try {
325
+ return await bcrypt.compare(password, exportConfig.password);
326
+ } catch (error) {
327
+ console.error('Password validation error:', error);
328
+ return false;
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Hash password for storage
334
+ */
335
+ async function hashPassword(password) {
336
+ if (!password) {
337
+ return null;
338
+ }
339
+
340
+ try {
341
+ return await bcrypt.hash(password, 10);
342
+ } catch (error) {
343
+ console.error('Password hashing error:', error);
344
+ throw new Error('Failed to hash password');
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Record access and update last accessed
350
+ */
351
+ async function recordExportAccess(exportName, req, authMethod = 'none') {
352
+ const result = await updateJsonConfigValueBySlug(PUBLIC_EXPORTS_KEY, (currentData) => {
353
+ const data = currentData || { exports: [] };
354
+ const exports = Array.isArray(data.exports) ? data.exports : [];
355
+
356
+ const exportIndex = exports.findIndex(e => e.name === exportName);
357
+ if (exportIndex === -1) {
358
+ // Export not found, don't update
359
+ return data;
360
+ }
361
+
362
+ // Increment access count and update last accessed
363
+ exports[exportIndex].accessCount = (exports[exportIndex].accessCount || 0) + 1;
364
+ exports[exportIndex].lastAccessed = new Date().toISOString();
365
+
366
+ return {
367
+ ...data,
368
+ exports,
369
+ lastUpdated: new Date().toISOString()
370
+ };
371
+ }, { invalidateCache: true });
372
+
373
+ // Log audit event
374
+ await logAudit({
375
+ action: 'public.waiting_list.export.access',
376
+ entityType: 'WaitingListPublicExport',
377
+ req,
378
+ details: {
379
+ exportName,
380
+ ip: req.ip,
381
+ userAgent: req.headers['user-agent'],
382
+ authMethod
383
+ }
384
+ });
385
+
386
+ return result;
387
+ }
388
+
389
+ /**
390
+ * Initialize public exports data structure if it doesn't exist
391
+ */
392
+ async function initializePublicExportsData() {
393
+ try {
394
+ await getJsonConfigValueBySlug(PUBLIC_EXPORTS_KEY, { bypassCache: true });
395
+ } catch (error) {
396
+ if (error.code === 'NOT_FOUND') {
397
+ // Create initial data structure
398
+ const { createJsonConfig } = require('./jsonConfigs.service');
399
+ await createJsonConfig({
400
+ title: 'Waiting List Public Exports',
401
+ alias: PUBLIC_EXPORTS_KEY,
402
+ jsonRaw: JSON.stringify({
403
+ exports: [],
404
+ lastUpdated: new Date().toISOString()
405
+ }),
406
+ publicEnabled: false,
407
+ cacheTtlSeconds: 0 // No caching - required for real-time persistence
408
+ });
409
+ } else {
410
+ throw error;
411
+ }
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Get available names (for admin UI suggestions)
417
+ */
418
+ async function getAvailableNames() {
419
+ const { exports } = await getPublicExports();
420
+ const existingNames = exports.map(e => e.name);
421
+
422
+ // Generate some unique name suggestions
423
+ const suggestions = [];
424
+ let attempts = 0;
425
+ const maxSuggestions = 10;
426
+
427
+ while (suggestions.length < maxSuggestions && attempts < 100) {
428
+ const name = generateName();
429
+ if (!existingNames.includes(name) && !suggestions.includes(name)) {
430
+ suggestions.push(name);
431
+ }
432
+ attempts++;
433
+ }
434
+
435
+ return {
436
+ existing: existingNames,
437
+ suggestions
438
+ };
439
+ }
440
+
441
+ module.exports = {
442
+ // Core operations
443
+ getPublicExports,
444
+ createPublicExport,
445
+ updatePublicExport,
446
+ deletePublicExport,
447
+ getPublicExportByName,
448
+
449
+ // Security
450
+ validateExportPassword,
451
+ hashPassword,
452
+
453
+ // Analytics
454
+ recordExportAccess,
455
+ getAvailableNames,
456
+
457
+ // Utilities
458
+ initializePublicExportsData,
459
+ validateExportConfig,
460
+ generateUniqueName,
461
+ generateName,
462
+
463
+ // Constants
464
+ PUBLIC_EXPORTS_KEY
465
+ };