@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.
- package/index.js +3 -1
- package/package.json +2 -1
- package/src/controllers/publicExport.controller.js +146 -0
- package/src/controllers/waitingList.controller.js +302 -0
- package/src/middleware.js +5 -10
- package/src/models/User.js +0 -5
- package/src/routes/publicExport.routes.js +9 -0
- package/src/routes/waitingList.routes.js +8 -0
- package/src/routes/waitingListAdmin.routes.js +6 -0
- package/src/services/waitingListPublicExports.service.js +465 -0
- package/views/admin-waiting-list.ejs +361 -11
- package/views/public-export-password.ejs +192 -0
|
@@ -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
|
+
};
|