Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
311300d
Add email change templates and subjects to config
tailan2023 Aug 31, 2026
04911a2
Update docker-compose.yml
tailan2023 Aug 31, 2026
b57f0f8
Update docker-compose.yml
tailan2023 Aug 31, 2026
ba25447
Change storage volume to local path in docker-compose
tailan2023 Aug 31, 2026
43b406b
Update docker-compose.yml
tailan2023 Aug 31, 2026
f539a2c
Create index.ts
tailan2023 Aug 31, 2026
964b696
Implement Stripe customer portal function
tailan2023 Aug 31, 2026
d873dea
Create index.ts
tailan2023 Aug 31, 2026
ce8e2d3
Add common utility functions for Supabase and Stripe
tailan2023 Aug 31, 2026
2e24e06
Add runtime secrets management
tailan2023 Aug 31, 2026
738ae6a
Update index.ts
tailan2023 Aug 31, 2026
602ceac
Update docker-compose.yml
tailan2023 Aug 31, 2026
5b01e19
Update index.ts
tailan2023 Sep 1, 2026
0da2444
Add STRIPE_PUBLISHABLE_KEY to docker-compose.yml
tailan2023 Sep 1, 2026
23b61f9
Update index.ts
tailan2023 Sep 1, 2026
8a12319
Update index.ts
tailan2023 Sep 1, 2026
72894b3
Add files via upload
tailan2023 Sep 7, 2026
b524190
Update index.ts
tailan2023 Sep 9, 2026
6ebee13
Update wa-common.ts
tailan2023 Sep 9, 2026
3e056aa
Update index.ts
tailan2023 Sep 9, 2026
5c7e526
Refactor invoice handling in Stripe webhook
tailan2023 Sep 9, 2026
bd22744
Update docker-compose.yml
tailan2023 Sep 15, 2026
9125c17
Clean up Gotrue environment variables in docker-compose
tailan2023 Sep 15, 2026
f7ef4ce
Update docker-compose.yml
tailan2023 Sep 15, 2026
7856b05
Update docker-compose.yml
tailan2023 Sep 15, 2026
b4433c3
Update docker-compose.yml
tailan2023 Sep 15, 2026
16997ae
Update docker-compose.yml
tailan2023 Sep 15, 2026
2a8916d
Update docker-compose.yml
tailan2023 Sep 15, 2026
b43cbc8
Refactor docker-compose.yml for clarity and consistency
tailan2023 Sep 15, 2026
32e8f8d
Set default value for LOGFLARE_API_KEY
tailan2023 Sep 15, 2026
afa87f1
Update docker-compose.yml
tailan2023 Sep 15, 2026
f8bc74f
Update docker-compose.yml
tailan2023 Sep 15, 2026
48653c0
Update docker-compose.yml
tailan2023 Sep 15, 2026
9443b10
Update docker-compose.yml
tailan2023 Sep 15, 2026
c3719f0
Refactor vector service configuration in docker-compose
tailan2023 Sep 15, 2026
972be7a
Update docker-compose.yml
tailan2023 Sep 15, 2026
b137acb
Update docker-compose.yml
tailan2023 Sep 15, 2026
bdcc57c
Update docker-compose.yml
tailan2023 Sep 15, 2026
fdbf550
Update docker-compose.yml
tailan2023 Sep 15, 2026
ab37ccb
Create index.ts
tailan2023 Sep 16, 2026
3d7b73d
Update docker-compose.yml
tailan2023 Sep 16, 2026
d7b71c9
Create index.ts
tailan2023 Sep 16, 2026
f946fb4
Add Supabase configuration and error handling
tailan2023 Sep 16, 2026
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
277 changes: 101 additions & 176 deletions supabase/code/docker-compose.yml

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions supabase/code/volumes/functions/_shared/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
export { secret } from './runtime-secrets.ts';
import { secret } from './runtime-secrets.ts';

export const cors = {'Access-Control-Allow-Origin':'*','Access-Control-Allow-Headers':'authorization, x-client-info, apikey, content-type, stripe-signature','Access-Control-Allow-Methods':'GET, POST, OPTIONS'};
export const json = (body: unknown, status=200) => new Response(JSON.stringify(body), {status, headers:{...cors,'content-type':'application/json'}});
export const admin = () => createClient(secret('SUPABASE_URL')!, secret('SUPABASE_SERVICE_ROLE_KEY')!, {auth:{persistSession:false}});
export const stripeSecret = () => { const v=secret('STRIPE_SECRET_KEY'); if(!v) throw new Error('STRIPE_SECRET_KEY ausente'); return v; };
export const stripeForm = async (path:string, body:URLSearchParams, account?:string, idempotency?:string) => {
const headers:Record<string,string>={'Authorization':`Bearer ${stripeSecret()}`,'Content-Type':'application/x-www-form-urlencoded'};
if(account) headers['Stripe-Account']=account;
if(idempotency) headers['Idempotency-Key']=idempotency;
const res=await fetch(`https://api.stripe.com/v1/${path}`,{method:'POST',headers,body});
const data=await res.json();
if(!res.ok) throw new Error(data?.error?.message || `Stripe HTTP ${res.status}`);
return data;
};
export const stripeGet = async (path:string) => {
const res=await fetch(`https://api.stripe.com/v1/${path}`,{headers:{Authorization:`Bearer ${stripeSecret()}`}});
const data=await res.json();
if(!res.ok) throw new Error(data?.error?.message || `Stripe HTTP ${res.status}`);
return data;
};
export const sha256 = async (value:string) => Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(value)))).map(b=>b.toString(16).padStart(2,'0')).join('');
8 changes: 8 additions & 0 deletions supabase/code/volumes/functions/_shared/runtime-secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
let saved: Record<string,string> = {};
try {
saved = JSON.parse(await Deno.readTextFile('/home/deno/functions/_shared/stripe-secrets.json'));
} catch (_) {
// Optional fallback for this self-hosted installation.
}

export const secret = (name:string) => Deno.env.get(name) || saved[name];
93 changes: 93 additions & 0 deletions supabase/code/volumes/functions/_shared/wa-common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
import { admin, cors, json, secret } from './common.ts';

export { admin, cors, json, secret };

export class HttpError extends Error {
status:number;
constructor(message:string,status=400){super(message);this.status=status;}
}

export async function requireTenant(req:Request){
const authorization=req.headers.get('authorization')||'';
if(!authorization.toLowerCase().startsWith('bearer ')) throw new HttpError('Sessão ausente.',401);
const url=secret('SUPABASE_URL');
const anon=secret('SUPABASE_ANON_KEY');
if(!url||!anon) throw new Error('Configuração interna do Supabase ausente.');
const client=createClient(url,anon,{global:{headers:{Authorization:authorization}},auth:{persistSession:false}});
const {data:{user},error}=await client.auth.getUser();
if(error||!user) throw new HttpError('Sessão inválida. Entre novamente.',401);
const db=admin();
const {data:profile,error:profileError}=await db.from('users').select('tenant_id,role').eq('id',user.id).maybeSingle();
if(profileError||!profile?.tenant_id) throw new HttpError('Usuário sem empresa vinculada.',403);
if(!['admin','owner'].includes(profile.role)) throw new HttpError('Somente o administrador da empresa pode executar esta ação.',403);
return {db,user,tenantId:String(profile.tenant_id),role:String(profile.role)};
}

export function handleError(error:unknown){
console.error(error);
if(error instanceof HttpError) return json({error:error.message},error.status);
return json({error:error instanceof Error?error.message:'Erro interno inesperado.'},500);
}

export async function evolutionSettings(db:ReturnType<typeof admin>){
const {data,error}=await db.from('platform_settings')
.select('whatsapp_evolution_url,whatsapp_evolution_api_key')
.limit(1).maybeSingle();
if(error) throw error;
const base=String(data?.whatsapp_evolution_url||'').replace(/\/$/,'');
const apiKey=String(data?.whatsapp_evolution_api_key||'');
if(!base||!apiKey) throw new HttpError('A Evolution API ainda não foi configurada no Painel Central.',409);
return {base,apiKey};
}

export async function evolutionFetch(
settings:{base:string;apiKey:string},
path:string,
init:RequestInit={},
accepted:number[]=[]
){
const res=await fetch(`${settings.base}${path.startsWith('/')?'':'/'}${path}`,{
...init,
headers:{'Content-Type':'application/json','apikey':settings.apiKey,...(init.headers||{})}
});
const raw=await res.text();
let data:any={};
try{data=raw?JSON.parse(raw):{};}catch{data={message:raw};}
if(!res.ok&&!accepted.includes(res.status)){
const message=data?.response?.message?.[0]||data?.response?.message||data?.message||data?.error||`Evolution API HTTP ${res.status}`;
throw new HttpError(Array.isArray(message)?message.join(', '):String(message),502);
}
return {res,data};
}

export const instanceName=(tenantId:string)=>`cardapioplus-${tenantId.toLowerCase()}`;

export function evolutionState(data:any){
const raw=String(data?.instance?.state||data?.state||data?.connectionStatus||'').toLowerCase();
if(['open','connected'].includes(raw)) return 'connected';
if(['connecting','qr','pairing'].includes(raw)) return 'connecting';
return 'disconnected';
}

export function evolutionPhone(data:any){
const value=data?.instance?.owner||data?.instance?.ownerJid||data?.owner||data?.ownerJid||data?.number||'';
return String(value).split('@')[0].replace(/\D/g,'');
}

export function qrCodeFrom(data:any){
return data?.base64||data?.qrcode?.base64||data?.qr?.base64||data?.qrcode||null;
}

const DIAL_CODES:Record<string,string>={AR:'54',BR:'55',CL:'56',CO:'57',MX:'52',PE:'51',UY:'598',PT:'351',US:'1',CA:'1',GB:'44'};
export function internationalPhone(raw:string,country='BR'){
let digits=String(raw||'').replace(/\D/g,'');
if(!digits) return '';
const dial=DIAL_CODES[country]||'';
if(dial&&digits.length<=11&&!digits.startsWith(dial)) digits=dial+digits;
return digits;
}

export function renderCampaignMessage(template:string,values:Record<string,string>){
return Object.entries(values).reduce((text,[key,value])=>text.split(`{{${key}}}`).join(value||''),template);
}
201 changes: 201 additions & 0 deletions supabase/code/volumes/functions/criar-pix/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
Deno.serve(async (req) => {
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};

if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}

try {
const WOOVI_APP_ID = Deno.env.get("WOOVI_APP_ID");
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");

if (!WOOVI_APP_ID) {
throw new Error("WOOVI_APP_ID não configurado");
}

if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
throw new Error("Configuração interna do Supabase não encontrada");
}

const { valor, nome } = await req.json();

const valorNumero = Number(valor);

if (!valorNumero || valorNumero <= 0) {
return new Response(
JSON.stringify({
erro: "Valor da doação inválido.",
}),
{
status: 400,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
}
);
}

const valorCentavos = Math.round(valorNumero * 100);
const correlationID = crypto.randomUUID();

// ============================
// CRIAR COBRANÇA NA WOOVI
// ============================

const wooviResponse = await fetch(
"https://api.woovi.com/api/v1/charge",
{
method: "POST",
headers: {
Authorization: WOOVI_APP_ID,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
correlationID,
value: valorCentavos,
comment: nome
? `Doação - ${nome}`
: "Doação - Campanha de arrecadação",
}),
}
);

const wooviData = await wooviResponse.json();

if (!wooviResponse.ok) {
console.error("Erro Woovi:", wooviData);

return new Response(
JSON.stringify({
erro: "Não foi possível gerar o PIX.",
detalhes: wooviData,
}),
{
status: wooviResponse.status,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
}
);
}

const charge = wooviData.charge;

if (!charge?.brCode) {
console.error("Resposta inesperada Woovi:", wooviData);
throw new Error("Woovi não retornou o código PIX.");
}

// ============================
// SALVAR NA TABELA DOACOES
// ============================

const bancoResponse = await fetch(
`${SUPABASE_URL}/rest/v1/doacoes`,
{
method: "POST",
headers: {
apikey: SERVICE_ROLE_KEY,
Authorization: `Bearer ${SERVICE_ROLE_KEY}`,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify({
mp_payment_id: correlationID,
valor: valorNumero,
status: "pending",
nome_doador: nome || null,
email_doador: null,
qr_code: charge.brCode,
qr_code_base64: null,
}),
}
);

const bancoTexto = await bancoResponse.text();

if (!bancoResponse.ok) {
console.error(
"PIX criado, mas erro ao salvar no banco:",
bancoTexto
);

return new Response(
JSON.stringify({
erro: "PIX criado, mas não foi possível registrar a doação.",
detalhes: bancoTexto,
payment_id: correlationID,
}),
{
status: 500,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
}
);
}

console.log(
"Cobrança criada e registrada:",
correlationID
);

// ============================
// RETORNO PARA O SITE
// ============================

return new Response(
JSON.stringify({
payment_id: correlationID,
correlation_id: correlationID,

qr_code: charge.brCode,

qr_code_base64: null,

qr_code_image:
charge.qrCodeImage || null,

payment_link:
charge.paymentLinkUrl || null,

status: "pending",
status_woovi:
String(charge.status || "").toUpperCase(),
}),
{
status: 200,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
}
);
} catch (error) {
console.error("Erro criar-pix:", error);

return new Response(
JSON.stringify({
erro: "Erro interno ao gerar PIX.",
detalhes: String(error),
}),
{
status: 500,
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json",
},
}
);
}
});
Loading