Skip to content

Commit 40df359

Browse files
Thales-Chagasclaude
andcommitted
Backup automatico a cada 3 dias (Edge Function + bucket)
- Edge Function 'backup': le todos os dados de todos os usuarios (service_role) e grava um JSON por usuario no bucket privado 'backups' (mesmo formato do Exportar dados). Mantem 12 por usuario. Protegida pelo header X-Backup-Secret. - Migration 20260703130000_backups.sql: cria o bucket privado. - config.toml: backup com verify_jwt=false (validado por secret). - Agendamento (pg_cron) e o segredo ficam num passo manual fora do repositorio (PASSO-A-PASSO-BACKUP.md, gitignored). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4854212 commit 40df359

4 files changed

Lines changed: 155 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,8 @@ supabase/.temp/
1111
# Guia local do bot do Telegram — contém segredos (TELEGRAM_SECRET). Nunca publicar.
1212
PASSO-A-PASSO-TELEGRAM.md
1313

14+
# Guia local do backup automático — contém segredo (BACKUP_SECRET). Nunca publicar.
15+
PASSO-A-PASSO-BACKUP.md
16+
1417
# Configura��o local do Claude Code
1518
.claude/

supabase/config.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,8 @@ verify_jwt = true
99
# A própria função valida o header secret do Telegram.
1010
[functions.telegram]
1111
verify_jwt = false
12+
13+
# Backup automático (chamado pelo agendador pg_cron, sem login Supabase).
14+
# A própria função valida o header X-Backup-Secret.
15+
[functions.backup]
16+
verify_jwt = false

supabase/functions/backup/index.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// ============================================================
2+
// Thayfinance — Backup automático (roda por agendamento a cada 3 dias)
3+
// Lê TODOS os dados de TODOS os usuários (service_role, passa por cima da
4+
// RLS), monta um JSON por usuário no MESMO formato do "Exportar dados" do
5+
// app e guarda no bucket privado 'backups' (backups/{user_id}/{data}.json).
6+
// Mantém os mais recentes e apaga os antigos.
7+
//
8+
// Segurança: só roda com o header X-Backup-Secret correto (BACKUP_SECRET).
9+
// O agendador (pg_cron) envia esse header. Sem ele = 401.
10+
// Segredos: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, BACKUP_SECRET
11+
// ============================================================
12+
13+
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
14+
15+
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
16+
const SERVICE_ROLE = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
17+
const BACKUP_SECRET = Deno.env.get("BACKUP_SECRET")!;
18+
19+
const db = createClient(SUPABASE_URL, SERVICE_ROLE);
20+
21+
const BUCKET = "backups";
22+
const MANTER = 12; // quantos backups guardar por usuário (apaga os mais antigos)
23+
24+
// ---- mapeadores linha do banco -> formato do app (igual ao cloudData.js) ----
25+
const txDeLinha = (r: any) => ({
26+
id: r.id,
27+
tipo: r.tipo,
28+
status: r.status,
29+
data: r.data,
30+
valor: Number(r.valor),
31+
descricao: r.descricao ?? "",
32+
categoriaId: r.categoria_id ?? null,
33+
clienteId: r.cliente_id ?? null,
34+
fornecedorId: r.fornecedor_id ?? null,
35+
centroCustoId: r.centro_custo_id ?? null,
36+
origem: r.origem,
37+
});
38+
const nomeDeLinha = (r: any) => ({ id: r.id, nome: r.nome });
39+
const contatoDeLinha = (r: any) => ({
40+
id: r.id, nome: r.nome, telefone: r.telefone ?? "", email: r.email ?? "", obs: r.obs ?? "",
41+
});
42+
const metaDeLinha = (r: any) => ({ id: r.id, nome: r.nome, alvo: Number(r.alvo), atual: Number(r.atual) });
43+
44+
// separa as linhas de um usuário por modo e monta {transacoes, categorias, ...}
45+
function montarModo(modo: string, dados: any) {
46+
const f = (arr: any[], map: (r: any) => any) => arr.filter((r) => r.modo === modo).map(map);
47+
return {
48+
transacoes: f(dados.tx, txDeLinha),
49+
categorias: f(dados.cat, nomeDeLinha),
50+
metas: f(dados.met, metaDeLinha),
51+
clientes: f(dados.cli, contatoDeLinha),
52+
fornecedores: f(dados.forn, contatoDeLinha),
53+
centrosCusto: f(dados.cc, nomeDeLinha),
54+
};
55+
}
56+
57+
async function rodarBackup() {
58+
// 1) puxa tudo de uma vez (uma consulta por tabela; service_role vê todos)
59+
const [tx, cat, met, cli, forn, cc] = await Promise.all([
60+
db.from("transacoes").select("*").order("data", { ascending: true }),
61+
db.from("categorias").select("*"),
62+
db.from("metas").select("*"),
63+
db.from("clientes").select("*"),
64+
db.from("fornecedores").select("*"),
65+
db.from("centros_custo").select("*"),
66+
]);
67+
const erro = tx.error || cat.error || met.error || cli.error || forn.error || cc.error;
68+
if (erro) throw erro;
69+
70+
// 2) descobre todos os user_ids que têm QUALQUER dado
71+
const todos = [
72+
...(tx.data || []), ...(cat.data || []), ...(met.data || []),
73+
...(cli.data || []), ...(forn.data || []), ...(cc.data || []),
74+
];
75+
const usuarios = [...new Set(todos.map((r: any) => r.user_id).filter(Boolean))];
76+
77+
const carimbo = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); // 2026-07-03T22-40-00
78+
let arquivos = 0;
79+
80+
// 3) um JSON por usuário, no formato do "Exportar dados"
81+
for (const uid of usuarios) {
82+
const meu = (arr: any[]) => arr.filter((r) => r.user_id === uid);
83+
const dados = {
84+
tx: meu(tx.data || []), cat: meu(cat.data || []), met: meu(met.data || []),
85+
cli: meu(cli.data || []), forn: meu(forn.data || []), cc: meu(cc.data || []),
86+
};
87+
const backup = {
88+
versao: 2,
89+
geradoEm: new Date().toISOString(),
90+
origem: "backup-automatico",
91+
userId: uid,
92+
pessoal: montarModo("pessoal", dados),
93+
empresarial: montarModo("empresarial", dados),
94+
};
95+
const caminho = `${uid}/${carimbo}.json`;
96+
const { error: eUp } = await db.storage.from(BUCKET).upload(
97+
caminho,
98+
new Blob([JSON.stringify(backup)], { type: "application/json" }),
99+
{ contentType: "application/json", upsert: true },
100+
);
101+
if (eUp) { console.error(`falha ao subir backup de ${uid}:`, eUp); continue; }
102+
arquivos++;
103+
104+
// 4) higiene: mantém os MANTER mais recentes, apaga o resto
105+
const { data: lista } = await db.storage.from(BUCKET).list(uid, {
106+
limit: 1000, sortBy: { column: "name", order: "desc" },
107+
});
108+
if (lista && lista.length > MANTER) {
109+
const apagar = lista.slice(MANTER).map((f) => `${uid}/${f.name}`);
110+
if (apagar.length) await db.storage.from(BUCKET).remove(apagar);
111+
}
112+
}
113+
114+
return { usuarios: usuarios.length, arquivos, carimbo };
115+
}
116+
117+
// ============================================================
118+
Deno.serve(async (req) => {
119+
if (req.method !== "POST") return new Response("ok", { status: 200 });
120+
if (req.headers.get("X-Backup-Secret") !== BACKUP_SECRET) {
121+
return new Response("forbidden", { status: 401 });
122+
}
123+
try {
124+
const resultado = await rodarBackup();
125+
console.log("backup ok:", JSON.stringify(resultado));
126+
return new Response(JSON.stringify({ ok: true, ...resultado }), {
127+
headers: { "Content-Type": "application/json" },
128+
});
129+
} catch (e) {
130+
console.error("erro no backup:", e);
131+
return new Response(JSON.stringify({ ok: false }), {
132+
status: 500, headers: { "Content-Type": "application/json" },
133+
});
134+
}
135+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- ============================================================
2+
-- Thayfinance — Bucket privado para os backups automáticos
3+
-- A Edge Function 'backup' grava aqui um JSON por usuário a cada 3 dias.
4+
-- O agendamento (pg_cron) fica num passo à parte porque leva o segredo.
5+
-- Cole no SQL Editor e clique em RUN. É idempotente.
6+
-- ============================================================
7+
8+
-- Bucket PRIVADO (public=false): só a service_role (a função) escreve/lê.
9+
-- O dono baixa os backups pelo painel (Storage) ou por signed URL.
10+
insert into storage.buckets (id, name, public)
11+
values ('backups', 'backups', false)
12+
on conflict (id) do nothing;

0 commit comments

Comments
 (0)