@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,202 @@
1
+ const crypto = require('crypto');
2
+ const GlobalSetting = require('../models/GlobalSetting');
3
+
4
+ const FEATURE_FLAG_PREFIX = 'FEATURE_FLAG.';
5
+
6
+ const stripPrefix = (key) => {
7
+ if (!key) return key;
8
+ return key.startsWith(FEATURE_FLAG_PREFIX) ? key.slice(FEATURE_FLAG_PREFIX.length) : key;
9
+ };
10
+
11
+ const normalizeArray = (value) => {
12
+ if (!value) return [];
13
+ if (Array.isArray(value)) return value.map(String);
14
+ return [String(value)];
15
+ };
16
+
17
+ const normalizeDefinition = ({ key, raw }) => {
18
+ const description = raw?.description ? String(raw.description) : '';
19
+ const enabled = Boolean(raw?.enabled);
20
+
21
+ let rolloutPercentage = Number(raw?.rolloutPercentage ?? 0);
22
+ if (Number.isNaN(rolloutPercentage)) rolloutPercentage = 0;
23
+ rolloutPercentage = Math.max(0, Math.min(100, rolloutPercentage));
24
+
25
+ return {
26
+ key,
27
+ description,
28
+ enabled,
29
+ rolloutPercentage,
30
+ allowListUserIds: normalizeArray(raw?.allowListUserIds),
31
+ allowListOrgIds: normalizeArray(raw?.allowListOrgIds),
32
+ denyListUserIds: normalizeArray(raw?.denyListUserIds),
33
+ denyListOrgIds: normalizeArray(raw?.denyListOrgIds),
34
+ payload: raw?.payload ?? null,
35
+ };
36
+ };
37
+
38
+ const computeBucket = ({ flagKey, subjectId }) => {
39
+ const input = `${flagKey}:${subjectId}`;
40
+ const hash = crypto.createHash('sha256').update(input).digest('hex');
41
+ const int = parseInt(hash.slice(0, 8), 16);
42
+ return int % 100;
43
+ };
44
+
45
+ const evaluateDefinition = ({ def, userId, orgId, anonId }) => {
46
+ const userIdStr = userId ? String(userId) : null;
47
+ const orgIdStr = orgId ? String(orgId) : null;
48
+ const anonIdStr = anonId ? String(anonId) : null;
49
+
50
+ const deny =
51
+ (userIdStr && def.denyListUserIds.includes(userIdStr)) ||
52
+ (orgIdStr && def.denyListOrgIds.includes(orgIdStr));
53
+ if (deny) {
54
+ return { key: def.key, enabled: false };
55
+ }
56
+
57
+ const allow =
58
+ (userIdStr && def.allowListUserIds.includes(userIdStr)) ||
59
+ (orgIdStr && def.allowListOrgIds.includes(orgIdStr));
60
+ if (allow) {
61
+ return { key: def.key, enabled: true, payload: def.payload };
62
+ }
63
+
64
+ if (def.enabled) {
65
+ return { key: def.key, enabled: true, payload: def.payload };
66
+ }
67
+
68
+ if (def.rolloutPercentage > 0) {
69
+ const subjectId = orgIdStr || userIdStr || anonIdStr;
70
+ if (!subjectId) {
71
+ return { key: def.key, enabled: false };
72
+ }
73
+
74
+ const bucket = computeBucket({ flagKey: def.key, subjectId });
75
+ const enabled = bucket < def.rolloutPercentage;
76
+ return enabled
77
+ ? { key: def.key, enabled: true, payload: def.payload }
78
+ : { key: def.key, enabled: false };
79
+ }
80
+
81
+ return { key: def.key, enabled: false };
82
+ };
83
+
84
+ const loadAllDefinitions = async () => {
85
+ const settings = await GlobalSetting.find({
86
+ key: { $regex: `^${FEATURE_FLAG_PREFIX}` },
87
+ type: 'json',
88
+ })
89
+ .sort({ key: 1 })
90
+ .lean();
91
+
92
+ return settings
93
+ .map((s) => {
94
+ let raw;
95
+ try {
96
+ raw = s?.value ? JSON.parse(s.value) : {};
97
+ } catch {
98
+ raw = {};
99
+ }
100
+
101
+ const key = stripPrefix(s.key);
102
+ return normalizeDefinition({ key, raw: { ...raw, description: raw?.description ?? s.description } });
103
+ });
104
+ };
105
+
106
+ const evaluateAllForRequest = async ({ userId, orgId, anonId }) => {
107
+ const defs = await loadAllDefinitions();
108
+ return defs.map((def) => evaluateDefinition({ def, userId, orgId, anonId }));
109
+ };
110
+
111
+ const flagsArrayToMap = (flagsArray) => {
112
+ const out = {};
113
+ (flagsArray || []).forEach((f) => {
114
+ if (!f || !f.key) return;
115
+ out[f.key] = {
116
+ enabled: Boolean(f.enabled),
117
+ ...(Object.prototype.hasOwnProperty.call(f, 'payload') ? { payload: f.payload } : {}),
118
+ };
119
+ });
120
+ return out;
121
+ };
122
+
123
+ const parseCookies = (cookieHeader) => {
124
+ const cookies = {};
125
+ if (!cookieHeader) return cookies;
126
+
127
+ cookieHeader.split(';').forEach((part) => {
128
+ const idx = part.indexOf('=');
129
+ if (idx === -1) return;
130
+ const k = part.slice(0, idx).trim();
131
+ const v = part.slice(idx + 1).trim();
132
+ if (!k) return;
133
+ cookies[k] = decodeURIComponent(v);
134
+ });
135
+
136
+ return cookies;
137
+ };
138
+
139
+ const ensureAnonIdCookie = (req, res) => {
140
+ const cookies = parseCookies(req.headers?.cookie);
141
+ const existing = cookies.saas_anon_id;
142
+ if (existing) return existing;
143
+
144
+ const generated = crypto.randomBytes(16).toString('hex');
145
+ if (res && typeof res.setHeader === 'function') {
146
+ const cookie = `saas_anon_id=${encodeURIComponent(generated)}; Path=/; Max-Age=31536000; SameSite=Lax`;
147
+ res.setHeader('Set-Cookie', cookie);
148
+ }
149
+ return generated;
150
+ };
151
+
152
+ function createFeatureFlagsEjsMiddleware(opts = {}) {
153
+ const enabled = opts.enabled !== false;
154
+ const includeForApi = Boolean(opts.includeForApi);
155
+
156
+ return async (req, res, next) => {
157
+ try {
158
+ if (!enabled) return next();
159
+
160
+ const isApi = String(req.originalUrl || req.url || '').startsWith('/api/');
161
+ if (isApi && !includeForApi) return next();
162
+
163
+ const accept = String(req.headers?.accept || '');
164
+ const isHtml = accept.includes('text/html') || accept.includes('*/*') || !accept;
165
+ if (!isHtml) return next();
166
+
167
+ const orgId = req.query.orgId || req.headers['x-org-id'] || null;
168
+ const anonId =
169
+ req.query.anonId ||
170
+ req.headers['x-anon-id'] ||
171
+ ensureAnonIdCookie(req, res);
172
+
173
+ const flagsArray = await evaluateAllForRequest({ userId: null, orgId, anonId });
174
+ const flags = flagsArrayToMap(flagsArray);
175
+
176
+ res.locals.featureFlags = flags;
177
+ res.locals.ff = (key, defaultValue = false) => {
178
+ if (!key) return defaultValue;
179
+ const entry = flags[String(key)];
180
+ return typeof entry?.enabled === 'boolean' ? entry.enabled : defaultValue;
181
+ };
182
+ res.locals.ffPayload = (key, defaultValue = null) => {
183
+ if (!key) return defaultValue;
184
+ const entry = flags[String(key)];
185
+ return Object.prototype.hasOwnProperty.call(entry || {}, 'payload') ? entry.payload : defaultValue;
186
+ };
187
+
188
+ next();
189
+ } catch (e) {
190
+ next(e);
191
+ }
192
+ };
193
+ }
194
+
195
+ module.exports = {
196
+ FEATURE_FLAG_PREFIX,
197
+ stripPrefix,
198
+ loadAllDefinitions,
199
+ evaluateAllForRequest,
200
+ flagsArrayToMap,
201
+ createFeatureFlagsEjsMiddleware,
202
+ };
@@ -0,0 +1,214 @@
1
+ const FormSubmission = require('../models/FormSubmission');
2
+ const JsonConfig = require('../models/JsonConfig');
3
+ const auditService = require('./audit.service');
4
+ const emailService = require('./email.service');
5
+ const webhookService = require('./webhook.service');
6
+ const jsonConfigsService = require('./jsonConfigs.service');
7
+
8
+ /**
9
+ * Forms Service
10
+ * Manages form definitions (stored in JsonConfigs) and submissions
11
+ */
12
+ class FormsService {
13
+ /**
14
+ * Get all form definitions
15
+ */
16
+ async getForms() {
17
+ const config = await JsonConfig.findOne({ slug: 'form-definitions' });
18
+ if (!config) return [];
19
+ try {
20
+ return JSON.parse(config.jsonRaw || '[]');
21
+ } catch (e) {
22
+ console.error('[FormsService] Error parsing jsonRaw in getForms:', e);
23
+ return [];
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Get a specific form definition by ID
29
+ */
30
+ async getFormById(formId) {
31
+ const forms = await this.getForms();
32
+ return forms.find(f => f.id === formId);
33
+ }
34
+
35
+ /**
36
+ * Save a form definition
37
+ */
38
+ async saveForm(formData) {
39
+ console.log('[FormsService] saveForm start', formData);
40
+ let config = await JsonConfig.findOne({ slug: 'form-definitions' });
41
+
42
+ let forms = [];
43
+ if (config) {
44
+ try {
45
+ forms = JSON.parse(config.jsonRaw || '[]');
46
+ } catch (e) {
47
+ console.error('[FormsService] Error parsing jsonRaw:', e);
48
+ }
49
+ }
50
+
51
+ const index = forms.findIndex(f => f.id === formData.id);
52
+ if (index >= 0) {
53
+ forms[index] = { ...forms[index], ...formData, updatedAt: new Date() };
54
+ } else {
55
+ forms.push({
56
+ ...formData,
57
+ id: formData.id || `form_${Date.now()}`,
58
+ createdAt: new Date(),
59
+ updatedAt: new Date()
60
+ });
61
+ }
62
+
63
+ const jsonRaw = JSON.stringify(forms);
64
+
65
+ if (config) {
66
+ await jsonConfigsService.updateJsonConfig(config._id, { jsonRaw });
67
+ } else {
68
+ await jsonConfigsService.createJsonConfig({
69
+ title: 'Form Definitions',
70
+ alias: 'form-definitions',
71
+ jsonRaw,
72
+ publicEnabled: false
73
+ });
74
+ // Note: createJsonConfig generates a unique slug based on title,
75
+ // but we want 'form-definitions' as the lookup key (slug or alias).
76
+ // Since createJsonConfig handles unique slug generation, we set alias to 'form-definitions'.
77
+ }
78
+
79
+ console.log('[FormsService] Form definition saved');
80
+ return formData;
81
+ }
82
+
83
+ /**
84
+ * Delete a form definition
85
+ */
86
+ async deleteForm(formId) {
87
+ const config = await JsonConfig.findOne({ slug: 'form-definitions' });
88
+ if (config) {
89
+ try {
90
+ let forms = JSON.parse(config.jsonRaw || '[]');
91
+ forms = forms.filter(f => f.id !== formId);
92
+ config.jsonRaw = JSON.stringify(forms);
93
+ await config.save();
94
+ } catch (e) {
95
+ console.error('[FormsService] Error during deleteForm:', e);
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Handle form submission
102
+ */
103
+ async submitForm(formId, fields, meta = {}) {
104
+ const formConfig = await this.getFormById(formId);
105
+ if (!formConfig) {
106
+ throw new Error('Form not found');
107
+ }
108
+
109
+ const submission = new FormSubmission({
110
+ formKey: formId,
111
+ actorType: meta.actorType || 'anonymous',
112
+ actorId: meta.actorId || 'guest',
113
+ userId: meta.userId || null,
114
+ fields: fields,
115
+ meta: {
116
+ ip: meta.ip,
117
+ userAgent: meta.userAgent,
118
+ referer: meta.referer,
119
+ organizationId: meta.organizationId || meta.orgId || formConfig.organizationId
120
+ }
121
+ });
122
+
123
+ await submission.save();
124
+
125
+ // Trigger Generic Webhooks
126
+ const orgId = submission.meta.organizationId;
127
+ if (orgId) {
128
+ webhookService.emit('form.submitted', {
129
+ submissionId: submission._id,
130
+ formId,
131
+ fields,
132
+ meta: submission.meta
133
+ }, orgId);
134
+ }
135
+
136
+ // Trigger Legacy per-form Webhook
137
+ if (formConfig.webhookUrl) {
138
+ this.triggerWebhook(formConfig.webhookUrl, submission);
139
+ }
140
+
141
+ // Trigger Email Notification
142
+ if (formConfig.notifyEmail) {
143
+ this.sendNotification(formConfig.notifyEmail, formConfig.name, fields);
144
+ }
145
+
146
+ // Audit Log
147
+ await auditService.createAuditEvent({
148
+ action: 'FORM_SUBMISSION',
149
+ entityType: 'FormSubmission',
150
+ entityId: submission._id,
151
+ actorType: submission.actorType,
152
+ actorId: submission.actorId,
153
+ meta: {
154
+ formId,
155
+ email: fields.email,
156
+ organizationId: orgId
157
+ }
158
+ });
159
+
160
+ return submission;
161
+ }
162
+
163
+ /**
164
+ * Get submissions for a form
165
+ */
166
+ async getSubmissions(query = {}, options = {}) {
167
+ const { limit = 50, offset = 0 } = options;
168
+ const filter = {};
169
+ if (query.formId) filter.formKey = query.formId;
170
+
171
+ const entries = await FormSubmission.find(filter)
172
+ .sort({ createdAt: -1 })
173
+ .limit(limit)
174
+ .skip(offset);
175
+
176
+ const total = await FormSubmission.countDocuments(filter);
177
+
178
+ return {
179
+ entries,
180
+ pagination: { total, limit, offset }
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Trigger external webhook (async)
186
+ */
187
+ triggerWebhook(url, data) {
188
+ fetch(url, {
189
+ method: 'POST',
190
+ headers: { 'Content-Type': 'application/json' },
191
+ body: JSON.stringify(data)
192
+ }).catch(err => console.error(`[FormsService] Webhook failed for ${url}:`, err.message));
193
+ }
194
+
195
+ /**
196
+ * Send email notification (async)
197
+ */
198
+ sendNotification(to, formName, data) {
199
+ emailService.sendEmail({
200
+ to,
201
+ subject: `New Submission: ${formName}`,
202
+ text: `New form submission received for ${formName}.\n\nData:\n${JSON.stringify(data, null, 2)}`
203
+ }).catch(err => console.error(`[FormsService] Email notification failed:`, err.message));
204
+ }
205
+
206
+ /**
207
+ * Delete a form submission
208
+ */
209
+ async deleteSubmission(submissionId) {
210
+ await FormSubmission.findByIdAndDelete(submissionId);
211
+ }
212
+ }
213
+
214
+ module.exports = new FormsService();
@@ -0,0 +1,49 @@
1
+ const GlobalSetting = require('../models/GlobalSetting');
2
+ const { decryptString } = require('../utils/encryption');
3
+
4
+ const settingsCache = new Map();
5
+ const CACHE_TTL = 60000;
6
+
7
+ async function getSettingValue(key, defaultValue = null) {
8
+ const cached = settingsCache.get(key);
9
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
10
+ return cached.value;
11
+ }
12
+
13
+ try {
14
+ const setting = await GlobalSetting.findOne({ key }).lean();
15
+ if (!setting) {
16
+ settingsCache.set(key, { value: defaultValue, timestamp: Date.now() });
17
+ return defaultValue;
18
+ }
19
+
20
+ let value;
21
+ if (setting.type === 'encrypted') {
22
+ try {
23
+ const payload = JSON.parse(setting.value);
24
+ value = decryptString(payload);
25
+ } catch (e) {
26
+ console.error(`Error decrypting setting ${key}:`, e);
27
+ value = defaultValue;
28
+ }
29
+ } else {
30
+ value = setting.value;
31
+ }
32
+
33
+ settingsCache.set(key, { value, timestamp: Date.now() });
34
+ return value;
35
+ } catch (error) {
36
+ console.error(`Error fetching setting ${key}:`, error);
37
+ settingsCache.set(key, { value: defaultValue, timestamp: Date.now() });
38
+ return defaultValue;
39
+ }
40
+ }
41
+
42
+ function clearSettingsCache() {
43
+ settingsCache.clear();
44
+ }
45
+
46
+ module.exports = {
47
+ getSettingValue,
48
+ clearSettingsCache,
49
+ };
@@ -0,0 +1,158 @@
1
+ const HeadlessApiToken = require('../models/HeadlessApiToken');
2
+ const { generateApiTokenPlaintext, hashToken, timingSafeEqualHex } = require('./headlessCrypto.service');
3
+
4
+ const VALID_OPERATIONS = new Set(['create', 'read', 'update', 'delete']);
5
+
6
+ function normalizeOperations(ops) {
7
+ const list = Array.isArray(ops) ? ops : [];
8
+ const normalized = list
9
+ .map((o) => String(o || '').trim().toLowerCase())
10
+ .filter((o) => o);
11
+
12
+ for (const op of normalized) {
13
+ if (!VALID_OPERATIONS.has(op)) {
14
+ const err = new Error(`Invalid operation: ${op}`);
15
+ err.code = 'VALIDATION';
16
+ throw err;
17
+ }
18
+ }
19
+
20
+ return Array.from(new Set(normalized));
21
+ }
22
+
23
+ function normalizePermissions(perms) {
24
+ const list = Array.isArray(perms) ? perms : [];
25
+ return list
26
+ .map((p) => {
27
+ const modelCode = String(p?.modelCode || '').trim();
28
+ const operations = normalizeOperations(p?.operations);
29
+ return { modelCode, operations };
30
+ })
31
+ .filter((p) => p.modelCode);
32
+ }
33
+
34
+ async function createApiToken({ name, permissions, ttlSeconds }) {
35
+ const normalizedName = String(name || '').trim();
36
+ if (!normalizedName) {
37
+ const err = new Error('name is required');
38
+ err.code = 'VALIDATION';
39
+ throw err;
40
+ }
41
+
42
+ const plaintext = generateApiTokenPlaintext();
43
+ const tokenHash = hashToken(plaintext);
44
+ const expiresAt = ttlSeconds ? new Date(Date.now() + Number(ttlSeconds) * 1000) : null;
45
+
46
+ const doc = await HeadlessApiToken.create({
47
+ name: normalizedName,
48
+ tokenHash,
49
+ permissions: normalizePermissions(permissions),
50
+ expiresAt,
51
+ isActive: true,
52
+ lastUsedAt: null,
53
+ });
54
+
55
+ return { token: plaintext, item: doc.toObject() };
56
+ }
57
+
58
+ async function listApiTokens() {
59
+ return HeadlessApiToken.find({}).sort({ createdAt: -1 }).lean();
60
+ }
61
+
62
+ async function getApiTokenById(id) {
63
+ return HeadlessApiToken.findById(id).lean();
64
+ }
65
+
66
+ async function updateApiToken(id, updates) {
67
+ const doc = await HeadlessApiToken.findById(id);
68
+ if (!doc) {
69
+ const err = new Error('API token not found');
70
+ err.code = 'NOT_FOUND';
71
+ throw err;
72
+ }
73
+
74
+ if (updates.name !== undefined) {
75
+ const normalizedName = String(updates.name || '').trim();
76
+ if (!normalizedName) {
77
+ const err = new Error('name is required');
78
+ err.code = 'VALIDATION';
79
+ throw err;
80
+ }
81
+ doc.name = normalizedName;
82
+ }
83
+
84
+ if (updates.permissions !== undefined) {
85
+ doc.permissions = normalizePermissions(updates.permissions);
86
+ }
87
+
88
+ if (updates.isActive !== undefined) {
89
+ doc.isActive = Boolean(updates.isActive);
90
+ }
91
+
92
+ if (updates.ttlSeconds !== undefined) {
93
+ const ttlSeconds = updates.ttlSeconds;
94
+ doc.expiresAt = ttlSeconds ? new Date(Date.now() + Number(ttlSeconds) * 1000) : null;
95
+ }
96
+
97
+ await doc.save();
98
+ return doc.toObject();
99
+ }
100
+
101
+ async function deleteApiToken(id) {
102
+ const doc = await HeadlessApiToken.findById(id);
103
+ if (!doc) {
104
+ const err = new Error('API token not found');
105
+ err.code = 'NOT_FOUND';
106
+ throw err;
107
+ }
108
+ await HeadlessApiToken.deleteOne({ _id: id });
109
+ return { success: true };
110
+ }
111
+
112
+ async function authenticateApiToken(plaintextToken) {
113
+ const provided = String(plaintextToken || '').trim();
114
+ if (!provided) return null;
115
+
116
+ const tokenHash = hashToken(provided);
117
+
118
+ const candidates = await HeadlessApiToken.find({ isActive: true }).select(
119
+ 'tokenHash permissions expiresAt isActive lastUsedAt',
120
+ );
121
+
122
+ const match = candidates.find((c) => timingSafeEqualHex(c.tokenHash, tokenHash));
123
+ if (!match) return null;
124
+
125
+ if (match.expiresAt && new Date(match.expiresAt).getTime() <= Date.now()) {
126
+ return null;
127
+ }
128
+
129
+ match.lastUsedAt = new Date();
130
+ await match.save();
131
+
132
+ return match.toObject();
133
+ }
134
+
135
+ function tokenAllowsOperation(tokenDoc, modelCode, operation) {
136
+ if (!tokenDoc || !tokenDoc.permissions) return false;
137
+ const op = String(operation || '').trim().toLowerCase();
138
+ if (!VALID_OPERATIONS.has(op)) return false;
139
+
140
+ const code = String(modelCode || '').trim();
141
+ if (!code) return false;
142
+
143
+ const perm = (tokenDoc.permissions || []).find((p) => p.modelCode === code);
144
+ if (!perm) return false;
145
+
146
+ return Array.isArray(perm.operations) && perm.operations.includes(op);
147
+ }
148
+
149
+ module.exports = {
150
+ VALID_OPERATIONS,
151
+ createApiToken,
152
+ listApiTokens,
153
+ getApiTokenById,
154
+ updateApiToken,
155
+ deleteApiToken,
156
+ authenticateApiToken,
157
+ tokenAllowsOperation,
158
+ };
@@ -0,0 +1,31 @@
1
+ const crypto = require('crypto');
2
+
3
+ function base64UrlEncode(buf) {
4
+ return buf
5
+ .toString('base64')
6
+ .replace(/\+/g, '-')
7
+ .replace(/\//g, '_')
8
+ .replace(/=+$/g, '');
9
+ }
10
+
11
+ function generateApiTokenPlaintext() {
12
+ const raw = crypto.randomBytes(32);
13
+ return `hcms_${base64UrlEncode(raw)}`;
14
+ }
15
+
16
+ function hashToken(plaintext) {
17
+ return crypto.createHash('sha256').update(String(plaintext)).digest('hex');
18
+ }
19
+
20
+ function timingSafeEqualHex(a, b) {
21
+ const aBuf = Buffer.from(String(a), 'hex');
22
+ const bBuf = Buffer.from(String(b), 'hex');
23
+ if (aBuf.length !== bBuf.length) return false;
24
+ return crypto.timingSafeEqual(aBuf, bBuf);
25
+ }
26
+
27
+ module.exports = {
28
+ generateApiTokenPlaintext,
29
+ hashToken,
30
+ timingSafeEqualHex,
31
+ };