@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,491 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const Organization = require('../models/Organization');
4
+ const OrganizationMember = require('../models/OrganizationMember');
5
+ const Invite = require('../models/Invite');
6
+ const emailService = require('../services/email.service');
7
+ const { isValidOrgRole, getAllowedOrgRoles, getDefaultOrgRole } = require('../utils/orgRoles');
8
+
9
+ const DEFAULT_LIMIT = 50;
10
+ const MAX_LIMIT = 500;
11
+ const DEFAULT_INVITE_EXPIRY_DAYS = 7;
12
+
13
+ function parseLimit(value) {
14
+ const parsed = parseInt(value, 10);
15
+ if (!Number.isFinite(parsed)) return DEFAULT_LIMIT;
16
+ return Math.min(MAX_LIMIT, Math.max(1, parsed));
17
+ }
18
+
19
+ function parseOffset(value) {
20
+ const parsed = parseInt(value, 10);
21
+ if (!Number.isFinite(parsed)) return 0;
22
+ return Math.max(0, parsed);
23
+ }
24
+
25
+ function escapeRegex(str) {
26
+ return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
27
+ }
28
+
29
+ function buildInviteLink(token) {
30
+ const base = process.env.PUBLIC_URL || 'http://localhost:3000';
31
+ return `${base}/accept-invite?token=${encodeURIComponent(token)}`;
32
+ }
33
+
34
+ exports.listOrgs = async (req, res) => {
35
+ try {
36
+ const { status, ownerUserId, slug, q, limit, offset } = req.query;
37
+
38
+ const parsedLimit = parseLimit(limit);
39
+ const parsedOffset = parseOffset(offset);
40
+
41
+ const query = {};
42
+ if (status) query.status = String(status);
43
+ if (slug) query.slug = String(slug).toLowerCase().trim();
44
+
45
+ if (ownerUserId && mongoose.Types.ObjectId.isValid(String(ownerUserId))) {
46
+ query.ownerUserId = new mongoose.Types.ObjectId(String(ownerUserId));
47
+ }
48
+
49
+ if (q) {
50
+ const pattern = escapeRegex(String(q).trim());
51
+ query.$or = [
52
+ { name: { $regex: pattern, $options: 'i' } },
53
+ { slug: { $regex: pattern, $options: 'i' } },
54
+ ];
55
+ }
56
+
57
+ const orgs = await Organization.find(query)
58
+ .sort({ createdAt: -1 })
59
+ .limit(parsedLimit)
60
+ .skip(parsedOffset)
61
+ .lean();
62
+
63
+ const total = await Organization.countDocuments(query);
64
+
65
+ return res.json({
66
+ orgs,
67
+ pagination: {
68
+ total,
69
+ limit: parsedLimit,
70
+ offset: parsedOffset,
71
+ },
72
+ });
73
+ } catch (error) {
74
+ console.error('Admin org list error:', error);
75
+ return res.status(500).json({ error: 'Failed to list organizations' });
76
+ }
77
+ };
78
+
79
+ exports.getOrg = async (req, res) => {
80
+ try {
81
+ const { orgId } = req.params;
82
+ if (!orgId || !mongoose.Types.ObjectId.isValid(String(orgId))) {
83
+ return res.status(400).json({ error: 'Invalid organization ID' });
84
+ }
85
+
86
+ const org = await Organization.findById(orgId).lean();
87
+ if (!org) {
88
+ return res.status(404).json({ error: 'Organization not found' });
89
+ }
90
+
91
+ const [memberCount, invitePendingCount, inviteCount] = await Promise.all([
92
+ OrganizationMember.countDocuments({ orgId: org._id, status: 'active' }),
93
+ Invite.countDocuments({ orgId: org._id, status: 'pending' }),
94
+ Invite.countDocuments({ orgId: org._id }),
95
+ ]);
96
+
97
+ return res.json({
98
+ org,
99
+ counts: {
100
+ membersActive: memberCount,
101
+ invitesPending: invitePendingCount,
102
+ invitesTotal: inviteCount,
103
+ },
104
+ });
105
+ } catch (error) {
106
+ console.error('Admin org get error:', error);
107
+ return res.status(500).json({ error: 'Failed to load organization' });
108
+ }
109
+ };
110
+
111
+ exports.listMembers = async (req, res) => {
112
+ try {
113
+ const { orgId } = req.params;
114
+ const { role, status, email, limit, offset } = req.query;
115
+
116
+ if (!orgId || !mongoose.Types.ObjectId.isValid(String(orgId))) {
117
+ return res.status(400).json({ error: 'Invalid organization ID' });
118
+ }
119
+
120
+ const parsedLimit = parseLimit(limit);
121
+ const parsedOffset = parseOffset(offset);
122
+
123
+ const match = {
124
+ orgId: new mongoose.Types.ObjectId(String(orgId)),
125
+ };
126
+ if (role) match.role = String(role);
127
+ if (status) match.status = String(status);
128
+
129
+ const emailFilter = email ? String(email).trim().toLowerCase() : '';
130
+
131
+ const basePipeline = [
132
+ { $match: match },
133
+ {
134
+ $lookup: {
135
+ from: 'users',
136
+ localField: 'userId',
137
+ foreignField: '_id',
138
+ as: 'user',
139
+ },
140
+ },
141
+ { $unwind: { path: '$user', preserveNullAndEmptyArrays: false } },
142
+ ];
143
+
144
+ const filterPipeline = emailFilter
145
+ ? [...basePipeline, { $match: { 'user.email': emailFilter } }]
146
+ : basePipeline;
147
+
148
+ const itemsPipeline = [
149
+ ...filterPipeline,
150
+ { $sort: { createdAt: -1 } },
151
+ { $skip: parsedOffset },
152
+ { $limit: parsedLimit },
153
+ {
154
+ $project: {
155
+ _id: 1,
156
+ orgId: 1,
157
+ userId: 1,
158
+ role: 1,
159
+ status: 1,
160
+ addedByUserId: 1,
161
+ createdAt: 1,
162
+ updatedAt: 1,
163
+ user: {
164
+ _id: '$user._id',
165
+ email: '$user.email',
166
+ name: '$user.name',
167
+ },
168
+ },
169
+ },
170
+ ];
171
+
172
+ const totalPipeline = [...filterPipeline, { $count: 'total' }];
173
+
174
+ const [members, totalAgg] = await Promise.all([
175
+ OrganizationMember.aggregate(itemsPipeline),
176
+ OrganizationMember.aggregate(totalPipeline),
177
+ ]);
178
+
179
+ const total = totalAgg?.[0]?.total || 0;
180
+
181
+ return res.json({
182
+ members,
183
+ pagination: {
184
+ total,
185
+ limit: parsedLimit,
186
+ offset: parsedOffset,
187
+ },
188
+ });
189
+ } catch (error) {
190
+ console.error('Admin org members list error:', error);
191
+ return res.status(500).json({ error: 'Failed to list organization members' });
192
+ }
193
+ };
194
+
195
+ exports.updateMember = async (req, res) => {
196
+ try {
197
+ const { orgId, memberId } = req.params;
198
+ const { role, status } = req.body;
199
+
200
+ if (!orgId || !mongoose.Types.ObjectId.isValid(String(orgId))) {
201
+ return res.status(400).json({ error: 'Invalid organization ID' });
202
+ }
203
+ if (!memberId || !mongoose.Types.ObjectId.isValid(String(memberId))) {
204
+ return res.status(400).json({ error: 'Invalid member ID' });
205
+ }
206
+
207
+ const member = await OrganizationMember.findOne({ _id: memberId, orgId });
208
+ if (!member) {
209
+ return res.status(404).json({ error: 'Member not found' });
210
+ }
211
+
212
+ if (role !== undefined) {
213
+ const nextRole = String(role);
214
+ if (!(await isValidOrgRole(nextRole))) {
215
+ const allowed = await getAllowedOrgRoles();
216
+ return res.status(400).json({ error: 'Invalid role', allowedRoles: allowed });
217
+ }
218
+ if (member.role === 'owner' && nextRole !== 'owner') {
219
+ return res.status(403).json({ error: 'Cannot change owner role' });
220
+ }
221
+ member.role = nextRole;
222
+ }
223
+
224
+ if (status !== undefined) {
225
+ if (!['active', 'removed'].includes(String(status))) {
226
+ return res.status(400).json({ error: 'Invalid status' });
227
+ }
228
+ member.status = String(status);
229
+ }
230
+
231
+ await member.save();
232
+
233
+ return res.json({ member: member.toObject() });
234
+ } catch (error) {
235
+ console.error('Admin org member update error:', error);
236
+ return res.status(500).json({ error: 'Failed to update member' });
237
+ }
238
+ };
239
+
240
+ exports.removeMember = async (req, res) => {
241
+ try {
242
+ const { orgId, memberId } = req.params;
243
+
244
+ if (!orgId || !mongoose.Types.ObjectId.isValid(String(orgId))) {
245
+ return res.status(400).json({ error: 'Invalid organization ID' });
246
+ }
247
+ if (!memberId || !mongoose.Types.ObjectId.isValid(String(memberId))) {
248
+ return res.status(400).json({ error: 'Invalid member ID' });
249
+ }
250
+
251
+ const member = await OrganizationMember.findOne({ _id: memberId, orgId, status: 'active' });
252
+ if (!member) {
253
+ return res.status(404).json({ error: 'Member not found' });
254
+ }
255
+
256
+ if (member.role === 'owner') {
257
+ return res.status(403).json({ error: 'Cannot remove owner' });
258
+ }
259
+
260
+ member.status = 'removed';
261
+ await member.save();
262
+
263
+ return res.json({ message: 'Member removed successfully' });
264
+ } catch (error) {
265
+ console.error('Admin org member remove error:', error);
266
+ return res.status(500).json({ error: 'Failed to remove member' });
267
+ }
268
+ };
269
+
270
+ exports.listInvites = async (req, res) => {
271
+ try {
272
+ const { orgId } = req.params;
273
+ const { status, email, limit, offset } = req.query;
274
+
275
+ if (!orgId || !mongoose.Types.ObjectId.isValid(String(orgId))) {
276
+ return res.status(400).json({ error: 'Invalid organization ID' });
277
+ }
278
+
279
+ const parsedLimit = parseLimit(limit);
280
+ const parsedOffset = parseOffset(offset);
281
+
282
+ const query = { orgId: new mongoose.Types.ObjectId(String(orgId)) };
283
+ if (status) query.status = String(status);
284
+ if (email) query.email = String(email).trim().toLowerCase();
285
+
286
+ const invites = await Invite.find(query)
287
+ .sort({ createdAt: -1 })
288
+ .limit(parsedLimit)
289
+ .skip(parsedOffset)
290
+ .select('-tokenHash')
291
+ .lean();
292
+
293
+ const total = await Invite.countDocuments(query);
294
+
295
+ return res.json({
296
+ invites,
297
+ pagination: {
298
+ total,
299
+ limit: parsedLimit,
300
+ offset: parsedOffset,
301
+ },
302
+ });
303
+ } catch (error) {
304
+ console.error('Admin org invites list error:', error);
305
+ return res.status(500).json({ error: 'Failed to list invites' });
306
+ }
307
+ };
308
+
309
+ exports.createInvite = async (req, res) => {
310
+ try {
311
+ const { orgId } = req.params;
312
+ const defaultRole = await getDefaultOrgRole();
313
+ const { email, role = defaultRole, expiresInDays } = req.body;
314
+
315
+ if (!orgId || !mongoose.Types.ObjectId.isValid(String(orgId))) {
316
+ return res.status(400).json({ error: 'Invalid organization ID' });
317
+ }
318
+ if (!email) {
319
+ return res.status(400).json({ error: 'Email is required' });
320
+ }
321
+
322
+ const nextRole = String(role);
323
+ if (!(await isValidOrgRole(nextRole))) {
324
+ const allowed = await getAllowedOrgRoles();
325
+ return res.status(400).json({ error: 'Invalid role', allowedRoles: allowed });
326
+ }
327
+
328
+ const org = await Organization.findById(orgId).lean();
329
+ if (!org) {
330
+ return res.status(404).json({ error: 'Organization not found' });
331
+ }
332
+
333
+ const normalizedEmail = String(email).toLowerCase().trim();
334
+
335
+ const existingPending = await Invite.findOne({
336
+ orgId: org._id,
337
+ email: normalizedEmail,
338
+ status: 'pending',
339
+ }).lean();
340
+ if (existingPending) {
341
+ return res.status(409).json({ error: 'Invite already pending for this email' });
342
+ }
343
+
344
+ const expiresDaysParsed = Math.max(
345
+ 1,
346
+ Math.min(365, parseInt(expiresInDays, 10) || DEFAULT_INVITE_EXPIRY_DAYS),
347
+ );
348
+
349
+ const { token, tokenHash } = Invite.generateToken();
350
+ const expiresAt = new Date(Date.now() + expiresDaysParsed * 24 * 60 * 60 * 1000);
351
+
352
+ const invite = await Invite.create({
353
+ email: normalizedEmail,
354
+ tokenHash,
355
+ expiresAt,
356
+ status: 'pending',
357
+ createdByUserId: org.ownerUserId,
358
+ orgId: org._id,
359
+ role: nextRole,
360
+ });
361
+
362
+ const inviteLink = buildInviteLink(token);
363
+
364
+ try {
365
+ await emailService.sendEmail({
366
+ to: normalizedEmail,
367
+ subject: `You're invited to join ${org.name}`,
368
+ html: `<p>You've been invited to join <strong>${org.name}</strong> as a ${nextRole}.</p>
369
+ <p><a href="${inviteLink}">Click here to accept the invitation</a></p>
370
+ <p>This invite expires in ${expiresDaysParsed} days.</p>
371
+ <p>If you didn't expect this invitation, you can ignore this email.</p>`,
372
+ type: 'invite',
373
+ });
374
+ } catch (emailError) {
375
+ console.error('Failed to send invite email (admin):', emailError);
376
+ }
377
+
378
+ return res.status(201).json({
379
+ message: 'Invite created successfully',
380
+ invite: {
381
+ _id: invite._id,
382
+ email: invite.email,
383
+ role: invite.role,
384
+ status: invite.status,
385
+ expiresAt: invite.expiresAt,
386
+ createdAt: invite.createdAt,
387
+ },
388
+ });
389
+ } catch (error) {
390
+ console.error('Admin org invite create error:', error);
391
+ return res.status(500).json({ error: 'Failed to create invite' });
392
+ }
393
+ };
394
+
395
+ exports.revokeInvite = async (req, res) => {
396
+ try {
397
+ const { orgId, inviteId } = req.params;
398
+
399
+ if (!orgId || !mongoose.Types.ObjectId.isValid(String(orgId))) {
400
+ return res.status(400).json({ error: 'Invalid organization ID' });
401
+ }
402
+ if (!inviteId || !mongoose.Types.ObjectId.isValid(String(inviteId))) {
403
+ return res.status(400).json({ error: 'Invalid invite ID' });
404
+ }
405
+
406
+ const invite = await Invite.findOne({ _id: inviteId, orgId, status: 'pending' });
407
+ if (!invite) {
408
+ return res.status(404).json({ error: 'Invite not found' });
409
+ }
410
+
411
+ invite.status = 'revoked';
412
+ await invite.save();
413
+
414
+ return res.json({ message: 'Invite revoked successfully' });
415
+ } catch (error) {
416
+ console.error('Admin org invite revoke error:', error);
417
+ return res.status(500).json({ error: 'Failed to revoke invite' });
418
+ }
419
+ };
420
+
421
+ exports.resendInvite = async (req, res) => {
422
+ try {
423
+ const { orgId, inviteId } = req.params;
424
+ const { expiresInDays } = req.body || {};
425
+
426
+ if (!orgId || !mongoose.Types.ObjectId.isValid(String(orgId))) {
427
+ return res.status(400).json({ error: 'Invalid organization ID' });
428
+ }
429
+ if (!inviteId || !mongoose.Types.ObjectId.isValid(String(inviteId))) {
430
+ return res.status(400).json({ error: 'Invalid invite ID' });
431
+ }
432
+
433
+ const org = await Organization.findById(orgId).lean();
434
+ if (!org) {
435
+ return res.status(404).json({ error: 'Organization not found' });
436
+ }
437
+
438
+ const invite = await Invite.findOne({ _id: inviteId, orgId, status: 'pending' });
439
+ if (!invite) {
440
+ return res.status(404).json({ error: 'Invite not found' });
441
+ }
442
+
443
+ if (invite.expiresAt < new Date()) {
444
+ invite.status = 'expired';
445
+ await invite.save();
446
+ return res.status(400).json({ error: 'Invite has expired' });
447
+ }
448
+
449
+ const expiresDaysParsed = Math.max(
450
+ 1,
451
+ Math.min(365, parseInt(expiresInDays, 10) || DEFAULT_INVITE_EXPIRY_DAYS),
452
+ );
453
+
454
+ const { token, tokenHash } = Invite.generateToken();
455
+ invite.tokenHash = tokenHash;
456
+ invite.expiresAt = new Date(Date.now() + expiresDaysParsed * 24 * 60 * 60 * 1000);
457
+ await invite.save();
458
+
459
+ const inviteLink = buildInviteLink(token);
460
+
461
+ try {
462
+ await emailService.sendEmail({
463
+ to: invite.email,
464
+ subject: `You're invited to join ${org.name}`,
465
+ html: `<p>You've been invited to join <strong>${org.name}</strong> as a ${invite.role}.</p>
466
+ <p><a href="${inviteLink}">Click here to accept the invitation</a></p>
467
+ <p>This invite expires in ${expiresDaysParsed} days.</p>
468
+ <p>If you didn't expect this invitation, you can ignore this email.</p>`,
469
+ type: 'invite',
470
+ });
471
+ } catch (emailError) {
472
+ console.error('Failed to resend invite email (admin):', emailError);
473
+ }
474
+
475
+ return res.json({
476
+ message: 'Invite resent successfully',
477
+ invite: {
478
+ _id: invite._id,
479
+ email: invite.email,
480
+ role: invite.role,
481
+ status: invite.status,
482
+ expiresAt: invite.expiresAt,
483
+ createdAt: invite.createdAt,
484
+ updatedAt: invite.updatedAt,
485
+ },
486
+ });
487
+ } catch (error) {
488
+ console.error('Admin org invite resend error:', error);
489
+ return res.status(500).json({ error: 'Failed to resend invite' });
490
+ }
491
+ };