@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,430 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Notifications Admin</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <style>
9
+ .toast { animation: slideIn 0.3s ease-out; }
10
+ @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
11
+ .fade-out { animation: fadeOut 0.3s ease-out forwards; }
12
+ @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
13
+ </style>
14
+ </head>
15
+ <body class="bg-gray-100">
16
+ <div class="min-h-screen">
17
+ <div class="bg-white shadow">
18
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
19
+ <div class="flex justify-between items-center">
20
+ <div>
21
+ <h1 class="text-2xl font-bold text-gray-900">Notifications</h1>
22
+ <p class="text-sm text-gray-600 mt-1">Send and manage system notifications</p>
23
+ </div>
24
+ <div class="flex items-center gap-4">
25
+ </div>
26
+ </div>
27
+ </div>
28
+ </div>
29
+
30
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
31
+ <div class="grid grid-cols-1 md:grid-cols-5 gap-4 mb-6">
32
+ <div class="bg-white rounded-lg shadow p-4">
33
+ <p class="text-sm text-gray-500">Total Sent</p>
34
+ <p id="stat-total" class="text-2xl font-bold text-gray-900">-</p>
35
+ </div>
36
+ <div class="bg-white rounded-lg shadow p-4">
37
+ <p class="text-sm text-gray-500">Unread</p>
38
+ <p id="stat-unread" class="text-2xl font-bold text-gray-900">-</p>
39
+ </div>
40
+ <div class="bg-white rounded-lg shadow p-4">
41
+ <p class="text-sm text-gray-500">Email Pending</p>
42
+ <p id="stat-pending" class="text-2xl font-bold text-yellow-600">-</p>
43
+ </div>
44
+ <div class="bg-white rounded-lg shadow p-4">
45
+ <p class="text-sm text-gray-500">Email Sent</p>
46
+ <p id="stat-sent" class="text-2xl font-bold text-green-600">-</p>
47
+ </div>
48
+ <div class="bg-white rounded-lg shadow p-4">
49
+ <p class="text-sm text-gray-500">Email Failed</p>
50
+ <p id="stat-failed" class="text-2xl font-bold text-red-600">-</p>
51
+ </div>
52
+ </div>
53
+
54
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
55
+ <div class="lg:col-span-1">
56
+ <div class="bg-white rounded-lg shadow p-6">
57
+ <h2 class="text-lg font-semibold text-gray-900 mb-4">Send Notification</h2>
58
+
59
+ <div class="space-y-4">
60
+ <div>
61
+ <label class="block text-sm font-medium text-gray-700 mb-1">Recipient</label>
62
+ <select id="send-recipient-type" class="w-full border rounded px-3 py-2">
63
+ <option value="user">Specific User</option>
64
+ <option value="broadcast">All Users (Broadcast)</option>
65
+ </select>
66
+ </div>
67
+
68
+ <div id="send-user-container">
69
+ <label class="block text-sm font-medium text-gray-700 mb-1">User Email</label>
70
+ <input id="send-user-email" type="email" class="w-full border rounded px-3 py-2" placeholder="user@example.com">
71
+ <p class="text-xs text-gray-500 mt-1">Enter exact email address</p>
72
+ </div>
73
+
74
+ <div>
75
+ <label class="block text-sm font-medium text-gray-700 mb-1">Type</label>
76
+ <select id="send-type" class="w-full border rounded px-3 py-2">
77
+ <option value="info">Info</option>
78
+ <option value="success">Success</option>
79
+ <option value="warning">Warning</option>
80
+ <option value="error">Error</option>
81
+ </select>
82
+ </div>
83
+
84
+ <div>
85
+ <label class="block text-sm font-medium text-gray-700 mb-1">Channel</label>
86
+ <select id="send-channel" class="w-full border rounded px-3 py-2">
87
+ <option value="in_app">In-App Only</option>
88
+ <option value="email">Email Only</option>
89
+ <option value="both">Both (In-App + Email)</option>
90
+ </select>
91
+ </div>
92
+
93
+ <div>
94
+ <label class="block text-sm font-medium text-gray-700 mb-1">Title *</label>
95
+ <input id="send-title" type="text" class="w-full border rounded px-3 py-2" placeholder="Notification title">
96
+ </div>
97
+
98
+ <div>
99
+ <label class="block text-sm font-medium text-gray-700 mb-1">Message *</label>
100
+ <textarea id="send-message" class="w-full border rounded px-3 py-2" rows="4" placeholder="Notification message..."></textarea>
101
+ </div>
102
+
103
+ <div class="flex gap-2">
104
+ <button id="btn-send" class="flex-1 bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600">Send</button>
105
+ <button id="btn-clear" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200">Clear</button>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </div>
110
+
111
+ <div class="lg:col-span-2">
112
+ <div class="bg-white rounded-lg shadow p-6">
113
+ <div class="flex items-center justify-between mb-4">
114
+ <div>
115
+ <h2 class="text-lg font-semibold text-gray-900">Notification History</h2>
116
+ <p id="history-subtitle" class="text-sm text-gray-600">-</p>
117
+ </div>
118
+ <button id="btn-refresh" class="bg-gray-100 text-gray-800 px-3 py-2 rounded hover:bg-gray-200">Refresh</button>
119
+ </div>
120
+
121
+ <div class="grid grid-cols-1 md:grid-cols-4 gap-3 mb-4">
122
+ <div>
123
+ <label class="block text-sm font-medium text-gray-700 mb-1">Type</label>
124
+ <select id="filter-type" class="w-full border rounded px-3 py-2">
125
+ <option value="">All</option>
126
+ <option value="info">info</option>
127
+ <option value="success">success</option>
128
+ <option value="warning">warning</option>
129
+ <option value="error">error</option>
130
+ </select>
131
+ </div>
132
+ <div>
133
+ <label class="block text-sm font-medium text-gray-700 mb-1">Channel</label>
134
+ <select id="filter-channel" class="w-full border rounded px-3 py-2">
135
+ <option value="">All</option>
136
+ <option value="in_app">in_app</option>
137
+ <option value="email">email</option>
138
+ <option value="both">both</option>
139
+ </select>
140
+ </div>
141
+ <div>
142
+ <label class="block text-sm font-medium text-gray-700 mb-1">Email Status</label>
143
+ <select id="filter-email-status" class="w-full border rounded px-3 py-2">
144
+ <option value="">All</option>
145
+ <option value="pending">pending</option>
146
+ <option value="sent">sent</option>
147
+ <option value="failed">failed</option>
148
+ <option value="skipped">skipped</option>
149
+ </select>
150
+ </div>
151
+ <div>
152
+ <label class="block text-sm font-medium text-gray-700 mb-1">Limit</label>
153
+ <input id="filter-limit" type="number" min="1" max="500" class="w-full border rounded px-3 py-2" value="50">
154
+ </div>
155
+ </div>
156
+
157
+ <div class="flex gap-2 mb-4">
158
+ <button id="btn-apply" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Apply</button>
159
+ <button id="btn-reset" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200">Reset</button>
160
+ <div class="flex-1"></div>
161
+ <button id="btn-prev" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200" disabled>Prev</button>
162
+ <button id="btn-next" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200" disabled>Next</button>
163
+ </div>
164
+
165
+ <div class="overflow-x-auto">
166
+ <table class="min-w-full divide-y divide-gray-200">
167
+ <thead class="bg-gray-50">
168
+ <tr>
169
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
170
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Recipient</th>
171
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
172
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
173
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Channel</th>
174
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
175
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
176
+ </tr>
177
+ </thead>
178
+ <tbody id="history-tbody" class="bg-white divide-y divide-gray-200"></tbody>
179
+ </table>
180
+ </div>
181
+ </div>
182
+ </div>
183
+ </div>
184
+ </div>
185
+ </div>
186
+
187
+ <div id="toast-container" class="fixed top-4 right-4 space-y-2 z-50"></div>
188
+
189
+ <script>
190
+ const API_BASE = window.location.origin + "<%= baseUrl %>" || window.location.origin;
191
+
192
+ function showToast(message, type = 'success') {
193
+ const container = document.getElementById('toast-container');
194
+ const toast = document.createElement('div');
195
+ toast.className = `toast px-6 py-4 rounded-lg shadow-lg text-white ${type === 'success' ? 'bg-green-500' : 'bg-red-500'}`;
196
+ toast.textContent = message;
197
+ container.appendChild(toast);
198
+ setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 300); }, 3000);
199
+ }
200
+
201
+ function escapeHtml(str) {
202
+ return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
203
+ }
204
+
205
+ function formatDateTime(iso) {
206
+ try { return new Date(iso).toLocaleString(); } catch { return String(iso || ''); }
207
+ }
208
+
209
+ function qs(obj) {
210
+ const params = new URLSearchParams();
211
+ Object.entries(obj).forEach(([k, v]) => { if (v !== undefined && v !== null && String(v).trim()) params.set(k, String(v).trim()); });
212
+ const out = params.toString();
213
+ return out ? `?${out}` : '';
214
+ }
215
+
216
+ const state = { offset: 0, limit: 50, total: 0 };
217
+
218
+ async function loadStats() {
219
+ try {
220
+ const res = await fetch(`${API_BASE}/api/admin/notifications/stats`);
221
+ const data = await res.json();
222
+ if (res.ok) {
223
+ document.getElementById('stat-total').textContent = data.total ?? '-';
224
+ document.getElementById('stat-unread').textContent = data.unread ?? '-';
225
+ document.getElementById('stat-pending').textContent = data.emailPending ?? '-';
226
+ document.getElementById('stat-sent').textContent = data.emailSent ?? '-';
227
+ document.getElementById('stat-failed').textContent = data.emailFailed ?? '-';
228
+ }
229
+ } catch (e) { console.error('Failed to load stats:', e); }
230
+ }
231
+
232
+ async function loadHistory() {
233
+ const type = document.getElementById('filter-type')?.value;
234
+ const channel = document.getElementById('filter-channel')?.value;
235
+ const emailStatus = document.getElementById('filter-email-status')?.value;
236
+ const limit = parseInt(document.getElementById('filter-limit')?.value || '50', 10);
237
+
238
+ state.limit = Math.min(500, Math.max(1, limit));
239
+
240
+ const subtitle = document.getElementById('history-subtitle');
241
+ const tbody = document.getElementById('history-tbody');
242
+ if (subtitle) subtitle.textContent = 'Loading...';
243
+ if (tbody) tbody.innerHTML = '';
244
+
245
+ try {
246
+ const url = `${API_BASE}/api/admin/notifications${qs({ type, channel, emailStatus, limit: state.limit, offset: state.offset })}`;
247
+ const res = await fetch(url);
248
+ const data = await res.json();
249
+
250
+ if (!res.ok) { showToast(data?.error || 'Failed to load notifications', 'error'); return; }
251
+
252
+ const notifications = Array.isArray(data?.notifications) ? data.notifications : [];
253
+ const pagination = data?.pagination || {};
254
+ state.total = pagination.total ?? 0;
255
+ state.limit = pagination.limit ?? state.limit;
256
+ state.offset = pagination.offset ?? state.offset;
257
+
258
+ if (subtitle) {
259
+ const from = state.total === 0 ? 0 : state.offset + 1;
260
+ const to = Math.min(state.offset + notifications.length, state.total);
261
+ subtitle.textContent = `${from}-${to} of ${state.total}`;
262
+ }
263
+
264
+ document.getElementById('btn-prev').disabled = state.offset <= 0;
265
+ document.getElementById('btn-next').disabled = state.offset + state.limit >= state.total;
266
+
267
+ if (tbody) {
268
+ if (notifications.length === 0) {
269
+ tbody.innerHTML = '<tr><td class="px-4 py-6 text-sm text-gray-600" colspan="7">No notifications found.</td></tr>';
270
+ return;
271
+ }
272
+
273
+ const typeColors = { info: 'bg-blue-100 text-blue-800', success: 'bg-green-100 text-green-800', warning: 'bg-yellow-100 text-yellow-800', error: 'bg-red-100 text-red-800' };
274
+ const emailColors = { pending: 'text-yellow-600', sent: 'text-green-600', failed: 'text-red-600', skipped: 'text-gray-400' };
275
+
276
+ tbody.innerHTML = notifications.map(n => {
277
+ const userEmail = n.userId?.email || 'Unknown';
278
+ const typeClass = typeColors[n.type] || 'bg-gray-100 text-gray-800';
279
+ const emailClass = emailColors[n.emailStatus] || 'text-gray-500';
280
+ const canRetry = (n.channel === 'email' || n.channel === 'both') && n.emailStatus === 'failed';
281
+
282
+ return `
283
+ <tr>
284
+ <td class="px-4 py-3 text-sm text-gray-700">${formatDateTime(n.createdAt)}</td>
285
+ <td class="px-4 py-3 text-sm text-gray-900">
286
+ <div class="font-medium">${escapeHtml(userEmail)}</div>
287
+ ${n.broadcastId ? '<span class="text-xs text-gray-400">broadcast</span>' : ''}
288
+ </td>
289
+ <td class="px-4 py-3 text-sm text-gray-900">
290
+ <div class="font-medium">${escapeHtml(n.title)}</div>
291
+ <div class="text-xs text-gray-500 truncate max-w-xs">${escapeHtml(n.message)}</div>
292
+ </td>
293
+ <td class="px-4 py-3"><span class="px-2 py-1 text-xs rounded ${typeClass}">${escapeHtml(n.type)}</span></td>
294
+ <td class="px-4 py-3 text-sm text-gray-700">${escapeHtml(n.channel)}</td>
295
+ <td class="px-4 py-3 text-sm ${emailClass}">${escapeHtml(n.emailStatus)}</td>
296
+ <td class="px-4 py-3 text-sm whitespace-nowrap">
297
+ ${canRetry ? `<button class="text-blue-600 hover:text-blue-800 mr-2" data-retry="${escapeHtml(n._id)}">Retry</button>` : ''}
298
+ <button class="text-red-600 hover:text-red-800" data-delete="${escapeHtml(n._id)}">Delete</button>
299
+ </td>
300
+ </tr>
301
+ `;
302
+ }).join('');
303
+
304
+ tbody.querySelectorAll('[data-retry]').forEach(btn => {
305
+ btn.addEventListener('click', async () => {
306
+ try {
307
+ const res = await fetch(`${API_BASE}/api/admin/notifications/${encodeURIComponent(btn.dataset.retry)}/retry-email`, { method: 'POST' });
308
+ const data = await res.json();
309
+ if (!res.ok) { showToast(data?.error || 'Retry failed', 'error'); return; }
310
+ showToast('Retry attempted', 'success');
311
+ await Promise.all([loadHistory(), loadStats()]);
312
+ } catch (e) { showToast(e.message, 'error'); }
313
+ });
314
+ });
315
+
316
+ tbody.querySelectorAll('[data-delete]').forEach(btn => {
317
+ btn.addEventListener('click', async () => {
318
+ if (!confirm('Delete this notification?')) return;
319
+ try {
320
+ const res = await fetch(`${API_BASE}/api/admin/notifications/${encodeURIComponent(btn.dataset.delete)}`, { method: 'DELETE' });
321
+ const data = await res.json();
322
+ if (!res.ok) { showToast(data?.error || 'Delete failed', 'error'); return; }
323
+ showToast('Notification deleted', 'success');
324
+ await Promise.all([loadHistory(), loadStats()]);
325
+ } catch (e) { showToast(e.message, 'error'); }
326
+ });
327
+ });
328
+ }
329
+ } catch (e) {
330
+ showToast(e.message || 'Failed to load notifications', 'error');
331
+ }
332
+ }
333
+
334
+ async function sendNotification() {
335
+ const recipientType = document.getElementById('send-recipient-type').value;
336
+ const userEmail = document.getElementById('send-user-email').value.trim();
337
+ const type = document.getElementById('send-type').value;
338
+ const channel = document.getElementById('send-channel').value;
339
+ const title = document.getElementById('send-title').value.trim();
340
+ const message = document.getElementById('send-message').value.trim();
341
+
342
+ if (!title || !message) { showToast('Title and message are required', 'error'); return; }
343
+
344
+ if (recipientType === 'broadcast') {
345
+ if (!confirm('Send notification to ALL users?')) return;
346
+ try {
347
+ const res = await fetch(`${API_BASE}/api/admin/notifications/broadcast`, {
348
+ method: 'POST',
349
+ headers: { 'Content-Type': 'application/json' },
350
+ body: JSON.stringify({ type, channel, title, message }),
351
+ });
352
+ const data = await res.json();
353
+ if (!res.ok) { showToast(data?.error || 'Broadcast failed', 'error'); return; }
354
+ showToast(`Broadcast sent to ${data.recipientCount} users`, 'success');
355
+ clearForm();
356
+ await Promise.all([loadHistory(), loadStats()]);
357
+ } catch (e) { showToast(e.message, 'error'); }
358
+ return;
359
+ }
360
+
361
+ if (!userEmail) { showToast('User email is required', 'error'); return; }
362
+
363
+ try {
364
+ const userRes = await fetch(`${API_BASE}/api/admin/users?q=${encodeURIComponent(userEmail)}&limit=1`);
365
+ const userData = await userRes.json();
366
+ if (!userRes.ok || !userData.users?.length) { showToast('User not found', 'error'); return; }
367
+
368
+ const userId = userData.users[0]._id;
369
+
370
+ const res = await fetch(`${API_BASE}/api/admin/notifications/send`, {
371
+ method: 'POST',
372
+ headers: { 'Content-Type': 'application/json' },
373
+ body: JSON.stringify({ userIds: [userId], type, channel, title, message }),
374
+ });
375
+ const data = await res.json();
376
+ if (!res.ok) { showToast(data?.error || 'Send failed', 'error'); return; }
377
+ showToast('Notification sent', 'success');
378
+ clearForm();
379
+ await Promise.all([loadHistory(), loadStats()]);
380
+ } catch (e) { showToast(e.message, 'error'); }
381
+ }
382
+
383
+ function clearForm() {
384
+ document.getElementById('send-recipient-type').value = 'user';
385
+ document.getElementById('send-user-email').value = '';
386
+ document.getElementById('send-type').value = 'info';
387
+ document.getElementById('send-channel').value = 'in_app';
388
+ document.getElementById('send-title').value = '';
389
+ document.getElementById('send-message').value = '';
390
+ toggleUserContainer();
391
+ }
392
+
393
+ function toggleUserContainer() {
394
+ const container = document.getElementById('send-user-container');
395
+ const recipientType = document.getElementById('send-recipient-type').value;
396
+ container.style.display = recipientType === 'broadcast' ? 'none' : 'block';
397
+ }
398
+
399
+ function bindEvents() {
400
+ document.getElementById('send-recipient-type').addEventListener('change', toggleUserContainer);
401
+ document.getElementById('btn-send').onclick = sendNotification;
402
+ document.getElementById('btn-clear').onclick = clearForm;
403
+ document.getElementById('btn-refresh').onclick = () => Promise.all([loadHistory(), loadStats()]);
404
+ document.getElementById('btn-apply').onclick = () => { state.offset = 0; loadHistory(); };
405
+ document.getElementById('btn-reset').onclick = () => {
406
+ document.getElementById('filter-type').value = '';
407
+ document.getElementById('filter-channel').value = '';
408
+ document.getElementById('filter-email-status').value = '';
409
+ document.getElementById('filter-limit').value = '50';
410
+ state.offset = 0;
411
+ loadHistory();
412
+ };
413
+ document.getElementById('btn-prev').onclick = () => { state.offset = Math.max(0, state.offset - state.limit); loadHistory(); };
414
+ document.getElementById('btn-next').onclick = () => { state.offset += state.limit; loadHistory(); };
415
+ toggleUserContainer();
416
+ }
417
+
418
+ bindEvents();
419
+ Promise.all([loadHistory(), loadStats()]);
420
+ </script>
421
+ <script>
422
+ window.addEventListener("keydown", (e) => {
423
+ if ((e.ctrlKey || e.metaKey) && e.key === "k") {
424
+ e.preventDefault();
425
+ window.parent.postMessage({ type: "keydown", ctrlK: true }, "*");
426
+ }
427
+ });
428
+ </script>
429
+ </body>
430
+ </html>