Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ node_modules/
.vercel/
*.log
.vercel
.DS_Store
.claude/
backup-historico/
109 changes: 79 additions & 30 deletions api/cron-limpeza.js
Original file line number Diff line number Diff line change
@@ -1,57 +1,106 @@
const { getCapturaAntigas, deleteCapturas, deletePhotos } = require('../lib/supabase');
const {
getCapturasParaArquivar,
archiveToHistorico,
deleteCapturas,
deletePhotos,
getFotosInfracaoParaApagar,
markFotoIndisponivel,
purgeHistoricoAntigo,
} = require('../lib/supabase');

// Política de retenção em 3 camadas
const RETENCAO_CAPTURAS_DIAS = 15; // capturas + foto regular
const RETENCAO_FOTOS_INFRACAO_DIAS = 90; // foto de infração no Storage
const RETENCAO_HISTORICO_DIAS = 180; // metadados no historico

// Limites por execução (evitar timeout na Vercel — default 300s)
const BATCH_SIZE = 100;
const MAX_POR_ETAPA = 2000;

module.exports = async function handler(req, res) {
// Apenas GET (Vercel Cron usa GET)
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Método não permitido' });
}

// Verificar autorização (Vercel Cron envia header Authorization)
const authHeader = req.headers['authorization'] || '';
const expectedSecret = `Bearer ${process.env.CRON_SECRET}`;

if (authHeader !== expectedSecret) {
return res.status(401).json({ error: 'Não autorizado' });
}

const stats = {
etapa1_arquivadas: 0,
etapa1_fotos_regulares_apagadas: 0,
etapa1_fotos_infracao_mantidas: 0,
etapa2_fotos_infracao_apagadas: 0,
etapa3_historico_purgado: 0,
timestamp: new Date().toISOString(),
};

try {
let totalDeletados = 0;
let continuar = true;
// ============================================
// ETAPA 1: Arquivar capturas > 15 dias
// - Arquiva metadados no historico
// - Apaga foto do Storage SE não for infração
// - Apaga row de capturas
// ============================================
let processados = 0;
while (processados < MAX_POR_ETAPA) {
const capturas = await getCapturasParaArquivar(RETENCAO_CAPTURAS_DIAS, BATCH_SIZE);
if (capturas.length === 0) break;

// Processar em lotes de 100 para evitar timeout
while (continuar) {
const capturas = await getCapturaAntigas(15, 100);
// Arquivar no historico (ON CONFLICT DO NOTHING — idempotente)
await archiveToHistorico(capturas);
stats.etapa1_arquivadas += capturas.length;

if (capturas.length === 0) {
continuar = false;
break;
}
// Separar fotos: regulares apagam, infrações ficam no Storage até dia 90
const fotosRegulares = capturas
.filter((c) => !c.eh_infracao && c.foto_path)
.map((c) => c.foto_path);
const fotosInfracao = capturas
.filter((c) => c.eh_infracao && c.foto_path);

// Deletar fotos do Storage
const fotoPaths = capturas.map((c) => c.foto_path).filter(Boolean);
if (fotoPaths.length > 0) {
await deletePhotos(fotoPaths);
if (fotosRegulares.length > 0) {
await deletePhotos(fotosRegulares);
stats.etapa1_fotos_regulares_apagadas += fotosRegulares.length;
}
stats.etapa1_fotos_infracao_mantidas += fotosInfracao.length;

// Deletar capturas do banco
const ids = capturas.map((c) => c.id);
await deleteCapturas(ids);
// Deletar rows de capturas (foto de infração continua no Storage, referenciada pelo historico)
await deleteCapturas(capturas.map((c) => c.id));

totalDeletados += capturas.length;
processados += capturas.length;
}

// ============================================
// ETAPA 2: Apagar fotos de infração > 90 dias
// - Busca no historico fotos ainda disponíveis
// - Apaga do Storage
// - Marca foto_disponivel=false
// ============================================
let processadosFotos = 0;
while (processadosFotos < MAX_POR_ETAPA) {
const fotos = await getFotosInfracaoParaApagar(RETENCAO_FOTOS_INFRACAO_DIAS, BATCH_SIZE);
if (fotos.length === 0) break;

// Safety: máximo 1000 por execução para não estourar timeout
if (totalDeletados >= 1000) {
continuar = false;
const paths = fotos.map((f) => f.foto_path).filter(Boolean);
if (paths.length > 0) {
await deletePhotos(paths);
}

await markFotoIndisponivel(fotos.map((f) => f.id));
stats.etapa2_fotos_infracao_apagadas += fotos.length;
processadosFotos += fotos.length;
}

return res.status(200).json({
ok: true,
deletados: totalDeletados,
timestamp: new Date().toISOString(),
});
// ============================================
// ETAPA 3: Purge do histórico > 180 dias
// ============================================
stats.etapa3_historico_purgado = await purgeHistoricoAntigo(RETENCAO_HISTORICO_DIAS);

return res.status(200).json({ ok: true, stats });
} catch (err) {
console.error('Erro no cron-limpeza:', err.message);
return res.status(500).json({ error: 'Erro interno do servidor' });
return res.status(500).json({ error: 'Erro interno', message: err.message, stats });
}
};
111 changes: 111 additions & 0 deletions lib/supabase.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,112 @@ async function getCapturaAntigas(dias, limit = 100) {
return data || [];
}

/**
* Busca capturas > N dias para arquivar no histórico.
* Retorna com flag eh_infracao (vel > limite do cliente).
*/
async function getCapturasParaArquivar(dias, limit = 100) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - dias);

const { data, error } = await supabase
.from('capturas')
.select('id, camera_id, cliente_id, placa, velocidade, pixels, tipo_veiculo, cor_veiculo, foto_path, timestamp, notificado, notificado_em, criado_em, clientes!inner(limite_velocidade)')
.lt('timestamp', cutoff.toISOString())
.limit(limit);

if (error) throw new Error(`Erro ao buscar capturas para arquivar: ${error.message}`);

return (data || []).map((c) => ({
...c,
eh_infracao: c.velocidade > (c.clientes?.limite_velocidade ?? 30),
}));
}

/**
* Arquiva capturas no histórico (idempotente via ON CONFLICT).
* Infrações mantêm foto_disponivel=true (foto continua no Storage por 90d).
* Regulares ficam foto_disponivel=false (foto será apagada junto com a captura).
*/
async function archiveToHistorico(capturas) {
if (!capturas || capturas.length === 0) return 0;

const rows = capturas.map((c) => ({
captura_id: c.id,
camera_id: c.camera_id,
cliente_id: c.cliente_id,
placa: c.placa,
velocidade: c.velocidade,
pixels: c.pixels,
tipo_veiculo: c.tipo_veiculo,
cor_veiculo: c.cor_veiculo,
foto_path: c.foto_path,
foto_disponivel: c.eh_infracao && !!c.foto_path,
timestamp: c.timestamp,
notificado: c.notificado,
notificado_em: c.notificado_em,
capturado_em: c.criado_em,
origem: 'producao',
}));

const { data, error } = await supabase
.from('capturas_historico')
.upsert(rows, { onConflict: 'captura_id', ignoreDuplicates: true })
.select('id');

if (error) throw new Error(`Erro ao arquivar no histórico: ${error.message}`);
return (data || []).length;
}

/**
* Busca fotos de infração > N dias do histórico para apagar do Storage.
*/
async function getFotosInfracaoParaApagar(dias, limit = 100) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - dias);

const { data, error } = await supabase
.from('capturas_historico')
.select('id, foto_path')
.eq('foto_disponivel', true)
.not('foto_path', 'is', null)
.lt('timestamp', cutoff.toISOString())
.limit(limit);

if (error) throw new Error(`Erro ao buscar fotos para purge: ${error.message}`);
return data || [];
}

/**
* Marca foto como indisponível no histórico (após deletá-la do Storage)
*/
async function markFotoIndisponivel(ids) {
if (!ids || ids.length === 0) return;
const { error } = await supabase
.from('capturas_historico')
.update({ foto_disponivel: false })
.in('id', ids);
if (error) throw new Error(`Erro ao marcar foto indisponível: ${error.message}`);
}

/**
* Apaga registros do histórico com mais de N dias.
* Retorna a quantidade apagada.
*/
async function purgeHistoricoAntigo(dias) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - dias);

const { data, error } = await supabase
.from('capturas_historico')
.delete()
.lt('timestamp', cutoff.toISOString())
.select('id');

if (error) throw new Error(`Erro ao purgar histórico: ${error.message}`);
return (data || []).length;
}

/**
* Deleta capturas por IDs
*/
Expand Down Expand Up @@ -211,4 +317,9 @@ module.exports = {
getCapturaAntigas,
deleteCapturas,
deletePhotos,
getCapturasParaArquivar,
archiveToHistorico,
getFotosInfracaoParaApagar,
markFotoIndisponivel,
purgeHistoricoAntigo,
};
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 53 additions & 0 deletions sql/migration-historico.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
-- ============================================
-- MIGRATION: Política de Retenção em 3 Camadas
-- ============================================
-- capturas — 15 dias (operacional + foto)
-- capturas_historico — 180 dias (metadados sem foto, para relatórios)
-- fotos de infrações — 90 dias no Storage (evidência de contestação)
-- ============================================

CREATE TABLE IF NOT EXISTS capturas_historico (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
captura_id UUID UNIQUE,
camera_id UUID,
cliente_id UUID NOT NULL,
placa TEXT NOT NULL,
velocidade INT NOT NULL,
pixels INT,
tipo_veiculo TEXT,
cor_veiculo TEXT,
foto_path TEXT,
foto_disponivel BOOLEAN DEFAULT false,
timestamp TIMESTAMPTZ NOT NULL,
notificado BOOLEAN DEFAULT false,
notificado_em TIMESTAMPTZ,
capturado_em TIMESTAMPTZ,
origem TEXT NOT NULL CHECK (origem IN ('producao', 'backup_restore', 'import_manual')),
importado_em TIMESTAMPTZ DEFAULT now()
);

-- Índices
CREATE INDEX IF NOT EXISTS idx_hist_timestamp ON capturas_historico(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_hist_cliente ON capturas_historico(cliente_id);
CREATE INDEX IF NOT EXISTS idx_hist_placa ON capturas_historico(placa);
CREATE INDEX IF NOT EXISTS idx_hist_camera ON capturas_historico(camera_id);
CREATE INDEX IF NOT EXISTS idx_hist_cliente_timestamp ON capturas_historico(cliente_id, timestamp DESC);
-- Índice composto para purge de fotos de infração (vel > 30 AND foto_disponivel)
CREATE INDEX IF NOT EXISTS idx_hist_foto_purge ON capturas_historico(timestamp)
WHERE foto_disponivel = true AND foto_path IS NOT NULL;

-- Índice para agilizar o archiving
CREATE INDEX IF NOT EXISTS idx_capturas_timestamp_cliente ON capturas(timestamp, cliente_id);

-- RLS
ALTER TABLE capturas_historico ENABLE ROW LEVEL SECURITY;

-- SELECT: super_admin total, admin_cliente/operador apenas do próprio cliente
DROP POLICY IF EXISTS "hist_select" ON capturas_historico;
CREATE POLICY "hist_select" ON capturas_historico
FOR SELECT USING (
is_super_admin()
OR cliente_id IN (SELECT id FROM clientes WHERE user_id = auth.uid())
);

-- INSERT/UPDATE/DELETE: apenas service_role (sem policy pra usuário autenticado)