|
| 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 | +}); |
0 commit comments