@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,814 @@
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>Migration</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ </head>
9
+ <body class="bg-gray-100">
10
+ <div class="flex h-screen">
11
+ <%- include('partials/admin-test-sidebar') %>
12
+
13
+ <div class="flex-1 p-8 overflow-y-auto">
14
+ <div class="max-w-5xl">
15
+ <div class="bg-white rounded-lg shadow p-6">
16
+ <div class="flex items-center justify-between">
17
+ <h1 class="text-2xl font-bold">Migration</h1>
18
+ </div>
19
+
20
+ <div class="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
21
+ <div class="border rounded-lg p-4">
22
+ <h2 class="font-semibold mb-3">Environments</h2>
23
+
24
+ <div class="grid grid-cols-1 gap-3">
25
+ <div class="flex items-center justify-between">
26
+ <label class="text-sm font-medium">Key (suffix or full ENV_CONF_*)</label>
27
+ <div class="flex items-center gap-2">
28
+ <select id="envSelect" class="border rounded px-2 py-1 text-xs">
29
+ <option value="">(new)</option>
30
+ </select>
31
+ <button id="btnNewEnv" class="text-xs text-blue-600 hover:underline" type="button">New</button>
32
+ </div>
33
+ </div>
34
+ <input id="envKey" class="border rounded px-3 py-2" placeholder="staging" />
35
+
36
+ <label class="text-sm font-medium">Name</label>
37
+ <input id="envName" class="border rounded px-3 py-2" placeholder="Staging" />
38
+
39
+ <label class="text-sm font-medium">Connection string</label>
40
+ <input id="envConn" class="border rounded px-3 py-2" placeholder="mongodb+srv://..." />
41
+
42
+ <label class="text-sm font-medium">Description</label>
43
+ <input id="envDesc" class="border rounded px-3 py-2" placeholder="Optional" />
44
+
45
+ <div class="border rounded p-3 bg-gray-50">
46
+ <div class="text-sm font-semibold flex items-center justify-between">
47
+ <span>Assets configuration (source env)</span>
48
+ <span class="text-[11px] text-gray-500">Defaults to fs_local → uploads/</span>
49
+ </div>
50
+ <div class="text-[11px] text-gray-600 mt-1">
51
+ Configure how assets are stored in this environment (source). This is persisted with the env.
52
+ </div>
53
+ <div class="mt-2 grid grid-cols-1 gap-2">
54
+ <label class="text-xs font-medium">Type</label>
55
+ <select id="assetType" class="border rounded px-2 py-1 text-sm">
56
+ <option value="">fs_local (default)</option>
57
+ <option value="fs_local">fs_local</option>
58
+ <option value="fs_remote">fs_remote (SSH/SFTP)</option>
59
+ <option value="s3">s3</option>
60
+ </select>
61
+
62
+ <div id="assetFsLocal" class="hidden">
63
+ <label class="text-xs font-medium">Base dir</label>
64
+ <input id="assetFsBaseDir" class="border rounded px-2 py-1 text-sm w-full" placeholder="uploads" />
65
+ </div>
66
+
67
+ <div id="assetFsRemote" class="hidden">
68
+ <label class="text-xs font-medium">Host</label>
69
+ <input id="assetSshHost" class="border rounded px-2 py-1 text-sm w-full" placeholder="example.com" />
70
+
71
+ <label class="text-xs font-medium mt-2">Port</label>
72
+ <input id="assetSshPort" class="border rounded px-2 py-1 text-sm w-full" placeholder="22" />
73
+
74
+ <label class="text-xs font-medium mt-2">Username</label>
75
+ <input id="assetSshUser" class="border rounded px-2 py-1 text-sm w-full" placeholder="ubuntu" />
76
+
77
+ <label class="text-xs font-medium mt-2">Remote base dir</label>
78
+ <input id="assetSshBaseDir" class="border rounded px-2 py-1 text-sm w-full" placeholder="/var/app/uploads" />
79
+
80
+ <label class="text-xs font-medium mt-2">Private key (PEM)</label>
81
+ <textarea id="assetSshKey" class="border rounded px-2 py-1 text-xs font-mono w-full" rows="5" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
82
+
83
+ <label class="text-xs font-medium mt-2">Passphrase (optional)</label>
84
+ <input id="assetSshPass" class="border rounded px-2 py-1 text-sm w-full" placeholder="" />
85
+ </div>
86
+
87
+ <div id="assetS3" class="hidden">
88
+ <label class="text-xs font-medium">Endpoint</label>
89
+ <input id="assetS3Endpoint" class="border rounded px-2 py-1 text-sm w-full" placeholder="https://s3..." />
90
+
91
+ <label class="text-xs font-medium mt-2">Region</label>
92
+ <input id="assetS3Region" class="border rounded px-2 py-1 text-sm w-full" placeholder="us-east-1" />
93
+
94
+ <label class="text-xs font-medium mt-2">Bucket</label>
95
+ <input id="assetS3Bucket" class="border rounded px-2 py-1 text-sm w-full" placeholder="my-bucket" />
96
+
97
+ <label class="text-xs font-medium mt-2">Access key id</label>
98
+ <input id="assetS3Access" class="border rounded px-2 py-1 text-sm w-full" placeholder="" />
99
+
100
+ <label class="text-xs font-medium mt-2">Secret access key</label>
101
+ <input id="assetS3Secret" class="border rounded px-2 py-1 text-sm w-full" placeholder="" />
102
+
103
+ <label class="inline-flex items-center gap-2 text-xs mt-2">
104
+ <input id="assetS3ForcePath" type="checkbox" />
105
+ forcePathStyle
106
+ </label>
107
+ </div>
108
+ </div>
109
+ </div>
110
+
111
+ <div class="flex gap-2 pt-2">
112
+ <button id="btnSaveEnv" class="bg-blue-600 text-white px-3 py-2 rounded">Save</button>
113
+ <button id="btnTestConn" class="bg-gray-700 text-white px-3 py-2 rounded">Test connection</button>
114
+ </div>
115
+
116
+ <div class="flex flex-col gap-2 border rounded p-3 bg-gray-50">
117
+ <div class="font-semibold text-sm">Assets migration tests</div>
118
+ <div class="text-[11px] text-gray-600">
119
+ These tests appear when both source (this env) and selected target have asset config set.
120
+ </div>
121
+ <div class="flex gap-2">
122
+ <button id="btnTestAssets" class="bg-gray-900 text-white px-3 py-2 rounded disabled:opacity-40" disabled>Test source asset exists</button>
123
+ <input id="assetTestKey" class="border rounded px-3 py-2 flex-1" placeholder="asset key (e.g. assets/...jpg)" />
124
+ <button id="btnTestAssetsCopy" class="bg-gray-900 text-white px-3 py-2 rounded disabled:opacity-40" disabled>Test copy to target</button>
125
+ </div>
126
+ <div id="assetTestsHint" class="text-[11px] text-gray-600"></div>
127
+ </div>
128
+
129
+ <div id="envMsg" class="text-sm text-gray-600"></div>
130
+
131
+ <div class="pt-4">
132
+ <button id="btnRefreshEnvs" class="text-sm text-blue-600 hover:underline">Refresh environments list</button>
133
+ <pre id="envList" class="mt-2 bg-gray-100 p-3 rounded text-xs overflow-auto max-h-64"></pre>
134
+ </div>
135
+ </div>
136
+ </div>
137
+
138
+ <div class="border rounded-lg p-4">
139
+ <h2 class="font-semibold mb-3">Run generic migration</h2>
140
+
141
+ <div class="grid grid-cols-1 gap-3">
142
+ <label class="text-sm font-medium">Target environment</label>
143
+ <select id="runEnvKey" class="border rounded px-3 py-2">
144
+ <option value="" selected>Select environment...</option>
145
+ </select>
146
+
147
+ <label class="text-sm font-medium">Model</label>
148
+ <select id="runModel" class="border rounded px-3 py-2">
149
+ <option value="" selected>Select model...</option>
150
+ </select>
151
+
152
+ <div class="border rounded p-3 bg-gray-50">
153
+ <div class="flex items-center justify-between">
154
+ <div class="text-sm font-semibold">Query builder</div>
155
+ <button id="btnAddFilter" class="text-xs text-blue-600 hover:underline" type="button">Add filter</button>
156
+ </div>
157
+ <div id="qbRows" class="mt-2 space-y-2"></div>
158
+ </div>
159
+
160
+ <label class="text-sm font-medium">Query JSON</label>
161
+ <textarea id="runQuery" class="border rounded px-3 py-2 font-mono text-sm" rows="6" placeholder='{}'></textarea>
162
+
163
+ <div class="border rounded p-3 bg-gray-50">
164
+ <div class="flex items-center justify-between">
165
+ <div class="text-sm font-semibold">Preview</div>
166
+ <button id="btnPreview" class="text-xs text-blue-600 hover:underline" type="button">Preview records</button>
167
+ </div>
168
+ <div class="mt-2 grid grid-cols-1 gap-2">
169
+ <input id="previewSearch" class="border rounded px-2 py-1 text-sm" placeholder="Search (string fields)" />
170
+ <div class="flex items-center gap-2">
171
+ <button id="previewPrev" class="text-xs px-2 py-1 border rounded">Prev</button>
172
+ <div id="previewMeta" class="text-xs text-gray-600">-</div>
173
+ <button id="previewNext" class="text-xs px-2 py-1 border rounded">Next</button>
174
+ </div>
175
+ <div class="overflow-auto max-h-48 border rounded bg-white">
176
+ <table class="min-w-full text-xs" id="previewTable"></table>
177
+ </div>
178
+ </div>
179
+ </div>
180
+
181
+ <label class="inline-flex items-center gap-2 text-sm">
182
+ <input id="runDry" type="checkbox" />
183
+ Dry run
184
+ </label>
185
+
186
+ <button id="btnRun" class="bg-green-600 text-white px-3 py-2 rounded">Run</button>
187
+
188
+ <pre id="runOut" class="bg-gray-100 p-3 rounded text-xs overflow-auto max-h-64"></pre>
189
+ </div>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ </div>
194
+ </div>
195
+ </div>
196
+
197
+ <script>
198
+ const API_BASE = window.location.origin + "<%= baseUrl %>" || window.location.origin;
199
+
200
+ const state = {
201
+ environments: [],
202
+ models: [],
203
+ schema: null,
204
+ preview: { page: 1, pages: 1, limit: 10, total: 0, items: [] },
205
+ targetAssetsConfigured: false,
206
+ sourceAssetsConfigured: false,
207
+ };
208
+
209
+ function updateAssetTestsState() {
210
+ const btnTestAssets = document.getElementById('btnTestAssets');
211
+ const btnTestAssetsCopy = document.getElementById('btnTestAssetsCopy');
212
+ const hint = document.getElementById('assetTestsHint');
213
+ if (!btnTestAssets || !btnTestAssetsCopy || !hint) return;
214
+
215
+ const targetKey = document.getElementById('runEnvKey')?.value || '';
216
+ const targetEnv = state.environments.find((e) => e.key === targetKey);
217
+ const targetConfigured = !!(targetEnv?.assetsTargetType || targetEnv?.assetsTarget || targetEnv?.assets?.target);
218
+
219
+ const sourceConfigured = !!state.sourceAssetsConfigured;
220
+
221
+ const enabled = sourceConfigured && targetConfigured;
222
+ btnTestAssets.disabled = !enabled;
223
+ btnTestAssetsCopy.disabled = !enabled;
224
+ btnTestAssets.classList.toggle('opacity-40', !enabled);
225
+ btnTestAssetsCopy.classList.toggle('opacity-40', !enabled);
226
+
227
+ if (!sourceConfigured && !targetConfigured) {
228
+ hint.textContent = 'Set assets configuration for this source env and select a target env with assets configured.';
229
+ } else if (!sourceConfigured) {
230
+ hint.textContent = 'Set assets configuration for this source env.';
231
+ } else if (!targetConfigured) {
232
+ hint.textContent = 'Select a target env that has assets configuration set.';
233
+ } else {
234
+ hint.textContent = 'Ready: tests will read from source and copy to target.';
235
+ }
236
+ }
237
+
238
+ async function jsonFetch(path, options) {
239
+ const res = await fetch(API_BASE + path, {
240
+ credentials: 'include',
241
+ headers: { 'Content-Type': 'application/json', ...(options && options.headers ? options.headers : {}) },
242
+ ...options,
243
+ });
244
+ const data = await res.json().catch(() => ({}));
245
+ if (!res.ok) {
246
+ throw new Error(data.error || 'Request failed');
247
+ }
248
+ return data;
249
+ }
250
+
251
+ function setMsg(el, msg, isError) {
252
+ el.textContent = msg;
253
+ el.className = isError ? 'text-sm text-red-600' : 'text-sm text-green-700';
254
+ }
255
+
256
+ function toggleAssetSections() {
257
+ const type = document.getElementById('assetType').value;
258
+ const fsLocal = document.getElementById('assetFsLocal');
259
+ const fsRemote = document.getElementById('assetFsRemote');
260
+ const s3 = document.getElementById('assetS3');
261
+ fsLocal.classList.add('hidden');
262
+ fsRemote.classList.add('hidden');
263
+ s3.classList.add('hidden');
264
+ if (type === 'fs_local') fsLocal.classList.remove('hidden');
265
+ if (type === 'fs_remote') fsRemote.classList.remove('hidden');
266
+ if (type === 's3') s3.classList.remove('hidden');
267
+ }
268
+
269
+ function buildAssetsTargetPayload() {
270
+ const type = document.getElementById('assetType').value;
271
+ if (!type) return undefined;
272
+
273
+ if (type === 'fs_local') {
274
+ return {
275
+ type,
276
+ fs: {
277
+ baseDir: document.getElementById('assetFsBaseDir').value,
278
+ }
279
+ };
280
+ }
281
+
282
+ if (type === 'fs_remote') {
283
+ return {
284
+ type,
285
+ ssh: {
286
+ host: document.getElementById('assetSshHost').value,
287
+ port: document.getElementById('assetSshPort').value,
288
+ username: document.getElementById('assetSshUser').value,
289
+ baseDir: document.getElementById('assetSshBaseDir').value,
290
+ privateKeyPem: document.getElementById('assetSshKey').value,
291
+ passphrase: document.getElementById('assetSshPass').value,
292
+ }
293
+ };
294
+ }
295
+
296
+ if (type === 's3') {
297
+ return {
298
+ type,
299
+ s3: {
300
+ endpoint: document.getElementById('assetS3Endpoint').value,
301
+ region: document.getElementById('assetS3Region').value,
302
+ bucket: document.getElementById('assetS3Bucket').value,
303
+ accessKeyId: document.getElementById('assetS3Access').value,
304
+ secretAccessKey: document.getElementById('assetS3Secret').value,
305
+ forcePathStyle: document.getElementById('assetS3ForcePath').checked,
306
+ }
307
+ };
308
+ }
309
+
310
+ return undefined;
311
+ }
312
+
313
+ function normalizeAssetsTarget(target) {
314
+ if (!target || typeof target !== 'object') {
315
+ return {
316
+ type: 'fs_local',
317
+ fs: { baseDir: document.getElementById('assetFsBaseDir').value || (window.UPLOAD_DIR || 'uploads') },
318
+ };
319
+ }
320
+ const type = String(target.type || '').trim() || 'fs_local';
321
+ if (type === 'fs_local') {
322
+ return { type, fs: { baseDir: target?.fs?.baseDir || document.getElementById('assetFsBaseDir').value || 'uploads' } };
323
+ }
324
+ if (type === 'fs_remote') {
325
+ return {
326
+ type,
327
+ ssh: {
328
+ host: target?.ssh?.host || document.getElementById('assetSshHost').value,
329
+ port: target?.ssh?.port || document.getElementById('assetSshPort').value,
330
+ username: target?.ssh?.username || document.getElementById('assetSshUser').value,
331
+ baseDir: target?.ssh?.baseDir || document.getElementById('assetSshBaseDir').value,
332
+ privateKeyPem: target?.ssh?.privateKeyPem || document.getElementById('assetSshKey').value,
333
+ passphrase: target?.ssh?.passphrase || document.getElementById('assetSshPass').value,
334
+ },
335
+ };
336
+ }
337
+ if (type === 's3') {
338
+ return {
339
+ type,
340
+ s3: {
341
+ endpoint: target?.s3?.endpoint || document.getElementById('assetS3Endpoint').value,
342
+ region: target?.s3?.region || document.getElementById('assetS3Region').value || 'us-east-1',
343
+ bucket: target?.s3?.bucket || document.getElementById('assetS3Bucket').value,
344
+ accessKeyId: target?.s3?.accessKeyId || document.getElementById('assetS3Access').value,
345
+ secretAccessKey: target?.s3?.secretAccessKey || document.getElementById('assetS3Secret').value,
346
+ forcePathStyle: target?.s3?.forcePathStyle ?? document.getElementById('assetS3ForcePath').checked,
347
+ },
348
+ };
349
+ }
350
+ return target;
351
+ }
352
+
353
+ function loadEnvIntoForm(env) {
354
+ document.getElementById('envKey').value = env?.key?.replace(/^ENV_CONF_/, '') || '';
355
+ document.getElementById('envName').value = env?.name || '';
356
+ document.getElementById('envConn').value = env?.connectionString || '';
357
+ document.getElementById('envDesc').value = env?.description || '';
358
+
359
+ const at = env?.assetsTarget || env?.assets?.target || {};
360
+ const type = at?.type || '';
361
+ document.getElementById('assetType').value = type || '';
362
+ document.getElementById('assetFsBaseDir').value = at?.fs?.baseDir || '';
363
+ document.getElementById('assetSshHost').value = at?.ssh?.host || '';
364
+ document.getElementById('assetSshPort').value = at?.ssh?.port || '';
365
+ document.getElementById('assetSshUser').value = at?.ssh?.username || '';
366
+ document.getElementById('assetSshBaseDir').value = at?.ssh?.baseDir || '';
367
+ document.getElementById('assetSshKey').value = at?.ssh?.privateKeyPem || '';
368
+ document.getElementById('assetSshPass').value = at?.ssh?.passphrase || '';
369
+ document.getElementById('assetS3Endpoint').value = at?.s3?.endpoint || '';
370
+ document.getElementById('assetS3Region').value = at?.s3?.region || '';
371
+ document.getElementById('assetS3Bucket').value = at?.s3?.bucket || '';
372
+ document.getElementById('assetS3Access').value = at?.s3?.accessKeyId || '';
373
+ document.getElementById('assetS3Secret').value = at?.s3?.secretAccessKey || '';
374
+ document.getElementById('assetS3ForcePath').checked = !!at?.s3?.forcePathStyle;
375
+ toggleAssetSections();
376
+ state.sourceAssetsConfigured = !!type;
377
+ updateAssetTestsState();
378
+ }
379
+
380
+ function resetEnvForm() {
381
+ loadEnvIntoForm({});
382
+ document.getElementById('envSelect').value = '';
383
+ document.getElementById('envMsg').textContent = '';
384
+ state.sourceAssetsConfigured = false;
385
+ updateAssetTestsState();
386
+ }
387
+
388
+ async function loadEnvDetails(key) {
389
+ if (!key) {
390
+ resetEnvForm();
391
+ return;
392
+ }
393
+ try {
394
+ // Prefer GET /environments/:key, fallback to list with include=full if needed
395
+ const res = await fetch(`${API_BASE}/api/admin/migration/environments/${encodeURIComponent(key)}`, { credentials: 'include' });
396
+ if (res.ok) {
397
+ const data = await res.json().catch(() => ({}));
398
+ if (data?.environment) loadEnvIntoForm(data.environment);
399
+ return;
400
+ }
401
+ // fallback to list?envKey=...&include=full (returns environment)
402
+ const data = await jsonFetch(`/api/admin/migration/environments?envKey=${encodeURIComponent(key)}&include=full`);
403
+ if (data?.environment) loadEnvIntoForm(data.environment);
404
+ } catch (err) {
405
+ console.error(err);
406
+ }
407
+ }
408
+
409
+ async function refreshEnvs() {
410
+ const list = document.getElementById('envList');
411
+ const data = await jsonFetch('/api/admin/migration/environments');
412
+ state.environments = Array.isArray(data?.environments) ? data.environments : [];
413
+
414
+ const envSelect = document.getElementById('runEnvKey');
415
+ if (envSelect) {
416
+ const current = envSelect.value;
417
+ envSelect.innerHTML = '<option value="" selected>Select environment...</option>';
418
+ state.environments.forEach((e) => {
419
+ const opt = document.createElement('option');
420
+ opt.value = e.key;
421
+ opt.textContent = `${e.name} (${e.key})`;
422
+ envSelect.appendChild(opt);
423
+ });
424
+ if (current) envSelect.value = current;
425
+ updateAssetTestsState();
426
+ }
427
+
428
+ const envFormSelect = document.getElementById('envSelect');
429
+ if (envFormSelect) {
430
+ const current = envFormSelect.value;
431
+ envFormSelect.innerHTML = '<option value="">(new)</option>';
432
+ state.environments.forEach((e) => {
433
+ const opt = document.createElement('option');
434
+ opt.value = e.key;
435
+ opt.textContent = `${e.name || e.key} (${e.key})`;
436
+ envFormSelect.appendChild(opt);
437
+ });
438
+ if (current && state.environments.find((e) => e.key === current)) {
439
+ envFormSelect.value = current;
440
+ }
441
+ }
442
+
443
+ list.textContent = JSON.stringify(data, null, 2);
444
+ updateAssetTestsState();
445
+ }
446
+
447
+ async function refreshModels() {
448
+ const data = await jsonFetch('/api/admin/migration/models');
449
+ state.models = Array.isArray(data?.models) ? data.models : [];
450
+
451
+ const modelSelect = document.getElementById('runModel');
452
+ if (modelSelect) {
453
+ const current = modelSelect.value;
454
+ modelSelect.innerHTML = '<option value="" selected>Select model...</option>';
455
+ state.models.forEach((m) => {
456
+ const opt = document.createElement('option');
457
+ opt.value = m;
458
+ opt.textContent = m;
459
+ modelSelect.appendChild(opt);
460
+ });
461
+ if (current) modelSelect.value = current;
462
+ }
463
+ }
464
+
465
+ function safeParseJson(raw, fallback) {
466
+ try {
467
+ return JSON.parse(raw);
468
+ } catch (_) {
469
+ return fallback;
470
+ }
471
+ }
472
+
473
+ function setQueryText(obj) {
474
+ const el = document.getElementById('runQuery');
475
+ if (el) el.value = JSON.stringify(obj || {}, null, 2);
476
+ }
477
+
478
+ function getQueryObject() {
479
+ const raw = document.getElementById('runQuery')?.value || '';
480
+ const trimmed = raw.trim();
481
+ if (!trimmed) return {};
482
+ return safeParseJson(trimmed, null) || {};
483
+ }
484
+
485
+ function renderQueryBuilder() {
486
+ const qb = document.getElementById('qbRows');
487
+ if (!qb) return;
488
+ qb.innerHTML = '';
489
+
490
+ const fields = Array.isArray(state.schema?.fields) ? state.schema.fields : [];
491
+ if (!fields.length) {
492
+ qb.innerHTML = '<div class="text-xs text-gray-600">Select a model to enable query builder.</div>';
493
+ return;
494
+ }
495
+
496
+ const current = getQueryObject();
497
+ const filters = Array.isArray(current.__qb) ? current.__qb : [];
498
+
499
+ if (!filters.length) {
500
+ qb.innerHTML = '<div class="text-xs text-gray-600">No filters yet. Click “Add filter”.</div>';
501
+ return;
502
+ }
503
+
504
+ filters.forEach((f, idx) => {
505
+ const row = document.createElement('div');
506
+ row.className = 'grid grid-cols-12 gap-2 items-center';
507
+
508
+ const selField = document.createElement('select');
509
+ selField.className = 'col-span-5 border rounded px-2 py-1 text-xs';
510
+ fields.forEach((sf) => {
511
+ const opt = document.createElement('option');
512
+ opt.value = sf.key;
513
+ opt.textContent = sf.key;
514
+ selField.appendChild(opt);
515
+ });
516
+ selField.value = f.field || fields[0].key;
517
+
518
+ const selOp = document.createElement('select');
519
+ selOp.className = 'col-span-3 border rounded px-2 py-1 text-xs';
520
+ ['eq', 'contains', 'in', 'gte', 'lte'].forEach((op) => {
521
+ const opt = document.createElement('option');
522
+ opt.value = op;
523
+ opt.textContent = op;
524
+ selOp.appendChild(opt);
525
+ });
526
+ selOp.value = f.op || 'eq';
527
+
528
+ const input = document.createElement('input');
529
+ input.className = 'col-span-3 border rounded px-2 py-1 text-xs';
530
+ input.placeholder = 'value';
531
+ input.value = f.value || '';
532
+
533
+ const del = document.createElement('button');
534
+ del.type = 'button';
535
+ del.className = 'col-span-1 text-xs px-2 py-1 border rounded';
536
+ del.textContent = 'X';
537
+
538
+ function update() {
539
+ const q = getQueryObject();
540
+ const qbArr = Array.isArray(q.__qb) ? q.__qb : [];
541
+ qbArr[idx] = { field: selField.value, op: selOp.value, value: input.value };
542
+ q.__qb = qbArr;
543
+ setQueryText(q);
544
+ }
545
+
546
+ selField.addEventListener('change', update);
547
+ selOp.addEventListener('change', update);
548
+ input.addEventListener('input', update);
549
+
550
+ del.addEventListener('click', () => {
551
+ const q = getQueryObject();
552
+ const qbArr = Array.isArray(q.__qb) ? q.__qb : [];
553
+ qbArr.splice(idx, 1);
554
+ q.__qb = qbArr;
555
+ setQueryText(q);
556
+ renderQueryBuilder();
557
+ });
558
+
559
+ row.appendChild(selField);
560
+ row.appendChild(selOp);
561
+ row.appendChild(input);
562
+ row.appendChild(del);
563
+ qb.appendChild(row);
564
+ });
565
+ }
566
+
567
+ function qbToMongo(queryObj) {
568
+ const q = queryObj && typeof queryObj === 'object' ? { ...queryObj } : {};
569
+ const qb = Array.isArray(q.__qb) ? q.__qb : [];
570
+ delete q.__qb;
571
+
572
+ for (const f of qb) {
573
+ const field = String(f?.field || '').trim();
574
+ const op = String(f?.op || 'eq');
575
+ const rawValue = f?.value;
576
+ if (!field) continue;
577
+
578
+ if (op === 'eq') {
579
+ q[field] = rawValue;
580
+ } else if (op === 'contains') {
581
+ q[field] = { $regex: String(rawValue || ''), $options: 'i' };
582
+ } else if (op === 'in') {
583
+ const arr = String(rawValue || '').split(',').map((v) => v.trim()).filter(Boolean);
584
+ q[field] = { $in: arr };
585
+ } else if (op === 'gte') {
586
+ q[field] = { ...(q[field] && typeof q[field] === 'object' ? q[field] : {}), $gte: rawValue };
587
+ } else if (op === 'lte') {
588
+ q[field] = { ...(q[field] && typeof q[field] === 'object' ? q[field] : {}), $lte: rawValue };
589
+ }
590
+ }
591
+
592
+ return q;
593
+ }
594
+
595
+ async function loadSchema(modelName) {
596
+ if (!modelName) {
597
+ state.schema = null;
598
+ renderQueryBuilder();
599
+ return;
600
+ }
601
+ const data = await jsonFetch(`/api/admin/migration/models/${encodeURIComponent(modelName)}/schema`);
602
+ state.schema = data?.schema || null;
603
+ renderQueryBuilder();
604
+ }
605
+
606
+ function renderPreviewTable(items) {
607
+ const table = document.getElementById('previewTable');
608
+ if (!table) return;
609
+
610
+ const rows = Array.isArray(items) ? items : [];
611
+ if (!rows.length) {
612
+ table.innerHTML = '<tr><td class="p-2 text-xs text-gray-600">No rows</td></tr>';
613
+ return;
614
+ }
615
+
616
+ const headers = Object.keys(rows[0]).slice(0, 8);
617
+ const thead = `<thead><tr>${headers.map((h) => `<th class="text-left p-2 border-b">${h}</th>`).join('')}</tr></thead>`;
618
+ const tbody = `<tbody>${rows.map((r) => `<tr>${headers.map((h) => {
619
+ const v = r[h];
620
+ const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
621
+ return `<td class=\"p-2 border-b align-top\">${s.slice(0, 120)}</td>`;
622
+ }).join('')}</tr>`).join('')}</tbody>`;
623
+
624
+ table.innerHTML = thead + tbody;
625
+ }
626
+
627
+ async function runPreview() {
628
+ const modelName = document.getElementById('runModel')?.value || '';
629
+ if (!modelName) throw new Error('Select a model first');
630
+ const search = document.getElementById('previewSearch')?.value || '';
631
+ const q = getQueryObject();
632
+ const mongoQuery = qbToMongo(q);
633
+
634
+ const data = await jsonFetch('/api/admin/migration/preview', {
635
+ method: 'POST',
636
+ body: JSON.stringify({
637
+ modelName,
638
+ query: mongoQuery,
639
+ page: state.preview.page,
640
+ limit: state.preview.limit,
641
+ search,
642
+ }),
643
+ });
644
+
645
+ state.preview.pages = data.pages;
646
+ state.preview.total = data.total;
647
+ state.preview.items = data.items;
648
+ document.getElementById('previewMeta').textContent = `page ${data.page}/${data.pages} • total ${data.total}`;
649
+ renderPreviewTable(data.items);
650
+ }
651
+
652
+ document.getElementById('btnRefreshEnvs').addEventListener('click', async (e) => {
653
+ e.preventDefault();
654
+ try {
655
+ await refreshEnvs();
656
+ } catch (err) {
657
+ document.getElementById('envList').textContent = err.message;
658
+ }
659
+ });
660
+
661
+ document.getElementById('runModel').addEventListener('change', async (e) => {
662
+ try {
663
+ await loadSchema(e.target.value);
664
+ // reset qb state in query
665
+ setQueryText({ __qb: [] });
666
+ state.preview.page = 1;
667
+ document.getElementById('previewMeta').textContent = '-';
668
+ renderPreviewTable([]);
669
+ } catch (_) {
670
+ // ignore
671
+ }
672
+ });
673
+
674
+ document.getElementById('btnAddFilter').addEventListener('click', () => {
675
+ const q = getQueryObject();
676
+ const qb = Array.isArray(q.__qb) ? q.__qb : [];
677
+ qb.push({ field: (state.schema?.fields?.[0]?.key || '_id'), op: 'eq', value: '' });
678
+ q.__qb = qb;
679
+ setQueryText(q);
680
+ renderQueryBuilder();
681
+ });
682
+
683
+ document.getElementById('btnPreview').addEventListener('click', async () => {
684
+ state.preview.page = 1;
685
+ await runPreview();
686
+ });
687
+
688
+ document.getElementById('previewPrev').addEventListener('click', async () => {
689
+ state.preview.page = Math.max(1, state.preview.page - 1);
690
+ await runPreview();
691
+ });
692
+
693
+ document.getElementById('previewNext').addEventListener('click', async () => {
694
+ state.preview.page = Math.min(state.preview.pages || 1, state.preview.page + 1);
695
+ await runPreview();
696
+ });
697
+
698
+ document.getElementById('btnSaveEnv').addEventListener('click', async () => {
699
+ const msg = document.getElementById('envMsg');
700
+ try {
701
+ const envKey = document.getElementById('envKey').value;
702
+ const name = document.getElementById('envName').value;
703
+ const connectionString = document.getElementById('envConn').value;
704
+ const description = document.getElementById('envDesc').value;
705
+ const assetsTarget = buildAssetsTargetPayload();
706
+ const normalizedAssetsTarget = normalizeAssetsTarget(assetsTarget);
707
+
708
+ await jsonFetch('/api/admin/migration/environments', {
709
+ method: 'POST',
710
+ body: JSON.stringify({ envKey, name, connectionString, description, assetsTarget: normalizedAssetsTarget })
711
+ });
712
+ setMsg(msg, 'Saved', false);
713
+ await refreshEnvs();
714
+ } catch (err) {
715
+ setMsg(msg, err.message, true);
716
+ }
717
+ });
718
+
719
+ document.getElementById('btnTestConn').addEventListener('click', async () => {
720
+ const msg = document.getElementById('envMsg');
721
+ try {
722
+ const envKey = document.getElementById('envKey').value;
723
+ await jsonFetch('/api/admin/migration/test-connection', {
724
+ method: 'POST',
725
+ body: JSON.stringify({ envKey })
726
+ });
727
+ setMsg(msg, 'Connection OK', false);
728
+ } catch (err) {
729
+ setMsg(msg, err.message, true);
730
+ }
731
+ });
732
+
733
+ document.getElementById('btnTestAssets').addEventListener('click', async () => {
734
+ const msg = document.getElementById('envMsg');
735
+ try {
736
+ const envKey = document.getElementById('envKey').value;
737
+ const res = await jsonFetch('/api/admin/migration/test-assets', {
738
+ method: 'POST',
739
+ body: JSON.stringify({ envKey })
740
+ });
741
+ setMsg(msg, `Assets OK (${res.endpointType})`, false);
742
+ } catch (err) {
743
+ setMsg(msg, err.message, true);
744
+ }
745
+ });
746
+
747
+ document.getElementById('btnTestAssetsCopy').addEventListener('click', async () => {
748
+ const msg = document.getElementById('envMsg');
749
+ try {
750
+ const envKey = document.getElementById('envKey').value;
751
+ const key = document.getElementById('assetTestKey').value;
752
+ const res = await jsonFetch('/api/admin/migration/test-assets-copy', {
753
+ method: 'POST',
754
+ body: JSON.stringify({ envKey, key })
755
+ });
756
+ setMsg(msg, res.ok ? 'Test copy OK' : 'Test copy failed', !res.ok);
757
+ } catch (err) {
758
+ setMsg(msg, err.message, true);
759
+ }
760
+ });
761
+
762
+ const runEnvSelect = document.getElementById('runEnvKey');
763
+ if (runEnvSelect) {
764
+ runEnvSelect.addEventListener('change', () => {
765
+ updateAssetTestsState();
766
+ });
767
+ }
768
+
769
+ document.getElementById('btnRun').addEventListener('click', async () => {
770
+ const out = document.getElementById('runOut');
771
+ out.textContent = '';
772
+ try {
773
+ const envKey = document.getElementById('runEnvKey').value;
774
+ const modelName = document.getElementById('runModel').value;
775
+ const dryRun = document.getElementById('runDry').checked;
776
+ const query = qbToMongo(getQueryObject());
777
+ const res = await jsonFetch('/api/admin/migration/run', {
778
+ method: 'POST',
779
+ body: JSON.stringify({ envKey, modelName, query, dryRun })
780
+ });
781
+ out.textContent = JSON.stringify(res, null, 2);
782
+ } catch (err) {
783
+ out.textContent = err.message;
784
+ }
785
+ });
786
+
787
+ document.getElementById('envSelect').addEventListener('change', async (e) => {
788
+ const key = e.target.value;
789
+ if (!key) {
790
+ resetEnvForm();
791
+ return;
792
+ }
793
+ await loadEnvDetails(key);
794
+ });
795
+
796
+ document.getElementById('btnNewEnv').addEventListener('click', (e) => {
797
+ e.preventDefault();
798
+ resetEnvForm();
799
+ });
800
+
801
+ Promise.all([refreshEnvs(), refreshModels()]).catch(() => {});
802
+ document.getElementById('assetType').addEventListener('change', toggleAssetSections);
803
+ toggleAssetSections();
804
+ </script>
805
+ <script>
806
+ window.addEventListener("keydown", (e) => {
807
+ if ((e.ctrlKey || e.metaKey) && e.key === "k") {
808
+ e.preventDefault();
809
+ window.parent.postMessage({ type: "keydown", ctrlK: true }, "*");
810
+ }
811
+ });
812
+ </script>
813
+ </body>
814
+ </html>