@kaikybrofc/omnizap-system 2.3.1 → 2.3.2

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 (43) hide show
  1. package/README.md +20 -18
  2. package/app/controllers/messageController.js +473 -255
  3. package/app/modules/analyticsModule/messageAnalysisEventRepository.js +83 -0
  4. package/app/modules/stickerPackModule/stickerDomainEventConsumerRuntime.js +1 -3
  5. package/app/observability/metrics.js +6 -3
  6. package/app/services/googleWebLinkService.js +77 -0
  7. package/database/index.js +2 -0
  8. package/database/migrations/20260301_0028_message_analysis_event.sql +32 -0
  9. package/database/migrations/20260301_0029_admin_action_audit.sql +16 -0
  10. package/package.json +1 -1
  11. package/public/index.html +12 -8
  12. package/public/js/apps/homeApp.js +75 -30
  13. package/public/js/apps/loginApp.js +184 -29
  14. package/public/js/apps/stickersAdminApp.js +3 -9
  15. package/public/js/apps/userApp.js +985 -55
  16. package/public/js/apps/userProfileApp.js +244 -0
  17. package/public/login/index.html +430 -100
  18. package/public/termos-de-uso/index.html +1 -1
  19. package/public/user/index.html +2 -180
  20. package/public/user/systemadm/index.html +774 -0
  21. package/server/controllers/stickerCatalog/nonCatalogHandlers.js +208 -0
  22. package/server/controllers/stickerCatalogController.js +1186 -363
  23. package/server/controllers/systemAdminController.js +141 -0
  24. package/server/controllers/userController.js +87 -0
  25. package/server/http/httpServer.js +72 -32
  26. package/server/middleware/cachePolicy.js +24 -0
  27. package/server/middleware/cachePolicyHelpers.js +2 -0
  28. package/server/middleware/rateLimit.js +82 -0
  29. package/server/middleware/requestLogger.js +16 -0
  30. package/server/middleware/requireAdminAuth.js +42 -0
  31. package/server/middleware/securityHeaders.js +6 -0
  32. package/server/routes/admin/systemAdminRouter.js +56 -0
  33. package/server/routes/health/healthRouter.js +41 -0
  34. package/server/routes/indexRouter.js +203 -0
  35. package/server/routes/metrics/metricsRouter.js +13 -0
  36. package/server/routes/stickerCatalog/catalogHandlers/catalogAdminHttp.js +44 -0
  37. package/server/routes/stickerCatalog/stickerApiRouter.js +84 -0
  38. package/server/routes/stickerCatalog/stickerDataRouter.js +140 -0
  39. package/server/routes/stickerCatalog/stickerSiteRouter.js +43 -0
  40. package/server/routes/user/userRouter.js +56 -0
  41. package/server/utils/safePath.js +26 -0
  42. package/server/routes/metricsRoute.js +0 -7
  43. package/server/routes/stickerCatalogRoute.js +0 -20
@@ -1,4 +1,4 @@
1
- /* global document, window, fetch, URL, URLSearchParams */
1
+ /* global document, window, fetch, URL, URLSearchParams, Blob, Element */
2
2
 
3
3
  const DEFAULT_API_BASE_PATH = '/api/sticker-packs';
4
4
  const DEFAULT_STICKERS_PATH = '/stickers';
@@ -31,6 +31,7 @@ if (root) {
31
31
  manageHeadLink: document.getElementById('user-manage-head-link'),
32
32
  manageMainLink: document.getElementById('user-manage-main-link'),
33
33
  currentYear: document.getElementById('user-current-year'),
34
+
34
35
  adminPanel: document.getElementById('user-admin-panel'),
35
36
  adminRole: document.getElementById('user-admin-role'),
36
37
  adminStatus: document.getElementById('user-admin-status'),
@@ -41,6 +42,12 @@ if (root) {
41
42
  adminOverview: document.getElementById('user-admin-overview'),
42
43
  adminRefreshBtn: document.getElementById('user-admin-refresh-btn'),
43
44
  adminLogoutBtn: document.getElementById('user-admin-logout-btn'),
45
+
46
+ adminBotsOnline: document.getElementById('user-admin-bots-online'),
47
+ adminMessagesToday: document.getElementById('user-admin-messages-today'),
48
+ adminSpamBlocked: document.getElementById('user-admin-spam-blocked'),
49
+ adminUptime: document.getElementById('user-admin-uptime'),
50
+ adminErrors5xx: document.getElementById('user-admin-errors-5xx'),
44
51
  adminTotalPacks: document.getElementById('user-admin-total-packs'),
45
52
  adminTotalStickers: document.getElementById('user-admin-total-stickers'),
46
53
  adminActiveBans: document.getElementById('user-admin-active-bans'),
@@ -49,6 +56,32 @@ if (root) {
49
56
  adminVisits24h: document.getElementById('user-admin-visits-24h'),
50
57
  adminVisits7d: document.getElementById('user-admin-visits-7d'),
51
58
  adminUniqueVisitors7d: document.getElementById('user-admin-unique-visitors-7d'),
59
+
60
+ adminHealthCpu: document.getElementById('user-admin-health-cpu'),
61
+ adminHealthRam: document.getElementById('user-admin-health-ram'),
62
+ adminHealthLatency: document.getElementById('user-admin-health-latency'),
63
+ adminHealthQueue: document.getElementById('user-admin-health-queue'),
64
+ adminHealthDb: document.getElementById('user-admin-health-db'),
65
+
66
+ adminModerationList: document.getElementById('user-admin-moderation-list'),
67
+ adminSessionsList: document.getElementById('user-admin-sessions-list'),
68
+ adminUsersList: document.getElementById('user-admin-users-list'),
69
+ adminBansList: document.getElementById('user-admin-bans-list'),
70
+ adminAuditList: document.getElementById('user-admin-audit-list'),
71
+ adminFlagsList: document.getElementById('user-admin-flags-list'),
72
+ adminAlertsList: document.getElementById('user-admin-alerts-list'),
73
+ adminOpsStatus: document.getElementById('user-admin-ops-status'),
74
+
75
+ adminSearchForm: document.getElementById('user-admin-search-form'),
76
+ adminSearchInput: document.getElementById('user-admin-search-input'),
77
+ adminSearchBtn: document.getElementById('user-admin-search-btn'),
78
+ adminSearchResults: document.getElementById('user-admin-search-results'),
79
+
80
+ adminExportMetricsJsonBtn: document.getElementById('user-admin-export-metrics-json'),
81
+ adminExportMetricsCsvBtn: document.getElementById('user-admin-export-metrics-csv'),
82
+ adminExportEventsJsonBtn: document.getElementById('user-admin-export-events-json'),
83
+ adminExportEventsCsvBtn: document.getElementById('user-admin-export-events-csv'),
84
+ adminOpButtons: Array.from(document.querySelectorAll('[data-admin-op-action]')),
52
85
  };
53
86
 
54
87
  const state = {
@@ -59,6 +92,8 @@ if (root) {
59
92
  adminBusy: false,
60
93
  adminStatusPayload: null,
61
94
  adminOverviewPayload: null,
95
+ adminSearchPayload: null,
96
+ adminOpsMessage: '',
62
97
  };
63
98
 
64
99
  const sessionApiPath = `${state.apiBasePath}/auth/google/session`;
@@ -66,12 +101,23 @@ if (root) {
66
101
  const botContactApiPath = `${state.apiBasePath}/bot-contact`;
67
102
  const adminSessionApiPath = `${state.apiBasePath}/admin/session`;
68
103
  const adminOverviewApiPath = `${state.apiBasePath}/admin/overview`;
104
+ const adminForceLogoutApiPath = `${state.apiBasePath}/admin/users/force-logout`;
105
+ const adminFeatureFlagsApiPath = `${state.apiBasePath}/admin/feature-flags`;
106
+ const adminOpsApiPath = `${state.apiBasePath}/admin/ops`;
107
+ const adminSearchApiPath = `${state.apiBasePath}/admin/search`;
108
+ const adminExportApiPath = `${state.apiBasePath}/admin/export`;
109
+ const adminBansApiPath = `${state.apiBasePath}/admin/bans`;
69
110
 
70
111
  const setText = (el, value) => {
71
112
  if (!el) return;
72
113
  el.textContent = String(value || '');
73
114
  };
74
115
 
116
+ const clearNode = (el) => {
117
+ if (!el) return;
118
+ while (el.firstChild) el.removeChild(el.firstChild);
119
+ };
120
+
75
121
  const showError = (message) => {
76
122
  if (!ui.error) return;
77
123
  const safeMessage = String(message || '').trim();
@@ -87,6 +133,8 @@ if (root) {
87
133
  };
88
134
 
89
135
  const normalizeDigits = (value) => String(value || '').replace(/\D+/g, '');
136
+ const normalizeString = (value) => String(value || '').trim();
137
+ const isObject = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
90
138
 
91
139
  const formatPhone = (digits) => {
92
140
  const value = normalizeDigits(digits);
@@ -106,9 +154,28 @@ if (root) {
106
154
  return new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(ms));
107
155
  };
108
156
 
157
+ const formatPercent = (value) => {
158
+ const numeric = Number(value);
159
+ if (!Number.isFinite(numeric)) return 'n/d';
160
+ return `${numeric.toFixed(1)}%`;
161
+ };
162
+
163
+ const formatMilliseconds = (value) => {
164
+ const numeric = Number(value);
165
+ if (!Number.isFinite(numeric)) return 'n/d';
166
+ return `${Math.round(numeric)} ms`;
167
+ };
168
+
169
+ const formatIntegerOrNd = (value) => {
170
+ const numeric = Number(value);
171
+ if (!Number.isFinite(numeric)) return 'n/d';
172
+ return formatNumber(numeric);
173
+ };
174
+
109
175
  const buildLoginRedirectUrl = () => {
110
176
  const loginUrl = new URL(state.loginPath, window.location.origin);
111
- loginUrl.searchParams.set('next', '/user/');
177
+ const nextPath = `${window.location.pathname || '/user/systemadm/'}${window.location.search || ''}`;
178
+ loginUrl.searchParams.set('next', nextPath);
112
179
  return `${loginUrl.pathname}${loginUrl.search}`;
113
180
  };
114
181
 
@@ -128,6 +195,7 @@ if (root) {
128
195
  credentials: 'include',
129
196
  ...init,
130
197
  });
198
+
131
199
  let payload = null;
132
200
  try {
133
201
  payload = await response.json();
@@ -143,24 +211,190 @@ if (root) {
143
211
  return payload || {};
144
212
  };
145
213
 
214
+ const fetchWithAuth = async (url, init = {}) => {
215
+ const response = await fetch(url, {
216
+ credentials: 'include',
217
+ ...init,
218
+ });
219
+ if (!response.ok) {
220
+ let message = `Falha HTTP ${response.status}`;
221
+ try {
222
+ const payload = await response.json();
223
+ if (payload?.error) message = payload.error;
224
+ } catch {
225
+ // noop
226
+ }
227
+ const error = new Error(message);
228
+ error.statusCode = response.status;
229
+ throw error;
230
+ }
231
+ return response;
232
+ };
233
+
146
234
  const redirectToLogin = () => {
147
235
  window.location.assign(buildLoginRedirectUrl());
148
236
  };
149
237
 
150
- const loadBotPhone = async () => {
151
- try {
152
- const payload = await fetchJson(botContactApiPath, { method: 'GET' });
153
- state.botPhone = normalizeDigits(payload?.data?.phone || '');
154
- } catch {
155
- state.botPhone = '';
238
+ const normalizeOwnerJidCandidate = (value) => {
239
+ const jid = normalizeString(value);
240
+ if (!jid || !jid.includes('@')) return '';
241
+ if (jid.endsWith('@g.us')) return '';
242
+ return jid;
243
+ };
244
+
245
+ const compactIdentityPayload = (raw = {}) => {
246
+ const payload = {};
247
+ const sessionToken = normalizeString(raw.session_token);
248
+ const googleSub = normalizeString(raw.google_sub);
249
+ const email = normalizeString(raw.email);
250
+ const ownerJid = normalizeOwnerJidCandidate(raw.owner_jid);
251
+ if (sessionToken) payload.session_token = sessionToken;
252
+ if (googleSub) payload.google_sub = googleSub;
253
+ if (email) payload.email = email;
254
+ if (ownerJid) payload.owner_jid = ownerJid;
255
+ return payload;
256
+ };
257
+
258
+ const buildIdentityLabel = (identity = {}) => {
259
+ const email = normalizeString(identity.email);
260
+ const ownerJid = normalizeString(identity.owner_jid);
261
+ const googleSub = normalizeString(identity.google_sub);
262
+ const sessionToken = normalizeString(identity.session_token);
263
+ if (email) return email;
264
+ if (ownerJid) return ownerJid;
265
+ if (googleSub) return googleSub;
266
+ if (sessionToken) return `${sessionToken.slice(0, 8)}...`;
267
+ return 'identidade';
268
+ };
269
+
270
+ const getAdminSession = () => state.adminStatusPayload?.session || null;
271
+ const isAdminAuthenticated = () => Boolean(getAdminSession()?.authenticated);
272
+ const isAdminEligible = () => Boolean(state.adminStatusPayload?.eligible_google_login || isAdminAuthenticated());
273
+
274
+ const resolveAdminRole = () =>
275
+ String(getAdminSession()?.role || state.adminStatusPayload?.eligible_role || '')
276
+ .trim()
277
+ .toLowerCase();
278
+
279
+ const formatAdminRole = (role) => {
280
+ if (role === 'owner') return 'dono';
281
+ if (role === 'moderator') return 'moderador';
282
+ return 'admin';
283
+ };
284
+
285
+ const createItemMeta = (text) => {
286
+ const p = document.createElement('p');
287
+ p.className = 'admin-item-meta';
288
+ p.textContent = text;
289
+ return p;
290
+ };
291
+
292
+ const createBadge = (label, severity = 'low') => {
293
+ const normalizedSeverity = ['critical', 'high', 'medium', 'low'].includes(String(severity)) ? String(severity) : 'low';
294
+ const badge = document.createElement('span');
295
+ badge.className = `admin-badge ${normalizedSeverity}`;
296
+ badge.textContent = String(label || '').trim() || normalizedSeverity.toUpperCase();
297
+ return badge;
298
+ };
299
+
300
+ const createMiniButton = (label, onClick) => {
301
+ const button = document.createElement('button');
302
+ button.type = 'button';
303
+ button.className = 'admin-mini-btn';
304
+ button.textContent = label;
305
+ button.addEventListener('click', () => {
306
+ if (typeof onClick === 'function') void onClick();
307
+ });
308
+ return button;
309
+ };
310
+
311
+ const createMiniLink = (label, href) => {
312
+ const link = document.createElement('a');
313
+ link.className = 'admin-mini-btn';
314
+ link.textContent = label;
315
+ link.href = href;
316
+ link.target = '_blank';
317
+ link.rel = 'noreferrer noopener';
318
+ return link;
319
+ };
320
+
321
+ const renderListPlaceholder = (container, message) => {
322
+ if (!container) return;
323
+ clearNode(container);
324
+ container.appendChild(createItemMeta(message));
325
+ };
326
+
327
+ const appendListItem = ({ container, title, severity = '', badgeLabel = '', meta = [], actions = [], customNode = null }) => {
328
+ if (!container) return;
329
+
330
+ const item = document.createElement('article');
331
+ item.className = 'admin-item';
332
+
333
+ const titleEl = document.createElement('p');
334
+ titleEl.className = 'admin-item-title';
335
+ titleEl.textContent = title;
336
+ item.appendChild(titleEl);
337
+
338
+ if (badgeLabel) {
339
+ item.appendChild(createBadge(badgeLabel, severity));
156
340
  }
157
- if (ui.chatLink) ui.chatLink.href = buildWhatsAppMenuUrl(state.botPhone);
341
+
342
+ for (const line of meta) {
343
+ const text = normalizeString(line);
344
+ if (!text) continue;
345
+ item.appendChild(createItemMeta(text));
346
+ }
347
+
348
+ if (customNode) item.appendChild(customNode);
349
+
350
+ if (Array.isArray(actions) && actions.length) {
351
+ const actionsWrap = document.createElement('div');
352
+ actionsWrap.className = 'admin-item-actions';
353
+ for (const actionNode of actions) {
354
+ if (actionNode instanceof Element) actionsWrap.appendChild(actionNode);
355
+ }
356
+ if (actionsWrap.childNodes.length > 0) {
357
+ item.appendChild(actionsWrap);
358
+ }
359
+ }
360
+
361
+ container.appendChild(item);
362
+ };
363
+
364
+ const setAdminBusy = (value) => {
365
+ const busy = Boolean(value);
366
+ state.adminBusy = busy;
367
+
368
+ const authenticated = isAdminAuthenticated();
369
+ const eligible = isAdminEligible();
370
+
371
+ if (ui.adminPanel) {
372
+ const controls = ui.adminPanel.querySelectorAll('button, input, select, textarea');
373
+ for (const control of controls) {
374
+ const inUnlockForm = Boolean(control.closest('#user-admin-unlock-form'));
375
+ const inOverview = Boolean(control.closest('#user-admin-overview'));
376
+ if (inUnlockForm) {
377
+ control.disabled = busy || !eligible || authenticated;
378
+ continue;
379
+ }
380
+ if (inOverview) {
381
+ control.disabled = busy || !authenticated;
382
+ continue;
383
+ }
384
+ control.disabled = busy;
385
+ }
386
+ }
387
+
388
+ if (ui.adminPassword) ui.adminPassword.disabled = busy || !isAdminEligible() || isAdminAuthenticated();
389
+ if (ui.adminUnlockBtn) ui.adminUnlockBtn.disabled = busy || !isAdminEligible() || isAdminAuthenticated();
390
+ if (ui.adminRefreshBtn) ui.adminRefreshBtn.disabled = busy || !isAdminAuthenticated();
391
+ if (ui.adminLogoutBtn) ui.adminLogoutBtn.disabled = busy || !isAdminAuthenticated();
158
392
  };
159
393
 
160
394
  const renderSession = (sessionData) => {
161
395
  const user = sessionData?.user || {};
162
- const ownerPhone = String(sessionData?.owner_phone || '').trim();
163
- const ownerJid = String(sessionData?.owner_jid || '').trim();
396
+ const ownerPhone = normalizeString(sessionData?.owner_phone);
397
+ const ownerJid = normalizeString(sessionData?.owner_jid);
164
398
 
165
399
  setText(ui.name, user?.name || 'Conta Google');
166
400
  setText(ui.email, user?.email || 'Email não disponível');
@@ -173,7 +407,7 @@ if (root) {
173
407
  }
174
408
 
175
409
  if (ui.avatar) {
176
- const picture = String(user?.picture || '').trim() || FALLBACK_AVATAR;
410
+ const picture = normalizeString(user?.picture) || FALLBACK_AVATAR;
177
411
  ui.avatar.src = picture;
178
412
  ui.avatar.onerror = () => {
179
413
  ui.avatar.src = FALLBACK_AVATAR;
@@ -181,7 +415,7 @@ if (root) {
181
415
  }
182
416
 
183
417
  setText(ui.ownerJid, ownerJid || 'n/d');
184
- setText(ui.googleSub, String(user?.sub || '').trim() || 'n/d');
418
+ setText(ui.googleSub, normalizeString(user?.sub) || 'n/d');
185
419
  setText(ui.expiresAt, formatDateTime(sessionData?.expires_at));
186
420
 
187
421
  if (ui.profile) ui.profile.hidden = false;
@@ -192,7 +426,7 @@ if (root) {
192
426
  const renderPackMetrics = (payload) => {
193
427
  const data = payload?.data || {};
194
428
  const packs = Array.isArray(data?.packs) ? data.packs : [];
195
- const stats = data?.stats && typeof data.stats === 'object' ? data.stats : {};
429
+ const stats = isObject(data?.stats) ? data.stats : {};
196
430
 
197
431
  let stickers = 0;
198
432
  let downloads = 0;
@@ -211,32 +445,12 @@ if (root) {
211
445
  if (ui.grid) ui.grid.hidden = false;
212
446
  };
213
447
 
214
- const getAdminSession = () => state.adminStatusPayload?.session || null;
215
- const isAdminAuthenticated = () => Boolean(getAdminSession()?.authenticated);
216
- const isAdminEligible = () => Boolean(state.adminStatusPayload?.eligible_google_login || isAdminAuthenticated());
217
-
218
- const resolveAdminRole = () =>
219
- String(getAdminSession()?.role || state.adminStatusPayload?.eligible_role || '')
220
- .trim()
221
- .toLowerCase();
222
-
223
- const formatAdminRole = (role) => {
224
- if (role === 'owner') return 'dono';
225
- if (role === 'moderator') return 'moderador';
226
- return 'admin';
227
- };
228
-
229
- const setAdminBusy = (value) => {
230
- const busy = Boolean(value);
231
- state.adminBusy = busy;
232
-
233
- if (ui.adminPassword) ui.adminPassword.disabled = busy || isAdminAuthenticated();
234
- if (ui.adminUnlockBtn) ui.adminUnlockBtn.disabled = busy || !isAdminEligible() || isAdminAuthenticated();
235
- if (ui.adminRefreshBtn) ui.adminRefreshBtn.disabled = busy || !isAdminAuthenticated();
236
- if (ui.adminLogoutBtn) ui.adminLogoutBtn.disabled = busy || !isAdminAuthenticated();
237
- };
238
-
239
- const resetAdminMetrics = () => {
448
+ const setAdminMetricsDefaults = () => {
449
+ setText(ui.adminBotsOnline, '0');
450
+ setText(ui.adminMessagesToday, 'n/d');
451
+ setText(ui.adminSpamBlocked, 'n/d');
452
+ setText(ui.adminUptime, 'n/d');
453
+ setText(ui.adminErrors5xx, '0');
240
454
  setText(ui.adminTotalPacks, '0');
241
455
  setText(ui.adminTotalStickers, '0');
242
456
  setText(ui.adminActiveBans, '0');
@@ -245,18 +459,594 @@ if (root) {
245
459
  setText(ui.adminVisits24h, '0');
246
460
  setText(ui.adminVisits7d, '0');
247
461
  setText(ui.adminUniqueVisitors7d, '0');
462
+
463
+ setText(ui.adminHealthCpu, 'n/d');
464
+ setText(ui.adminHealthRam, 'n/d');
465
+ setText(ui.adminHealthLatency, 'n/d');
466
+ setText(ui.adminHealthQueue, 'n/d');
467
+ setText(ui.adminHealthDb, 'n/d');
468
+
469
+ renderListPlaceholder(ui.adminModerationList, 'Nenhum evento recente de moderação.');
470
+ renderListPlaceholder(ui.adminSessionsList, 'Nenhuma sessão ativa encontrada.');
471
+ renderListPlaceholder(ui.adminUsersList, 'Nenhum usuário encontrado.');
472
+ renderListPlaceholder(ui.adminBansList, 'Nenhuma conta bloqueada.');
473
+ renderListPlaceholder(ui.adminAuditList, 'Sem eventos de auditoria recentes.');
474
+ renderListPlaceholder(ui.adminFlagsList, 'Nenhuma feature flag disponível.');
475
+ renderListPlaceholder(ui.adminAlertsList, 'Sem alertas ativos no momento.');
476
+ renderListPlaceholder(ui.adminSearchResults, 'Faça uma busca para ver usuários, grupos, packs e sessões.');
477
+ setText(ui.adminOpsStatus, state.adminOpsMessage || 'Ações operacionais disponíveis.');
478
+ };
479
+
480
+ const buildBanPayloadFromEvent = (event) => {
481
+ const metadata = isObject(event?.metadata) ? event.metadata : {};
482
+ const payload = compactIdentityPayload({
483
+ google_sub: metadata.google_sub,
484
+ email: metadata.email,
485
+ owner_jid: event?.sender_id || metadata.owner_jid,
486
+ });
487
+ if (!Object.keys(payload).length) return null;
488
+ payload.reason = `Ban via moderação (${normalizeString(event?.event_type) || 'evento'})`;
489
+ return payload;
490
+ };
491
+
492
+ const buildForceLogoutPayloadFromAny = (entry = {}) =>
493
+ compactIdentityPayload({
494
+ session_token: entry?.session_token,
495
+ google_sub: entry?.google_sub,
496
+ email: entry?.email,
497
+ owner_jid: entry?.owner_jid,
498
+ });
499
+
500
+ const handleAdminForceLogout = async (identity, contextLabel = '') => {
501
+ const payload = compactIdentityPayload(identity);
502
+ if (!Object.keys(payload).length) {
503
+ showAdminError('Não foi possível forçar logout: identidade ausente.');
504
+ return;
505
+ }
506
+ if (state.adminBusy) return;
507
+
508
+ showAdminError('');
509
+ setAdminBusy(true);
510
+ try {
511
+ const response = await fetchJson(adminForceLogoutApiPath, {
512
+ method: 'POST',
513
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
514
+ body: JSON.stringify(payload),
515
+ });
516
+ const removed = Number(response?.data?.removed_sessions || 0);
517
+ const label = normalizeString(contextLabel) || buildIdentityLabel(payload);
518
+ state.adminOpsMessage = `Logout forçado concluído para ${label}. Sessões removidas: ${removed}.`;
519
+ await refreshAdminArea({ keepCurrentError: true });
520
+ } catch (error) {
521
+ showAdminError(error?.message || 'Falha ao forçar logout.');
522
+ } finally {
523
+ setAdminBusy(false);
524
+ renderAdminPanel();
525
+ }
526
+ };
527
+
528
+ const handleAdminBanCreate = async (banPayload, contextLabel = '') => {
529
+ if (!isObject(banPayload)) return;
530
+ const payload = {
531
+ ...compactIdentityPayload(banPayload),
532
+ reason: normalizeString(banPayload.reason),
533
+ };
534
+ if (!payload.google_sub && !payload.email && !payload.owner_jid) {
535
+ showAdminError('Não foi possível banir: identidade ausente.');
536
+ return;
537
+ }
538
+ if (state.adminBusy) return;
539
+
540
+ showAdminError('');
541
+ setAdminBusy(true);
542
+ try {
543
+ const response = await fetchJson(adminBansApiPath, {
544
+ method: 'POST',
545
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
546
+ body: JSON.stringify(payload),
547
+ });
548
+ const created = Boolean(response?.data?.created);
549
+ const label = normalizeString(contextLabel) || buildIdentityLabel(payload);
550
+ state.adminOpsMessage = created ? `Conta banida: ${label}.` : `Conta já estava banida: ${label}.`;
551
+ await refreshAdminArea({ keepCurrentError: true });
552
+ } catch (error) {
553
+ showAdminError(error?.message || 'Falha ao criar ban.');
554
+ } finally {
555
+ setAdminBusy(false);
556
+ renderAdminPanel();
557
+ }
558
+ };
559
+
560
+ const handleAdminBanRevoke = async (banId) => {
561
+ const normalizedId = normalizeString(banId);
562
+ if (!normalizedId || state.adminBusy) return;
563
+
564
+ showAdminError('');
565
+ setAdminBusy(true);
566
+ try {
567
+ await fetchJson(`${adminBansApiPath}/${encodeURIComponent(normalizedId)}/revoke`, { method: 'DELETE' });
568
+ state.adminOpsMessage = `Ban ${normalizedId} revogado com sucesso.`;
569
+ await refreshAdminArea({ keepCurrentError: true });
570
+ } catch (error) {
571
+ showAdminError(error?.message || 'Falha ao revogar ban.');
572
+ } finally {
573
+ setAdminBusy(false);
574
+ renderAdminPanel();
575
+ }
576
+ };
577
+
578
+ const handleAdminFeatureFlagUpdate = async ({ flagName = '', isEnabled = false, rolloutPercent = 100, description = '' } = {}) => {
579
+ const normalizedName = normalizeString(flagName);
580
+ if (!normalizedName || state.adminBusy) return;
581
+
582
+ showAdminError('');
583
+ setAdminBusy(true);
584
+ try {
585
+ await fetchJson(adminFeatureFlagsApiPath, {
586
+ method: 'POST',
587
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
588
+ body: JSON.stringify({
589
+ flag_name: normalizedName,
590
+ is_enabled: Boolean(isEnabled),
591
+ rollout_percent: Math.max(0, Math.min(100, Math.floor(Number(rolloutPercent) || 0))),
592
+ description: normalizeString(description),
593
+ }),
594
+ });
595
+ state.adminOpsMessage = `Feature flag ${normalizedName} atualizada.`;
596
+ await refreshAdminArea({ keepCurrentError: true });
597
+ } catch (error) {
598
+ showAdminError(error?.message || 'Falha ao atualizar feature flag.');
599
+ } finally {
600
+ setAdminBusy(false);
601
+ renderAdminPanel();
602
+ }
603
+ };
604
+
605
+ const handleAdminOpsAction = async (action) => {
606
+ const normalizedAction = normalizeString(action);
607
+ if (!normalizedAction || state.adminBusy) return;
608
+
609
+ showAdminError('');
610
+ setAdminBusy(true);
611
+ try {
612
+ const response = await fetchJson(adminOpsApiPath, {
613
+ method: 'POST',
614
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
615
+ body: JSON.stringify({ action: normalizedAction }),
616
+ });
617
+ const message = normalizeString(response?.data?.message) || `Ação ${normalizedAction} concluída.`;
618
+ state.adminOpsMessage = `${message} (${formatDateTime(response?.data?.updated_at)})`;
619
+ setText(ui.adminOpsStatus, state.adminOpsMessage);
620
+ await refreshAdminArea({ keepCurrentError: true });
621
+ } catch (error) {
622
+ showAdminError(error?.message || 'Falha ao executar ação operacional.');
623
+ } finally {
624
+ setAdminBusy(false);
625
+ renderAdminPanel();
626
+ }
627
+ };
628
+
629
+ const renderModerationQueue = (events) => {
630
+ const list = Array.isArray(events) ? events : [];
631
+ clearNode(ui.adminModerationList);
632
+
633
+ if (!list.length) {
634
+ renderListPlaceholder(ui.adminModerationList, 'Nenhum evento recente de moderação.');
635
+ return;
636
+ }
637
+
638
+ for (const event of list) {
639
+ const title = normalizeString(event?.title) || 'Evento de moderação';
640
+ const severity = normalizeString(event?.severity) || 'low';
641
+ const badgeLabel = severity.toUpperCase();
642
+ const createdAt = formatDateTime(event?.created_at || event?.revoked_at);
643
+ const meta = [
644
+ normalizeString(event?.subtitle),
645
+ `Tipo: ${normalizeString(event?.event_type) || 'evento'} • ${createdAt}`,
646
+ ];
647
+
648
+ if (normalizeString(event?.reason)) meta.push(`Motivo: ${normalizeString(event.reason)}`);
649
+
650
+ const actions = [];
651
+ if (event?.event_type === 'ban') {
652
+ if (event?.ban_id && !event?.revoked_at) {
653
+ actions.push(createMiniButton('Revogar ban', () => handleAdminBanRevoke(event.ban_id)));
654
+ }
655
+ } else {
656
+ const banPayload = buildBanPayloadFromEvent(event);
657
+ if (banPayload) {
658
+ actions.push(createMiniButton('Banir conta', () => handleAdminBanCreate(banPayload, buildIdentityLabel(banPayload))));
659
+ }
660
+ const logoutPayload = compactIdentityPayload({
661
+ session_token: event?.metadata?.session_token,
662
+ google_sub: event?.metadata?.google_sub,
663
+ email: event?.metadata?.email,
664
+ owner_jid: event?.sender_id || event?.metadata?.owner_jid,
665
+ });
666
+ if (Object.keys(logoutPayload).length) {
667
+ actions.push(createMiniButton('Forçar logout', () => handleAdminForceLogout(logoutPayload, buildIdentityLabel(logoutPayload))));
668
+ }
669
+ }
670
+
671
+ appendListItem({
672
+ container: ui.adminModerationList,
673
+ title,
674
+ severity,
675
+ badgeLabel,
676
+ meta,
677
+ actions,
678
+ });
679
+ }
680
+ };
681
+
682
+ const renderActiveSessions = (sessions) => {
683
+ const list = Array.isArray(sessions) ? sessions : [];
684
+ clearNode(ui.adminSessionsList);
685
+
686
+ if (!list.length) {
687
+ renderListPlaceholder(ui.adminSessionsList, 'Nenhuma sessão ativa encontrada.');
688
+ return;
689
+ }
690
+
691
+ for (const session of list) {
692
+ const identity = compactIdentityPayload(session);
693
+ const title = normalizeString(session?.name || session?.email || session?.owner_jid || 'Sessão ativa');
694
+ const meta = [
695
+ `Email: ${normalizeString(session?.email) || 'n/d'}`,
696
+ `Owner: ${normalizeString(session?.owner_jid) || 'n/d'}`,
697
+ `Último acesso: ${formatDateTime(session?.last_seen_at)} • Expira: ${formatDateTime(session?.expires_at)}`,
698
+ ];
699
+ const actions = [];
700
+ if (Object.keys(identity).length) {
701
+ actions.push(createMiniButton('Forçar logout', () => handleAdminForceLogout(identity, buildIdentityLabel(identity))));
702
+ }
703
+ appendListItem({
704
+ container: ui.adminSessionsList,
705
+ title,
706
+ severity: 'low',
707
+ badgeLabel: 'ATIVA',
708
+ meta,
709
+ actions,
710
+ });
711
+ }
712
+ };
713
+
714
+ const renderKnownUsers = (users) => {
715
+ const list = Array.isArray(users) ? users : [];
716
+ clearNode(ui.adminUsersList);
717
+
718
+ if (!list.length) {
719
+ renderListPlaceholder(ui.adminUsersList, 'Nenhum usuário encontrado.');
720
+ return;
721
+ }
722
+
723
+ for (const user of list) {
724
+ const identity = buildForceLogoutPayloadFromAny(user);
725
+ const title = normalizeString(user?.name || user?.email || user?.owner_jid || 'Usuário');
726
+ const meta = [
727
+ `Email: ${normalizeString(user?.email) || 'n/d'}`,
728
+ `Owner: ${normalizeString(user?.owner_jid) || 'n/d'}`,
729
+ `Último login: ${formatDateTime(user?.last_login_at)} • Último acesso: ${formatDateTime(user?.last_seen_at)}`,
730
+ ];
731
+ const actions = [];
732
+ if (Object.keys(identity).length) {
733
+ actions.push(createMiniButton('Forçar logout', () => handleAdminForceLogout(identity, buildIdentityLabel(identity))));
734
+ }
735
+ appendListItem({
736
+ container: ui.adminUsersList,
737
+ title,
738
+ severity: 'low',
739
+ badgeLabel: 'USER',
740
+ meta,
741
+ actions,
742
+ });
743
+ }
744
+ };
745
+
746
+ const renderBlockedAccounts = (bans) => {
747
+ const list = Array.isArray(bans) ? bans : [];
748
+ clearNode(ui.adminBansList);
749
+
750
+ if (!list.length) {
751
+ renderListPlaceholder(ui.adminBansList, 'Nenhuma conta bloqueada.');
752
+ return;
753
+ }
754
+
755
+ for (const ban of list) {
756
+ const identity = normalizeString(ban?.email || ban?.owner_jid || ban?.google_sub || `ban:${ban?.id || ''}`);
757
+ const isRevoked = Boolean(ban?.revoked_at);
758
+ const meta = [
759
+ `Criado: ${formatDateTime(ban?.created_at)}${isRevoked ? ` • Revogado: ${formatDateTime(ban?.revoked_at)}` : ''}`,
760
+ `Motivo: ${normalizeString(ban?.reason) || 'não informado'}`,
761
+ ];
762
+ const actions = [];
763
+ if (!isRevoked && normalizeString(ban?.id)) {
764
+ actions.push(createMiniButton('Revogar ban', () => handleAdminBanRevoke(ban.id)));
765
+ }
766
+ appendListItem({
767
+ container: ui.adminBansList,
768
+ title: identity,
769
+ severity: isRevoked ? 'low' : 'critical',
770
+ badgeLabel: isRevoked ? 'REVOGADO' : 'BLOQUEADO',
771
+ meta,
772
+ actions,
773
+ });
774
+ }
775
+ };
776
+
777
+ const renderAuditLog = (events) => {
778
+ const list = Array.isArray(events) ? events : [];
779
+ clearNode(ui.adminAuditList);
780
+
781
+ if (!list.length) {
782
+ renderListPlaceholder(ui.adminAuditList, 'Sem eventos de auditoria recentes.');
783
+ return;
784
+ }
785
+
786
+ for (const item of list.slice(0, 80)) {
787
+ const action = normalizeString(item?.action || 'action');
788
+ const targetType = normalizeString(item?.target_type || 'target');
789
+ const targetId = normalizeString(item?.target_id || '');
790
+ const status = normalizeString(item?.status || 'success');
791
+ const details = isObject(item?.details) ? Object.entries(item.details).slice(0, 3) : [];
792
+ const detailLine = details.length ? `Detalhes: ${details.map(([key, value]) => `${key}=${value}`).join(' • ')}` : '';
793
+
794
+ const meta = [
795
+ `Admin: ${normalizeString(item?.admin_email || item?.admin_google_sub || item?.admin_owner_jid || 'n/d')} (${formatAdminRole(normalizeString(item?.admin_role || 'admin'))})`,
796
+ `Alvo: ${targetType}${targetId ? ` / ${targetId}` : ''} • Em: ${formatDateTime(item?.created_at)}`,
797
+ ];
798
+ if (detailLine) meta.push(detailLine);
799
+
800
+ appendListItem({
801
+ container: ui.adminAuditList,
802
+ title: action,
803
+ severity: status === 'success' ? 'low' : 'high',
804
+ badgeLabel: status.toUpperCase(),
805
+ meta,
806
+ });
807
+ }
808
+ };
809
+
810
+ const renderFeatureFlags = (flags) => {
811
+ const list = Array.isArray(flags) ? flags : [];
812
+ clearNode(ui.adminFlagsList);
813
+
814
+ if (!list.length) {
815
+ renderListPlaceholder(ui.adminFlagsList, 'Nenhuma feature flag disponível.');
816
+ return;
817
+ }
818
+
819
+ for (const flag of list) {
820
+ const flagName = normalizeString(flag?.flag_name);
821
+ const isEnabled = Boolean(flag?.is_enabled);
822
+ const rollout = Math.max(0, Math.min(100, Math.floor(Number(flag?.rollout_percent) || 0)));
823
+ const description = normalizeString(flag?.description);
824
+ const updatedBy = normalizeString(flag?.updated_by);
825
+
826
+ const rolloutForm = document.createElement('form');
827
+ rolloutForm.className = 'admin-inline-form';
828
+
829
+ const rolloutInput = document.createElement('input');
830
+ rolloutInput.className = 'admin-input';
831
+ rolloutInput.type = 'number';
832
+ rolloutInput.min = '0';
833
+ rolloutInput.max = '100';
834
+ rolloutInput.step = '1';
835
+ rolloutInput.value = String(rollout);
836
+ rolloutInput.setAttribute('aria-label', `Rollout de ${flagName}`);
837
+
838
+ const rolloutBtn = document.createElement('button');
839
+ rolloutBtn.type = 'submit';
840
+ rolloutBtn.className = 'admin-mini-btn';
841
+ rolloutBtn.textContent = 'Salvar rollout';
842
+
843
+ rolloutForm.appendChild(rolloutInput);
844
+ rolloutForm.appendChild(rolloutBtn);
845
+ rolloutForm.addEventListener('submit', (event) => {
846
+ event.preventDefault();
847
+ void handleAdminFeatureFlagUpdate({
848
+ flagName,
849
+ isEnabled,
850
+ rolloutPercent: rolloutInput.value,
851
+ description,
852
+ });
853
+ });
854
+
855
+ const actions = [
856
+ createMiniButton(isEnabled ? 'Desativar' : 'Ativar', () =>
857
+ handleAdminFeatureFlagUpdate({
858
+ flagName,
859
+ isEnabled: !isEnabled,
860
+ rolloutPercent: rollout,
861
+ description,
862
+ }),
863
+ ),
864
+ ];
865
+
866
+ appendListItem({
867
+ container: ui.adminFlagsList,
868
+ title: flagName || 'feature_flag',
869
+ severity: isEnabled ? 'low' : 'medium',
870
+ badgeLabel: isEnabled ? 'ON' : 'OFF',
871
+ meta: [
872
+ `Rollout: ${rollout}%`,
873
+ description ? `Descrição: ${description}` : 'Descrição: n/d',
874
+ `Atualizado por: ${updatedBy || 'n/d'} • ${formatDateTime(flag?.updated_at)}`,
875
+ ],
876
+ actions,
877
+ customNode: rolloutForm,
878
+ });
879
+ }
880
+ };
881
+
882
+ const renderAlerts = (alerts) => {
883
+ const list = Array.isArray(alerts) ? alerts : [];
884
+ clearNode(ui.adminAlertsList);
885
+
886
+ if (!list.length) {
887
+ renderListPlaceholder(ui.adminAlertsList, 'Sem alertas ativos no momento.');
888
+ return;
889
+ }
890
+
891
+ for (const alert of list) {
892
+ const severity = normalizeString(alert?.severity || 'low');
893
+ const title = normalizeString(alert?.title || alert?.code || 'Alerta');
894
+ const meta = [
895
+ normalizeString(alert?.message || ''),
896
+ `Código: ${normalizeString(alert?.code || 'n/d')} • ${formatDateTime(alert?.created_at)}`,
897
+ ];
898
+ appendListItem({
899
+ container: ui.adminAlertsList,
900
+ title,
901
+ severity,
902
+ badgeLabel: severity.toUpperCase(),
903
+ meta,
904
+ });
905
+ }
906
+ };
907
+
908
+ const renderSearchResults = (payload = state.adminSearchPayload) => {
909
+ if (!ui.adminSearchResults) return;
910
+ clearNode(ui.adminSearchResults);
911
+
912
+ if (!payload || !isObject(payload)) {
913
+ renderListPlaceholder(ui.adminSearchResults, 'Faça uma busca para ver usuários, grupos, packs e sessões.');
914
+ return;
915
+ }
916
+
917
+ const q = normalizeString(payload?.q);
918
+ const totals = isObject(payload?.totals) ? payload.totals : {};
919
+ const results = isObject(payload?.results) ? payload.results : {};
920
+
921
+ appendListItem({
922
+ container: ui.adminSearchResults,
923
+ title: `Resultado para "${q || 'consulta'}"`,
924
+ severity: 'low',
925
+ badgeLabel: 'BUSCA',
926
+ meta: [
927
+ `Usuários: ${formatIntegerOrNd(totals.users)}`,
928
+ `Sessões: ${formatIntegerOrNd(totals.sessions)}`,
929
+ `Grupos: ${formatIntegerOrNd(totals.groups)}`,
930
+ `Packs: ${formatIntegerOrNd(totals.packs)}`,
931
+ ],
932
+ });
933
+
934
+ const users = Array.isArray(results.users) ? results.users : [];
935
+ for (const user of users) {
936
+ const identity = buildForceLogoutPayloadFromAny(user);
937
+ const actions = [];
938
+ if (Object.keys(identity).length) {
939
+ actions.push(createMiniButton('Forçar logout', () => handleAdminForceLogout(identity, buildIdentityLabel(identity))));
940
+ }
941
+ appendListItem({
942
+ container: ui.adminSearchResults,
943
+ title: `[Usuário] ${normalizeString(user?.name || user?.email || user?.owner_jid || 'registro')}`,
944
+ severity: 'low',
945
+ badgeLabel: 'USER',
946
+ meta: [`Email: ${normalizeString(user?.email) || 'n/d'}`, `Owner: ${normalizeString(user?.owner_jid) || 'n/d'}`],
947
+ actions,
948
+ });
949
+ }
950
+
951
+ const sessions = Array.isArray(results.sessions) ? results.sessions : [];
952
+ for (const session of sessions) {
953
+ const identity = buildForceLogoutPayloadFromAny(session);
954
+ const actions = [];
955
+ if (Object.keys(identity).length) {
956
+ actions.push(createMiniButton('Forçar logout', () => handleAdminForceLogout(identity, buildIdentityLabel(identity))));
957
+ }
958
+ appendListItem({
959
+ container: ui.adminSearchResults,
960
+ title: `[Sessão] ${normalizeString(session?.name || session?.email || session?.owner_jid || 'ativa')}`,
961
+ severity: 'low',
962
+ badgeLabel: 'SESSÃO',
963
+ meta: [`Email: ${normalizeString(session?.email) || 'n/d'}`, `Expira: ${formatDateTime(session?.expires_at)}`],
964
+ actions,
965
+ });
966
+ }
967
+
968
+ const groups = Array.isArray(results.groups) ? results.groups : [];
969
+ for (const group of groups) {
970
+ appendListItem({
971
+ container: ui.adminSearchResults,
972
+ title: `[Grupo] ${normalizeString(group?.subject || group?.id || 'grupo')}`,
973
+ severity: 'medium',
974
+ badgeLabel: 'GRUPO',
975
+ meta: [`ID: ${normalizeString(group?.id) || 'n/d'}`, `Atualizado: ${formatDateTime(group?.updated_at)}`],
976
+ });
977
+ }
978
+
979
+ const packs = Array.isArray(results.packs) ? results.packs : [];
980
+ for (const pack of packs) {
981
+ const packUrl = normalizeString(pack?.web_url);
982
+ const actions = [];
983
+ if (packUrl) actions.push(createMiniLink('Abrir pack', packUrl));
984
+ appendListItem({
985
+ container: ui.adminSearchResults,
986
+ title: `[Pack] ${normalizeString(pack?.name || pack?.pack_key || 'pack')}`,
987
+ severity: 'low',
988
+ badgeLabel: normalizeString(pack?.visibility || 'pack').toUpperCase(),
989
+ meta: [`Owner: ${normalizeString(pack?.owner_jid) || 'n/d'}`, `Stickers: ${formatIntegerOrNd(pack?.stickers_count)}`],
990
+ actions,
991
+ });
992
+ }
993
+
994
+ if (!users.length && !sessions.length && !groups.length && !packs.length) {
995
+ appendListItem({
996
+ container: ui.adminSearchResults,
997
+ title: 'Nenhum resultado encontrado.',
998
+ severity: 'low',
999
+ badgeLabel: 'VAZIO',
1000
+ meta: ['Tente outro termo de busca.'],
1001
+ });
1002
+ }
1003
+ };
1004
+
1005
+ const renderSystemHealth = (health) => {
1006
+ const statusMap = {
1007
+ ok: 'OK',
1008
+ degraded: 'Degradado',
1009
+ unknown: 'Indefinido',
1010
+ };
1011
+ setText(ui.adminHealthCpu, formatPercent(health?.cpu_percent));
1012
+ setText(ui.adminHealthRam, formatPercent(health?.ram_percent));
1013
+ setText(ui.adminHealthLatency, formatMilliseconds(health?.http_latency_p95_ms));
1014
+ setText(ui.adminHealthQueue, formatIntegerOrNd(health?.queue_pending));
1015
+ setText(ui.adminHealthDb, statusMap[normalizeString(health?.db_status)] || 'n/d');
248
1016
  };
249
1017
 
250
1018
  const renderAdminOverview = () => {
251
- const counters = state.adminOverviewPayload?.counters || {};
252
- setText(ui.adminTotalPacks, formatNumber(counters.total_packs_any_status || 0));
253
- setText(ui.adminTotalStickers, formatNumber(counters.total_stickers_any_status || 0));
254
- setText(ui.adminActiveBans, formatNumber(counters.active_bans || 0));
255
- setText(ui.adminKnownUsers, formatNumber(counters.known_google_users || 0));
256
- setText(ui.adminActiveSessions, formatNumber(counters.active_google_sessions || 0));
257
- setText(ui.adminVisits24h, formatNumber(counters.visit_events_24h || 0));
258
- setText(ui.adminVisits7d, formatNumber(counters.visit_events_7d || 0));
259
- setText(ui.adminUniqueVisitors7d, formatNumber(counters.unique_visitors_7d || 0));
1019
+ const payload = state.adminOverviewPayload || {};
1020
+ const counters = isObject(payload?.counters) ? payload.counters : {};
1021
+ const dashboard = isObject(payload?.dashboard_quick) ? payload.dashboard_quick : {};
1022
+ const usersSessions = isObject(payload?.users_sessions) ? payload.users_sessions : {};
1023
+ const health = isObject(payload?.system_health) ? payload.system_health : {};
1024
+
1025
+ setText(ui.adminBotsOnline, formatIntegerOrNd(dashboard?.bots_online || 0));
1026
+ setText(ui.adminMessagesToday, formatIntegerOrNd(dashboard?.messages_today));
1027
+ setText(ui.adminSpamBlocked, formatIntegerOrNd(dashboard?.spam_blocked_today));
1028
+ setText(ui.adminUptime, normalizeString(dashboard?.uptime) || 'n/d');
1029
+ setText(ui.adminErrors5xx, formatIntegerOrNd(dashboard?.errors_5xx || 0));
1030
+
1031
+ setText(ui.adminTotalPacks, formatIntegerOrNd(counters?.total_packs_any_status || 0));
1032
+ setText(ui.adminTotalStickers, formatIntegerOrNd(counters?.total_stickers_any_status || 0));
1033
+ setText(ui.adminActiveBans, formatIntegerOrNd(counters?.active_bans || 0));
1034
+ setText(ui.adminKnownUsers, formatIntegerOrNd(counters?.known_google_users || 0));
1035
+ setText(ui.adminActiveSessions, formatIntegerOrNd(counters?.active_google_sessions || 0));
1036
+ setText(ui.adminVisits24h, formatIntegerOrNd(counters?.visit_events_24h || 0));
1037
+ setText(ui.adminVisits7d, formatIntegerOrNd(counters?.visit_events_7d || 0));
1038
+ setText(ui.adminUniqueVisitors7d, formatIntegerOrNd(counters?.unique_visitors_7d || 0));
1039
+
1040
+ renderSystemHealth(health);
1041
+ renderModerationQueue(payload?.moderation_queue);
1042
+ renderActiveSessions(usersSessions?.active_sessions);
1043
+ renderKnownUsers(usersSessions?.users);
1044
+ renderBlockedAccounts(usersSessions?.blocked_accounts);
1045
+ renderAuditLog(payload?.audit_log);
1046
+ renderFeatureFlags(payload?.feature_flags);
1047
+ renderAlerts(payload?.alerts);
1048
+ renderSearchResults();
1049
+ setText(ui.adminOpsStatus, state.adminOpsMessage || 'Ações operacionais disponíveis.');
260
1050
  };
261
1051
 
262
1052
  const renderAdminPanel = () => {
@@ -265,6 +1055,7 @@ if (root) {
265
1055
  const enabled = state.adminStatusPayload?.enabled !== false;
266
1056
  const authenticated = isAdminAuthenticated();
267
1057
  const eligible = isAdminEligible();
1058
+ const role = resolveAdminRole();
268
1059
 
269
1060
  if (!enabled || (!eligible && !authenticated)) {
270
1061
  ui.adminPanel.hidden = true;
@@ -275,24 +1066,33 @@ if (root) {
275
1066
  }
276
1067
 
277
1068
  ui.adminPanel.hidden = false;
278
- const role = resolveAdminRole();
279
1069
  setText(ui.adminRole, formatAdminRole(role));
280
1070
 
281
1071
  if (authenticated) {
282
- setText(ui.adminStatus, `Sessão admin ativa como ${formatAdminRole(role)}.`);
1072
+ setText(ui.adminStatus, `Sessão admin ativa como ${formatAdminRole(role)}. Ferramentas operacionais liberadas abaixo.`);
283
1073
  if (ui.adminUnlockForm) ui.adminUnlockForm.hidden = true;
284
1074
  if (ui.adminOverview) ui.adminOverview.hidden = false;
285
1075
  renderAdminOverview();
286
1076
  } else {
287
- setText(ui.adminStatus, `Conta elegível para admin (${formatAdminRole(role)}). Informe a senha para liberar os dados.`);
1077
+ setText(ui.adminStatus, `Conta elegível para admin (${formatAdminRole(role)}). Informe a senha para liberar os dados sensíveis.`);
288
1078
  if (ui.adminUnlockForm) ui.adminUnlockForm.hidden = false;
289
1079
  if (ui.adminOverview) ui.adminOverview.hidden = true;
290
- resetAdminMetrics();
1080
+ setAdminMetricsDefaults();
291
1081
  }
292
1082
 
293
1083
  setAdminBusy(state.adminBusy);
294
1084
  };
295
1085
 
1086
+ const loadBotPhone = async () => {
1087
+ try {
1088
+ const payload = await fetchJson(botContactApiPath, { method: 'GET' });
1089
+ state.botPhone = normalizeDigits(payload?.data?.phone || '');
1090
+ } catch {
1091
+ state.botPhone = '';
1092
+ }
1093
+ if (ui.chatLink) ui.chatLink.href = buildWhatsAppMenuUrl(state.botPhone);
1094
+ };
1095
+
296
1096
  const loadAdminStatus = async () => {
297
1097
  const payload = await fetchJson(adminSessionApiPath, { method: 'GET' });
298
1098
  state.adminStatusPayload = payload?.data || null;
@@ -324,7 +1124,7 @@ if (root) {
324
1124
  };
325
1125
 
326
1126
  const handleAdminUnlock = async () => {
327
- const password = String(ui.adminPassword?.value || '').trim();
1127
+ const password = normalizeString(ui.adminPassword?.value);
328
1128
  if (!password) {
329
1129
  showAdminError('Informe a senha do painel admin.');
330
1130
  return;
@@ -340,6 +1140,8 @@ if (root) {
340
1140
  body: JSON.stringify({ password }),
341
1141
  });
342
1142
  state.adminStatusPayload = payload?.data || null;
1143
+ state.adminOpsMessage = '';
1144
+ state.adminSearchPayload = null;
343
1145
  if (ui.adminPassword) ui.adminPassword.value = '';
344
1146
  await loadAdminOverview();
345
1147
  } catch (error) {
@@ -359,9 +1161,11 @@ if (root) {
359
1161
  try {
360
1162
  await fetchJson(adminSessionApiPath, { method: 'DELETE' });
361
1163
  } catch {
362
- // no-op: we still revalidate status below
1164
+ // no-op
363
1165
  }
364
1166
  state.adminOverviewPayload = null;
1167
+ state.adminSearchPayload = null;
1168
+ state.adminOpsMessage = '';
365
1169
  await loadAdminStatus().catch(() => {
366
1170
  state.adminStatusPayload = null;
367
1171
  });
@@ -377,6 +1181,93 @@ if (root) {
377
1181
  renderAdminPanel();
378
1182
  };
379
1183
 
1184
+ const handleAdminSearchSubmit = async () => {
1185
+ if (state.adminBusy) return;
1186
+
1187
+ const q = normalizeString(ui.adminSearchInput?.value);
1188
+ if (!q) {
1189
+ state.adminSearchPayload = null;
1190
+ renderSearchResults();
1191
+ return;
1192
+ }
1193
+
1194
+ showAdminError('');
1195
+ setAdminBusy(true);
1196
+ try {
1197
+ const query = new URLSearchParams({ q, limit: '12' }).toString();
1198
+ const payload = await fetchJson(`${adminSearchApiPath}?${query}`, { method: 'GET' });
1199
+ state.adminSearchPayload = payload?.data || null;
1200
+ state.adminOpsMessage = `Busca concluída para "${q}".`;
1201
+ renderSearchResults();
1202
+ setText(ui.adminOpsStatus, state.adminOpsMessage);
1203
+ } catch (error) {
1204
+ showAdminError(error?.message || 'Falha ao buscar dados.');
1205
+ } finally {
1206
+ setAdminBusy(false);
1207
+ renderAdminPanel();
1208
+ }
1209
+ };
1210
+
1211
+ const downloadBlob = (blob, filename) => {
1212
+ const objectUrl = window.URL.createObjectURL(blob);
1213
+ const link = document.createElement('a');
1214
+ link.href = objectUrl;
1215
+ link.download = filename;
1216
+ document.body.appendChild(link);
1217
+ link.click();
1218
+ link.remove();
1219
+ window.setTimeout(() => {
1220
+ window.URL.revokeObjectURL(objectUrl);
1221
+ }, 250);
1222
+ };
1223
+
1224
+ const extractFilenameFromDisposition = (contentDisposition, fallbackName) => {
1225
+ const source = normalizeString(contentDisposition);
1226
+ if (!source) return fallbackName;
1227
+ const utf8Match = source.match(/filename\*=UTF-8''([^;]+)/i);
1228
+ if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
1229
+ const plainMatch = source.match(/filename="?([^"]+)"?/i);
1230
+ if (plainMatch?.[1]) return plainMatch[1];
1231
+ return fallbackName;
1232
+ };
1233
+
1234
+ const handleAdminExport = async ({ type = 'metrics', format = 'json' } = {}) => {
1235
+ if (state.adminBusy) return;
1236
+
1237
+ const normalizedType = normalizeString(type || 'metrics').toLowerCase();
1238
+ const normalizedFormat = normalizeString(format || 'json').toLowerCase();
1239
+ const fallbackName = `admin-${normalizedType}-${Date.now()}.${normalizedFormat === 'csv' ? 'csv' : 'json'}`;
1240
+
1241
+ showAdminError('');
1242
+ setAdminBusy(true);
1243
+ try {
1244
+ const query = new URLSearchParams({
1245
+ type: normalizedType,
1246
+ format: normalizedFormat,
1247
+ }).toString();
1248
+ const response = await fetchWithAuth(`${adminExportApiPath}?${query}`, { method: 'GET' });
1249
+
1250
+ if (normalizedFormat === 'csv') {
1251
+ const blob = await response.blob();
1252
+ const contentDisposition = response.headers.get('content-disposition');
1253
+ const filename = extractFilenameFromDisposition(contentDisposition, fallbackName);
1254
+ downloadBlob(blob, filename);
1255
+ } else {
1256
+ const payload = await response.json().catch(() => ({}));
1257
+ const blob = new Blob([JSON.stringify(payload?.data || payload || {}, null, 2)], { type: 'application/json; charset=utf-8' });
1258
+ downloadBlob(blob, fallbackName);
1259
+ }
1260
+
1261
+ state.adminOpsMessage = `Exportação ${normalizedType.toUpperCase()} (${normalizedFormat.toUpperCase()}) concluída.`;
1262
+ setText(ui.adminOpsStatus, state.adminOpsMessage);
1263
+ } catch (error) {
1264
+ showAdminError(error?.message || 'Falha ao exportar dados.');
1265
+ } finally {
1266
+ setAdminBusy(false);
1267
+ renderAdminPanel();
1268
+ }
1269
+ };
1270
+
380
1271
  const handleLogout = async () => {
381
1272
  if (!ui.logoutBtn) return;
382
1273
  ui.logoutBtn.disabled = true;
@@ -397,6 +1288,7 @@ if (root) {
397
1288
 
398
1289
  setText(ui.status, 'Validando sua sessão...');
399
1290
  showError('');
1291
+ setAdminMetricsDefaults();
400
1292
 
401
1293
  let sessionData = null;
402
1294
  try {
@@ -456,5 +1348,43 @@ if (root) {
456
1348
  });
457
1349
  }
458
1350
 
1351
+ if (ui.adminSearchForm) {
1352
+ ui.adminSearchForm.addEventListener('submit', (event) => {
1353
+ event.preventDefault();
1354
+ void handleAdminSearchSubmit();
1355
+ });
1356
+ }
1357
+
1358
+ if (ui.adminOpButtons.length) {
1359
+ for (const button of ui.adminOpButtons) {
1360
+ button.addEventListener('click', () => {
1361
+ const action = normalizeString(button.dataset.adminOpAction);
1362
+ if (!action) return;
1363
+ void handleAdminOpsAction(action);
1364
+ });
1365
+ }
1366
+ }
1367
+
1368
+ if (ui.adminExportMetricsJsonBtn) {
1369
+ ui.adminExportMetricsJsonBtn.addEventListener('click', () => {
1370
+ void handleAdminExport({ type: 'metrics', format: 'json' });
1371
+ });
1372
+ }
1373
+ if (ui.adminExportMetricsCsvBtn) {
1374
+ ui.adminExportMetricsCsvBtn.addEventListener('click', () => {
1375
+ void handleAdminExport({ type: 'metrics', format: 'csv' });
1376
+ });
1377
+ }
1378
+ if (ui.adminExportEventsJsonBtn) {
1379
+ ui.adminExportEventsJsonBtn.addEventListener('click', () => {
1380
+ void handleAdminExport({ type: 'events', format: 'json' });
1381
+ });
1382
+ }
1383
+ if (ui.adminExportEventsCsvBtn) {
1384
+ ui.adminExportEventsCsvBtn.addEventListener('click', () => {
1385
+ void handleAdminExport({ type: 'events', format: 'csv' });
1386
+ });
1387
+ }
1388
+
459
1389
  void init();
460
1390
  }