diff --git a/api/src/platform/integrations.service.ts b/api/src/platform/integrations.service.ts index f3e7467..0c35021 100644 --- a/api/src/platform/integrations.service.ts +++ b/api/src/platform/integrations.service.ts @@ -1,6 +1,12 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { buildIntegrations, type Integration } from './integrations'; +import { + buildIntegrations, + firebaseProbeUrl, + nominatimIsHealthy, + nominatimProbeUrl, + type Integration, +} from './integrations'; @Injectable() export class IntegrationsService { @@ -41,21 +47,16 @@ export class IntegrationsService { private async probeNominatim() { return this.ok(async () => { - const query = new URLSearchParams({ - format: 'json', - limit: '1', - countrycodes: 'br', - q: 'Sao Paulo, SP, Brasil', - }); - const response = await fetch(`https://nominatim.openstreetmap.org/search?${query.toString()}`, { + const response = await fetch(nominatimProbeUrl(), { headers: { 'User-Agent': 'SinkaLogistica/1.0 (desafio; tina.r@example.net)' }, signal: AbortSignal.timeout(4000), }); if (!response.ok) { return false; } - const rows = (await response.json()) as unknown[]; - return rows.length > 0; + return nominatimIsHealthy( + (await response.json()) as { status?: number; message?: string }, + ); }); } @@ -63,7 +64,7 @@ export class IntegrationsService { const url = (this.config.get('FIREBASE_DATABASE_URL')?.replace(/\/$/, '') || 'https://sinka-8ec10-default-rtdb.firebaseio.com'); return this.ok(async () => { - const response = await fetch(`${url}/.json?shallow=true`, { + const response = await fetch(firebaseProbeUrl(url), { signal: AbortSignal.timeout(4000), }); return response.ok; diff --git a/api/src/platform/integrations.spec.ts b/api/src/platform/integrations.spec.ts index e0cbb82..6f22175 100644 --- a/api/src/platform/integrations.spec.ts +++ b/api/src/platform/integrations.spec.ts @@ -1,4 +1,23 @@ -import { buildIntegrations } from './integrations'; +import { buildIntegrations, firebaseProbeUrl, nominatimIsHealthy, nominatimProbeUrl } from './integrations'; + +describe('firebaseProbeUrl', () => { + it('checks the chat path, not the database root', () => { + expect(firebaseProbeUrl('https://sinka-8ec10-default-rtdb.firebaseio.com')).toBe( + 'https://sinka-8ec10-default-rtdb.firebaseio.com/chats/_health/messages.json', + ); + }); +}); + +describe('nominatim probe', () => { + it('uses the status endpoint instead of a search that OSM often blocks', () => { + expect(nominatimProbeUrl()).toBe('https://nominatim.openstreetmap.org/status.php?format=json'); + }); + + it('treats status 0 as healthy', () => { + expect(nominatimIsHealthy({ status: 0, message: 'OK' })).toBe(true); + expect(nominatimIsHealthy({ status: 1, message: 'ERROR' })).toBe(false); + }); +}); describe('buildIntegrations', () => { it('marks each integration from the probe result', () => { diff --git a/api/src/platform/integrations.ts b/api/src/platform/integrations.ts index 91dbd7a..e84a842 100644 --- a/api/src/platform/integrations.ts +++ b/api/src/platform/integrations.ts @@ -7,6 +7,18 @@ export type Integration = { status: IntegrationStatus; }; +export function firebaseProbeUrl(databaseUrl: string) { + return `${databaseUrl.replace(/\/$/, '')}/chats/_health/messages.json`; +} + +export function nominatimProbeUrl() { + return 'https://nominatim.openstreetmap.org/status.php?format=json'; +} + +export function nominatimIsHealthy(payload: { status?: number; message?: string }) { + return payload.status === 0 || payload.message === 'OK'; +} + export function buildIntegrations(probes: { viaCep: boolean; nominatim: boolean; diff --git a/web/src/app/admin/(panel)/clientes/page.tsx b/web/src/app/admin/(panel)/clientes/page.tsx index 0348f7d..e06f492 100644 --- a/web/src/app/admin/(panel)/clientes/page.tsx +++ b/web/src/app/admin/(panel)/clientes/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { FormEvent, useEffect, useState } from "react"; +import { FormEvent, useEffect, useLayoutEffect, useRef, useState } from "react"; import { api } from "@/lib/api"; import { Modal, RegistryTable, fieldClass } from "@/components/registry-table"; @@ -30,6 +30,166 @@ const roleLabel = { OPERATOR: "Operador", } as const; +function TenantRowActions({ + tenant, + chatCount, + onUsers, +}: { + tenant: Tenant; + chatCount: number; + onUsers: () => void; +}) { + const hostRef = useRef(null); + const rowRef = useRef(null); + const neededRef = useRef(0); + const [compact, setCompact] = useState(false); + const [menu, setMenu] = useState<{ top: number; right: number } | null>(null); + + useLayoutEffect(() => { + const host = hostRef.current; + if (!host) { + return; + } + + function measure() { + const node = hostRef.current; + const row = rowRef.current; + if (!node) { + return; + } + if (row) { + neededRef.current = row.scrollWidth; + } + setCompact(node.clientWidth < neededRef.current); + } + + measure(); + const observer = new ResizeObserver(measure); + observer.observe(host); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!menu) { + return; + } + function close() { + setMenu(null); + } + function onKey(event: KeyboardEvent) { + if (event.key === "Escape") { + close(); + } + } + window.addEventListener("click", close); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("click", close); + window.removeEventListener("keydown", onKey); + }; + }, [menu]); + + const buttons = ( + <> + + + Chat + {chatCount ? ( + + {chatCount > 99 ? "99+" : chatCount} + + ) : null} + + + Entrar + + + ); + + return ( +
+
+ {buttons} +
+ {compact ? ( + <> + + {menu ? ( +
event.stopPropagation()} + > + + + Chat + {chatCount ? ( + + {chatCount > 99 ? "99+" : chatCount} + + ) : null} + + + Entrar + +
+ ) : null} + + ) : null} +
+ ); +} + function slugFromName(value: string) { return value .normalize("NFD") @@ -155,35 +315,12 @@ export default function PlatformClientesPage() { /{tenant.slug} {tenant.status === "ACTIVE" ? "Ativo" : "Suspenso"} -
- - - Chat - {chatCounts[tenant.slug] ? ( - - {chatCounts[tenant.slug] > 99 ? "99+" : chatCounts[tenant.slug]} - - ) : null} - - - Entrar - -
- + void openUsers(tenant)} + /> + ))}