Primeiro commit

This commit is contained in:
joao.herculano
2026-08-06 11:13:33 -03:00
commit 4e1ebe861c
110 changed files with 18052 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
import { clearSession, updateSession, useSession } from "@tanstack/react-start/server";
import { assertSessionConfig, sessionConfig, type SessionUser } from "@/lib/session.server";
export async function getSessionUserId(): Promise<string | null> {
assertSessionConfig();
const session = await useSession<SessionUser>(sessionConfig);
return session.data.userId ?? null;
}
export async function setSessionUserId(userId: string): Promise<void> {
assertSessionConfig();
await updateSession<SessionUser>(sessionConfig, { userId });
}
export async function clearAuthSession(): Promise<void> {
assertSessionConfig();
await clearSession(sessionConfig);
}
+301
View File
@@ -0,0 +1,301 @@
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { toLocalDateTimeString } from "@/lib/datetime";
export interface Perfil {
id: string;
email: string;
nome: string;
aprovado: boolean;
created_at: string;
}
export interface SessaoAtual {
userId: string;
nome: string;
admin: boolean;
perfil: Perfil;
}
interface PerfilRow {
id: string;
email: string;
nome: string;
aprovado: boolean;
created_at: Date | string;
}
const loginSchema = z.object({
nome: z.string().min(3),
senha: z.string().min(6),
});
function toPerfil(row: PerfilRow): Perfil {
return {
id: row.id,
email: row.email,
nome: row.nome,
aprovado: Boolean(row.aprovado),
created_at: toLocalDateTimeString(row.created_at),
};
}
async function getSessionUserId(): Promise<string | null> {
const { getSessionUserId: getServerSessionUserId } = await import("@/lib/auth.server");
return await getServerSessionUserId();
}
async function fetchSessionProfile(userId: string): Promise<SessaoAtual | null> {
const [{ getPool, sql }] = await Promise.all([import("@/lib/db.server")]);
const pool = await getPool();
const result = await pool
.request()
.input("userId", sql.UniqueIdentifier, userId)
.query<PerfilRow & { admin: boolean }>(`
SELECT
u.id,
u.email,
u.nome,
u.aprovado,
u.created_at,
CAST(CASE
WHEN EXISTS (
SELECT 1
FROM slalog.usuarios_papeis up
WHERE up.user_id = u.id AND up.role = 'admin'
) THEN 1
ELSE 0
END AS bit) AS admin
FROM slalog.usuarios u
WHERE u.id = @userId AND u.ativo = 1
`);
const row = result.recordset[0];
if (!row) return null;
const perfil = toPerfil(row);
return {
userId: perfil.id,
nome: perfil.nome,
admin: Boolean(row.admin),
perfil,
};
}
async function requireSession(): Promise<SessaoAtual> {
const userId = await getSessionUserId();
if (!userId) throw new Error("Não autenticado");
const sessao = await fetchSessionProfile(userId);
if (!sessao) throw new Error("Sessão inválida");
return sessao;
}
async function requireAdminSession(): Promise<SessaoAtual> {
const sessao = await requireSession();
if (!sessao.admin) throw new Error("Apenas administradores podem executar esta ação.");
return sessao;
}
async function requireApprovedSession(): Promise<SessaoAtual> {
const sessao = await requireSession();
if (!sessao.perfil.aprovado) throw new Error("Usuário aguardando aprovação.");
return sessao;
}
export function normalizarNome(nome: string): string {
return nome
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9._-]/g, "");
}
export function emailDeNome(nome: string): string {
return `${normalizarNome(nome)}@coleta.local`;
}
export const obterSessaoAtual = createServerFn({ method: "GET" }).handler(async () => {
const userId = await getSessionUserId();
if (!userId) return null;
return await fetchSessionProfile(userId);
});
export const entrar = createServerFn({ method: "POST" })
.validator(loginSchema)
.handler(async ({ data }) => {
const nome = normalizarNome(data.nome);
const [{ getPool, sql }, bcrypt] = await Promise.all([
import("@/lib/db.server"),
import("bcryptjs"),
]);
const pool = await getPool();
const result = await pool
.request()
.input("nome", sql.NVarChar(150), nome)
.query<(PerfilRow & { senha_hash: string; admin: boolean; ativo: boolean })>(`
SELECT
u.id,
u.email,
u.nome,
u.aprovado,
u.ativo,
u.created_at,
u.senha_hash,
CAST(CASE
WHEN EXISTS (
SELECT 1
FROM slalog.usuarios_papeis up
WHERE up.user_id = u.id AND up.role = 'admin'
) THEN 1
ELSE 0
END AS bit) AS admin
FROM slalog.usuarios u
WHERE LOWER(u.nome) = LOWER(@nome)
`);
const row = result.recordset[0];
if (!row || !row.ativo) throw new Error("Nome de usuário ou senha incorretos.");
const ok = await bcrypt.compare(data.senha, row.senha_hash);
if (!ok) throw new Error("Nome de usuário ou senha incorretos.");
const { setSessionUserId } = await import("@/lib/auth.server");
await setSessionUserId(row.id);
const perfil = toPerfil(row);
return {
userId: row.id,
nome: row.nome,
admin: Boolean(row.admin),
perfil,
} satisfies SessaoAtual;
});
export const criarConta = createServerFn({ method: "POST" })
.validator(loginSchema)
.handler(async ({ data }) => {
const nome = normalizarNome(data.nome);
const email = emailDeNome(nome);
const [{ getPool, sql }, bcrypt] = await Promise.all([
import("@/lib/db.server"),
import("bcryptjs"),
]);
const pool = await getPool();
const existente = await pool
.request()
.input("nome", sql.NVarChar(150), nome)
.query<{ id: string }>(`
SELECT TOP 1 id
FROM slalog.usuarios
WHERE LOWER(nome) = LOWER(@nome)
`);
if (existente.recordset[0]) throw new Error("Este nome de usuário já existe. Faça login.");
const primeiro = await pool.request().query<{ total: number }>(`
SELECT COUNT(1) AS total
FROM slalog.usuarios
`);
const senhaHash = await bcrypt.hash(data.senha, 10);
const aprovado = (primeiro.recordset[0]?.total ?? 0) === 0;
const criado = await pool
.request()
.input("id", sql.UniqueIdentifier, crypto.randomUUID())
.input("email", sql.NVarChar(255), email)
.input("nome", sql.NVarChar(150), nome)
.input("aprovado", sql.Bit, aprovado)
.input("senhaHash", sql.NVarChar(255), senhaHash)
.query<PerfilRow>(`
INSERT INTO slalog.usuarios (
id,
email,
nome,
aprovado,
ativo,
senha_hash,
created_at,
updated_at
)
OUTPUT inserted.id, inserted.email, inserted.nome, inserted.aprovado, inserted.created_at
VALUES (
@id,
@email,
@nome,
@aprovado,
1,
@senhaHash,
SYSDATETIME(),
SYSDATETIME()
)
`);
if (aprovado) {
await pool
.request()
.input("id", sql.UniqueIdentifier, crypto.randomUUID())
.input("userId", sql.UniqueIdentifier, criado.recordset[0].id)
.input("role", sql.NVarChar(20), "admin")
.query(`
INSERT INTO slalog.usuarios_papeis (id, user_id, role, created_at)
VALUES (@id, @userId, @role, SYSDATETIME())
`);
}
return {
perfil: toPerfil(criado.recordset[0]),
aprovado,
};
});
export const sair = createServerFn({ method: "POST" }).handler(async () => {
const { clearAuthSession } = await import("@/lib/auth.server");
await clearAuthSession();
return { success: true };
});
export const listarPerfis = createServerFn({ method: "GET" }).handler(async () => {
await requireAdminSession();
const [{ getPool }] = await Promise.all([import("@/lib/db.server")]);
const pool = await getPool();
const result = await pool.query<PerfilRow>(`
SELECT id, email, nome, aprovado, created_at
FROM slalog.usuarios
WHERE ativo = 1
ORDER BY created_at DESC
`);
return result.recordset.map(toPerfil);
});
export const definirAprovacao = createServerFn({ method: "POST" })
.validator(z.object({ id: z.string().uuid(), aprovado: z.boolean() }))
.handler(async ({ data }) => {
await requireAdminSession();
const [{ getPool, sql }] = await Promise.all([import("@/lib/db.server")]);
const pool = await getPool();
await pool
.request()
.input("id", sql.UniqueIdentifier, data.id)
.input("aprovado", sql.Bit, data.aprovado)
.query(`
UPDATE slalog.usuarios
SET aprovado = @aprovado,
updated_at = SYSDATETIME()
WHERE id = @id
`);
});
export async function obterSessaoAutenticada(): Promise<SessaoAtual> {
return await requireSession();
}
export async function obterSessaoAprovada(): Promise<SessaoAtual> {
return await requireApprovedSession();
}
+165
View File
@@ -0,0 +1,165 @@
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { obterSessaoAprovada } from "@/lib/auth";
export interface Coleta {
id: number;
usuario: string;
codigo_barras: string;
inicio_coleta: string;
fim_coleta: string | null;
}
interface ColetaRow {
id: number;
usuario: string;
codigo_barras: string;
inicio_coleta: string;
fim_coleta: string | null;
}
function toColeta(row: ColetaRow): Coleta {
return {
id: row.id,
usuario: row.usuario,
codigo_barras: row.codigo_barras,
inicio_coleta: row.inicio_coleta,
fim_coleta: row.fim_coleta,
};
}
export const iniciarColeta = createServerFn({ method: "POST" })
.validator(z.object({ codigo: z.string().min(1) }))
.handler(async ({ data }) => {
const sessao = await obterSessaoAprovada();
const [{ getPool, sql }] = await Promise.all([import("@/lib/db.server")]);
const pool = await getPool();
const dataAberta = await pool
.request()
.input("usuario", sql.NVarChar(150), sessao.nome)
.query<{ id: number }>(`
SELECT TOP 1 id
FROM slalog.coletas
WHERE usuario = @usuario AND fim_coleta IS NULL
ORDER BY inicio_coleta DESC
`);
if (dataAberta.recordset[0]) throw new Error("Já existe uma coleta em andamento.");
const result = await pool
.request()
.input("usuario", sql.NVarChar(150), sessao.nome)
.input("codigo", sql.NVarChar(100), data.codigo)
.query<ColetaRow>(`
INSERT INTO slalog.coletas (usuario, codigo_barras, inicio_coleta, created_at)
OUTPUT
inserted.id AS id,
inserted.usuario AS usuario,
inserted.codigo_barras AS codigo_barras,
CONVERT(varchar(19), inserted.inicio_coleta, 126) AS inicio_coleta,
CASE
WHEN inserted.fim_coleta IS NULL THEN NULL
ELSE CONVERT(varchar(19), inserted.fim_coleta, 126)
END AS fim_coleta
VALUES (@usuario, @codigo, SYSDATETIME(), SYSDATETIME())
`);
return toColeta(result.recordset[0]);
});
export const finalizarColeta = createServerFn({ method: "POST" }).handler(async () => {
const sessao = await obterSessaoAprovada();
const [{ getPool, sql }] = await Promise.all([import("@/lib/db.server")]);
const pool = await getPool();
const result = await pool
.request()
.input("usuario", sql.NVarChar(150), sessao.nome)
.query<ColetaRow>(`
UPDATE c
SET fim_coleta = SYSDATETIME()
OUTPUT
inserted.id AS id,
inserted.usuario AS usuario,
inserted.codigo_barras AS codigo_barras,
CONVERT(varchar(19), inserted.inicio_coleta, 126) AS inicio_coleta,
CASE
WHEN inserted.fim_coleta IS NULL THEN NULL
ELSE CONVERT(varchar(19), inserted.fim_coleta, 126)
END AS fim_coleta
FROM slalog.coletas c
INNER JOIN (
SELECT TOP 1 id
FROM slalog.coletas
WHERE usuario = @usuario
AND fim_coleta IS NULL
ORDER BY inicio_coleta DESC
) aberta ON aberta.id = c.id
`);
const coleta = result.recordset[0];
if (!coleta) throw new Error("Coleta não encontrada ou já finalizada.");
return toColeta(coleta);
});
export const buscarColetaAberta = createServerFn({ method: "GET" }).handler(async () => {
const sessao = await obterSessaoAprovada();
const [{ getPool, sql }] = await Promise.all([import("@/lib/db.server")]);
const pool = await getPool();
const result = await pool
.request()
.input("usuario", sql.NVarChar(150), sessao.nome)
.query<ColetaRow>(`
SELECT TOP 1
id,
usuario,
codigo_barras,
CONVERT(varchar(19), inicio_coleta, 126) AS inicio_coleta,
CASE WHEN fim_coleta IS NULL THEN NULL ELSE CONVERT(varchar(19), fim_coleta, 126) END AS fim_coleta
FROM slalog.coletas
WHERE usuario = @usuario AND fim_coleta IS NULL
ORDER BY inicio_coleta DESC
`);
return result.recordset[0] ? toColeta(result.recordset[0]) : null;
});
export const listarColetas = createServerFn({ method: "POST" })
.validator(z.object({ somenteMinhas: z.boolean(), limite: z.number().int().positive().max(500) }))
.handler(async ({ data }) => {
const sessao = await obterSessaoAprovada();
const [{ getPool, sql }] = await Promise.all([import("@/lib/db.server")]);
const pool = await getPool();
const request = pool.request().input("limite", sql.Int, data.limite);
let query = `
SELECT TOP (@limite)
id,
usuario,
codigo_barras,
CONVERT(varchar(19), inicio_coleta, 126) AS inicio_coleta,
CASE WHEN fim_coleta IS NULL THEN NULL ELSE CONVERT(varchar(19), fim_coleta, 126) END AS fim_coleta
FROM slalog.coletas
`;
if (data.somenteMinhas) {
request.input("usuario", sql.NVarChar(150), sessao.nome);
query += ` WHERE usuario = @usuario`;
}
query += ` ORDER BY inicio_coleta DESC`;
const result = await request.query<ColetaRow>(query);
return result.recordset.map(toColeta);
});
export function formatarDuracao(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const h = String(Math.floor(total / 3600)).padStart(2, "0");
const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0");
const s = String(total % 60).padStart(2, "0");
return `${h}:${m}:${s}`;
}
+31
View File
@@ -0,0 +1,31 @@
function pad(value: number): string {
return String(value).padStart(2, "0");
}
export function toLocalDateTimeString(value: Date | string): string {
const date = value instanceof Date ? value : new Date(value);
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
].join("-") + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
export function parseLocalDateTime(value: string): Date {
const match = value.match(
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/,
);
if (!match) return new Date(value);
const [, year, month, day, hour, minute, second] = match;
return new Date(
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute),
Number(second),
);
}
+63
View File
@@ -0,0 +1,63 @@
import net from "node:net";
import sql from "mssql";
let poolPromise: Promise<sql.ConnectionPool> | null = null;
function getRequiredEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing required environment variable: ${name}`);
return value;
}
function envFlag(name: string, defaultValue: boolean): boolean {
const raw = process.env[name];
if (raw == null) return defaultValue;
return ["1", "true", "yes", "y"].includes(raw.toLowerCase());
}
function getServerName(host: string): string | undefined {
const configured = process.env["DB_SERVER_NAME"]?.trim();
if (configured) return configured;
if (net.isIP(host)) {
// Avoid sending an IP as TLS SNI; with trustServerCertificate=true we only
// need a non-IP placeholder to suppress the Node deprecation warning.
return "sqlserver.local";
}
return host;
}
function getConfig(): sql.config {
const server = getRequiredEnv("DB_HOST");
return {
server,
port: Number(process.env["DB_PORT"] ?? 1433),
database: getRequiredEnv("DB_NAME"),
user: getRequiredEnv("DB_USER"),
password: getRequiredEnv("DB_PASSWORD"),
options: {
encrypt: envFlag("DB_ENCRYPT", true),
serverName: getServerName(server),
trustServerCertificate: envFlag("DB_TRUST_SERVER_CERT", true),
},
pool: {
max: 10,
min: 0,
idleTimeoutMillis: 30000,
},
};
}
export async function getPool(): Promise<sql.ConnectionPool> {
if (!poolPromise) {
poolPromise = new sql.ConnectionPool(getConfig()).connect().catch((error) => {
poolPromise = null;
throw error;
});
}
return poolPromise;
}
export { sql };
+81
View File
@@ -0,0 +1,81 @@
// Captures the original Error out-of-band so server.ts can recover the stack
// when h3 has already swallowed the throw into a generic 500 Response.
let lastCapturedError: { error: unknown; at: number } | undefined;
const TTL_MS = 5_000;
function record(error: unknown) {
lastCapturedError = { error, at: Date.now() };
}
// h3's HTTPError serializes to {"status":500,"unhandled":true,"message":"HTTPError"} —
// no stack, no cause — so a plain console.error(error) reaches the log pipeline with
// the failure detail stripped. Expand Error-like args into a string that keeps the
// message, stack, and the full cause chain.
const CAUSE_DEPTH_LIMIT = 5;
const DESCRIPTION_LENGTH_LIMIT = 8_000;
export function describeError(error: unknown): string {
const parts: string[] = [];
let current: unknown = error;
for (let depth = 0; depth < CAUSE_DEPTH_LIMIT && current != null; depth++) {
if (!(current instanceof Error)) {
parts.push(typeof current === "string" ? current : safeStringify(current));
break;
}
const label = depth === 0 ? "" : "caused by: ";
const status = describeStatus(current);
parts.push(`${label}${current.stack ?? `${current.name}: ${current.message}`}${status}`);
current = current.cause;
}
return parts.join("\n").slice(0, DESCRIPTION_LENGTH_LIMIT);
}
function describeStatus(error: Error): string {
const { status, statusCode } = error as { status?: unknown; statusCode?: unknown };
const value = status ?? statusCode;
return typeof value === "number" ? ` (status ${value})` : "";
}
function safeStringify(value: unknown): string {
try {
return JSON.stringify(value) ?? String(value);
} catch {
return String(value);
}
}
function isErrorLike(value: unknown): value is Error {
return value instanceof Error;
}
// Wrap console.error so errors logged by any layer — including h3's internal
// unhandled-error logging, which this file cannot hook directly — are both
// recorded for consumeLastCapturedError and expanded before serialization.
const originalConsoleError = console.error.bind(console);
console.error = (...args: unknown[]) => {
const expanded = args.map((arg) => {
if (!isErrorLike(arg)) return arg;
record(arg);
return describeError(arg);
});
originalConsoleError(...expanded);
};
if (typeof globalThis.addEventListener === "function") {
globalThis.addEventListener("error", (event) => record((event as ErrorEvent).error ?? event));
globalThis.addEventListener("unhandledrejection", (event) =>
record((event as PromiseRejectionEvent).reason),
);
}
export function consumeLastCapturedError(): unknown {
if (!lastCapturedError) return undefined;
if (Date.now() - lastCapturedError.at > TTL_MS) {
lastCapturedError = undefined;
return undefined;
}
const { error } = lastCapturedError;
lastCapturedError = undefined;
return error;
}
+30
View File
@@ -0,0 +1,30 @@
export function renderErrorPage(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>This page didn't load</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body { font: 15px/1.5 system-ui, -apple-system, sans-serif; background: #fafafa; color: #111; display: grid; place-items: center; min-height: 100vh; margin: 0; padding: 1.5rem; }
.card { max-width: 28rem; width: 100%; text-align: center; padding: 2rem; }
h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
p { color: #4b5563; margin: 0 0 1.5rem; }
.actions { display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; }
a, button { padding: 0.5rem 1rem; border-radius: 0.375rem; font: inherit; cursor: pointer; text-decoration: none; border: 1px solid transparent; }
.primary { background: #111; color: #fff; }
.secondary { background: #fff; color: #111; border-color: #d1d5db; }
</style>
</head>
<body>
<div class="card">
<h1>This page didn't load</h1>
<p>Something went wrong on our end. You can try refreshing or head back home.</p>
<div class="actions">
<button class="primary" onclick="location.reload()">Try again</button>
<a class="secondary" href="/">Go home</a>
</div>
</div>
</body>
</html>`;
}
+58
View File
@@ -0,0 +1,58 @@
type LovableErrorOptions = {
mechanism?: "manual" | "onerror" | "unhandledrejection" | "react_error_boundary";
handled?: boolean;
severity?: "error" | "warning" | "info";
};
type LovableEvents = {
captureException?: (
error: unknown,
context?: Record<string, unknown>,
options?: LovableErrorOptions,
) => void;
};
declare global {
interface Window {
__lovableEvents?: LovableEvents;
__lovableReportRuntimeError?: (payload: {
message: string;
stack?: string;
filename?: string;
}) => void;
}
}
export function reportLovableError(error: unknown, context: Record<string, unknown> = {}) {
if (typeof window === "undefined") return;
window.__lovableEvents?.captureException?.(
error,
{
source: "react_error_boundary",
route: window.location.pathname,
...context,
},
{
mechanism: "react_error_boundary",
handled: false,
severity: "error",
},
);
// Prod React does not rethrow boundary-caught errors to window.onerror, so the
// editor's telemetry never sees them. Forward to lovable.js's reporting hook,
// which is present only inside the editor preview.
// Loaders and server fns commonly throw a raw Response; String(it) is the
// opaque "[object Response]", so pull out the status and URL instead.
const message =
error instanceof Response
? `Response ${error.status}${error.url ? ` at ${error.url}` : ""}`
: error instanceof Error
? error.message
: String(error);
const stack = error instanceof Error ? error.stack : undefined;
window.__lovableReportRuntimeError?.({
message,
...(stack !== undefined && { stack }),
filename: window.location.pathname,
});
}
+18
View File
@@ -0,0 +1,18 @@
import { createContext, useContext } from "react";
import type { Perfil } from "@/lib/auth";
export interface Sessao {
userId: string;
nome: string;
admin: boolean;
perfil: Perfil;
}
export const SessaoContext = createContext<Sessao | null>(null);
export function useSessao(): Sessao {
const ctx = useContext(SessaoContext);
if (!ctx) throw new Error("useSessao deve ser usado dentro da área autenticada");
return ctx;
}
+21
View File
@@ -0,0 +1,21 @@
export const sessionConfig = {
password: process.env["SESSION_SECRET"] || "",
name: "slalog-session",
maxAge: 60 * 60 * 12,
cookie: {
httpOnly: true,
sameSite: "lax" as const,
path: "/",
secure: process.env["NODE_ENV"] === "production",
},
};
export interface SessionUser {
userId: string;
}
export function assertSessionConfig() {
if (!sessionConfig.password || sessionConfig.password.length < 32) {
throw new Error("SESSION_SECRET must be set with at least 32 characters.");
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}