From 9126b3bbe1c06cfa131ed90ad295a3890d16e523 Mon Sep 17 00:00:00 2001 From: "Glauber.x86" Date: Sun, 13 Sep 2026 02:16:54 -0300 Subject: [PATCH 01/12] =?UTF-8?q?perf:=20connection=5Flimit=20para=20ambie?= =?UTF-8?q?nte=20dev=20e=20corre=C3=A7=C3=A3o=20de=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LOG_QUERIES=1 acrescenta "query" ao log do PrismaClient - LOG_HTTP=1 loga "método caminho status duração_ms" por request via middleware em server/index.ts - compression() registrado antes das rotas: gzip/brotli nas respostas (bundle JS/CSS e JSON grandes) — cada KB ajuda na viagem até o navegador - index.html passa a ir com Cache-Control: no-cache (revalida a cada load para pegar deploy novo); assets com hash continuam immutable - correção incidental: sendFile(path.join(...)) falhava com 404 quando o diretório do projeto tem algum segmento começando com "." (o módulo `send` aplica o check de dotfile no path absoluto inteiro); trocado para sendFile("index.html", { root: clientDist }), que checa só o path relativo ao root - connection_limit do Prisma configurável via DB_CONNECTION_LIMIT, com padrão 10 em produção e 2 fora dela: o pooler do Supabase em modo sessão limita a 15 clientes no total, e um `npm run dev` local com 10 conexões disputava slots com o container em produção (EMAXCONNSESSION) - adiciona compression e @types/compression --- .env.example | 5 +- .gitignore | 3 ++ package-lock.json | 101 ++++++++++++++++++++++++++++++++---- package.json | 2 + server/index.ts | 25 ++++++++- server/lib/prisma.server.ts | 19 +++++-- 6 files changed, 138 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index d0e28d9..e28cc4f 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,7 @@ DIRECT_URL="postgresql://postgres:password@db.supabase.co:5432/postgres" VITE_SUPABASE_URL="https://your-project.supabase.co" VITE_SUPABASE_ANON_KEY="your-anon-key" SUPABASE_SERVICE_ROLE_KEY="your-service-role-key" -SESSION_SECRET="sua-chave-secreta" \ No newline at end of file +SESSION_SECRET="sua-chave-secreta" +# Opcional: conexões do pool do Prisma por processo (padrão: 10 em produção, 2 fora dela). +# O pooler do Supabase em modo sessão aceita 15 clientes no total. +# DB_CONNECTION_LIMIT=2 diff --git a/.gitignore b/.gitignore index 60903a6..b9a7a6e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ yarn-error.log* *.swp *.swo localhost.har + +# Specs e rascunhos locais +.tmp/ diff --git a/package-lock.json b/package-lock.json index 89ae33d..839cb21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "@supabase/ssr": "^0.12.0", "@supabase/supabase-js": "^2.110.0", "@tanstack/react-query": "^5.102.8", + "@types/compression": "^1.8.1", "clsx": "2.1.1", + "compression": "^1.8.2", "dotenv": "^17.4.2", "express": "^5.2.1", "lucide-react": "0.473.0", @@ -1436,18 +1438,26 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", "@types/node": "*" } }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1462,7 +1472,6 @@ "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -1474,7 +1483,6 @@ "version": "5.1.3", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -1487,7 +1495,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, "license": "MIT" }, "node_modules/@types/multer": { @@ -1504,7 +1511,6 @@ "version": "22.13.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.0.tgz", "integrity": "sha512-ClIbNe36lawluuvq3+YYhnIN2CELi+6q8NpnM7PYp4hBn/TatfboPgVSm2rwKRfnV2M+Ty9GWDFI64KEe+kysA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.20.0" @@ -1514,14 +1520,12 @@ "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { @@ -1544,7 +1548,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1554,7 +1557,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -1979,6 +1981,56 @@ "node": ">= 6" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "destroy": "1.2.0", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -2112,6 +2164,16 @@ "node": ">= 0.8" } }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "dev": true, @@ -2906,6 +2968,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-releases": { "version": "2.0.37", "dev": true, @@ -2963,6 +3034,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4449,7 +4529,6 @@ "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { diff --git a/package.json b/package.json index a7a28a0..0b9139f 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "@supabase/ssr": "^0.12.0", "@supabase/supabase-js": "^2.110.0", "@tanstack/react-query": "^5.102.8", + "@types/compression": "^1.8.1", "clsx": "2.1.1", + "compression": "^1.8.2", "dotenv": "^17.4.2", "express": "^5.2.1", "lucide-react": "0.473.0", diff --git a/server/index.ts b/server/index.ts index df29c81..0eb9348 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,5 +1,6 @@ import "dotenv/config"; import express from "express"; +import compression from "compression"; import path from "node:path"; import fs from "node:fs"; import { requireUser, requireAdmin, type AuthedResponse } from "./middlewares/auth"; @@ -19,6 +20,23 @@ const clientDist = path.resolve(process.cwd(), "dist/client"); const app = express(); app.disable("x-powered-by"); + +//LOG_HTTP=1 registra método, caminho, status e duração de cada request — sem +//a env, nenhum middleware extra é adicionado. +if (process.env.LOG_HTTP === "1") { + app.use((req, res, next) => { + const inicio = Date.now(); + res.on("finish", () => { + console.log(`${req.method} ${req.path} ${res.statusCode} ${Date.now() - inicio}ms`); + }); + next(); + }); +} + +//Gzip/brotli nas respostas — a VPS fica na Europa, então cada KB a menos +//conta na viagem até o navegador (BR). +app.use(compression()); + app.use(express.json({ limit: "2mb" })); const api = express.Router(); @@ -49,7 +67,12 @@ if (process.env.NODE_ENV === "production" && fs.existsSync(clientDist)) { ); app.use((req, res, next) => { if (req.method === "GET" && !req.path.startsWith("/api")) { - res.sendFile(path.join(clientDist, "index.html")); + // index.html não tem hash no nome: precisa revalidar a cada load para + // pegar o novo deploy (os assets com hash é que ficam "immutable"). + // { root } em vez de path.join: sendFile aplica o check de dotfile no + // path absoluto inteiro, e falha se algum diretório pai começar com "." + res.setHeader("Cache-Control", "no-cache"); + res.sendFile("index.html", { root: clientDist }); return; } next(); diff --git a/server/lib/prisma.server.ts b/server/lib/prisma.server.ts index 87098f5..c3d3559 100644 --- a/server/lib/prisma.server.ts +++ b/server/lib/prisma.server.ts @@ -9,9 +9,15 @@ function buildDatasourceUrl(): string | undefined { if (!base) return undefined; try { const url = new URL(base); - // Cap no pool para não esgotar o pooler do Supabase e timeout para não - // deixar request travado esperando conexão (o gargalo é latência EU). - url.searchParams.set("connection_limit", "10"); + //Cap no pool: o pooler do Supabase em modo sessão limita o total de clientes + //(15 no plano atual) e cada processo segura `connection_limit` conexões. + //Produção fica com 10; fora dela o padrão é 2, para um `npm run dev` local + //não disputar slots com o container em produção. DB_CONNECTION_LIMIT + //sobrescreve. pool_timeout evita request travado esperando conexão (a VPS + //fica na EU e o banco no BR, então cada ida ao banco já custa ~200 ms). + const connectionLimit = + process.env.DB_CONNECTION_LIMIT || (process.env.NODE_ENV === "production" ? "10" : "2"); + url.searchParams.set("connection_limit", connectionLimit); url.searchParams.set("pool_timeout", "20"); return url.toString(); } catch { @@ -19,8 +25,13 @@ function buildDatasourceUrl(): string | undefined { } } +//LOG_QUERIES=1 acrescenta "query" ao log do Prisma — útil para contar viagens +//de rede ao banco sem mexer em nada quando a env não está setada. +const logLevels: ("query" | "warn" | "error")[] = + process.env.LOG_QUERIES === "1" ? ["query", "warn", "error"] : ["warn", "error"]; + const prisma = global.__prisma ?? new PrismaClient({ - log: ["warn", "error"], + log: logLevels, datasourceUrl: buildDatasourceUrl(), }); From c579663fa51f13ea9d2da367fb73550c4c579c64 Mon Sep 17 00:00:00 2001 From: "Glauber.x86" Date: Sun, 13 Sep 2026 07:02:51 -0300 Subject: [PATCH 02/12] =?UTF-8?q?perf:=20abrir=20pastas=20fica=20mais=20r?= =?UTF-8?q?=C3=A1pido=20e=20sem=20requests=20perdidas=20no=20boot=20e=20no?= =?UTF-8?q?=20hover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - login e recarregar a página passam a usar só a request de boot (/init) para saber quem é o usuário, eliminando uma chamada extra só para isso - abrir uma pasta ou trocar de pasta dispara a busca na hora; o atraso de digitação só entra quando o usuário está de fato digitando num filtro, e uma busca em andamento é cancelada se a tela mudar antes dela terminar - corrigido o cache do "pré-carregamento" ao passar o mouse pelas pastas na barra lateral: a chave usada para guardar o resultado agora é a mesma usada ao abrir a pasta, então o hover realmente evita a busca repetida; o pré-carregamento só dispara depois de um instante parado sobre a pasta e é cancelado se o mouse sair antes disso - pastas passam a ser identificadas pelo nome (já único) tanto ao carregar a grade quanto ao mover/excluir em lote, permitindo abrir a grade de uma pasta em paralelo com o carregamento da barra lateral em vez de esperar por ela; a API continua aceitando o identificador antigo e devolve erro claro para um nome de pasta inexistente --- app/components/Sidebar.tsx | 4 +- app/components/SidebarFolderItem.tsx | 5 +- app/context/AuthContext.tsx | 11 +- app/hooks/useOperacoesGridData.ts | 125 ++++++++++++++---- app/lib/query.ts | 6 +- app/pages/OperacoesPage.tsx | 21 ++- app/views/OperacoesView.tsx | 13 +- .../services/operacao-query-builder.server.ts | 18 ++- server/services/operacao.server.ts | 5 +- 9 files changed, 154 insertions(+), 54 deletions(-) diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 9261a47..834b223 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -5,7 +5,7 @@ import { useIsFetching } from "@tanstack/react-query"; import { buscarNomeUsuario } from "~/utils/formatters"; import { api, errorMessage } from "~/lib/api"; import { queryClient, queryKeys, useFaturistas } from "~/lib/query"; -import { prefetchOperacoes } from "~/hooks/useOperacoesGridData"; +import { prefetchOperacoes, cancelPrefetch } from "~/hooks/useOperacoesGridData"; import { useUI } from "~/hooks/use-ui"; import { COLOR_NAMES, PRESET_COLORS, SidebarFolderItem } from "./SidebarFolderItem"; @@ -108,7 +108,7 @@ export const Sidebar = React.memo(({ )} - prefetchOperacoes(null)} title={emissaoAntigasPorPasta.inbox ? `${emissaoAntigasPorPasta.inbox} emissão(ões) antiga(s) na Caixa de Entrada` : undefined} className={({ isActive }) => `flex items-center justify-between px-3 py-2.5 rounded-xl text-xs font-bold transition-all relative ${isActive ? 'text-primary bg-primary/10 dark:bg-transparent before:absolute before:-left-2 before:top-1/2 before:-translate-y-1/2 before:w-1 before:h-5 before:bg-primary before:rounded-r' : 'text-text-muted hover:text-text hover:bg-surface-light'}`}> + prefetchOperacoes({ pastaId: null })} onMouseLeave={cancelPrefetch} title={emissaoAntigasPorPasta.inbox ? `${emissaoAntigasPorPasta.inbox} emissão(ões) antiga(s) na Caixa de Entrada` : undefined} className={({ isActive }) => `flex items-center justify-between px-3 py-2.5 rounded-xl text-xs font-bold transition-all relative ${isActive ? 'text-primary bg-primary/10 dark:bg-transparent before:absolute before:-left-2 before:top-1/2 before:-translate-y-1/2 before:w-1 before:h-5 before:bg-primary before:rounded-r' : 'text-text-muted hover:text-text hover:bg-surface-light'}`}> {({ isActive }) => ( <>
diff --git a/app/components/SidebarFolderItem.tsx b/app/components/SidebarFolderItem.tsx index bbf56f8..996a5a0 100644 --- a/app/components/SidebarFolderItem.tsx +++ b/app/components/SidebarFolderItem.tsx @@ -4,7 +4,7 @@ import { NavLink } from "react-router"; import { useUI } from "~/hooks/use-ui"; import { api, errorMessage } from "~/lib/api"; import { queryClient, queryKeys, useFaturistas } from "~/lib/query"; -import { prefetchOperacoes } from "~/hooks/useOperacoesGridData"; +import { prefetchOperacoes, cancelPrefetch } from "~/hooks/useOperacoesGridData"; export const PRESET_COLORS = ["#64748b", "#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899"]; @@ -149,7 +149,8 @@ export const SidebarFolderItem = React.memo(({ folder, isCollapsed, antigas = 0
prefetchOperacoes(folder.id)} + onMouseEnter={() => prefetchOperacoes({ pastaId: null, pastaNome: folder.nome })} + onMouseLeave={cancelPrefetch} className={({ isActive }) => `flex items-center justify-between px-3 py-2.5 rounded-xl text-xs font-bold transition-all relative ${ isActive diff --git a/app/context/AuthContext.tsx b/app/context/AuthContext.tsx index 8c0773e..a366079 100644 --- a/app/context/AuthContext.tsx +++ b/app/context/AuthContext.tsx @@ -1,7 +1,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router"; import { api } from "~/lib/api"; -import { queryClient, queryKeys } from "~/lib/query"; +import { queryClient, queryKeys, fetchInit } from "~/lib/query"; type AuthContextType = { user: any | null; @@ -19,8 +19,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { useEffect(() => { let alive = true; - api - .get<{ user: any }>("/auth/me") + // Boot com uma única request: /init já traz o usuário (sem /auth/me à parte). + // retry: false — deslogado, /init dá 401 e não adianta tentar de novo. + queryClient + .fetchQuery({ queryKey: queryKeys.init, queryFn: fetchInit, staleTime: 60_000, retry: false }) .then((d) => { if (alive) setUser(d.user); }) @@ -45,9 +47,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const signIn = useCallback( async (email: string, password: string, redirectTo = "/caixa-de-entrada") => { await api.post("/auth/login", { email, password }); - const d = await api.get<{ user: any }>("/auth/me"); + const d = await queryClient.fetchQuery({ queryKey: queryKeys.init, queryFn: fetchInit, staleTime: 60_000, retry: false }); setUser(d.user); - queryClient.invalidateQueries({ queryKey: queryKeys.init }); navigate(redirectTo, { replace: true }); }, [navigate] diff --git a/app/hooks/useOperacoesGridData.ts b/app/hooks/useOperacoesGridData.ts index dbccb24..103f7b7 100644 --- a/app/hooks/useOperacoesGridData.ts +++ b/app/hooks/useOperacoesGridData.ts @@ -43,46 +43,100 @@ export interface GridMeta { const EMPTY_META: GridMeta = { total: 0, totalVl: 0, page: 0, limit: 200, totalPages: 0 }; +// Chave de filtros/ordenação: mesma função usada pelo hook (com o estado +// atual) e pelo prefetch (sem filtros), para as duas baterem no mesmo cache — +// era aqui que o bug antigo estava (prefetch usava "{}" na mão). +function computeFiltersKey(columnFilters: Record, sortColumns: any[]): string { + return JSON.stringify({ columnFilters, sortColumns }); +} + +export const EMPTY_FILTERS_KEY = computeFiltersKey({}, []); + +// Identifica a pasta pedida: pastaNome (rota de pasta, único no banco) tem +// prioridade sobre pastaId; inbox = null/sem nome. +export interface PastaIdentificador { + pastaId: number | null; + pastaNome?: string; +} + +function pastaChave(pasta: PastaIdentificador): string { + return pasta.pastaNome ?? String(pasta.pastaId ?? "inbox"); +} + +function aplicarPastaNosParams(p: URLSearchParams, pasta: PastaIdentificador) { + if (pasta.pastaNome) p.set("pastaNome", pasta.pastaNome); + else if (pasta.pastaId != null) p.set("pastaId", String(pasta.pastaId)); +} + // Cache cliente da primeira página por pasta+filtros. O gargalo é a latência de // rede (VPS/DB na Europa), então voltar a uma pasta já visitada deve ser // instantâneo — os dados antigos aparecem na hora e revalidam em background. const GRID_CACHE_TTL = 60_000; const gridCache = new Map(); -function cacheKeyOf(pastaId: number | null, filtersKey: string) { - return `${pastaId ?? "inbox"}|${filtersKey}`; +function cacheKeyOf(pasta: PastaIdentificador, filtersKey: string) { + return `${pastaChave(pasta)}|${filtersKey}`; } -function buildPage1Params(pastaId: number | null, limit = "200") { +function buildPage1Params(pasta: PastaIdentificador, limit = "200") { const p = new URLSearchParams(); - if (pastaId != null) p.set("pastaId", String(pastaId)); + aplicarPastaNosParams(p, pasta); p.set("page", "1"); p.set("limit", limit); return p.toString(); } -//Prefetch leve usado no hover da sidebar para pré-carregar a primeira página -//da pasta antes mesmo do usuário clicar. -export async function prefetchOperacoes(pastaId: number | null) { - const key = cacheKeyOf(pastaId, "{}"); +// Agendamento do prefetch (hover na sidebar): só dispara 150ms depois do +// mouse parar numa pasta; cancelPrefetch() (onMouseLeave) aborta o timer e a +// request em voo, evitando rajada de queries ao passar rápido pela lista. +let prefetchTimeout: ReturnType | null = null; +let prefetchController: AbortController | null = null; + +export function prefetchOperacoes(pasta: PastaIdentificador) { + cancelPrefetch(); + prefetchTimeout = setTimeout(() => { + prefetchTimeout = null; + executarPrefetch(pasta); + }, 150); +} + +export function cancelPrefetch() { + if (prefetchTimeout) { + clearTimeout(prefetchTimeout); + prefetchTimeout = null; + } + if (prefetchController) { + prefetchController.abort(); + prefetchController = null; + } +} + +async function executarPrefetch(pasta: PastaIdentificador) { + const key = cacheKeyOf(pasta, EMPTY_FILTERS_KEY); const cached = gridCache.get(key); if (cached && Date.now() - cached.ts < GRID_CACHE_TTL) return; + const controller = new AbortController(); + prefetchController = controller; try { - const res = await api.get<{ data: any[]; meta: GridMeta }>(`/operacoes?${buildPage1Params(pastaId)}`); + const res = await api.get<{ data: any[]; meta: GridMeta }>(`/operacoes?${buildPage1Params(pasta)}`, controller.signal); gridCache.set(key, { dados: res.data, meta: res.meta, ts: Date.now() }); } catch { - // prefetch falhou — a navegação normal resolve + // prefetch falhou ou foi cancelado — a navegação normal resolve + } finally { + if (prefetchController === controller) prefetchController = null; } } export function useOperacoesGridData({ pastaId, + pastaNome, columnFilters, sortColumns, enabled = true, }: { pastaId: number | null; + pastaNome?: string; columnFilters: Record; sortColumns: any[]; enabled?: boolean; @@ -95,16 +149,19 @@ export function useOperacoesGridData({ const [searchParams] = useSearchParams(); const seqRef = useRef(0); const loadingMoreRef = useRef(false); + // Última combinação pasta+filtros processada — usada para só aplicar o + // debounce de digitação quando é a MESMA pasta e só os filtros mudaram. + const lastRunRef = useRef<{ pasta: string; filters: string } | null>(null); const filtersKey = useMemo( - () => JSON.stringify({ columnFilters, sortColumns }), + () => computeFiltersKey(columnFilters, sortColumns), [columnFilters, sortColumns] ); const buildParams = useCallback( (page: number) => { const p = new URLSearchParams(); - if (pastaId != null) p.set("pastaId", String(pastaId)); + aplicarPastaNosParams(p, { pastaId, pastaNome }); p.set("page", String(page)); p.set("limit", searchParams.get("limit") || "200"); const sort = sortColumns?.[0]; @@ -118,17 +175,30 @@ export function useOperacoesGridData({ } return p.toString(); }, - [pastaId, sortColumns, columnFilters, searchParams] + [pastaId, pastaNome, sortColumns, columnFilters, searchParams] ); // Carrega a primeira página. Se já existe cache fresco, mostra na hora e - // revalida em background; senão busca com um debounce curto (200ms). + // revalida em background; senão busca — com debounce curto (200ms) só + // quando é digitação de filtro na mesma pasta; troca de pasta/montagem + // dispara na hora. useEffect(() => { if (!enabled) return; const seq = ++seqRef.current; - const key = cacheKeyOf(pastaId, filtersKey); + const pasta = { pastaId, pastaNome }; + const chave = pastaChave(pasta); + // Só é "digitação" quando a pasta é a mesma, os filtros mudaram e ainda há + // filtro ativo — limpar filtros (ex.: ao trocar de pasta) dispara na hora. + const isFilterChange = + lastRunRef.current?.pasta === chave && + lastRunRef.current?.filters !== filtersKey && + filtersKey !== EMPTY_FILTERS_KEY; + lastRunRef.current = { pasta: chave, filters: filtersKey }; + + const key = cacheKeyOf(pasta, filtersKey); const cached = gridCache.get(key); const url = `/operacoes?${buildParams(1)}`; + const controller = new AbortController(); const storeResult = (raw: { data: any[]; meta: GridMeta }) => { gridCache.set(key, { dados: raw.data, meta: raw.meta, ts: Date.now() }); @@ -136,14 +206,14 @@ export function useOperacoesGridData({ const fetchSilently = async () => { try { - const res = await api.get<{ data: any[]; meta: GridMeta }>(url); + const res = await api.get<{ data: any[]; meta: GridMeta }>(url, controller.signal); if (seq !== seqRef.current) return; storeResult(res); setDados(processarDatas(res.data)); setMeta(res.meta); setStatus("idle"); } catch { - // mantém os dados em cache mesmo se o refresh falhar + // mantém os dados em cache mesmo se o refresh falhar (inclui abort) } }; @@ -151,13 +221,14 @@ export function useOperacoesGridData({ setStatus("loading"); setError(null); try { - const res = await api.get<{ data: any[]; meta: GridMeta }>(url); + const res = await api.get<{ data: any[]; meta: GridMeta }>(url, controller.signal); if (seq !== seqRef.current) return; storeResult(res); setDados(processarDatas(res.data)); setMeta(res.meta); setStatus("idle"); } catch (err: any) { + if (err?.name === "AbortError") return; // cancelado pelo cleanup do efeito — não é erro if (seq !== seqRef.current) return; setStatus("error"); setError(err?.message || "Erro ao carregar operações."); @@ -170,13 +241,17 @@ export function useOperacoesGridData({ setStatus("idle"); setError(null); const t = setTimeout(() => fetchSilently(), 250); - return () => clearTimeout(t); + return () => { clearTimeout(t); controller.abort(); }; } setStatus("loading"); - const t = setTimeout(() => fetchFresh(), 200); - return () => clearTimeout(t); - }, [pastaId, filtersKey, enabled, buildParams]); + if (isFilterChange) { + const t = setTimeout(() => fetchFresh(), 200); + return () => { clearTimeout(t); controller.abort(); }; + } + fetchFresh(); + return () => controller.abort(); + }, [pastaId, pastaNome, filtersKey, enabled, buildParams]); // Recarrega a primeira página (após mutações), ignorando o cache. const refresh = useCallback(async () => { @@ -185,7 +260,7 @@ export function useOperacoesGridData({ try { const res = await api.get<{ data: any[]; meta: GridMeta }>(`/operacoes?${buildParams(1)}`); if (seq !== seqRef.current) return; - gridCache.set(cacheKeyOf(pastaId, filtersKey), { dados: res.data, meta: res.meta, ts: Date.now() }); + gridCache.set(cacheKeyOf({ pastaId, pastaNome }, filtersKey), { dados: res.data, meta: res.meta, ts: Date.now() }); setDados(processarDatas(res.data)); setMeta(res.meta); setStatus("idle"); @@ -194,7 +269,7 @@ export function useOperacoesGridData({ setStatus("error"); setError(err?.message || "Erro ao recarregar operações."); } - }, [buildParams, pastaId, filtersKey]); + }, [buildParams, pastaId, pastaNome, filtersKey]); // Scroll infinito: carrega a próxima página e faz append sem duplicar const handleScroll = useCallback( @@ -230,4 +305,4 @@ export function useOperacoesGridData({ ); return { dados, setDados, meta, status, error, handleScroll, refresh }; -} \ No newline at end of file +} diff --git a/app/lib/query.ts b/app/lib/query.ts index 380a1cd..e84f504 100644 --- a/app/lib/query.ts +++ b/app/lib/query.ts @@ -30,13 +30,17 @@ export interface InitData { emissaoAntigasPorPasta: Record; } +// Extraída para ser reaproveitada pelo AuthContext (boot com uma única +// request: sem /auth/me separado, ver AuthContext.tsx). +export const fetchInit = () => api.get("/init"); + // Dados de boot (sidebar + ordem de colunas). Mantidos frescos com polling // leve apenas com a aba em foco — é o que dá o "tempo real" dos contadores // da sidebar sem custo quando o usuário não está olhando. export function useInit() { return useQuery({ queryKey: queryKeys.init, - queryFn: () => api.get("/init"), + queryFn: fetchInit, staleTime: 60_000, refetchOnWindowFocus: true, refetchInterval: 120_000, diff --git a/app/pages/OperacoesPage.tsx b/app/pages/OperacoesPage.tsx index e9bc4f0..618ae9c 100644 --- a/app/pages/OperacoesPage.tsx +++ b/app/pages/OperacoesPage.tsx @@ -6,22 +6,17 @@ export function OperacoesPage() { const { nome } = useParams(); const { data: init } = useInit(); - // Rota de pasta (/pastas/:nome) + // Rota de pasta (/pastas/:nome): renderiza pelo nome da URL direto, em + // paralelo com o /init (não espera resolver o id da pasta); só redireciona + // para a inbox quando o /init chega e a pasta não existe. if (nome) { - const pasta = (init?.pastas || []).find((p: any) => p.nome === decodeURIComponent(nome)); - if (init && !pasta) return ; - if (!pasta) return ; - return ; + const nomePasta = decodeURIComponent(nome); + if (init && !(init.pastas || []).some((p: any) => p.nome === nomePasta)) { + return ; + } + return ; } // Caixa de entrada (/caixa-de-entrada) return ; } - -function GridLoading() { - return ( -
-

Carregando...

-
- ); -} diff --git a/app/views/OperacoesView.tsx b/app/views/OperacoesView.tsx index 78eb7e3..edab4c8 100644 --- a/app/views/OperacoesView.tsx +++ b/app/views/OperacoesView.tsx @@ -20,11 +20,12 @@ import DataGrid from "react-data-grid"; interface OperacoesViewProps { pastaId?: number | null; + pastaNome?: string; nomePasta: string; showImport?: boolean; } -export function OperacoesView({ pastaId = null, nomePasta, showImport = true }: OperacoesViewProps) { +export function OperacoesView({ pastaId = null, pastaNome, nomePasta, showImport = true }: OperacoesViewProps) { const { data: init } = useInit(); const pastas = init?.pastas || []; const columnOrder = init?.columnOrder ?? null; @@ -55,7 +56,7 @@ export function OperacoesView({ pastaId = null, nomePasta, showImport = true }: const [sortColumns, setSortColumns] = useState([]); const [importing, setImporting] = useState(false); - const grid = useOperacoesGridData({ pastaId, columnFilters, sortColumns }); + const grid = useOperacoesGridData({ pastaId, pastaNome, columnFilters, sortColumns }); const { dados, setDados, meta, status, error: gridError, handleScroll, refresh } = grid; const [currentMetaTotal, setCurrentMetaTotal] = useState(0); @@ -73,10 +74,14 @@ export function OperacoesView({ pastaId = null, nomePasta, showImport = true }: if (!location.state) { setColumnFilters({}); } - }, [pastaId, location.pathname, location.state, setColumnFilters, resetSelection]); + }, [pastaId, pastaNome, location.pathname, location.state, setColumnFilters, resetSelection]); + // Envia pastaNome quando a pasta é identificada por nome (rota /pastas/:nome), + // para que mover/excluir "selecionar todas" fiquem restritos a ela. const getActiveFilters = () => { - const activeFilters: Record = { ...Object.fromEntries(searchParams), pastaId }; + const activeFilters: Record = { ...Object.fromEntries(searchParams) }; + if (pastaNome) activeFilters.pastaNome = pastaNome; + else activeFilters.pastaId = pastaId; for (const [key, filter] of Object.entries(columnFilters)) { if (isFilterEmpty(filter)) continue; activeFilters[`colFilter_${key}`] = `${filter.type}:${filter.value}`; diff --git a/server/services/operacao-query-builder.server.ts b/server/services/operacao-query-builder.server.ts index 6688cfb..3ab87b0 100644 --- a/server/services/operacao-query-builder.server.ts +++ b/server/services/operacao-query-builder.server.ts @@ -1,8 +1,24 @@ import { PrazoService, type PrazoRegras } from "./prazo.server"; +import { PastaService } from "./pasta.server"; const MILIS_DIA = 24 * 60 * 60 * 1000; export class OperacaoQueryBuilder { + // Resolve o identificador da pasta: pastaNome (rota de pasta, cacheada via + // PastaService) tem prioridade sobre pastaId; nome inexistente vira 404. + static async resolverPastaId(pastaId: any, filtros: any): Promise { + if (filtros?.pastaNome) { + const pasta = await PastaService.buscarPorNome(String(filtros.pastaNome)); + if (!pasta) { + const err: any = new Error("Pasta não encontrada."); + err.status = 404; + throw err; + } + return pasta.id; + } + return pastaId && pastaId !== "null" ? Number(pastaId) : null; + } + // Condição SQL de "emissão antiga": fora do prazo padrão ou do prazo // específico do cliente (nm_pessoa_pagador). Compartilhada com // `emissoesAntigasPorPasta` para que o filtro e os contadores batam. @@ -151,7 +167,7 @@ export class OperacaoQueryBuilder { whereAnd.push(`${condition} $${params.length}`); }; - const pid = pastaId && pastaId !== "null" ? Number(pastaId) : null; + const pid = await this.resolverPastaId(pastaId, filtros); // Filtros especiais por coluna (sem valor digitado, baseados em regra de negócio) if (this.temFiltro(filtros, "dt_emissao_", "antigos")) { diff --git a/server/services/operacao.server.ts b/server/services/operacao.server.ts index d57ba9e..4615acd 100644 --- a/server/services/operacao.server.ts +++ b/server/services/operacao.server.ts @@ -73,6 +73,9 @@ export class OperacaoService { const offset = (p - 1) * l; const orderClause = this.montarOrderBy(filtros); + // Resolve pastaNome -> pastaId uma vez (cache) para usar tanto no WHERE + // quanto nas placas duplicadas, que hoje recebem o pastaId cru. + const pid = await OperacaoQueryBuilder.resolverPastaId(pastaId, filtros); const whereClause = await OperacaoQueryBuilder.construirWhere(pastaId, filtros); const cacheKey = JSON.stringify({ sql: whereClause.sql, params: whereClause.params }); const cachedEntry = this.countCache.get(cacheKey); @@ -99,7 +102,7 @@ export class OperacaoService { `, ...whereClause.params); const [placasDuplicadas, regras] = await Promise.all([ - this.placasDuplicadasDaPasta(pastaId), + this.placasDuplicadasDaPasta(pid), PrazoService.regras(), ]); const agora = Date.now(); From f39b7fcb519fd17ab51f290b6e6eb450d4b43dfb Mon Sep 17 00:00:00 2001 From: "Glauber.x86" Date: Sun, 13 Sep 2026 09:03:14 -0300 Subject: [PATCH 03/12] =?UTF-8?q?perf:=20menos=20idas=20ao=20banco/Supabas?= =?UTF-8?q?e=20ao=20abrir=20pastas,=20listar=20usu=C3=A1rios=20e=20editar?= =?UTF-8?q?=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Grid de operações: query principal, placas duplicadas e regras de prazo agora disparam juntas (antes a principal esperava terminar para só então buscar as outras duas). - Editar o status de uma operação grava tudo em uma única instrução no banco (antes eram duas idas: uma para conferir o status atual, outra para salvar), sem mudar quando a data de status é atualizada. - Novo cache em memória (server/lib/cache.ts) para dados que mudam pouco: lista de usuários do Supabase e ordem das colunas da grid. Fica quente por ~10 minutos e é invalidado nas telas de gestão de usuários assim que algo muda (criar, editar, bloquear, ativar, excluir). - O servidor já aquece esses caches e outros dados de boot logo ao subir, para que o primeiro acesso de cada usuário não pague essa espera. - A lista de faturistas só é buscada quando o formulário de criar/editar pasta está aberto, em vez de em toda navegação pela sidebar. - Sessão própria de 30 dias guardada em cookie assinado, sem depender mais de chamadas ao Supabase a cada requisição para saber quem é o usuário logado (só login, logout e a tela de usuários continuam falando com o Supabase). Rebaixar ou bloquear alguém pelo painel passa a valer em poucos minutos. - Tela de perfil: os dados da página carregam em paralelo, e renomear o próprio usuário usa o mesmo caminho administrativo já usado na tela de usuários, refletindo o novo nome sem precisar relogar. - Removido código que não era mais usado depois dessas mudanças de sessão. --- app/components/Sidebar.tsx | 6 +- app/components/SidebarFolderItem.tsx | 3 +- app/lib/query.ts | 7 +- index.html | 6 +- package-lock.json | 462 +++++++++++++---------- package.json | 12 +- public/apple-touch-icon.png | Bin 0 -> 3701 bytes public/favicon.ico | Bin 0 -> 15086 bytes public/favicon.svg | 10 + server/index.ts | 20 + server/lib/cache.ts | 17 + server/lib/http.ts | 16 - server/middlewares/auth.ts | 47 ++- server/routes/auth.ts | 50 +-- server/routes/perfil.ts | 28 +- server/routes/usuarios.ts | 6 + server/services/auth.server.ts | 140 +------ server/services/config.server.ts | 26 +- server/services/dashboard.server.ts | 12 +- server/services/operacao.server.ts | 40 +- server/services/session.server.ts | 10 +- server/services/supabase-admin.server.ts | 87 +++-- server/services/supabase.server.ts | 46 +-- 23 files changed, 535 insertions(+), 516 deletions(-) create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon.svg create mode 100644 server/lib/cache.ts delete mode 100644 server/lib/http.ts diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 834b223..1af9c0f 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -33,8 +33,6 @@ export const Sidebar = React.memo(({ const isFetching = useIsFetching() > 0; const { alert: showAlert } = useUI(); - const { data: faturistasData } = useFaturistas(); - const faturistas = faturistasData?.faturistas || []; const [newFolderName, setNewFolderName] = useState(""); const [newFolderColor, setNewFolderColor] = useState(PRESET_COLORS[0]); @@ -44,6 +42,10 @@ export const Sidebar = React.memo(({ const [searchQuery, setSearchQuery] = useState(""); const [filterColor, setFilterColor] = useState(""); + // Só busca a lista de faturistas com o formulário de nova pasta aberto. + const { data: faturistasData } = useFaturistas(isAddingFolder); + const faturistas = faturistasData?.faturistas || []; + const normalize = (value: string) => value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); const filteredPastas = useMemo(() => { diff --git a/app/components/SidebarFolderItem.tsx b/app/components/SidebarFolderItem.tsx index 996a5a0..8de604e 100644 --- a/app/components/SidebarFolderItem.tsx +++ b/app/components/SidebarFolderItem.tsx @@ -39,7 +39,8 @@ export const SidebarFolderItem = React.memo(({ folder, isCollapsed, antigas = 0 const [editingFaturista, setEditingFaturista] = useState(folder.faturistaId || ""); const [isPending, setIsPending] = useState(false); const { confirm: confirmAction, alert: showAlert } = useUI(); - const { data: faturistasData } = useFaturistas(); + // Só busca a lista de faturistas com o formulário de edição aberto. + const { data: faturistasData } = useFaturistas(isEditing); const faturistas = faturistasData?.faturistas || []; const refreshFolders = () => { diff --git a/app/lib/query.ts b/app/lib/query.ts index e84f504..2d41b40 100644 --- a/app/lib/query.ts +++ b/app/lib/query.ts @@ -54,12 +54,15 @@ export interface Faturista { email: string; } -// Lista de usuários ativos que podem ser atribuídos como faturista de uma pasta. -export function useFaturistas() { +// Lista de usuários ativos que podem ser atribuídos como faturista de uma +// pasta. Só busca quando o formulário de criar/editar pasta está aberto — +// o select não aparece em mais lugar nenhum. +export function useFaturistas(enabled: boolean) { return useQuery({ queryKey: queryKeys.faturistas, queryFn: () => api.get<{ faturistas: Faturista[] }>("/pastas/faturistas"), staleTime: 60_000, + enabled, }); } diff --git a/index.html b/index.html index cd00545..d6a1be1 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,11 @@ Logicell - + + + + =6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -162,25 +158,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -200,7 +200,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -208,7 +210,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -216,7 +220,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -224,23 +230,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -282,29 +292,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -312,21 +326,23 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", - "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -341,9 +357,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", - "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -358,9 +374,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", - "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -375,9 +391,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", - "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -392,9 +408,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", - "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -409,9 +425,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", - "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -426,9 +442,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", - "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -443,9 +459,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", - "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -460,9 +476,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", - "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -477,9 +493,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", - "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -494,9 +510,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", - "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -511,9 +527,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", - "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -528,9 +544,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", - "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -545,9 +561,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", - "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -562,9 +578,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", - "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -579,9 +595,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", - "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -596,7 +612,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.24.2", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -611,9 +629,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", - "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], @@ -628,9 +646,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", - "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -645,9 +663,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", - "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], @@ -662,9 +680,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", - "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -696,9 +714,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", - "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -713,9 +731,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", - "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -730,9 +748,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", - "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -747,9 +765,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", - "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -1745,7 +1763,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.19", + "version": "2.11.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", + "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1815,7 +1835,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -1833,11 +1855,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -1902,7 +1924,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001788", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -2213,7 +2237,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.339", + "version": "1.5.427", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", "dev": true, "license": "ISC" }, @@ -2256,7 +2282,9 @@ } }, "node_modules/esbuild": { - "version": "0.24.2", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2267,31 +2295,49 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.24.2", - "@esbuild/android-arm": "0.24.2", - "@esbuild/android-arm64": "0.24.2", - "@esbuild/android-x64": "0.24.2", - "@esbuild/darwin-arm64": "0.24.2", - "@esbuild/darwin-x64": "0.24.2", - "@esbuild/freebsd-arm64": "0.24.2", - "@esbuild/freebsd-x64": "0.24.2", - "@esbuild/linux-arm": "0.24.2", - "@esbuild/linux-arm64": "0.24.2", - "@esbuild/linux-ia32": "0.24.2", - "@esbuild/linux-loong64": "0.24.2", - "@esbuild/linux-mips64el": "0.24.2", - "@esbuild/linux-ppc64": "0.24.2", - "@esbuild/linux-riscv64": "0.24.2", - "@esbuild/linux-s390x": "0.24.2", - "@esbuild/linux-x64": "0.24.2", - "@esbuild/netbsd-arm64": "0.24.2", - "@esbuild/netbsd-x64": "0.24.2", - "@esbuild/openbsd-arm64": "0.24.2", - "@esbuild/openbsd-x64": "0.24.2", - "@esbuild/sunos-x64": "0.24.2", - "@esbuild/win32-arm64": "0.24.2", - "@esbuild/win32-ia32": "0.24.2", - "@esbuild/win32-x64": "0.24.2" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/escalade": { @@ -2733,11 +2779,15 @@ }, "node_modules/js-tokens": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, "license": "MIT" }, "node_modules/jsesc": { - "version": "3.0.2", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "license": "MIT", "bin": { @@ -2776,6 +2826,8 @@ }, "node_modules/lru-cache": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", "dependencies": { @@ -2952,7 +3004,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ { @@ -2978,9 +3032,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.37", + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -3097,7 +3156,9 @@ } }, "node_modules/postcss": { - "version": "8.5.1", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -3115,7 +3176,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3222,7 +3283,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3352,7 +3415,9 @@ } }, "node_modules/react-router": { - "version": "7.15.1", + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -3549,6 +3614,16 @@ "version": "0.25.0", "license": "MIT" }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -4539,7 +4614,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { @@ -4579,13 +4656,18 @@ } }, "node_modules/vite": { - "version": "6.0.11", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.24.2", - "postcss": "^8.4.49", - "rollup": "^4.23.0" + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" @@ -4735,6 +4817,8 @@ }, "node_modules/yallist": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" }, diff --git a/package.json b/package.json index 0b9139f..7c616ed 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "react": "19.0.0", "react-data-grid": "7.0.0-beta.47", "react-dom": "19.0.0", - "react-router": "7.15.1", + "react-router": "^7.18.3", "tailwind-merge": "3.5.0", "xlsx": "0.18.5", "zustand": "^5.0.14" @@ -51,12 +51,18 @@ "@vitejs/plugin-react": "^4.7.0", "autoprefixer": "10.4.20", "concurrently": "^10.0.5", - "postcss": "8.5.1", + "postcss": "^8.5.28", "prisma": "5.22.0", "tailwindcss": "3.4.17", "tsx": "^4.23.13", "typescript": "5.7.3", - "vite": "6.0.11", + "vite": "^6.4.3", "vite-tsconfig-paths": "5.1.4" + }, + "allowScripts": { + "@prisma/client": true, + "@prisma/engines": true, + "esbuild": true, + "prisma": true } } diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c288c28f4edba3ee89689d332eee1f8ea24f7327 GIT binary patch literal 3701 zcma)9cQ72>*VbFK)%ym~SIJ9==siT=C=q4x23ftVC_$DL2}!gN)&?7*6GRuCNW(_& ztP*7{tF6wr`M&vPet-Vv&U4S5J9o~UGxy%}Jm;mQxe*fs9|IW~8I$o{eQT1f`wKc6 z(yTE+^AE|;xZN|-C%gJP^4p5j$;gm6!fg>qI88|4_N zMq_2k9n(^@QxIQ@V;mQd2+$L)C*hikyx(#nhr)HEXHiG9kwZ4U#`W7qxiuVCZ;AwN z^WA2c1oAMxjXLm*iL>!AmZ`qxb-X_0q2&Le#Gr=UOtv}~1yyu-MEk>QM==EOQ6OO{ zSW=t}{LH%G7JZ|`vo$!M(hxdq`rpn0sumMF3Pr1@lstZgG=NMRh*&iW6(P4dSa2<* zUXJ;EV?0n$|?+= z`|+OSAKFgNN&gkZBt7UuR$gGjG_N5omMY>+?i&w4fR`jbsjyrxP%vq%O-Dt*C*Kn% zD!F8?1g7-0D;$G@+P^zN!In%k-0P~RK1x4R0&4?`>CHIrjmGIH{n5e%ACeIR*cDV# zi2AmxUJyAMoANxvb81461}gOO*^<5so{l^}3@{K9v>CNy76!}Kd?k`gCavBm+}l1+ zQIrPu51*Ra8-0{fRrq2pMImYNHBIiLH=5%=2e*?kW2fNGk#9L#fT{?P`Lf($OV0aQ z>eGj(tVC-{fhDMx@R!max7eJ=8f)+d3)i0tdir>?y{B(l$3i9e)%f*>#waoB&k+yw zcO*f!H}%w7XyzZ}9|e3i9iWdhN5A4n`AiI& zak2apt(9u|K^Iy90b3{CV*)UdcVf$m|m}bQ=RX**pL-aW($!+lBjph$^9OA!SiTei7bKvV>Hy8iRa@rtS_l zh~!Gu>I1~zO$)q_+l{x-WDd#AHv9qT?zr-t zhz8%*1g&KI)CmZm4~IU#=Du!B za}OXwmrll^7!vcN#K@M8^Lqn357f$#n$08i=~)4fJ9_1-T`F6+G^mcsp%C$*TQ^gj z^)Y%&^dPke$0~BYK=!RVw`C!sXq3XlbV68P+vEmD#LNV=5-O7_B*wLOJuFp-@=N8S zyuPV%zCqdD{{kF2a-HQrtQ1Nqn1M86XW3O>bBgAwn}x7<;Ds6)Ggm)lgyt1 zQPiBGrgrQVhck=C$z2!nI?g#&;>{dmcK}yU^=Nx8YywX0M=}t&4JHLq+MQcDRX4KP zZax=M=5aoDKJ8%)a8OlO8@glx%_c?VCxOE9`F?NBt3s|&=1Fe(X_FYz5|ZkicG zY3C-mu9twU>Qp~b36L0vXO_zFh$opgvEdZs{fa7;eG)o+=ssm$k3!F$DE*siI@ipG z({9~8f2gpgIbw7B^A>IjX_ssbrFd^EcGi_j+cH!9k+b1eJlJB^INyEy%PukDA?$T^ z9gW{picma#dsS27VU8|#u7q-nT`{I@{4&z^Za%mwh@+xeS`D44&99)oME&Zg(Z3R- zY+jVia!Kt&6$l3k-_FixkJAC&5-|+TmY?FplW9F!xH9Bk+|=cW0F=IteCO+8bXVPT z2a5Nuj~VN!?ko>;K~)b0gIPKlCe&w-LP^2J3Hgi5AtHuliBkJF(zW{?_Y(-jv%!Xo z^8v!Bjly7$@55EmMR~iPLYK}=ssxAU0#dbY^x_K?-i;A6F~0)rgKr6-7O@FbcOKOW zLFq#n9;uG9t-t*NB?Tpw*=Kn#A1vohO2M;}D1Q6G_G~ar{wrf+F(r#>XBq?B`l}M} zItx@76YPY}UQ=cg*?k>8$l8IhH6aNDyMgCoUqYKVvF$X#d zwWIB}*o*hozO-LXaxb!$GHpj}LNj}j1#iD==S6{jA<2r9aZwMd=6nfj^~5_rlg~w4 zPFvD@k!IiS=Ub?<`Dp^pVPP8Goza9BP!ra%wXH-5w9^P= zzm@a$RC^(yN`uHaTtQFx{E2GDV=~SDGj}QTftW@`sP$}KD0 zTIs@Bm6uDMSuNCP#D%p=Q_zqRKHf%LbM~}ak{pK*dauo7CM)bWVc{2Bk$9mn)zkJi z>4wE&@E82d_L8TYZbgCsa4WIu(>rC-7w-C*Gq)2-41~X1evyZ4JB4_LM1=!zq6cb+ z*rOUxdB-NI=%b@#7qzvprkDnekiexv(eBbp37yd`8xAdYEw?B{G&Jfs3S>X0%0pJ} z{5dZbKU!jK3COLpCp29l+ak)8&o$3q!15>j;tyxK@$P0USC6C5ko(F+9IYNLH8`2Q z{Br&R=bGS)x3ymjG_EP>y@Mgs3Lg!j?wV=tU$`t9gh*gL0cYA;gn!N-`-b5yG+WLl zhBW5G`GE~OQAzvwtLDCwhB?*ZglTcRWA8?l(VcHHrPF2a>x{=s4FtpR9l6P(+t0 zM*fBRwc38*rr!dmd{^I-)|s`mbnL{t(@4dQVvl0m4%ZleCb?+J?6+CkaZxAB)Oqp1 z_tfqh>KjrZ7bgmqh}$(m-@jKAbR`9Oo|;8h&M|03a|lI<@f!V!B~{jfn!M`%R|(AZ z^7@-40h_IovE*ah$~-4dUZ1rk_VdkEeygGB5uWO+pLPa zbU<4LmsRxgAtrJ4D@jJM;Fh;SoqyFkTwS{@z8QfaxXP;9JUA}I3CbK9I=*6!o*UUs zAA}2ay5$T1fFcO!+_sfF?HE-|W2v}Oqme@z>%8oW(AY-AWt4v!eyH-B?*oxbq@Bh^R|&0!>CWbjrtl4vs{)|x?!+us$*+C>nPdr$xd|+`BOP#I(emAoiKA{ z7}t768RtL!ca2;Z5~Rb#g=6Ubu^Y}7oM z+w8DMQIED(n$o@7{A~(j_aM|Ro;Yp7IN0G=P4jkio(&(eez6@spAnp6QoYk+3N!?i z!wWS2O5<_zkyB(Y?&Xn@^<)QwIE8$epWchX++rYm+jHJ?O?R>4c~6c!qhQgXJ%g}f zc)XIrMnSoT#=hptdYMTKZ{I%OwVqA%U$XT*EXo(mj;Sb^b z-%2wT?$F6&sDlq#9ZtlN-YL5<+gs@7baj=Q`|%Oha_8FIzsB^AQ=lC0C^Dja<*czx z5Z=h|ur@U{&~H-lkanW~rN0Y<6sGd&+i$`Xo~^FjU%|b}p%3YL>RS_EV{fXxF9u<- zgw}~;$K@8OIV^?;)1RhIFkF*BLXLbaF4b1hcbRn_- E15|w%6aWAK literal 0 HcmV?d00001 diff --git a/public/favicon.ico b/public/favicon.ico index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..c9731e2aa4a4a9cf7e30648fa0266fae30c6bba2 100644 GIT binary patch literal 15086 zcmeHOS!`5Q7(S#dCT_f-5d^Bypr}cW#s_`DxFkj}K8W$fL=6mWp>@HC5M!GVR1(?J zM%EBTM5qu1WJwBG7DWvxP^5)|V2~w%&=wS>Eua54XAbwyow+mj-kCeoiJau`+5Y7_ z|5@hTb551&p^8-BzDnX^RoYXjN0d^<#X7%K$`?vmNl7e!w^C2{Q|cb!kt&hUfd)%u7dqS^z?M^tNq z?3faRtp)b127Xxyl=TAk zeghodq02Vc0OUy}@2sb;+7Y@PKd%5*Ozr*sv;YkxPTHPl&rVBL+T@cE&kEx?k z^iGrYX{eidj8wgauSTdd3pU*_Q}-;PUUnQY+Z#i)>%>!U#~|t29NIQmX3+;IRribS zFnw<9Ipa=_2cemHXuHP3F&R<6=FPsIV_wzpWne@Yn-&__b^Jjh=-s16`da zC+QApm-vQ!pGS20l`FP-;iZfTMxWo=?vMhO>8D`4RND&A80Oq1F$c+9%O4@?p%Ki zo@6m?r}L-Z`sn(*93T!iI`R{+Zi;-vT%UdWe(@G2e>V2`L4D7?PrmnG{2mC3KT|n= zXFVX)l?M!+{ML(p1B_O)^I*Tme1oAv^%47xADU-UamzV+<3m~K_-<_4vTs^yq~Bt@Zy>X@Adu}RxJrzjD6 zMd<1|#a)u|5n|_+W2K$zgqnnMv8K^Rlgy#DLgnV%)dy^aS5-gJ!M^Sg%Cw&|mFKu$ zBy^jXHb|SF6W)A&ySlZvO|T75)ds;`DHP^Bcf;njyi4Y5MY1+X+ny2Z`8cofirw(B z4I}Jrkh7)%-E_kAe?f8!$RK*8eA-JZboceZ1$Lz(O3xe&>#F2DR<_4?(j8VWRR1UmlJw7(vxy4SrO ze);|4@xwoHFmS0Y@x+98(eDmQY=d8ZKUlo-PAmpC&IESW>HBhn-uU0-w=amtKkr33 z|CaMc6P)~Ke$zUsn>M`S@xwR%7U04egCIVh{sb`fF{|u&qlJO@=be|WI;N&RDre14 z#+R8KZH(_{$@{^Wiua2$V<9oFpAL{rIJm{K|4x5XYQ^=_q#tKkd1q>IsU@EZlb<@i zb1;G9XU8IwON{H@wCpFt@08$M2kJDz`KH1TPB&gVex^%%>bD-oriu5gk-DPf_%B~F zTvlxOo%-Ey1T9adpKoR6zogH{d0x$V$}+nRyZ_H0cjr&0->xUqbWr_H{BAgc>UY!U zEDx&R3x3{t9{JH4Q>&}u`_PFcNIoq5#D3^I3p?)%?e};=uqKlU13&K;4m8ALUs{=c z-?Kg0#-B^<*}~7eX7kpp{dBzFzpMzy?`A9o_5b@rES$+w9PeRc&&2-|1|&nR%eXIE zGB#YlC-Xml>*tR2M&=%xh}%wC?;QC)gYQF({;=aEox&z>QSB@V)wBs{i&I|eTeX2HHtNXwAOD^uT!tyb(x8!UM&i-6!7VL$7 ze#<+ru=+P_y}a8UBvgp^7oFyASnw|_z&@C7?(kywFom=Zwc|C_-{6nlgGJQy(g)r7 z?$^wvF5a=*v75A%zuwK;K>t=6yvbszXFqhuvDEJ7cV|0d_t|r$9o=DFqR;CDQ?V0g zy1d8@;(G(34U*<>T{hkwnQ`nE(?)a5<~Z{|Q*KzVu%b$-e;5yrzFU&lLrNWGzK>E3 qjEm&|rWngBCKEGzEm$6?X42-L2Ij?YM;VJB + + + + + + + + + diff --git a/server/index.ts b/server/index.ts index 0eb9348..cb50749 100644 --- a/server/index.ts +++ b/server/index.ts @@ -14,6 +14,11 @@ import { automacoesRouter } from "./routes/automacoes"; import { prazosRouter } from "./routes/prazos"; import { usuariosRouter } from "./routes/usuarios"; import { perfilRouter } from "./routes/perfil"; +import { SupabaseAdminService } from "./services/supabase-admin.server"; +import { PrazoService } from "./services/prazo.server"; +import { PastaService } from "./services/pasta.server"; +import { OrdemColunasService } from "./services/config.server"; +import { DashboardService } from "./services/dashboard.server"; //Em produção, serve o build estático do SPA com fallback para index.html const clientDist = path.resolve(process.cwd(), "dist/client"); @@ -89,6 +94,21 @@ app.use((err: any, _req: express.Request, res: express.Response, _next: express. const port = Number(process.env.PORT) || 3000; app.listen(port, () => { console.log(`[Logicell API] rodando em http://localhost:${port}`); + + // Aquecimento: popula os caches em memória logo no boot para que o + // primeiro request de cada usuário não pague a ida ao Supabase/banco. + // Erros aqui não derrubam o servidor — só ficam registrados no log. + Promise.allSettled([ + SupabaseAdminService.listarUsuarios(), + PrazoService.regras(), + PastaService.listar(), + OrdemColunasService.get(), + DashboardService.opcoes(), + ]).then((resultados) => { + resultados.forEach((r) => { + if (r.status === "rejected") console.error("[Aquecimento] falhou:", r.reason); + }); + }); }); export type { AuthedResponse }; diff --git a/server/lib/cache.ts b/server/lib/cache.ts new file mode 100644 index 0000000..4a57f93 --- /dev/null +++ b/server/lib/cache.ts @@ -0,0 +1,17 @@ +//Cache em memória com TTL para dados que mudam pouco (usuários do Supabase, +//ordem de colunas, regras de prazo). Uma instância só: sem Redis por ora. +interface Entry { value: unknown; expiresAt: number } +const store = new Map(); + +export async function getOrSet(key: string, ttlMs: number, loader: () => Promise): Promise { + const hit = store.get(key); + if (hit && Date.now() < hit.expiresAt) return hit.value as T; + const value = await loader(); + store.set(key, { value, expiresAt: Date.now() + ttlMs }); + return value; +} + +export function invalidate(prefix?: string): void { + if (!prefix) { store.clear(); return; } + for (const k of store.keys()) if (k.startsWith(prefix)) store.delete(k); +} diff --git a/server/lib/http.ts b/server/lib/http.ts deleted file mode 100644 index 3324e3a..0000000 --- a/server/lib/http.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Response } from "express"; - -//Aplica os headers (ex.: Set-Cookie gerado pelo Supabase ao renovar tokens) -//produzidos pelo client SSR nas rotas do Express. -export function applySupabaseHeaders(res: Response, headers: Headers): void { - const anyHeaders = headers as any; - const setCookies: string[] = - typeof anyHeaders.getSetCookie === "function" ? (anyHeaders.getSetCookie() as string[]) : []; - - headers.forEach((value, key) => { - if (key.toLowerCase() === "set-cookie") return; - res.setHeader(key, value); - }); - - setCookies.forEach((cookie) => res.append("Set-Cookie", cookie)); -} diff --git a/server/middlewares/auth.ts b/server/middlewares/auth.ts index 0f29185..b7e015f 100644 --- a/server/middlewares/auth.ts +++ b/server/middlewares/auth.ts @@ -1,10 +1,6 @@ import type { NextFunction, Request, Response } from "express"; -import { - requireAdminByCookie, - resolveAuth, - type AuthedUser, -} from "../services/auth.server"; -import { applySupabaseHeaders } from "../lib/http"; +import { resolveAuth, type AuthedUser } from "../services/auth.server"; +import { SupabaseAdminService } from "../services/supabase-admin.server"; export interface AuthedResponse extends Response { locals: { user?: AuthedUser; [key: string]: any }; @@ -23,17 +19,27 @@ export function getUser(res: AuthedResponse): AuthedUser { return res.locals.user; } +//A sessão é local (cookie de 30 dias, sem rede). Para um bloqueio ou exclusão +//feitos depois do login valerem antes disso, confere o usuário na lista +//cacheada do Supabase: sem rede na maioria das requests (cache de 10 min, +//invalidado na hora pelas telas de usuários). Se o Supabase estiver fora, a +//consulta devolve undefined e a request segue só com o cookie. export async function requireUser(req: Request, res: AuthedResponse, next: NextFunction) { try { - const { user, headers } = await resolveAuth(getCookieHeader(req)); + const user = resolveAuth(getCookieHeader(req)); if (!user) { const err: any = new Error("Não autenticado"); err.status = 401; throw err; } - // Aplica cookies de rotação do refresh_token — sem isso, o Supabase renova - // o token a cada request e estoura o rate-limit de auth (429). - applySupabaseHeaders(res, headers); + + const conta = await SupabaseAdminService.buscarPorId(user.id); + if (conta === null || conta?.bloqueado) { + const err: any = new Error("Sessão encerrada. Entre novamente."); + err.status = 401; + throw err; + } + res.locals.user = user; next(); } catch (err) { @@ -41,13 +47,28 @@ export async function requireUser(req: Request, res: AuthedResponse, next: NextF } } +//Além do cookie dizer "admin", confere na lista cacheada que o usuário continua +//admin — rebaixamento pelo app vale na hora (invalidação), pelo painel do +//Supabase em até 10 min (TTL do cache), sem chamada remota por request. export async function requireAdmin(req: Request, res: AuthedResponse, next: NextFunction) { try { - const { user, headers } = await requireAdminByCookie(getCookieHeader(req)); - applySupabaseHeaders(res, headers); + const user = resolveAuth(getCookieHeader(req)); + if (!user || user.app_metadata.role !== "admin") { + const err: any = new Error("Acesso restrito a administradores"); + err.status = 403; + throw err; + } + + const admin = await SupabaseAdminService.buscarPorId(user.id); + if (!admin || admin.bloqueado || admin.role !== "admin") { + const err: any = new Error("Acesso restrito a administradores"); + err.status = 403; + throw err; + } + res.locals.user = user; next(); } catch (err) { next(err); } -} \ No newline at end of file +} diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 735f190..3a31250 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -1,12 +1,13 @@ import { Router } from "express"; -import { createSupabaseServerClient } from "../services/supabase.server"; +import { createSupabaseAuthClient } from "../services/supabase.server"; import { sessionStorage } from "../services/session.server"; import { resolveAuth } from "../services/auth.server"; import { getCookieHeader } from "../middlewares/auth"; -import { applySupabaseHeaders } from "../lib/http"; export const authRouter = Router(); +const SESSAO_DURACAO_SEGUNDOS = 60 * 60 * 24 * 30; // 30 dias + authRouter.post("/login", async (req, res, next) => { try { const email = String(req.body?.email || "").trim(); @@ -17,49 +18,38 @@ authRouter.post("/login", async (req, res, next) => { return; } - const cookieHeader = getCookieHeader(req); - const { supabase, response } = await createSupabaseServerClient(cookieHeader); - + const supabase = createSupabaseAuthClient(); const { data, error } = await supabase.auth.signInWithPassword({ email, password }); - if (error) { - res.status(401).json({ error: error.message }); + if (error || !data.user) { + res.status(401).json({ error: error?.message || "Não autenticado" }); return; } - const session = sessionStorage.getSession(cookieHeader); - session.access_token = data.session?.access_token; - session.refresh_token = data.session?.refresh_token; - - applySupabaseHeaders(res, response.headers); - res.append("Set-Cookie", sessionStorage.commitSession(session)); + // Sessão própria de 30 dias: só o que cabe no cookie, sem tokens do + // Supabase e sem renovação remota depois disso (ver session.server.ts). + res.append("Set-Cookie", sessionStorage.commitSession({ + sub: data.user.id, + email: data.user.email || "", + role: data.user.app_metadata?.role === "admin" ? "admin" : "usuario", + nome: data.user.user_metadata?.nome || "", + exp: Math.floor(Date.now() / 1000) + SESSAO_DURACAO_SEGUNDOS, + })); res.json({ success: true }); } catch (err) { next(err); } }); -authRouter.post("/logout", async (req, res, next) => { - try { - const cookieHeader = getCookieHeader(req); - const { supabase, response } = await createSupabaseServerClient(cookieHeader, { - skipSessionSync: true, - }); - await supabase.auth.signOut().catch(() => null); - - applySupabaseHeaders(res, response.headers); - res.append("Set-Cookie", sessionStorage.destroySession()); - res.json({ success: true }); - } catch (err) { - next(err); - } +authRouter.post("/logout", (_req, res) => { + res.append("Set-Cookie", sessionStorage.destroySession()); + res.json({ success: true }); }); //Retorna o usuário autenticado ou null (200) — o cliente decide o redirect. -authRouter.get("/me", async (req, res, next) => { +authRouter.get("/me", (req, res, next) => { try { - const { user, headers } = await resolveAuth(getCookieHeader(req)); - applySupabaseHeaders(res, headers); + const user = resolveAuth(getCookieHeader(req)); res.json({ user }); } catch (err) { next(err); diff --git a/server/routes/perfil.ts b/server/routes/perfil.ts index 398dcf8..99cb52e 100644 --- a/server/routes/perfil.ts +++ b/server/routes/perfil.ts @@ -1,8 +1,8 @@ import { Router } from "express"; import prisma from "../lib/prisma.server"; -import { createSupabaseServerClient } from "../services/supabase.server"; +import { SupabaseAdminService } from "../services/supabase-admin.server"; +import { sessionStorage, getSession } from "../services/session.server"; import { getUser, getCookieHeader, type AuthedResponse } from "../middlewares/auth"; -import { applySupabaseHeaders } from "../lib/http"; export const perfilRouter = Router(); @@ -19,11 +19,10 @@ function parseId(value: any): number { perfilRouter.get("/", async (_req, res: AuthedResponse, next) => { try { const user = getUser(res); - const totalPlanilhas = await prisma.importacao.count(); - const ultimasImportacoes = await prisma.importacao.findMany({ - orderBy: { createdAt: "desc" }, - take: 5, - }); + const [totalPlanilhas, ultimasImportacoes] = await Promise.all([ + prisma.importacao.count(), + prisma.importacao.findMany({ orderBy: { createdAt: "desc" }, take: 5 }), + ]); res.json({ user, ultimasImportacoes, stats: { totalPlanilhas } }); } catch (err) { @@ -34,23 +33,18 @@ perfilRouter.get("/", async (_req, res: AuthedResponse, next) => { perfilRouter.patch("/", async (req, res: AuthedResponse, next) => { try { const user = getUser(res); - const cookieHeader = getCookieHeader(req); const nome = String(req.body?.nome || "").trim(); if (!nome) { res.status(400).json({ error: "Nome não pode estar vazio" }); return; } - const { supabase, response } = await createSupabaseServerClient(cookieHeader); - const { error } = await supabase.auth.updateUser({ - data: { nome, nickname: nome }, - }); + await SupabaseAdminService.renomear(user.id, nome); - applySupabaseHeaders(res, response.headers); - if (error) { - res.status(400).json({ error: error.message }); - return; - } + // Atualiza o cookie de sessão com o novo nome — sem isso, o nome só + // mudaria no próximo login (a sessão é local, ver session.server.ts). + const session = getSession(getCookieHeader(req)); + res.append("Set-Cookie", sessionStorage.commitSession({ ...session, nome })); res.json({ success: true, user: { ...user, user_metadata: { ...user.user_metadata, nome } } }); } catch (err) { diff --git a/server/routes/usuarios.ts b/server/routes/usuarios.ts index 51ea24a..6927a14 100644 --- a/server/routes/usuarios.ts +++ b/server/routes/usuarios.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import { SupabaseAdminService } from "../services/supabase-admin.server"; import { PastaService } from "../services/pasta.server"; import { getUser, type AuthedResponse } from "../middlewares/auth"; +import { invalidate } from "../lib/cache"; export const usuariosRouter = Router(); @@ -76,6 +77,7 @@ usuariosRouter.post("/", async (req, res: AuthedResponse, next) => { } const usuario = await SupabaseAdminService.criarUsuario({ email, senha, nome, role }); + invalidate("usuarios"); res.json({ success: true, mensagem: `Usuário "${usuario.email}" criado com sucesso.` }); } catch (err) { next(traduzirErroSupabase(err)); @@ -116,6 +118,7 @@ usuariosRouter.patch("/:id", async (req, res: AuthedResponse, next) => { await SupabaseAdminService.renomear(usuarioId, nome); await SupabaseAdminService.atualizarCargo(usuarioId, role); if (novaSenha) await SupabaseAdminService.redefinirSenha(usuarioId, novaSenha); + invalidate("usuarios"); res.json({ success: true, @@ -136,6 +139,7 @@ usuariosRouter.post("/:id/bloquear", async (req, res: AuthedResponse, next) => { } await PastaService.removerFaturista(usuarioId); await SupabaseAdminService.bloquear(usuarioId); + invalidate("usuarios"); res.json({ success: true, mensagem: "Usuário bloqueado com sucesso. As pastas em que ele era faturista ficaram sem responsável." }); } catch (err) { next(traduzirErroSupabase(err)); @@ -146,6 +150,7 @@ usuariosRouter.post("/:id/ativar", async (req, res: AuthedResponse, next) => { try { const usuarioId = parseId(req.params.id); await SupabaseAdminService.ativar(usuarioId); + invalidate("usuarios"); res.json({ success: true, mensagem: "Usuário ativado com sucesso." }); } catch (err) { next(traduzirErroSupabase(err)); @@ -167,6 +172,7 @@ usuariosRouter.delete("/:id", async (req, res: AuthedResponse, next) => { } await PastaService.removerFaturista(usuarioId); await SupabaseAdminService.excluir(usuarioId); + invalidate("usuarios"); res.json({ success: true, mensagem: "Usuário excluído com sucesso. As pastas em que ele era faturista ficaram sem responsável." }); } catch (err) { next(traduzirErroSupabase(err)); diff --git a/server/services/auth.server.ts b/server/services/auth.server.ts index e04b986..86c51f2 100644 --- a/server/services/auth.server.ts +++ b/server/services/auth.server.ts @@ -1,135 +1,27 @@ import { getSession } from "./session.server"; -import { createSupabaseServerClient } from "./supabase.server"; export interface AuthedUser { id: string; - aud?: string; - role?: string; email?: string; - app_metadata?: any; - user_metadata?: any; - created_at?: string; + app_metadata: { role: string }; + user_metadata: { nome: string }; } -function parseJwt(token: string): Record | null { - try { - const base64Url = token.split(".")[1]; - const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); - const jsonPayload = decodeURIComponent( - atob(base64) - .split("") - .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) - .join("") - ); - return JSON.parse(jsonPayload); - } catch { - return null; - } -} - -export function isAdmin(user: any) { - return user?.app_metadata?.role === "admin"; -} - -//Validação local ultra-rápida (0ms de rede): decodifica o JWT do cookie. -//Enquanto o token ainda não expirou, retorna o usuário SEM chamar o Supabase -//— isso evita gastar o rate-limit de auth a cada request. -export function getUserFromJwt(cookieHeader?: string | null): AuthedUser | null { - const session = getSession(cookieHeader); - const accessToken = session.access_token; - if (!accessToken) return null; - - const decoded = parseJwt(accessToken); - const now = Math.floor(Date.now() / 1000); - - if (decoded && decoded.exp > now) { - return { - id: decoded.sub, - aud: decoded.aud || "authenticated", - role: decoded.role || "authenticated", - email: decoded.email, - app_metadata: decoded.app_metadata || {}, - user_metadata: decoded.user_metadata || {}, - created_at: "", - }; - } - return null; -} - -//Cache em memória da validação remota por access token. Quando o token expira, -//o refresh do Supabase é caro (rede) e rotaciona o refresh_token — sem cache, -//vários requests paralelos tentavam renovar ao mesmo tempo, estourando o -//rate-limit de auth (429). Com cache, renovamos no máximo 1x a cada 30s. -const remoteUserCache = new Map(); -const REMOTE_CACHE_TTL = 30_000; - -//Resolve o usuário da requisição e retorna também os headers gerados pelo -//Supabase (ex.: Set-Cookie com o refresh_token rotacionado). O middleware de -//auth deve aplicar esses headers na resposta para NÃO repetir o refresh em -//todo request. -export async function resolveAuth( - cookieHeader?: string | null -): Promise<{ user: AuthedUser | null; headers: Headers }> { - const local = getUserFromJwt(cookieHeader); - if (local) return { user: local, headers: new Headers() }; - +//Resolve o usuário direto do cookie de sessão (HMAC, ver session.server.ts): +//nenhuma chamada de rede ao Supabase por request. Cookie ausente, inválido ou +//com `exp` vencido (sessão de 30 dias) resolve para null — sem sessão remota +//para renovar, o cliente decide o redirect para o login. +export function resolveAuth(cookieHeader?: string | null): AuthedUser | null { const session = getSession(cookieHeader); - const accessToken = session.access_token; - - if (accessToken) { - const cached = remoteUserCache.get(accessToken); - if (cached && Date.now() - cached.ts < REMOTE_CACHE_TTL) { - return { user: cached.user, headers: new Headers() }; - } - } + if (!session.sub || !session.exp) return null; - try { - const { supabase, response } = await createSupabaseServerClient(cookieHeader); - const { - data: { user }, - error, - } = await supabase.auth.getUser(); - - const result = (user as AuthedUser) ?? null; - if (!error && accessToken) { - remoteUserCache.set(accessToken, { user: result, ts: Date.now() }); - } - return { user: result, headers: response.headers }; - } catch { - // Falha de rede / rate-limit transitório: não derruba a sessão se ainda - // existe um token local minimamente válido. - return { user: getUserFromJwt(cookieHeader), headers: new Headers() }; - } -} + const agora = Math.floor(Date.now() / 1000); + if (session.exp < agora) return null; -//Verifica se há um usuário sem disparar redirect. -export async function getUserByCookie(cookieHeader?: string | null): Promise { - const { user } = await resolveAuth(cookieHeader); - return user; + return { + id: session.sub, + email: session.email, + app_metadata: { role: session.role === "admin" ? "admin" : "usuario" }, + user_metadata: { nome: session.nome || "" }, + }; } - -//Validação REMOTA (getUser) para que rebaixamentos e bloqueios de cargo -//tenham efeito imediato no acesso. Também aplica a rotação do refresh token. -export async function requireAdminByCookie( - cookieHeader?: string | null -): Promise<{ user: AuthedUser; headers: Headers }> { - const { supabase, response } = await createSupabaseServerClient(cookieHeader); - const { - data: { user }, - error, - } = await supabase.auth.getUser(); - - if (error || !user) { - const err: any = new Error("Não autenticado"); - err.status = 401; - throw err; - } - - if (!isAdmin(user)) { - const err: any = new Error("Acesso restrito a administradores"); - err.status = 403; - throw err; - } - - return { user: user as AuthedUser, headers: response.headers }; -} \ No newline at end of file diff --git a/server/services/config.server.ts b/server/services/config.server.ts index ae24815..8b53c7f 100644 --- a/server/services/config.server.ts +++ b/server/services/config.server.ts @@ -1,22 +1,30 @@ import db from "../lib/prisma.server"; +import { getOrSet, invalidate } from "../lib/cache"; + +const ORDEM_CACHE_KEY = "ordemColunas"; +const ORDEM_TTL = 1000 * 60 * 10; // 10 minutos export const OrdemColunasService = { get: async () => { - const conf = await db.ordemColunas.findUnique({ where: { id: 1 } }); - if (!conf) return null; - try { - return JSON.parse(conf.ordem); - } catch { - return conf.ordem; - } + return getOrSet(ORDEM_CACHE_KEY, ORDEM_TTL, async () => { + const conf = await db.ordemColunas.findUnique({ where: { id: 1 } }); + if (!conf) return null; + try { + return JSON.parse(conf.ordem); + } catch { + return conf.ordem; + } + }); }, - + set: async (valor: any) => { const v = typeof valor === 'string' ? valor : JSON.stringify(valor); - return db.ordemColunas.upsert({ + const resultado = await db.ordemColunas.upsert({ where: { id: 1 }, update: { ordem: v }, create: { id: 1, ordem: v } }); + invalidate(ORDEM_CACHE_KEY); + return resultado; } }; diff --git a/server/services/dashboard.server.ts b/server/services/dashboard.server.ts index 804ed27..452ea74 100644 --- a/server/services/dashboard.server.ts +++ b/server/services/dashboard.server.ts @@ -256,11 +256,6 @@ function parseDataUTC(valor: any): Date | null { //faturista. Otimizado para a latência do banco: poucas idas ao banco + cache em //memória (o cache só é usado quando não há filtros ativos). export class DashboardService { - // Nomes dos faturistas vêm do Supabase (rede) — cache longo. - private static nomesCache: Map | null = null; - private static nomesCacheTime = 0; - private static readonly NOMES_TTL = 1000 * 60 * 10; // 10 minutos - // O resumo inteiro é cacheado no servidor: o client já faz polling de 30s, // então o custo alto (rede + Supabase) acontece no máximo 1x por 30s. private static resumoCache: DashboardResumo | null = null; @@ -271,10 +266,9 @@ export class DashboardService { private static opcoesCacheTime = 0; private static readonly OPCOES_TTL = 1000 * 60 * 5; + // Lista de usuários já vem cacheada de SupabaseAdminService (A6a) — sem + // cache próprio aqui para não duplicar a mesma informação em dois lugares. private static async nomesFaturista(): Promise> { - if (this.nomesCache && Date.now() - this.nomesCacheTime < this.NOMES_TTL) { - return this.nomesCache; - } const nomes = new Map(); try { const { usuarios } = await SupabaseAdminService.listarUsuarios(1, 1000); @@ -282,8 +276,6 @@ export class DashboardService { } catch { // mantém fallback (id) se o Supabase falhar } - this.nomesCache = nomes; - this.nomesCacheTime = Date.now(); return nomes; } diff --git a/server/services/operacao.server.ts b/server/services/operacao.server.ts index 4615acd..d423567 100644 --- a/server/services/operacao.server.ts +++ b/server/services/operacao.server.ts @@ -84,8 +84,11 @@ export class OperacaoService { // Uma única ida ao banco: COUNT e SUM vêm como colunas de janela (OVER()), // eliminando a segunda query (COUNT+SUM) que antes dobrava o round-trip // de rede — o gargalo real aqui é latência (VPS/DB na Europa), não o SQL. - const data = await prisma.$queryRawUnsafe(` - SELECT + // As duas outras queries (placas duplicadas e regras de prazo) não dependem + // do resultado da principal: disparam todas juntas no mesmo Promise.all. + const [data, placasDuplicadas, regras] = await Promise.all([ + prisma.$queryRawUnsafe(` + SELECT o.id, o.nm_agencia, o.dt_emissao_, o.cd_pessoa_pagador, o.nm_pessoa_pagador, o.nr_cpf_cnpj_raiz, o.nr_cpf_cnpj_pagador, o.nr_ctrc, o.status, o.comentarios, o.id_tipo_documento, o.nm_pessoa_remetente, o.nm_cidade_origem, o.ds_sigla_origem, @@ -99,9 +102,7 @@ export class OperacaoService { ${whereClause.sql} ${orderClause} LIMIT ${l} OFFSET ${offset} - `, ...whereClause.params); - - const [placasDuplicadas, regras] = await Promise.all([ + `, ...whereClause.params), this.placasDuplicadasDaPasta(pid), PrazoService.regras(), ]); @@ -302,24 +303,23 @@ export class OperacaoService { } else if (campo.startsWith("vl_")) { valorLimpo = Number(valorNovo.replace(",", ".")); } - - const dataUpdate: any = { [campo]: valorLimpo }; + if (campo === "status") { - const operacaoAtual = await prisma.operacao.findUnique({ - where: { id }, - select: { status: true } - }); - if (operacaoAtual?.status !== valorLimpo) { - dataUpdate.data_status = new Date(); - } + // Uma única ida ao banco: o CASE só grava data_status quando o status + // muda de fato (IS DISTINCT FROM cobre o caso de status atual nulo) — + // antes eram 2 idas (findUnique para comparar + update). + await prisma.$executeRawUnsafe( + `UPDATE "Operacao" + SET status = $1, + data_status = CASE WHEN status IS DISTINCT FROM $1 THEN now() ELSE data_status END + WHERE id = $2`, + valorLimpo, + id + ); + return; } - - const operacaoAtualizada = await prisma.operacao.update({ - where: { id }, - data: dataUpdate - }); - return operacaoAtualizada; + await prisma.operacao.update({ where: { id }, data: { [campo]: valorLimpo } }); } static async bulkUpdate(ids: number[], campo: string, valor: string) { diff --git a/server/services/session.server.ts b/server/services/session.server.ts index cc7506f..0925af5 100644 --- a/server/services/session.server.ts +++ b/server/services/session.server.ts @@ -8,9 +8,15 @@ if (!sessionSecret) { export const SESSION_COOKIE_NAME = "__logicell_session"; const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 dias +// Sessão própria (sem Supabase por request): os dados do usuário ficam no +// próprio cookie, assinado por HMAC. `exp` em segundos Unix, 30 dias a partir +// do login — sem refresh remoto, ver auth.server.ts. export interface SessionData { - access_token?: string; - refresh_token?: string; + sub?: string; + email?: string; + role?: string; + nome?: string; + exp?: number; } function sign(value: string): string { diff --git a/server/services/supabase-admin.server.ts b/server/services/supabase-admin.server.ts index 33242ea..baa488e 100644 --- a/server/services/supabase-admin.server.ts +++ b/server/services/supabase-admin.server.ts @@ -1,4 +1,5 @@ import { createClient } from "@supabase/supabase-js"; +import { getOrSet } from "../lib/cache"; export interface UsuarioAdmin { id: string; @@ -12,6 +13,10 @@ export interface UsuarioAdmin { const ROLE_ADMIN = "admin"; const ROLE_USUARIO = "usuario"; const BAN_PERMANENTE = "876000h"; // 100 anos = banimento efetivamente permanente +//Lista completa de usuários do Supabase muda pouco: cacheada 10 min e paginada +//em memória (listarUsuarios, contarAdmins e quem mais precisar da lista toda). +const USUARIOS_CACHE_KEY = "usuarios"; +const USUARIOS_TTL = 1000 * 60 * 10; // 10 minutos //Client administrativo do Supabase (service_role). //IMPORTANTE: Este arquivo é exclusivo do servidor. A service role key @@ -45,38 +50,56 @@ function mapearUsuario(u: any): UsuarioAdmin { }; } -export const SupabaseAdminService = { - //Lista todos os usuários (consultando todas as páginas do Supabase), ordena - //alfabeticamente por nome e devolve apenas a fatia da página pedida — assim a - //ordenação continua correta mesmo quando a base passa de uma página (200). - async listarUsuarios(page = 1, perPage = 200) { - const supabase = createSupabaseAdminClient(); - const totalUsuarios: any[] = []; +//Busca todas as páginas do Supabase, já mapeadas e ordenadas por nome — +//chamada só pelo getOrSet abaixo, no máximo 1x a cada TTL. +async function carregarUsuarios(): Promise { + const supabase = createSupabaseAdminClient(); + const totalUsuarios: any[] = []; - for (let pagina = 1; ; pagina++) { - const { data, error } = await supabase.auth.admin.listUsers({ page: pagina, perPage: 1000 }); - if (error) throw error; + for (let pagina = 1; ; pagina++) { + const { data, error } = await supabase.auth.admin.listUsers({ page: pagina, perPage: 1000 }); + if (error) throw error; - const users = data?.users || []; - totalUsuarios.push(...users); + const users = data?.users || []; + totalUsuarios.push(...users); - if (!users.length || (data?.total ?? 0) <= pagina * 1000) break; - } + if (!users.length || (data?.total ?? 0) <= pagina * 1000) break; + } - const chave = (u: any) => { - const nome = (u.user_metadata?.nome || u.user_metadata?.nickname || u.email || "") - .trim() - .toLowerCase(); - return nome.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); - }; + const chave = (u: any) => { + const nome = (u.user_metadata?.nome || u.user_metadata?.nickname || u.email || "") + .trim() + .toLowerCase(); + return nome.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + }; - totalUsuarios.sort((a, b) => chave(a).localeCompare(chave(b))); + totalUsuarios.sort((a, b) => chave(a).localeCompare(chave(b))); + return totalUsuarios.map(mapearUsuario); +} +export const SupabaseAdminService = { + //Lista completa vem do cache (getOrSet); a paginação pedida é só uma fatia + //em memória — a ordenação continua correta mesmo com a base > 1 página. + async listarUsuarios(page = 1, perPage = 200) { + const totalUsuarios = await getOrSet(USUARIOS_CACHE_KEY, USUARIOS_TTL, carregarUsuarios); const inicio = (page - 1) * perPage; - const usuarios = totalUsuarios.slice(inicio, inicio + perPage).map(mapearUsuario); + const usuarios = totalUsuarios.slice(inicio, inicio + perPage); return { usuarios, total: totalUsuarios.length }; }, + //Procura um usuário na lista cacheada (sem rede na maioria das vezes). + //null = não existe mais; undefined = não deu para consultar (Supabase fora), + //e quem chama decide se segue só com o cookie. + async buscarPorId(id: string): Promise { + try { + const totalUsuarios = await getOrSet(USUARIOS_CACHE_KEY, USUARIOS_TTL, carregarUsuarios); + return totalUsuarios.find((u) => u.id === id) ?? null; + } catch (err) { + console.error("[Auth] não foi possível consultar a lista de usuários:", err); + return undefined; + } + }, + async criarUsuario(dados: { email: string; senha: string; nome: string; role: string }) { const supabase = createSupabaseAdminClient(); const role = dados.role === ROLE_ADMIN ? ROLE_ADMIN : ROLE_USUARIO; @@ -148,23 +171,9 @@ export const SupabaseAdminService = { if (error) throw error; }, - //Conta quantos usuários são admin consultando todas as páginas (máx. 1000 por página). + //Conta quantos usuários são admin sobre a lista cacheada (sem ida ao Supabase). async contarAdmins() { - const supabase = createSupabaseAdminClient(); - let pagina = 1; - let totalAdmins = 0; - - for (;;) { - const { data, error } = await supabase.auth.admin.listUsers({ page: pagina, perPage: 1000 }); - if (error) throw error; - - const users = data?.users || []; - totalAdmins += users.filter((u: any) => u.app_metadata?.role === ROLE_ADMIN).length; - - if (!users.length || (data?.total ?? 0) <= pagina * 1000) break; - pagina += 1; - } - - return totalAdmins; + const totalUsuarios = await getOrSet(USUARIOS_CACHE_KEY, USUARIOS_TTL, carregarUsuarios); + return totalUsuarios.filter((u) => u.role === ROLE_ADMIN).length; }, }; \ No newline at end of file diff --git a/server/services/supabase.server.ts b/server/services/supabase.server.ts index 39e8d0e..e7cb1bc 100644 --- a/server/services/supabase.server.ts +++ b/server/services/supabase.server.ts @@ -1,10 +1,10 @@ -import { createServerClient, parseCookieHeader, serializeCookieHeader } from "@supabase/ssr"; -import { getSession } from "./session.server"; +import { createClient } from "@supabase/supabase-js"; -export async function createSupabaseServerClient( - cookieHeader?: string | null, - options?: { skipSessionSync?: boolean } -) { +//Client do Supabase usado exclusivamente pelo login: valida e-mail/senha e +//devolve os dados do usuário (id, e-mail, role, nome) para montar o cookie de +//sessão próprio (ver session.server.ts). Sem sincronização de cookies do +//Supabase — a sessão da aplicação é local, resolvida sem rede (A9). +export function createSupabaseAuthClient() { const supabaseUrl = process.env.VITE_SUPABASE_URL || ""; const supabaseAnonKey = process.env.VITE_SUPABASE_ANON_KEY || ""; @@ -12,37 +12,7 @@ export async function createSupabaseServerClient( throw new Error("VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY must be set"); } - const response = { - headers: new Headers(), - }; - - const supabase = createServerClient(supabaseUrl, supabaseAnonKey, { - cookies: { - getAll() { - return (cookieHeader ? parseCookieHeader(cookieHeader) : []).map((c) => ({ - name: c.name, - value: c.value ?? "", - })); - }, - setAll(cookiesToSet) { - cookiesToSet.forEach(({ name, value, options }) => - response.headers.append("Set-Cookie", serializeCookieHeader(name, value, options)) - ); - }, - }, + return createClient(supabaseUrl, supabaseAnonKey, { + auth: { autoRefreshToken: false, persistSession: false }, }); - - // SINCRONIZAÇÃO: Carrega os tokens do nosso cookie de sessão corporativo - const session = getSession(cookieHeader); - const accessToken = session.access_token; - const refreshToken = session.refresh_token; - - if (accessToken && !options?.skipSessionSync) { - await supabase.auth.setSession({ - access_token: accessToken, - refresh_token: refreshToken || "", - }); - } - - return { supabase, response }; } From e3890f199cf5750f0605073d6caf878fedc8843c Mon Sep 17 00:00:00 2001 From: "Glauber.x86" Date: Sun, 13 Sep 2026 09:11:58 -0300 Subject: [PATCH 04/12] =?UTF-8?q?perf:=20bundle=20com=20chunk=20por=20p?= =?UTF-8?q?=C3=A1gina?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A planilha de exportação (xlsx, ~1 MB) só é baixada quando o usuário realmente clica em "Exportar Excel", em vez de entrar no pacote inicial. - Dashboard, Automações, Usuários, Perfil e Login viram pedaços separados do bundle, carregados só na primeira vez que a tela é aberta; a tela de operações (a mais usada) continua no pacote principal. - Se um novo deploy apagar os pedaços antigos do site enquanto alguém ainda está com a aba aberta, o app recarrega a página sozinho para buscar a versão nova (no máximo uma vez por minuto, pra não entrar em loop). - A folha de estilo das fontes do Google deixou de travar a primeira pintura da página: ela carrega em paralelo e só é aplicada quando termina. --- app/AppRoutes.tsx | 45 ++++++++++++++++++++---------------- app/components/AppLayout.tsx | 8 +++++-- app/main.tsx | 12 ++++++++++ app/utils/export.ts | 5 ++-- app/views/OperacoesView.tsx | 6 ++--- index.html | 9 ++++++++ 6 files changed, 57 insertions(+), 28 deletions(-) diff --git a/app/AppRoutes.tsx b/app/AppRoutes.tsx index 3f28727..af05e42 100644 --- a/app/AppRoutes.tsx +++ b/app/AppRoutes.tsx @@ -1,12 +1,15 @@ +import { lazy, Suspense } from "react"; import { Navigate, Outlet, Route, Routes, useLocation } from "react-router"; import { useAuth } from "~/context/AuthContext"; import { AppLayout } from "~/components/AppLayout"; -import { LoginPage } from "~/pages/LoginPage"; -import { DashboardPage } from "~/pages/DashboardPage"; import { OperacoesPage } from "~/pages/OperacoesPage"; -import { AutomacoesPage } from "~/pages/AutomacoesPage"; -import { UsuariosPage } from "~/pages/UsuariosPage"; -import { PerfilPage } from "~/pages/PerfilPage"; + +//Páginas menos acessadas ficam em chunks separados, baixados só quando visitadas +const LoginPage = lazy(() => import("~/pages/LoginPage").then((m) => ({ default: m.LoginPage }))); +const DashboardPage = lazy(() => import("~/pages/DashboardPage").then((m) => ({ default: m.DashboardPage }))); +const AutomacoesPage = lazy(() => import("~/pages/AutomacoesPage").then((m) => ({ default: m.AutomacoesPage }))); +const UsuariosPage = lazy(() => import("~/pages/UsuariosPage").then((m) => ({ default: m.UsuariosPage }))); +const PerfilPage = lazy(() => import("~/pages/PerfilPage").then((m) => ({ default: m.PerfilPage }))); function SplashScreen() { return ( @@ -43,24 +46,26 @@ function RequireAdmin() { export function AppRoutes() { return ( - - } /> + }> + + } /> - }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - }> - } /> + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + }> + } /> + - - } /> - + } /> + + ); } diff --git a/app/components/AppLayout.tsx b/app/components/AppLayout.tsx index ca81290..d232f12 100644 --- a/app/components/AppLayout.tsx +++ b/app/components/AppLayout.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; import { Outlet } from "react-router"; import { useAuth } from "~/context/AuthContext"; import { UIContext } from "~/hooks/use-ui"; @@ -71,7 +71,11 @@ export function AppLayout() { setIsCollapsed={setIsCollapsed} />
- + {/* Boundary interno: enquanto o chunk de uma página lazy baixa, a + sidebar continua no lugar (o Suspense de AppRoutes só cobre o login) */} + + +
diff --git a/app/main.tsx b/app/main.tsx index bb90d7a..421bd37 100644 --- a/app/main.tsx +++ b/app/main.tsx @@ -6,6 +6,18 @@ import { AuthProvider } from "./context/AuthContext"; import { AppRoutes } from "./AppRoutes"; import "./styles/tailwind.css"; +//Um deploy pode apagar os chunks antigos enquanto uma aba aberta ainda os pede +//(rotas lazy). Recarrega a página para buscar os chunks novos, no máximo 1x/min. +window.addEventListener("vite:preloadError", () => { + const chave = "logicell-last-reload"; + const agora = Date.now(); + const ultima = Number(sessionStorage.getItem(chave) || 0); + if (agora - ultima > 60_000) { + sessionStorage.setItem(chave, String(agora)); + window.location.reload(); + } +}); + createRoot(document.getElementById("root")!).render( diff --git a/app/utils/export.ts b/app/utils/export.ts index 147246c..5f35e92 100644 --- a/app/utils/export.ts +++ b/app/utils/export.ts @@ -1,8 +1,9 @@ -import * as XLSX from "xlsx"; import { formatarData } from "./formatters"; -export function exportarExcel(dados: any[], colunas: any[], nomePasta: string, showAlert: any) { +export async function exportarExcel(dados: any[], colunas: any[], nomePasta: string, showAlert: any) { try { + //xlsx só é baixado quando o usuário realmente exporta (~1 MB fora do bundle principal) + const XLSX = await import("xlsx"); const exportData = dados.map(row => { const obj: any = {}; colunas.forEach(col => { diff --git a/app/views/OperacoesView.tsx b/app/views/OperacoesView.tsx index edab4c8..e42ce58 100644 --- a/app/views/OperacoesView.tsx +++ b/app/views/OperacoesView.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useSearchParams } from "react-router"; -import { useOperacoesGridState } from "~/hooks/useOperacoesGridState"; +import { useOperacoesGridState, COLUNAS_OPERACAO } from "~/hooks/useOperacoesGridState"; import { useOperacoesGridData, isFilterEmpty } from "~/hooks/useOperacoesGridData"; import { useOperacoesStore } from "~/store/useOperacoesStore"; import "react-data-grid/lib/styles.css"; @@ -126,9 +126,7 @@ export function OperacoesView({ pastaId = null, pastaNome, nomePasta, showImport const onFillEnd = useMemo(() => (colKey: string) => handleFillEndRef.current(colKey), []); const lidarExportarExcel = () => { - import("~/hooks/useOperacoesGridState").then(({ COLUNAS_OPERACAO }) => { - exportarExcel(dadosRef.current, COLUNAS_OPERACAO, nomePasta, showAlert); - }); + exportarExcel(dadosRef.current, COLUNAS_OPERACAO, nomePasta, showAlert); }; const colDefs = useMemo(() => getOperacoesColumns({ diff --git a/index.html b/index.html index d6a1be1..dc8eb9d 100644 --- a/index.html +++ b/index.html @@ -11,10 +11,19 @@ + +