From 311300db009c1d3829b9bf7f21846d7e6f16030d Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Mon, 31 Aug 2026 10:14:08 -0300 Subject: [PATCH 01/43] Add email change templates and subjects to config --- supabase/code/docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index a8d8584d9..b7fec96cc 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -133,6 +133,8 @@ services: GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION} GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY} GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE} + GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE: ${GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE} + GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE: ${GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE} GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP} GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM} From 04911a2f3f907cef75a281d1860ba391c84081a0 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Mon, 31 Aug 2026 10:40:56 -0300 Subject: [PATCH 02/43] Update docker-compose.yml --- supabase/code/docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index b7fec96cc..9cb645c2b 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -135,6 +135,8 @@ services: GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE} GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE: ${GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE} GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE: ${GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE} + GOTRUE_MAILER_TEMPLATES_RECOVERY: ${GOTRUE_MAILER_TEMPLATES_RECOVERY} + GOTRUE_MAILER_SUBJECTS_RECOVERY: ${GOTRUE_MAILER_SUBJECTS_RECOVERY} GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP} GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM} From b57f0f897a9cf397041d50595a7e732fa1de9abe Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Mon, 31 Aug 2026 11:09:33 -0300 Subject: [PATCH 03/43] Update docker-compose.yml --- supabase/code/docker-compose.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index 9cb645c2b..5e9816061 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -228,7 +228,7 @@ services: image: supabase/storage-api:v1.22.17 restart: unless-stopped volumes: - - ./volumes/storage:/var/lib/storage:z + - storage-data:/var/lib/storage healthcheck: test: [ @@ -482,3 +482,4 @@ services: volumes: db-config: + storage-data: From ba25447cf01b91a16e49aec28463f80fbfe5521c Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Mon, 31 Aug 2026 11:16:52 -0300 Subject: [PATCH 04/43] Change storage volume to local path in docker-compose Updated storage volume configuration in docker-compose.yml to use a local path instead of a named volume. --- supabase/code/docker-compose.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index 5e9816061..9cb645c2b 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -228,7 +228,7 @@ services: image: supabase/storage-api:v1.22.17 restart: unless-stopped volumes: - - storage-data:/var/lib/storage + - ./volumes/storage:/var/lib/storage:z healthcheck: test: [ @@ -482,4 +482,3 @@ services: volumes: db-config: - storage-data: From 43b406b03c204c5c77ca4ce9bf5e6280ca246659 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Mon, 31 Aug 2026 11:34:54 -0300 Subject: [PATCH 05/43] Update docker-compose.yml --- supabase/code/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index 9cb645c2b..a10a90727 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -259,7 +259,7 @@ services: FILE_SIZE_LIMIT: 52428800 STORAGE_BACKEND: file FILE_STORAGE_BACKEND_PATH: /var/lib/storage - TENANT_ID: stub + TENANT_ID: ${STORAGE_TENANT_ID} # TODO: https://github.com/supabase/storage-api/issues/55 REGION: stub GLOBAL_S3_BUCKET: stub From f539a2cd33b46aa745a1c0f78f773c6dbb14fc14 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Mon, 31 Aug 2026 18:28:58 -0300 Subject: [PATCH 06/43] Create index.ts --- .../saas-stripe-create-checkout/index.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts diff --git a/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts b/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts new file mode 100644 index 000000000..b71da9358 --- /dev/null +++ b/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts @@ -0,0 +1,72 @@ +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; +import { admin, cors, json, secret, stripeForm } from '../_shared/common.ts'; + +const zeroDecimal = new Set(['BIF','CLP','DJF','GNF','JPY','KMF','KRW','MGA','PYG','RWF','UGX','VND','VUV','XAF','XOF','XPF']); + +Deno.serve(async req => { + if (req.method === 'OPTIONS') return new Response('ok', { headers: cors }); + try { + const auth = req.headers.get('authorization') || ''; + const client = createClient(secret('SUPABASE_URL')!, secret('SUPABASE_ANON_KEY')!, { + global:{headers:{Authorization:auth}}, auth:{persistSession:false} + }); + const {data:{user}} = await client.auth.getUser(); + if (!user) return json({error:'Sessão inválida.'},401); + + const {tenant_id} = await req.json(); + const db = admin(); + const {data:profile} = await db.from('users').select('tenant_id,role').eq('id',user.id).maybeSingle(); + if (!profile || profile.tenant_id !== tenant_id || !['admin','owner'].includes(profile.role)) return json({error:'Acesso negado.'},403); + + const [{data:company},{data:subscription}] = await Promise.all([ + db.from('companies').select('id,name,email,billing_currency').eq('id',tenant_id).maybeSingle(), + db.from('subscriptions').select('id,plan_id,status,trial_ends_at,stripe_customer_id,stripe_subscription_id').eq('tenant_id',tenant_id).maybeSingle() + ]); + if (!company || !subscription) return json({error:'Assinatura não encontrada.'},404); + if (subscription.stripe_subscription_id) return json({error:'Esta assinatura já está vinculada à Stripe.'},409); + + const currency = String(company.billing_currency || 'USD').toUpperCase(); + if (currency === 'BRL') return json({error:'Assinaturas em BRL continuam no Mercado Pago.'},409); + const {data:price} = await db.from('plan_prices').select('monthly_price,stripe_price_id').eq('plan_id',subscription.plan_id).eq('currency_code',currency).eq('is_active',true).maybeSingle(); + if (!price) return json({error:`Preço do plano não configurado em ${currency}.`},409); + + const factor = zeroDecimal.has(currency) ? 1 : 100; + const amount = Math.round(Number(price.monthly_price) * factor); + if (!Number.isFinite(amount) || amount < 1) return json({error:'Valor da assinatura inválido.'},400); + + const dashboard = (secret('DASHBOARD_URL') || 'https://dashboard.cardapioplus.com').replace(/\/$/,''); + const body = new URLSearchParams(); + body.set('mode','subscription'); + body.set('success_url',`${dashboard}/?view=assinatura&billing=success&session_id={CHECKOUT_SESSION_ID}`); + body.set('cancel_url',`${dashboard}/?view=assinatura&billing=cancelled`); + body.set('client_reference_id',tenant_id); + body.set('metadata[tenant_id]',tenant_id); + body.set('metadata[subscription_id]',subscription.id); + body.set('subscription_data[metadata][tenant_id]',tenant_id); + body.set('subscription_data[metadata][subscription_id]',subscription.id); + body.set('line_items[0][quantity]','1'); + if (price.stripe_price_id) { + body.set('line_items[0][price]',price.stripe_price_id); + } else { + body.set('line_items[0][price_data][currency]',currency.toLowerCase()); + body.set('line_items[0][price_data][unit_amount]',String(amount)); + body.set('line_items[0][price_data][recurring][interval]','month'); + body.set('line_items[0][price_data][product_data][name]',`Assinatura Cardápio+`); + } + if (subscription.stripe_customer_id) body.set('customer',subscription.stripe_customer_id); + else if (company.email) body.set('customer_email',company.email); + + const trialEnd = subscription.trial_ends_at ? Math.floor(new Date(subscription.trial_ends_at).getTime()/1000) : 0; + if (trialEnd > Math.floor(Date.now()/1000) + 60) body.set('subscription_data[trial_end]',String(trialEnd)); + + const session = await stripeForm('checkout/sessions',body,undefined,`saas-checkout-${subscription.id}-${currency}`); + await db.from('subscriptions').update({ + payment_provider:'stripe', billing_currency:currency, stripe_checkout_session_id:session.id + }).eq('id',subscription.id); + return json({url:session.url}); + } catch (e) { + console.error(e); + return json({error:e instanceof Error ? e.message : 'Não foi possível abrir a cobrança internacional.'},500); + } +}); + From 964b6962675706b3f2d7d7e778403197d43ceafe Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Mon, 31 Aug 2026 18:29:32 -0300 Subject: [PATCH 07/43] Implement Stripe customer portal function --- .../saas-stripe-customer-portal/index.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 supabase/code/volumes/functions/saas-stripe-customer-portal/index.ts diff --git a/supabase/code/volumes/functions/saas-stripe-customer-portal/index.ts b/supabase/code/volumes/functions/saas-stripe-customer-portal/index.ts new file mode 100644 index 000000000..76c707c11 --- /dev/null +++ b/supabase/code/volumes/functions/saas-stripe-customer-portal/index.ts @@ -0,0 +1,23 @@ +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; +import { admin, cors, json, secret, stripeForm } from '../_shared/common.ts'; + +Deno.serve(async req => { + if (req.method === 'OPTIONS') return new Response('ok',{headers:cors}); + try { + const auth=req.headers.get('authorization')||''; + const client=createClient(secret('SUPABASE_URL')!,secret('SUPABASE_ANON_KEY')!,{global:{headers:{Authorization:auth}},auth:{persistSession:false}}); + const {data:{user}}=await client.auth.getUser(); + if(!user)return json({error:'Sessão inválida.'},401); + const {tenant_id}=await req.json(); + const db=admin(); + const {data:profile}=await db.from('users').select('tenant_id,role').eq('id',user.id).maybeSingle(); + if(!profile||profile.tenant_id!==tenant_id||!['admin','owner'].includes(profile.role))return json({error:'Acesso negado.'},403); + const {data:sub}=await db.from('subscriptions').select('stripe_customer_id').eq('tenant_id',tenant_id).maybeSingle(); + if(!sub?.stripe_customer_id)return json({error:'Cliente Stripe ainda não cadastrado.'},409); + const dashboard=(secret('DASHBOARD_URL')||'https://dashboard.cardapioplus.com').replace(/\/$/,''); + const body=new URLSearchParams({customer:sub.stripe_customer_id,return_url:`${dashboard}/?view=assinatura`}); + const session=await stripeForm('billing_portal/sessions',body); + return json({url:session.url}); + }catch(e){console.error(e);return json({error:e instanceof Error?e.message:'Não foi possível abrir o portal Stripe.'},500);} +}); + From d873dea2008c2529d4982d893688f1ce2e89c7f2 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Mon, 31 Aug 2026 18:30:08 -0300 Subject: [PATCH 08/43] Create index.ts --- .../functions/saas-stripe-webhook/index.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 supabase/code/volumes/functions/saas-stripe-webhook/index.ts diff --git a/supabase/code/volumes/functions/saas-stripe-webhook/index.ts b/supabase/code/volumes/functions/saas-stripe-webhook/index.ts new file mode 100644 index 000000000..2f8e126ff --- /dev/null +++ b/supabase/code/volumes/functions/saas-stripe-webhook/index.ts @@ -0,0 +1,77 @@ +import { admin, json, secret } from '../_shared/common.ts'; + +const hex=(b:ArrayBuffer)=>Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,'0')).join(''); +const safeEqual=(a:string,b:string)=>{if(a.length!==b.length)return false;let d=0;for(let i=0;ix.split('=')); + const timestamp=parts.find(x=>x[0]==='t')?.[1]; + const signatures=parts.filter(x=>x[0]==='v1').map(x=>x[1]); + if(!timestamp||Math.abs(Date.now()/1000-Number(timestamp))>300)return false; + const key=await crypto.subtle.importKey('raw',new TextEncoder().encode(webhookSecret),{name:'HMAC',hash:'SHA-256'},false,['sign']); + const expected=hex(await crypto.subtle.sign('HMAC',key,new TextEncoder().encode(`${timestamp}.${payload}`))); + return signatures.some(s=>safeEqual(s,expected)); +} +const subscriptionStatus=(stripeStatus:string)=>{ + if(['active','trialing'].includes(stripeStatus))return stripeStatus==='trialing'?'trial':'active'; + if(['past_due','unpaid','incomplete'].includes(stripeStatus))return 'payment_pending'; + if(['canceled','incomplete_expired'].includes(stripeStatus))return 'cancelled'; + if(stripeStatus==='paused')return 'suspended'; + return null; +}; + +Deno.serve(async req=>{ + if(req.method!=='POST')return json({error:'Método inválido.'},405); + const payload=await req.text(); + const signature=req.headers.get('stripe-signature')||''; + const webhookSecret=secret('STRIPE_BILLING_WEBHOOK_SECRET')||''; + if(!webhookSecret||!await verify(payload,signature,webhookSecret))return json({error:'Assinatura inválida.'},400); + let event:any;try{event=JSON.parse(payload);}catch{return json({error:'JSON inválido.'},400);} + const db=admin(); + const {error:duplicate}=await db.from('stripe_webhook_events').insert({event_id:event.id,event_type:`saas.${event.type}`,stripe_account_id:null}); + if(duplicate?.code==='23505')return json({received:true,duplicate:true}); + if(duplicate)return json({error:'Falha de idempotência.'},500); + + try{ + const o=event.data?.object||{}; + if(event.type==='checkout.session.completed'&&o.mode==='subscription'){ + const tenant=o.metadata?.tenant_id||o.client_reference_id; + if(tenant)await db.from('subscriptions').update({ + payment_provider:'stripe',stripe_customer_id:typeof o.customer==='string'?o.customer:null, + stripe_subscription_id:typeof o.subscription==='string'?o.subscription:null, + stripe_checkout_session_id:o.id,payment_method:'card' + }).eq('tenant_id',tenant); + }else if(event.type.startsWith('customer.subscription.')){ + const tenant=o.metadata?.tenant_id; + const status=subscriptionStatus(o.status); + if(tenant&&status){ + const periodEndUnix=o.current_period_end||o.items?.data?.[0]?.current_period_end; + const subscriptionUpdate:any={status,stripe_customer_id:o.customer,stripe_subscription_id:o.id,payment_provider:'stripe'}; + if(periodEndUnix)subscriptionUpdate.current_period_end=new Date(periodEndUnix*1000).toISOString(); + await db.from('subscriptions').update(subscriptionUpdate).eq('tenant_id',tenant); + await db.from('companies').update({status}).eq('id',tenant); + } + }else if(event.type==='invoice.paid'||event.type==='invoice.payment_failed'){ + const stripeSub=typeof o.subscription==='string'?o.subscription:null; + if(stripeSub){ + const {data:sub}=await db.from('subscriptions').select('id,tenant_id,billing_currency').eq('stripe_subscription_id',stripeSub).maybeSingle(); + if(sub){ + const paid=event.type==='invoice.paid'; + const due=o.due_date?new Date(o.due_date*1000).toISOString().slice(0,10):new Date().toISOString().slice(0,10); + await db.from('subscription_invoices').upsert({ + subscription_id:sub.id,amount:Number(o.amount_due||0)/100,status:paid?'paid':'failed',due_date:due, + paid_at:paid?new Date((o.status_transitions?.paid_at||Math.floor(Date.now()/1000))*1000).toISOString():null, + currency_code:String(o.currency||sub.billing_currency||'USD').toUpperCase(),payment_provider:'stripe', + provider_invoice_id:o.id,hosted_invoice_url:o.hosted_invoice_url||null + },{onConflict:'provider_invoice_id'}); + const status=paid?'active':'payment_pending'; + await db.from('subscriptions').update({status}).eq('id',sub.id); + await db.from('companies').update({status}).eq('id',sub.tenant_id); + } + } + } + return json({received:true}); + }catch(e){ + console.error(e);await db.from('stripe_webhook_events').delete().eq('event_id',event.id); + return json({error:'Falha ao processar evento Stripe Billing.'},500); + } +}); From ce8e2d37e7c260699d22434cc3ed36fd10f007bf Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Mon, 31 Aug 2026 18:31:16 -0300 Subject: [PATCH 09/43] Add common utility functions for Supabase and Stripe --- .../code/volumes/functions/_shared/common.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 supabase/code/volumes/functions/_shared/common.ts diff --git a/supabase/code/volumes/functions/_shared/common.ts b/supabase/code/volumes/functions/_shared/common.ts new file mode 100644 index 000000000..288947779 --- /dev/null +++ b/supabase/code/volumes/functions/_shared/common.ts @@ -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={'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(''); From 2e24e06aaa1bbda1fabe975542b6a18017cbda4c Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Mon, 31 Aug 2026 18:32:15 -0300 Subject: [PATCH 10/43] Add runtime secrets management Load secrets from a JSON file and fallback to environment variables. --- .../code/volumes/functions/_shared/runtime-secrets.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 supabase/code/volumes/functions/_shared/runtime-secrets.ts diff --git a/supabase/code/volumes/functions/_shared/runtime-secrets.ts b/supabase/code/volumes/functions/_shared/runtime-secrets.ts new file mode 100644 index 000000000..066482d42 --- /dev/null +++ b/supabase/code/volumes/functions/_shared/runtime-secrets.ts @@ -0,0 +1,8 @@ +let saved: Record = {}; +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]; From 738ae6ab95187f750265a883c2c17eae562554e9 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Mon, 31 Aug 2026 18:41:24 -0300 Subject: [PATCH 11/43] Update index.ts --- .../code/volumes/functions/saas-stripe-webhook/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/supabase/code/volumes/functions/saas-stripe-webhook/index.ts b/supabase/code/volumes/functions/saas-stripe-webhook/index.ts index 2f8e126ff..7c624d6db 100644 --- a/supabase/code/volumes/functions/saas-stripe-webhook/index.ts +++ b/supabase/code/volumes/functions/saas-stripe-webhook/index.ts @@ -51,7 +51,11 @@ Deno.serve(async req=>{ await db.from('companies').update({status}).eq('id',tenant); } }else if(event.type==='invoice.paid'||event.type==='invoice.payment_failed'){ - const stripeSub=typeof o.subscription==='string'?o.subscription:null; + const stripeSub=typeof o.subscription==='string' + ? o.subscription + : typeof o.parent?.subscription_details?.subscription==='string' + ? o.parent.subscription_details.subscription + : null; if(stripeSub){ const {data:sub}=await db.from('subscriptions').select('id,tenant_id,billing_currency').eq('stripe_subscription_id',stripeSub).maybeSingle(); if(sub){ From 602ceacab8694b0ea743b05baa91db0458789e67 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Mon, 31 Aug 2026 18:59:13 -0300 Subject: [PATCH 12/43] Update docker-compose.yml --- supabase/code/docker-compose.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index a10a90727..d1ac17eda 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -313,6 +313,9 @@ services: SUPABASE_ANON_KEY: ${ANON_KEY} SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} + STRIPE_BILLING_WEBHOOK_SECRET: ${STRIPE_BILLING_WEBHOOK_SECRET} + DASHBOARD_URL: ${DASHBOARD_URL} # TODO: Allow configuring VERIFY_JWT per function. This PR might help: https://github.com/supabase/cli/pull/786 VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" command: [ "start", "--main-service", "/home/deno/functions/main" ] From 5b01e19082c8df126ae071a4848e18c9372ff13c Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Mon, 31 Aug 2026 23:38:26 -0300 Subject: [PATCH 13/43] Update index.ts --- .../saas-stripe-create-checkout/index.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts b/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts index b71da9358..02c114043 100644 --- a/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts +++ b/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts @@ -1,4 +1,3 @@ -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; import { admin, cors, json, secret, stripeForm } from '../_shared/common.ts'; const zeroDecimal = new Set(['BIF','CLP','DJF','GNF','JPY','KMF','KRW','MGA','PYG','RWF','UGX','VND','VUV','XAF','XOF','XPF']); @@ -6,15 +5,12 @@ const zeroDecimal = new Set(['BIF','CLP','DJF','GNF','JPY','KMF','KRW','MGA','PY Deno.serve(async req => { if (req.method === 'OPTIONS') return new Response('ok', { headers: cors }); try { - const auth = req.headers.get('authorization') || ''; - const client = createClient(secret('SUPABASE_URL')!, secret('SUPABASE_ANON_KEY')!, { - global:{headers:{Authorization:auth}}, auth:{persistSession:false} - }); - const {data:{user}} = await client.auth.getUser(); + const token = (req.headers.get('authorization') || '').replace(/^Bearer\s+/i,''); + const db = admin(); + const {data:{user}} = await db.auth.getUser(token); if (!user) return json({error:'Sessão inválida.'},401); const {tenant_id} = await req.json(); - const db = admin(); const {data:profile} = await db.from('users').select('tenant_id,role').eq('id',user.id).maybeSingle(); if (!profile || profile.tenant_id !== tenant_id || !['admin','owner'].includes(profile.role)) return json({error:'Acesso negado.'},403); @@ -35,10 +31,13 @@ Deno.serve(async req => { if (!Number.isFinite(amount) || amount < 1) return json({error:'Valor da assinatura inválido.'},400); const dashboard = (secret('DASHBOARD_URL') || 'https://dashboard.cardapioplus.com').replace(/\/$/,''); + const publishableKey = secret('STRIPE_PUBLISHABLE_KEY'); + if (!publishableKey) throw new Error('STRIPE_PUBLISHABLE_KEY ausente'); const body = new URLSearchParams(); body.set('mode','subscription'); - body.set('success_url',`${dashboard}/?view=assinatura&billing=success&session_id={CHECKOUT_SESSION_ID}`); - body.set('cancel_url',`${dashboard}/?view=assinatura&billing=cancelled`); + body.set('ui_mode','embedded'); + body.set('redirect_on_completion','never'); + body.set('payment_method_types[0]','card'); body.set('client_reference_id',tenant_id); body.set('metadata[tenant_id]',tenant_id); body.set('metadata[subscription_id]',subscription.id); @@ -63,10 +62,9 @@ Deno.serve(async req => { await db.from('subscriptions').update({ payment_provider:'stripe', billing_currency:currency, stripe_checkout_session_id:session.id }).eq('id',subscription.id); - return json({url:session.url}); + return json({clientSecret:session.client_secret,publishableKey}); } catch (e) { console.error(e); return json({error:e instanceof Error ? e.message : 'Não foi possível abrir a cobrança internacional.'},500); } }); - From 0da244499e31d51ec7f64b1fb49e748d4cced3b7 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Mon, 31 Aug 2026 23:40:18 -0300 Subject: [PATCH 14/43] Add STRIPE_PUBLISHABLE_KEY to docker-compose.yml --- supabase/code/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index d1ac17eda..c7cc534ef 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -313,6 +313,7 @@ services: SUPABASE_ANON_KEY: ${ANON_KEY} SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} STRIPE_BILLING_WEBHOOK_SECRET: ${STRIPE_BILLING_WEBHOOK_SECRET} DASHBOARD_URL: ${DASHBOARD_URL} From 23b61f90c6c226e7540efb6195e1d25b378ec8ce Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Mon, 31 Aug 2026 23:45:14 -0300 Subject: [PATCH 15/43] Update index.ts --- .../code/volumes/functions/saas-stripe-create-checkout/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts b/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts index 02c114043..8972b7d0f 100644 --- a/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts +++ b/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts @@ -58,7 +58,7 @@ Deno.serve(async req => { const trialEnd = subscription.trial_ends_at ? Math.floor(new Date(subscription.trial_ends_at).getTime()/1000) : 0; if (trialEnd > Math.floor(Date.now()/1000) + 60) body.set('subscription_data[trial_end]',String(trialEnd)); - const session = await stripeForm('checkout/sessions',body,undefined,`saas-checkout-${subscription.id}-${currency}`); + const session = await stripeForm('checkout/sessions',body,undefined,`saas-checkout-embedded-v1-${subscription.id}-${currency}`); await db.from('subscriptions').update({ payment_provider:'stripe', billing_currency:currency, stripe_checkout_session_id:session.id }).eq('id',subscription.id); From 8a123194f105ba0ca2defa7ea42e856b2c1123af Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Mon, 31 Aug 2026 23:50:01 -0300 Subject: [PATCH 16/43] Update index.ts --- .../volumes/functions/saas-stripe-create-checkout/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts b/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts index 8972b7d0f..a16183efb 100644 --- a/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts +++ b/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts @@ -35,7 +35,7 @@ Deno.serve(async req => { if (!publishableKey) throw new Error('STRIPE_PUBLISHABLE_KEY ausente'); const body = new URLSearchParams(); body.set('mode','subscription'); - body.set('ui_mode','embedded'); + body.set('ui_mode','embedded_page'); body.set('redirect_on_completion','never'); body.set('payment_method_types[0]','card'); body.set('client_reference_id',tenant_id); @@ -58,7 +58,7 @@ Deno.serve(async req => { const trialEnd = subscription.trial_ends_at ? Math.floor(new Date(subscription.trial_ends_at).getTime()/1000) : 0; if (trialEnd > Math.floor(Date.now()/1000) + 60) body.set('subscription_data[trial_end]',String(trialEnd)); - const session = await stripeForm('checkout/sessions',body,undefined,`saas-checkout-embedded-v1-${subscription.id}-${currency}`); + const session = await stripeForm('checkout/sessions',body,undefined,`saas-checkout-embedded-page-v2-${subscription.id}-${currency}`); await db.from('subscriptions').update({ payment_provider:'stripe', billing_currency:currency, stripe_checkout_session_id:session.id }).eq('id',subscription.id); From 72894b30697af994df08f32cd05dc545069d8cd4 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Mon, 7 Sep 2026 12:08:06 -0300 Subject: [PATCH 17/43] Add files via upload --- .../volumes/functions/_shared/wa-common.ts | 93 +++++++++++ .../functions/send-renewal-reminders/index.ts | 152 ++++++++++++++++++ .../functions/wa-campaign-create/index.ts | 79 +++++++++ .../functions/wa-campaign-send/index.ts | 81 ++++++++++ .../functions/wa-instance-connect/index.ts | 65 ++++++++ .../functions/wa-instance-disconnect/index.ts | 18 +++ .../functions/wa-instance-status/index.ts | 34 ++++ 7 files changed, 522 insertions(+) create mode 100644 supabase/code/volumes/functions/_shared/wa-common.ts create mode 100644 supabase/code/volumes/functions/send-renewal-reminders/index.ts create mode 100644 supabase/code/volumes/functions/wa-campaign-create/index.ts create mode 100644 supabase/code/volumes/functions/wa-campaign-send/index.ts create mode 100644 supabase/code/volumes/functions/wa-instance-connect/index.ts create mode 100644 supabase/code/volumes/functions/wa-instance-disconnect/index.ts create mode 100644 supabase/code/volumes/functions/wa-instance-status/index.ts diff --git a/supabase/code/volumes/functions/_shared/wa-common.ts b/supabase/code/volumes/functions/_shared/wa-common.ts new file mode 100644 index 000000000..645693356 --- /dev/null +++ b/supabase/code/volumes/functions/_shared/wa-common.ts @@ -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){ + 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={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){ + return Object.entries(values).reduce((text,[key,value])=>text.split(`{{${key}}}`).join(value||''),template); +} diff --git a/supabase/code/volumes/functions/send-renewal-reminders/index.ts b/supabase/code/volumes/functions/send-renewal-reminders/index.ts new file mode 100644 index 000000000..c5fc99e01 --- /dev/null +++ b/supabase/code/volumes/functions/send-renewal-reminders/index.ts @@ -0,0 +1,152 @@ +// send-renewal-reminders/index.ts +// Dois modos: +// 1. Sem body (ou {}) -> modo lote: varre assinaturas perto de vencer e +// manda o lembrete (e-mail + WhatsApp, sempre os dois juntos) pra cada +// uma. Chamado pelo pg_cron diário, ou manualmente pelo botão +// "Enviar lembretes agora" no admin. +// 2. Com { test_email } e/ou { test_whatsapp } -> manda só um teste pro(s) +// destino(s) informado(s), sem mexer em nenhuma assinatura. +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { SMTPClient } from "https://deno.land/x/denomailer@1.6.0/mod.ts"; + +const supabaseAdmin = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, +); + +function fillTemplate(template: string, vars: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? ""); +} + +function renewalLink(settings: any): string { + const base = settings.app_url || "https://dashboard.cardapioplus.com"; + return `${base.replace(/\/$/, "")}/?view=assinatura`; +} + +function formatSubscriptionMoney(value: unknown, currency = "BRL"): string { + const locale = ({ BRL:"pt-BR", EUR:"pt-PT", USD:"en-US", GBP:"en-GB" } as Record)[currency] || "en-US"; + return Number(value || 0).toLocaleString(locale, { style:"currency", currency }); +} + +async function sendEmail(settings: any, to: string, subject: string, bodyText: string) { + const client = new SMTPClient({ + connection: { + hostname: settings.smtp_host, + port: settings.smtp_port || 587, + tls: settings.smtp_port === 465, + auth: { username: settings.smtp_username, password: settings.smtp_password }, + }, + }); + const bodyHtml = `

Cardápio+

${bodyText.replace(/
`; + await client.send({ + from: `${settings.smtp_from_name || "Cardápio+"} <${settings.smtp_from_email}>`, + to, subject, content: bodyText, html: bodyHtml, + }); + await client.close(); +} + +function formatBrazilPhone(raw: string): string { + let digits = raw.replace(/\D/g, ""); + if (digits.length <= 11) digits = "55" + digits; + return digits; +} + +async function sendWhatsapp(settings: any, phone: string, text: string) { + const url = `${settings.whatsapp_evolution_url.replace(/\/$/, "")}/message/sendText/${settings.whatsapp_evolution_instance}`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", "apikey": settings.whatsapp_evolution_api_key }, + body: JSON.stringify({ number: formatBrazilPhone(phone), text }), + }); + if (!res.ok) throw new Error(`Evolution API respondeu ${res.status}: ${await res.text()}`); +} + +Deno.serve(async (req) => { + try { + const body = req.method === "POST" ? await req.json().catch(() => ({})) : {}; + const { data: settings, error: settingsErr } = await supabaseAdmin + .from("platform_settings").select("*").eq("id", 1).single(); + if (settingsErr) return json({ error: "Erro ao carregar configurações." }, 400); + + if (body.test_email || body.test_whatsapp) { + const vars = { + empresa: "Empresa de Teste", plano: "Plano Profissional", valor: "R$ 99,90", + vencimento: new Date(Date.now() + 3 * 86400000).toLocaleDateString("pt-BR"), + link_renovacao: renewalLink(settings), + }; + const results: Record = {}; + if (body.test_email) { + if (!settings.smtp_host) return json({ error: "SMTP não configurado." }, 400); + const subject = fillTemplate(settings.reminder_email_subject, vars); + const text = fillTemplate(settings.reminder_email_body, vars); + await sendEmail(settings, body.test_email, `[TESTE] ${subject}`, text); + results.email = "enviado"; + } + if (body.test_whatsapp) { + if (!settings.whatsapp_evolution_url || !settings.whatsapp_evolution_instance || !settings.whatsapp_evolution_api_key) { + return json({ error: "Evolution API não configurada (URL, instância ou API Key faltando)." }, 400); + } + await sendWhatsapp(settings, body.test_whatsapp, fillTemplate(settings.whatsapp_reminder_message, vars)); + results.whatsapp = "enviado"; + } + return json({ ok: true, message: "Teste enviado.", results }); + } + + const daysBefore = settings.reminder_days_before || 3; + const now = new Date(); + const windowEnd = new Date(now.getTime() + daysBefore * 86400000); + const { data: subs } = await supabaseAdmin + .from("subscriptions") + .select("id, status, billing_currency, current_period_start, current_period_end, trial_ends_at, last_reminder_sent_at, tenant_id, companies(name, email, whatsapp), plans(name, monthly_price, plan_prices(currency_code, monthly_price, is_active))") + .in("status", ["active", "trial", "payment_pending"]); + + let sentCount = 0; + const emailReady = !!settings.smtp_host; + const whatsappReady = !!(settings.whatsapp_evolution_url && settings.whatsapp_evolution_instance && settings.whatsapp_evolution_api_key); + for (const sub of subs || []) { + const relevantDate = sub.status === "trial" ? sub.trial_ends_at : sub.current_period_end; + if (!relevantDate) continue; + const dueDate = new Date(relevantDate); + if (dueDate < now || dueDate > windowEnd) continue; + const cycleStart = sub.current_period_start ? new Date(sub.current_period_start) : new Date(0); + if (sub.last_reminder_sent_at && new Date(sub.last_reminder_sent_at) > cycleStart) continue; + const email = sub.companies?.email; + const whatsapp = sub.companies?.whatsapp; + if (!email && !whatsapp) continue; + const billingCurrency = sub.billing_currency || "BRL"; + const localizedPrice = (sub.plans?.plan_prices || []).find((price: any) => price.currency_code === billingCurrency && price.is_active !== false); + const vars = { + empresa: sub.companies?.name || "sua empresa", + plano: sub.plans?.name || "seu plano", + valor: formatSubscriptionMoney(localizedPrice?.monthly_price ?? sub.plans?.monthly_price, billingCurrency), + vencimento: dueDate.toLocaleDateString("pt-BR"), + link_renovacao: renewalLink(settings), + }; + let anySent = false; + if (emailReady && email) { + try { + await sendEmail(settings, email, fillTemplate(settings.reminder_email_subject, vars), fillTemplate(settings.reminder_email_body, vars)); + anySent = true; + } catch (err) { console.error(`Erro ao enviar e-mail pra ${email}:`, err); } + } + if (whatsappReady && whatsapp) { + try { + await sendWhatsapp(settings, whatsapp, fillTemplate(settings.whatsapp_reminder_message, vars)); + anySent = true; + } catch (err) { console.error(`Erro ao enviar WhatsApp pra ${whatsapp}:`, err); } + } + if (anySent) { + await supabaseAdmin.from("subscriptions").update({ last_reminder_sent_at: new Date().toISOString() }).eq("id", sub.id); + sentCount++; + } + } + return json({ ok: true, sent: sentCount }); + } catch (err) { + console.error(err); + return json({ error: "Erro inesperado ao processar lembretes." }, 500); + } +}); + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} diff --git a/supabase/code/volumes/functions/wa-campaign-create/index.ts b/supabase/code/volumes/functions/wa-campaign-create/index.ts new file mode 100644 index 000000000..f92ec9cfa --- /dev/null +++ b/supabase/code/volumes/functions/wa-campaign-create/index.ts @@ -0,0 +1,79 @@ +import {cors,json,requireTenant,handleError,HttpError,secret} from '../_shared/wa-common.ts'; + +const ALLOWED_AUDIENCES=new Set(['all','purchased','recent_30','inactive_30','repeat_2','repeat_3']); +const ALLOWED_MIME=new Set(['image/jpeg','image/png','image/webp']); + +function decodeDataUrl(value:string,mimeHint?:string){ + const match=value.match(/^data:([^;]+);base64,(.+)$/s); + const mime=match?.[1]||mimeHint||''; + const encoded=(match?.[2]||value).replace(/\s/g,''); + if(!ALLOWED_MIME.has(mime)) throw new HttpError('Formato da imagem inválido.',400); + let binary=''; + try{binary=atob(encoded);}catch{throw new HttpError('Imagem inválida.',400);} + if(binary.length>3*1024*1024) throw new HttpError('A imagem deve ter no máximo 3 MB.',413); + const bytes=new Uint8Array(binary.length); + for(let i=0;i{ + if(req.method==='OPTIONS') return new Response('ok',{headers:cors}); + if(req.method!=='POST') return json({error:'Método não permitido.'},405); + try{ + const {db,user,tenantId}=await requireTenant(req); + const body=await req.json(); + const name=String(body.name||'').trim(); + const message=String(body.message||'').trim(); + const audience=String(body.audience_type||'all'); + const couponId=body.coupon_id?String(body.coupon_id):null; + if(!name||name.length>120) throw new HttpError('Informe um nome de campanha com até 120 caracteres.'); + if(!message||message.length>4096) throw new HttpError('Informe uma mensagem com até 4096 caracteres.'); + if(!ALLOWED_AUDIENCES.has(audience)) throw new HttpError('Público inválido.'); + if(message.includes('{{cupom}}')&&!couponId) throw new HttpError('Selecione um cupom para utilizar {{cupom}}.'); + + const {data:integration}=await db.from('whatsapp_integrations').select('status').eq('tenant_id',tenantId).maybeSingle(); + if(integration?.status!=='connected') throw new HttpError('Conecte o WhatsApp da empresa antes de criar a campanha.',409); + if(couponId){ + const {data:coupon}=await db.from('coupons').select('id').eq('id',couponId).eq('tenant_id',tenantId).eq('is_active',true).maybeSingle(); + if(!coupon) throw new HttpError('Cupom inválido ou inativo.',400); + } + + const {data:campaign,error}=await db.from('whatsapp_campaigns').insert({ + tenant_id:tenantId,created_by:user.id,name,audience_type:audience,coupon_id:couponId,message,status:'draft' + }).select('id').single(); + if(error||!campaign) throw error||new Error('Não foi possível criar a campanha.'); + + let storagePath:string|null=null; + try{ + if(body.image_base64){ + const {bytes,mime}=decodeDataUrl(String(body.image_base64),body.image_mime_type?String(body.image_mime_type):undefined); + const ext=mime==='image/png'?'png':mime==='image/webp'?'webp':'jpg'; + storagePath=`${tenantId}/${campaign.id}.${ext}`; + const upload=await db.storage.from('whatsapp-campaign-media').upload(storagePath,bytes,{contentType:mime,upsert:false}); + if(upload.error) throw upload.error; + const publicSupabaseUrl=String( + secret('PUBLIC_SUPABASE_URL')||'https://supabase-cardapio.softwaresolucoes.com' + ).replace(/\/$/,''); + const publicPath=storagePath.split('/').map(encodeURIComponent).join('/'); + const imageUrl=`${publicSupabaseUrl}/storage/v1/object/public/whatsapp-campaign-media/${publicPath}`; + const {error:updateError}=await db.from('whatsapp_campaigns').update({image_url:imageUrl,image_storage_path:storagePath,image_mime_type:mime}).eq('id',campaign.id); + if(updateError) throw updateError; + } + + const {data:recipientCount,error:buildError}=await db.rpc('wa_build_campaign_recipients',{ + p_campaign_id:campaign.id,p_tenant_id:tenantId,p_audience_type:audience + }); + if(buildError) throw buildError; + const recipients=Number(recipientCount||0); + if(recipients<1) throw new HttpError('Nenhum cliente autorizado foi encontrado para esse público.',409); + if(recipients>1000) throw new HttpError('A campanha excede o limite de 1.000 destinatários.',413); + const {error:queueError}=await db.from('whatsapp_campaigns').update({status:'queued',total_recipients:recipients}).eq('id',campaign.id); + if(queueError) throw queueError; + return json({campaign_id:campaign.id,recipients}); + }catch(error){ + if(storagePath) await db.storage.from('whatsapp-campaign-media').remove([storagePath]); + await db.from('whatsapp_campaigns').delete().eq('id',campaign.id); + throw error; + } + }catch(error){return handleError(error);} +}); diff --git a/supabase/code/volumes/functions/wa-campaign-send/index.ts b/supabase/code/volumes/functions/wa-campaign-send/index.ts new file mode 100644 index 000000000..a2cfa287b --- /dev/null +++ b/supabase/code/volumes/functions/wa-campaign-send/index.ts @@ -0,0 +1,81 @@ +import {cors,json,requireTenant,handleError,HttpError,evolutionSettings,evolutionFetch,internationalPhone,renderCampaignMessage,secret} from '../_shared/wa-common.ts'; + +const wait=(ms:number)=>new Promise(resolve=>setTimeout(resolve,ms)); + +Deno.serve(async req=>{ + if(req.method==='OPTIONS') return new Response('ok',{headers:cors}); + if(req.method!=='POST') return json({error:'Método não permitido.'},405); + try{ + const {db,tenantId}=await requireTenant(req); + const {campaign_id}=await req.json(); + if(!campaign_id) throw new HttpError('Campanha não informada.'); + const {data:campaign,error}=await db.from('whatsapp_campaigns') + .select('id,name,message,image_url,image_mime_type,status,coupon_id,total_recipients') + .eq('id',campaign_id).eq('tenant_id',tenantId).maybeSingle(); + if(error) throw error; + if(!campaign) throw new HttpError('Campanha não encontrada.',404); + if(['completed','cancelled'].includes(campaign.status)) throw new HttpError('Esta campanha já foi encerrada.',409); + + const [{data:integration},{data:company}]=await Promise.all([ + db.from('whatsapp_integrations').select('instance_name,status').eq('tenant_id',tenantId).maybeSingle(), + db.from('companies').select('name,slug,country_code').eq('id',tenantId).maybeSingle() + ]); + if(integration?.status!=='connected') throw new HttpError('O WhatsApp da empresa não está conectado.',409); + if(!company) throw new HttpError('Empresa não encontrada.',404); + const settings=await evolutionSettings(db); + let couponCode=''; + if(campaign.coupon_id){ + const {data:coupon}=await db.from('coupons').select('code').eq('id',campaign.coupon_id).eq('tenant_id',tenantId).maybeSingle(); + couponCode=String(coupon?.code||''); + } + const {data:claimed,error:claimError}=await db.rpc('wa_claim_campaign_recipients',{ + p_campaign_id:campaign.id,p_tenant_id:tenantId,p_limit:5 + }); + if(claimError) throw claimError; + const recipients=claimed||[]; + if(campaign.status!=='sending') await db.from('whatsapp_campaigns').update({status:'sending',started_at:new Date().toISOString()}).eq('id',campaign.id); + + for(const recipient of recipients){ + const number=internationalPhone(recipient.phone,String(company.country_code||'BR')); + const text=renderCampaignMessage(String(campaign.message),{ + nome:String(recipient.customer_name||'Cliente'), + empresa:String(company.name||''), + cupom:couponCode, + link_cardapio:`${(secret('MENU_URL')||'https://app.cardapioplus.com').replace(/\/$/,'')}/?slug=${encodeURIComponent(company.slug||'')}` + }); + try{ + if(number.length<10) throw new Error('Número de WhatsApp inválido.'); + let result:any; + if(campaign.image_url){ + const mime=campaign.image_mime_type||'image/jpeg'; + const ext=mime==='image/png'?'png':mime==='image/webp'?'webp':'jpg'; + result=(await evolutionFetch(settings,`/message/sendMedia/${encodeURIComponent(integration.instance_name)}`,{ + method:'POST',body:JSON.stringify({number,mediatype:'image',mimetype:mime,caption:text,media:campaign.image_url,fileName:`campanha.${ext}`,delay:500}) + })).data; + }else{ + result=(await evolutionFetch(settings,`/message/sendText/${encodeURIComponent(integration.instance_name)}`,{ + method:'POST',body:JSON.stringify({number,text,delay:500,linkPreview:true}) + })).data; + } + const providerId=result?.key?.id||result?.id||null; + await db.from('whatsapp_campaign_recipients').update({status:'sent',sent_at:new Date().toISOString(),provider_message_id:providerId,error_message:null}).eq('id',recipient.id).eq('tenant_id',tenantId); + }catch(sendError){ + const message=sendError instanceof Error?sendError.message:'Falha ao enviar.'; + await db.from('whatsapp_campaign_recipients').update({status:'failed',error_message:message.slice(0,500)}).eq('id',recipient.id).eq('tenant_id',tenantId); + } + await wait(350); + } + + const [sentRes,failedRes,pendingRes]=await Promise.all([ + db.from('whatsapp_campaign_recipients').select('id',{count:'exact',head:true}).eq('campaign_id',campaign.id).eq('tenant_id',tenantId).eq('status','sent'), + db.from('whatsapp_campaign_recipients').select('id',{count:'exact',head:true}).eq('campaign_id',campaign.id).eq('tenant_id',tenantId).eq('status','failed'), + db.from('whatsapp_campaign_recipients').select('id',{count:'exact',head:true}).eq('campaign_id',campaign.id).eq('tenant_id',tenantId).in('status',['pending','processing']) + ]); + const totalSent=sentRes.count||0,totalFailed=failedRes.count||0,remaining=pendingRes.count||0; + const hasMore=remaining>0; + await db.from('whatsapp_campaigns').update({ + total_sent:totalSent,total_failed:totalFailed,status:hasMore?'sending':'completed',completed_at:hasMore?null:new Date().toISOString() + }).eq('id',campaign.id).eq('tenant_id',tenantId); + return json({has_more:hasMore,total_sent:totalSent,total_failed:totalFailed,processed:recipients.length}); + }catch(error){return handleError(error);} +}); diff --git a/supabase/code/volumes/functions/wa-instance-connect/index.ts b/supabase/code/volumes/functions/wa-instance-connect/index.ts new file mode 100644 index 000000000..bf7cdde43 --- /dev/null +++ b/supabase/code/volumes/functions/wa-instance-connect/index.ts @@ -0,0 +1,65 @@ +import {cors,json,requireTenant,handleError,evolutionSettings,evolutionFetch,instanceName,evolutionState,evolutionPhone,qrCodeFrom} from '../_shared/wa-common.ts'; + +Deno.serve(async req=>{ + if(req.method==='OPTIONS') return new Response('ok',{headers:cors}); + if(req.method!=='POST') return json({error:'Método não permitido.'},405); + try{ + const {db,tenantId}=await requireTenant(req); + const settings=await evolutionSettings(db); + const {data:stored}=await db.from('whatsapp_integrations').select('*').eq('tenant_id',tenantId).maybeSingle(); + let name=String(stored?.instance_name||instanceName(tenantId)); + let stateData:any={}; + let mustCreate=!stored; + + if(stored){ + try{ + stateData=(await evolutionFetch(settings,`/instance/connectionState/${encodeURIComponent(name)}`)).data; + }catch(error){ + const message=error instanceof Error?error.message:''; + if(!message.toLowerCase().includes('does not exist')&&!message.toLowerCase().includes('não existe')) throw error; + // Não reutilizar o identificador órfão mantido pela Evolution. + name=`${instanceName(tenantId)}-${Date.now().toString(36)}`; + mustCreate=true; + } + } + + let created:any=null; + if(mustCreate){ + const result=await evolutionFetch(settings,'/instance/create',{ + method:'POST', + body:JSON.stringify({ + instanceName:name, + integration:'WHATSAPP-BAILEYS', + qrcode:true, + rejectCall:true, + groupsIgnore:true, + alwaysOnline:false, + readMessages:false, + readStatus:false, + syncFullHistory:false + }) + }); + created=result.data; + const {error:upsertError}=await db.from('whatsapp_integrations').upsert({ + tenant_id:tenantId,instance_name:name,status:'connecting',phone_number:null,connected_at:null + },{onConflict:'tenant_id'}); + if(upsertError) throw upsertError; + } + + const status=evolutionState(stateData); + const phone=evolutionPhone(stateData); + if(status==='connected'){ + await db.from('whatsapp_integrations').update({status,phone_number:phone||stored?.phone_number||null,connected_at:new Date().toISOString()}).eq('tenant_id',tenantId); + return json({status,phone_number:phone||stored?.phone_number||null}); + } + + let qrcode=qrCodeFrom(created); + if(!qrcode){ + const connect=(await evolutionFetch(settings,`/instance/connect/${encodeURIComponent(name)}`)).data; + qrcode=qrCodeFrom(connect); + } + await db.from('whatsapp_integrations').update({status:'connecting'}).eq('tenant_id',tenantId); + if(!qrcode) return json({status:'connecting',qrcode:null,message:'Instância criada, mas o QR Code ainda não ficou disponível.'},202); + return json({status:'connecting',qrcode}); + }catch(error){return handleError(error);} +}); diff --git a/supabase/code/volumes/functions/wa-instance-disconnect/index.ts b/supabase/code/volumes/functions/wa-instance-disconnect/index.ts new file mode 100644 index 000000000..e8485ab41 --- /dev/null +++ b/supabase/code/volumes/functions/wa-instance-disconnect/index.ts @@ -0,0 +1,18 @@ +import {cors,json,requireTenant,handleError,evolutionSettings,evolutionFetch} from '../_shared/wa-common.ts'; + +Deno.serve(async req=>{ + if(req.method==='OPTIONS') return new Response('ok',{headers:cors}); + if(req.method!=='POST') return json({error:'Método não permitido.'},405); + try{ + const {db,tenantId}=await requireTenant(req); + const {data:integration,error}=await db.from('whatsapp_integrations').select('instance_name').eq('tenant_id',tenantId).maybeSingle(); + if(error) throw error; + if(!integration) return json({ok:true,status:'disconnected'}); + const settings=await evolutionSettings(db); + try{await evolutionFetch(settings,`/instance/logout/${encodeURIComponent(integration.instance_name)}`,{method:'DELETE'},[400,404]);}catch(_){/* continua para excluir */} + try{await evolutionFetch(settings,`/instance/delete/${encodeURIComponent(integration.instance_name)}`,{method:'DELETE'},[400,404]);}catch(_){/* remove o vínculo local mesmo se já não existir */} + const {error:deleteError}=await db.from('whatsapp_integrations').delete().eq('tenant_id',tenantId); + if(deleteError) throw deleteError; + return json({ok:true,status:'disconnected'}); + }catch(error){return handleError(error);} +}); diff --git a/supabase/code/volumes/functions/wa-instance-status/index.ts b/supabase/code/volumes/functions/wa-instance-status/index.ts new file mode 100644 index 000000000..cf09a2256 --- /dev/null +++ b/supabase/code/volumes/functions/wa-instance-status/index.ts @@ -0,0 +1,34 @@ +import {cors,json,requireTenant,handleError,evolutionSettings,evolutionFetch,evolutionState,evolutionPhone} from '../_shared/wa-common.ts'; + +Deno.serve(async req=>{ + if(req.method==='OPTIONS') return new Response('ok',{headers:cors}); + if(req.method!=='GET') return json({error:'Método não permitido.'},405); + try{ + const {db,tenantId}=await requireTenant(req); + const {data:integration,error}=await db.from('whatsapp_integrations').select('*').eq('tenant_id',tenantId).maybeSingle(); + if(error) throw error; + if(!integration) return json({status:'disconnected',phone_number:null}); + const settings=await evolutionSettings(db); + let stateData:any={}; + try{ + stateData=(await evolutionFetch(settings,`/instance/connectionState/${encodeURIComponent(integration.instance_name)}`)).data; + }catch(error){ + await db.from('whatsapp_integrations').update({status:'disconnected',phone_number:null}).eq('tenant_id',tenantId); + return json({status:'disconnected',phone_number:null}); + } + let status=evolutionState(stateData); + let phone=evolutionPhone(stateData)||integration.phone_number||''; + if(status==='connected'&&!phone){ + try{ + const fetched=(await evolutionFetch(settings,`/instance/fetchInstances?instanceName=${encodeURIComponent(integration.instance_name)}`)).data; + const row=Array.isArray(fetched)?fetched[0]:fetched; + phone=evolutionPhone(row); + }catch(_){/* número é apenas informativo */} + } + await db.from('whatsapp_integrations').update({ + status,phone_number:phone||null, + connected_at:status==='connected'?(integration.connected_at||new Date().toISOString()):integration.connected_at + }).eq('tenant_id',tenantId); + return json({status,phone_number:phone||null}); + }catch(error){return handleError(error);} +}); From b524190ab1e1e4350b1973e3f3410b2bcbbd9acf Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Wed, 9 Sep 2026 10:07:52 -0300 Subject: [PATCH 18/43] Update index.ts --- .../functions/send-renewal-reminders/index.ts | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/supabase/code/volumes/functions/send-renewal-reminders/index.ts b/supabase/code/volumes/functions/send-renewal-reminders/index.ts index c5fc99e01..04ef6d406 100644 --- a/supabase/code/volumes/functions/send-renewal-reminders/index.ts +++ b/supabase/code/volumes/functions/send-renewal-reminders/index.ts @@ -8,6 +8,7 @@ // destino(s) informado(s), sem mexer em nenhuma assinatura. import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; import { SMTPClient } from "https://deno.land/x/denomailer@1.6.0/mod.ts"; +import { internationalPhone } from "../_shared/wa-common.ts"; const supabaseAdmin = createClient( Deno.env.get("SUPABASE_URL")!, @@ -45,18 +46,14 @@ async function sendEmail(settings: any, to: string, subject: string, bodyText: s await client.close(); } -function formatBrazilPhone(raw: string): string { - let digits = raw.replace(/\D/g, ""); - if (digits.length <= 11) digits = "55" + digits; - return digits; -} - -async function sendWhatsapp(settings: any, phone: string, text: string) { +async function sendWhatsapp(settings: any, phone: string, text: string, countryCode = "BR") { + const number = internationalPhone(phone, countryCode); + if (!number) throw new Error("Número de WhatsApp ausente ou inválido."); const url = `${settings.whatsapp_evolution_url.replace(/\/$/, "")}/message/sendText/${settings.whatsapp_evolution_instance}`; const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "apikey": settings.whatsapp_evolution_api_key }, - body: JSON.stringify({ number: formatBrazilPhone(phone), text }), + body: JSON.stringify({ number, text }), }); if (!res.ok) throw new Error(`Evolution API respondeu ${res.status}: ${await res.text()}`); } @@ -86,28 +83,38 @@ Deno.serve(async (req) => { if (!settings.whatsapp_evolution_url || !settings.whatsapp_evolution_instance || !settings.whatsapp_evolution_api_key) { return json({ error: "Evolution API não configurada (URL, instância ou API Key faltando)." }, 400); } - await sendWhatsapp(settings, body.test_whatsapp, fillTemplate(settings.whatsapp_reminder_message, vars)); + await sendWhatsapp(settings, body.test_whatsapp, fillTemplate(settings.whatsapp_reminder_message, vars), body.test_country || "BR"); results.whatsapp = "enviado"; } return json({ ok: true, message: "Teste enviado.", results }); } + // Faz a transição do trial/ciclo vencido e cria a fatura antes de procurar + // destinatários. A função SQL é idempotente, então também pode ser chamada + // pelo cron e pelo painel sem duplicar faturas. + const { data: billingResult, error: billingError } = await supabaseAdmin.rpc("check_subscription_billing_status"); + if (billingError) console.error("Erro ao processar vencimentos:", billingError); + const daysBefore = settings.reminder_days_before || 3; const now = new Date(); const windowEnd = new Date(now.getTime() + daysBefore * 86400000); const { data: subs } = await supabaseAdmin .from("subscriptions") - .select("id, status, billing_currency, current_period_start, current_period_end, trial_ends_at, last_reminder_sent_at, tenant_id, companies(name, email, whatsapp), plans(name, monthly_price, plan_prices(currency_code, monthly_price, is_active))") - .in("status", ["active", "trial", "payment_pending"]); + .select("id, status, billing_currency, current_period_start, current_period_end, trial_ends_at, last_reminder_sent_at, tenant_id, companies(name, email, whatsapp, country_code), plans(name, monthly_price, plan_prices(currency_code, monthly_price, is_active))") + .in("status", ["active", "trial", "payment_pending", "overdue"]); let sentCount = 0; const emailReady = !!settings.smtp_host; const whatsappReady = !!(settings.whatsapp_evolution_url && settings.whatsapp_evolution_instance && settings.whatsapp_evolution_api_key); for (const sub of subs || []) { - const relevantDate = sub.status === "trial" ? sub.trial_ends_at : sub.current_period_end; + const relevantDate = sub.status === "trial" + ? sub.trial_ends_at + : (sub.current_period_end || sub.trial_ends_at); if (!relevantDate) continue; const dueDate = new Date(relevantDate); - if (dueDate < now || dueDate > windowEnd) continue; + const isPastDue = dueDate <= now; + if (!isPastDue && dueDate > windowEnd) continue; + if (isPastDue && !["trial", "payment_pending", "overdue"].includes(sub.status)) continue; const cycleStart = sub.current_period_start ? new Date(sub.current_period_start) : new Date(0); if (sub.last_reminder_sent_at && new Date(sub.last_reminder_sent_at) > cycleStart) continue; const email = sub.companies?.email; @@ -131,7 +138,12 @@ Deno.serve(async (req) => { } if (whatsappReady && whatsapp) { try { - await sendWhatsapp(settings, whatsapp, fillTemplate(settings.whatsapp_reminder_message, vars)); + await sendWhatsapp( + settings, + whatsapp, + fillTemplate(settings.whatsapp_reminder_message, vars), + sub.companies?.country_code || "BR", + ); anySent = true; } catch (err) { console.error(`Erro ao enviar WhatsApp pra ${whatsapp}:`, err); } } @@ -140,7 +152,7 @@ Deno.serve(async (req) => { sentCount++; } } - return json({ ok: true, sent: sentCount }); + return json({ ok: true, sent: sentCount, billing: billingResult || null, billing_error: billingError?.message || null }); } catch (err) { console.error(err); return json({ error: "Erro inesperado ao processar lembretes." }, 500); From 6ebee136d2af30bc8f05978632c73e420b23d0ca Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Wed, 9 Sep 2026 10:08:35 -0300 Subject: [PATCH 19/43] Update wa-common.ts From 3e056aa002343c18ebca4530c81f2a256e4fc2ac Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Wed, 9 Sep 2026 11:52:46 -0300 Subject: [PATCH 20/43] Update index.ts --- .../functions/saas-stripe-create-checkout/index.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts b/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts index a16183efb..e2648d82d 100644 --- a/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts +++ b/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts @@ -58,7 +58,16 @@ Deno.serve(async req => { const trialEnd = subscription.trial_ends_at ? Math.floor(new Date(subscription.trial_ends_at).getTime()/1000) : 0; if (trialEnd > Math.floor(Date.now()/1000) + 60) body.set('subscription_data[trial_end]',String(trialEnd)); - const session = await stripeForm('checkout/sessions',body,undefined,`saas-checkout-embedded-page-v2-${subscription.id}-${currency}`); + // A chave antiga era fixa por assinatura/moeda. Quando o trial, preço ou + // outros parâmetros mudavam, a Stripe recusava a nova sessão por ela usar + // a mesma chave com um payload diferente. A chave abaixo inclui os dados + // que alteram o Checkout e uma janela curta: cliques repetidos no mesmo + // minuto continuam idempotentes, mas uma nova tentativa válida não colide + // com uma sessão criada anteriormente. + const requestWindow = Math.floor(Date.now() / 60000); + const priceReference = String(price.stripe_price_id || amount).replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 48); + const idempotencyKey = `saas-checkout-embedded-v3-${subscription.id}-${currency}-${priceReference}-${trialEnd || 0}-${requestWindow}`; + const session = await stripeForm('checkout/sessions',body,undefined,idempotencyKey); await db.from('subscriptions').update({ payment_provider:'stripe', billing_currency:currency, stripe_checkout_session_id:session.id }).eq('id',subscription.id); From 5c7e5264038c769ac023bd1bbf17a90df4c4c8b4 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Wed, 9 Sep 2026 12:04:30 -0300 Subject: [PATCH 21/43] Refactor invoice handling in Stripe webhook --- .../volumes/functions/saas-stripe-webhook/index.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/supabase/code/volumes/functions/saas-stripe-webhook/index.ts b/supabase/code/volumes/functions/saas-stripe-webhook/index.ts index 7c624d6db..ed7a360e2 100644 --- a/supabase/code/volumes/functions/saas-stripe-webhook/index.ts +++ b/supabase/code/volumes/functions/saas-stripe-webhook/index.ts @@ -61,12 +61,20 @@ Deno.serve(async req=>{ if(sub){ const paid=event.type==='invoice.paid'; const due=o.due_date?new Date(o.due_date*1000).toISOString().slice(0,10):new Date().toISOString().slice(0,10); - await db.from('subscription_invoices').upsert({ - subscription_id:sub.id,amount:Number(o.amount_due||0)/100,status:paid?'paid':'failed',due_date:due, + const invoicePayload={ + tenant_id:sub.tenant_id,subscription_id:sub.id,amount:Number(o.amount_due||0)/100,status:paid?'paid':'failed',due_date:due, paid_at:paid?new Date((o.status_transitions?.paid_at||Math.floor(Date.now()/1000))*1000).toISOString():null, currency_code:String(o.currency||sub.billing_currency||'USD').toUpperCase(),payment_provider:'stripe', provider_invoice_id:o.id,hosted_invoice_url:o.hosted_invoice_url||null - },{onConflict:'provider_invoice_id'}); + }; + const {data:placeholder}=await db.from('subscription_invoices') + .select('id').eq('subscription_id',sub.id).eq('due_date',due) + .is('provider_invoice_id',null).in('status',['pending','failed']).limit(1).maybeSingle(); + if(placeholder?.id){ + await db.from('subscription_invoices').update(invoicePayload).eq('id',placeholder.id); + }else{ + await db.from('subscription_invoices').upsert(invoicePayload,{onConflict:'provider_invoice_id'}); + } const status=paid?'active':'payment_pending'; await db.from('subscriptions').update({status}).eq('id',sub.id); await db.from('companies').update({status}).eq('id',sub.tenant_id); From bd2274451cec927141054c816d174d65c92d3002 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Tue, 15 Sep 2026 10:39:23 -0300 Subject: [PATCH 22/43] Update docker-compose.yml --- supabase/code/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index c7cc534ef..b4751a725 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -312,6 +312,7 @@ services: SUPABASE_URL: http://kong:8000 SUPABASE_ANON_KEY: ${ANON_KEY} SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} + MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN} SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} From 9125c17bf092811dc589fa24291c9a6f96020b46 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Tue, 15 Sep 2026 11:39:11 -0300 Subject: [PATCH 23/43] Clean up Gotrue environment variables in docker-compose Removed several environment variables related to Gotrue configuration. --- supabase/code/docker-compose.yml | 42 -------------------------------- 1 file changed, 42 deletions(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index b4751a725..99b7133ec 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -96,50 +96,8 @@ services: condition: service_healthy analytics: condition: service_healthy - environment: - GOTRUE_API_HOST: 0.0.0.0 - GOTRUE_API_PORT: 9999 - API_EXTERNAL_URL: ${API_EXTERNAL_URL} - - GOTRUE_DB_DRIVER: postgres - GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} - - GOTRUE_SITE_URL: ${SITE_URL} - GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS} - GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP} - - GOTRUE_JWT_ADMIN_ROLES: service_role - GOTRUE_JWT_AUD: authenticated - GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated - GOTRUE_JWT_EXP: ${JWT_EXPIRY} - GOTRUE_JWT_SECRET: ${JWT_SECRET} - - GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP} - GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS} - GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM} - # Uncomment to bypass nonce check in ID Token flow. Commonly set to true when using Google Sign In on mobile. # GOTRUE_EXTERNAL_SKIP_NONCE_CHECK: true - - # GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: true - # GOTRUE_SMTP_MAX_FREQUENCY: 1s - GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL} - GOTRUE_SMTP_HOST: ${SMTP_HOST} - GOTRUE_SMTP_PORT: ${SMTP_PORT} - GOTRUE_SMTP_USER: ${SMTP_USER} - GOTRUE_SMTP_PASS: ${SMTP_PASS} - GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME} - GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE} - GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION} - GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY} - GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE} - GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE: ${GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE} - GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE: ${GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE} - GOTRUE_MAILER_TEMPLATES_RECOVERY: ${GOTRUE_MAILER_TEMPLATES_RECOVERY} - GOTRUE_MAILER_SUBJECTS_RECOVERY: ${GOTRUE_MAILER_SUBJECTS_RECOVERY} - - GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP} - GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM} # Uncomment to enable custom access token hook. Please see: https://supabase.com/docs/guides/auth/auth-hooks for full list of hooks and additional details about custom_access_token_hook # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true" From f7ef4cec05670109dfe79e263dfb75a61155a880 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Tue, 15 Sep 2026 11:40:30 -0300 Subject: [PATCH 24/43] Update docker-compose.yml --- supabase/code/docker-compose.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index 99b7133ec..07d0a9c01 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -272,10 +272,6 @@ services: SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN} SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} - STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY} - STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} - STRIPE_BILLING_WEBHOOK_SECRET: ${STRIPE_BILLING_WEBHOOK_SECRET} - DASHBOARD_URL: ${DASHBOARD_URL} # TODO: Allow configuring VERIFY_JWT per function. This PR might help: https://github.com/supabase/cli/pull/786 VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" command: [ "start", "--main-service", "/home/deno/functions/main" ] From 7856b055b8d51a0b06e438ef78bd451777e0f74e Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Tue, 15 Sep 2026 11:41:09 -0300 Subject: [PATCH 25/43] Update docker-compose.yml --- supabase/code/docker-compose.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index 07d0a9c01..1f8ac98dc 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -40,10 +40,6 @@ services: SUPABASE_ANON_KEY: ${ANON_KEY} SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} AUTH_JWT_SECRET: ${JWT_SECRET} - - LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} - LOGFLARE_URL: http://analytics:4000 - NEXT_PUBLIC_ENABLE_LOGS: true # Comment to use Big Query backend for analytics NEXT_ANALYTICS_BACKEND_PROVIDER: postgres # Uncomment to use Big Query backend for analytics From b4433c3a89f366f86f0f22ae506deaf3abc3fbc3 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Tue, 15 Sep 2026 11:42:33 -0300 Subject: [PATCH 26/43] Update docker-compose.yml --- supabase/code/docker-compose.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index 1f8ac98dc..cf0e9a4a1 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -234,7 +234,6 @@ services: IMGPROXY_BIND: ":5001" IMGPROXY_LOCAL_FILESYSTEM_ROOT: / IMGPROXY_USE_ETAG: "true" - IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION} meta: image: supabase/postgres-meta:v0.89.0 @@ -298,7 +297,6 @@ services: DB_PORT: ${POSTGRES_PORT} DB_PASSWORD: ${POSTGRES_PASSWORD} DB_SCHEMA: _analytics - LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} LOGFLARE_SINGLE_TENANT: true LOGFLARE_SUPABASE_MODE: true LOGFLARE_MIN_CLUSTER_SIZE: 1 @@ -380,7 +378,6 @@ services: interval: 5s retries: 3 environment: - LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} command: [ "--config", "/etc/vector/vector.yml" ] security_opt: - "label=disable" From 16997ae84c0379a98d223ab4535ddc6751e3d5d7 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Tue, 15 Sep 2026 11:46:42 -0300 Subject: [PATCH 27/43] Update docker-compose.yml --- supabase/code/docker-compose.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index cf0e9a4a1..abd616af6 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -358,7 +358,7 @@ services: "log_min_messages=fatal" # prevents Realtime polling queries from appearing in logs ] - vector: + vector: image: timberio/vector:0.28.1-alpine restart: unless-stopped volumes: @@ -377,11 +377,9 @@ services: timeout: 5s interval: 5s retries: 3 - environment: command: [ "--config", "/etc/vector/vector.yml" ] security_opt: - "label=disable" - # Update the DATABASE_URL if you are using an external Postgres database supavisor: image: supabase/supavisor:2.5.1 From 2a8916dad023f6ea548eeebc6e6d83fd9a69a209 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Tue, 15 Sep 2026 11:48:57 -0300 Subject: [PATCH 28/43] Update docker-compose.yml --- supabase/code/docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index abd616af6..cf6929b51 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -349,7 +349,7 @@ services: POSTGRES_DB: ${POSTGRES_DB} JWT_SECRET: ${JWT_SECRET} JWT_EXP: ${JWT_EXPIRY} - command: + command: [ "postgres", "-c", @@ -358,7 +358,7 @@ services: "log_min_messages=fatal" # prevents Realtime polling queries from appearing in logs ] - vector: + vector: image: timberio/vector:0.28.1-alpine restart: unless-stopped volumes: From b43cbc8e3afd4a69c481d23acbc93187f15a1fe2 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Tue, 15 Sep 2026 11:56:21 -0300 Subject: [PATCH 29/43] Refactor docker-compose.yml for clarity and consistency Updated comments and cleaned up formatting in docker-compose.yml. Adjusted fetch command syntax and added environment variables for improved configuration. --- supabase/code/docker-compose.yml | 120 +++++++++++++++---------------- 1 file changed, 58 insertions(+), 62 deletions(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index cf6929b51..4784d6090 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -3,7 +3,7 @@ # With helpers: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml up # Stop: docker compose down # Destroy: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans -# Reset everything: ./reset.sh +# Reset everything: ./reset.sh name: supabase @@ -18,8 +18,7 @@ services: "CMD", "node", "-e", - "fetch('http://studio:3000/api/platform/profile').then((r) => {if - (r.status !== 200) throw new Error(r.status)})" + "fetch('http://studio:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})" ] timeout: 10s interval: 5s @@ -40,16 +39,16 @@ services: SUPABASE_ANON_KEY: ${ANON_KEY} SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} AUTH_JWT_SECRET: ${JWT_SECRET} - # Comment to use Big Query backend for analytics + + LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} + LOGFLARE_URL: http://analytics:4000 + NEXT_PUBLIC_ENABLE_LOGS: true NEXT_ANALYTICS_BACKEND_PROVIDER: postgres - # Uncomment to use Big Query backend for analytics - # NEXT_ANALYTICS_BACKEND_PROVIDER: bigquery kong: image: kong:2.8.1 restart: unless-stopped volumes: - # https://github.com/supabase/supabase/issues/12661 - ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z depends_on: analytics: @@ -57,7 +56,6 @@ services: environment: KONG_DATABASE: "off" KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml - # https://github.com/supabase/cli/issues/14 KONG_DNS_ORDER: LAST,A,CNAME KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k @@ -66,8 +64,8 @@ services: SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} DASHBOARD_USERNAME: ${DASHBOARD_USERNAME} DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD} - # https://unix.stackexchange.com/a/294837 - entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && + entrypoint: > + bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start' auth: @@ -88,38 +86,56 @@ services: retries: 3 depends_on: db: - # Disable this if you are using an external Postgres database condition: service_healthy analytics: condition: service_healthy - # Uncomment to bypass nonce check in ID Token flow. Commonly set to true when using Google Sign In on mobile. - # GOTRUE_EXTERNAL_SKIP_NONCE_CHECK: true - # Uncomment to enable custom access token hook. Please see: https://supabase.com/docs/guides/auth/auth-hooks for full list of hooks and additional details about custom_access_token_hook + environment: + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: 9999 + API_EXTERNAL_URL: ${API_EXTERNAL_URL} + + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + + GOTRUE_SITE_URL: ${SITE_URL} + GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS} + GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP} - # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true" - # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI: "pg-functions://postgres/public/custom_access_token_hook" - # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS: "" + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: ${JWT_EXPIRY} + GOTRUE_JWT_SECRET: ${JWT_SECRET} - # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED: "true" - # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/mfa_verification_attempt" + GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP} + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS} + GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM} - # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED: "true" - # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/password_verification_attempt" + GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL} + GOTRUE_SMTP_HOST: ${SMTP_HOST} + GOTRUE_SMTP_PORT: ${SMTP_PORT} + GOTRUE_SMTP_USER: ${SMTP_USER} + GOTRUE_SMTP_PASS: ${SMTP_PASS} + GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME} - # GOTRUE_HOOK_SEND_SMS_ENABLED: "false" - # GOTRUE_HOOK_SEND_SMS_URI: "pg-functions://postgres/public/custom_access_token_hook" - # GOTRUE_HOOK_SEND_SMS_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE} + GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION} + GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY} + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE} - # GOTRUE_HOOK_SEND_EMAIL_ENABLED: "false" - # GOTRUE_HOOK_SEND_EMAIL_URI: "http://host.docker.internal:54321/functions/v1/email_sender" - # GOTRUE_HOOK_SEND_EMAIL_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE: ${GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE:-} + GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE: ${GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE:-} + GOTRUE_MAILER_TEMPLATES_RECOVERY: ${GOTRUE_MAILER_TEMPLATES_RECOVERY:-} + GOTRUE_MAILER_SUBJECTS_RECOVERY: ${GOTRUE_MAILER_SUBJECTS_RECOVERY:-} + + GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP} + GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM} rest: image: postgrest/postgrest:v12.2.12 restart: unless-stopped depends_on: db: - # Disable this if you are using an external Postgres database condition: service_healthy analytics: condition: service_healthy @@ -134,12 +150,10 @@ services: command: [ "postgrest" ] realtime: - # This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain image: supabase/realtime:v2.34.47 restart: unless-stopped depends_on: db: - # Disable this if you are using an external Postgres database condition: service_healthy analytics: condition: service_healthy @@ -177,7 +191,6 @@ services: SEED_SELF_HOST: true RUN_JANITOR: true - # To use S3 backed storage: docker compose -f docker-compose.yml -f docker-compose.s3.yml up storage: image: supabase/storage-api:v1.22.17 restart: unless-stopped @@ -198,7 +211,6 @@ services: retries: 3 depends_on: db: - # Disable this if you are using an external Postgres database condition: service_healthy rest: condition: service_started @@ -214,7 +226,6 @@ services: STORAGE_BACKEND: file FILE_STORAGE_BACKEND_PATH: /var/lib/storage TENANT_ID: ${STORAGE_TENANT_ID} - # TODO: https://github.com/supabase/storage-api/issues/55 REGION: stub GLOBAL_S3_BUCKET: stub ENABLE_IMAGE_TRANSFORMATION: "true" @@ -234,13 +245,13 @@ services: IMGPROXY_BIND: ":5001" IMGPROXY_LOCAL_FILESYSTEM_ROOT: / IMGPROXY_USE_ETAG: "true" + IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION:-true} meta: image: supabase/postgres-meta:v0.89.0 restart: unless-stopped depends_on: db: - # Disable this if you are using an external Postgres database condition: service_healthy analytics: condition: service_healthy @@ -265,21 +276,19 @@ services: SUPABASE_URL: http://kong:8000 SUPABASE_ANON_KEY: ${ANON_KEY} SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} - MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN} SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} - # TODO: Allow configuring VERIFY_JWT per function. This PR might help: https://github.com/supabase/cli/pull/786 + + # MERCADO PAGO + MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN} + + # Edge Functions VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" + command: [ "start", "--main-service", "/home/deno/functions/main" ] analytics: image: supabase/logflare:1.12.0 restart: unless-stopped - # Uncomment to use Big Query backend for analytics - # volumes: - # - type: bind - # source: ${PWD}/gcloud.json - # target: /opt/app/rel/logflare/bin/gcloud.json - # read_only: true healthcheck: test: [ "CMD", "curl", "http://localhost:4000/health" ] timeout: 5s @@ -287,7 +296,6 @@ services: retries: 10 depends_on: db: - # Disable this if you are using an external Postgres database condition: service_healthy environment: LOGFLARE_NODE_HOST: 127.0.0.1 @@ -297,39 +305,26 @@ services: DB_PORT: ${POSTGRES_PORT} DB_PASSWORD: ${POSTGRES_PASSWORD} DB_SCHEMA: _analytics + LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} LOGFLARE_SINGLE_TENANT: true LOGFLARE_SUPABASE_MODE: true LOGFLARE_MIN_CLUSTER_SIZE: 1 - - # Comment variables to use Big Query backend for analytics POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase POSTGRES_BACKEND_SCHEMA: _analytics LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true - # Uncomment to use Big Query backend for analytics - # GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID} - # GOOGLE_PROJECT_NUMBER: ${GOOGLE_PROJECT_NUMBER} - # Comment out everything below this point if you are using an external Postgres database db: image: supabase/postgres:15.8.1.060 restart: unless-stopped volumes: - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z - # Must be superuser to create event trigger - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z - # Must be superuser to alter reserved role - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z - # Initialize the database settings with JWT_SECRET and JWT_EXP - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z - # PGDATA directory is persisted between restarts - ./volumes/db/data:/var/lib/postgresql/data:Z - # Changes required for internal supabase data such as _analytics - ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z - # Changes required for Analytics support - ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z - # Changes required for Pooler support - ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z - # Use named volume to persist pgsodium decryption key between restarts - db-config:/etc/postgresql-custom healthcheck: test: [ "CMD", "pg_isready", "-U", "postgres", "-h", "localhost" ] @@ -349,13 +344,13 @@ services: POSTGRES_DB: ${POSTGRES_DB} JWT_SECRET: ${JWT_SECRET} JWT_EXP: ${JWT_EXPIRY} - command: + command: [ "postgres", "-c", "config_file=/etc/postgresql/postgresql.conf", "-c", - "log_min_messages=fatal" # prevents Realtime polling queries from appearing in logs + "log_min_messages=fatal" ] vector: @@ -377,10 +372,12 @@ services: timeout: 5s interval: 5s retries: 3 + environment: + LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} command: [ "--config", "/etc/vector/vector.yml" ] security_opt: - "label=disable" - # Update the DATABASE_URL if you are using an external Postgres database + supavisor: image: supabase/supavisor:2.5.1 restart: unless-stopped @@ -426,8 +423,7 @@ services: [ "/bin/sh", "-c", - "/app/bin/migrate && /app/bin/supavisor eval \"$$(cat - /etc/pooler/pooler.exs)\" && /app/bin/server" + "/app/bin/migrate && /app/bin/supavisor eval \"$$(cat /etc/pooler/pooler.exs)\" && /app/bin/server" ] volumes: From 32e8f8d6789ce17261cfa35233df9e25b94107f1 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Tue, 15 Sep 2026 11:58:26 -0300 Subject: [PATCH 30/43] Set default value for LOGFLARE_API_KEY --- supabase/code/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index 4784d6090..f21f5b064 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -40,7 +40,7 @@ services: SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} AUTH_JWT_SECRET: ${JWT_SECRET} - LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} + LOGFLARE_API_KEY: ${LOGFLARE_API_KEY:-your-super-secret-and-long-logflare-key} LOGFLARE_URL: http://analytics:4000 NEXT_PUBLIC_ENABLE_LOGS: true NEXT_ANALYTICS_BACKEND_PROVIDER: postgres From afa87f169371209dae02a8e46460f04e232f6f10 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Tue, 15 Sep 2026 11:59:54 -0300 Subject: [PATCH 31/43] Update docker-compose.yml --- supabase/code/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index f21f5b064..c89e859eb 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -305,7 +305,7 @@ services: DB_PORT: ${POSTGRES_PORT} DB_PASSWORD: ${POSTGRES_PASSWORD} DB_SCHEMA: _analytics - LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} + LOGFLARE_API_KEY: ${LOGFLARE_API_KEY:-} LOGFLARE_SINGLE_TENANT: true LOGFLARE_SUPABASE_MODE: true LOGFLARE_MIN_CLUSTER_SIZE: 1 From f8bc74f2050ba3c8064c2d3f3f8b86aeb3f13cb0 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Tue, 15 Sep 2026 12:01:06 -0300 Subject: [PATCH 32/43] Update docker-compose.yml --- supabase/code/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index c89e859eb..00d4c17b3 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -373,7 +373,7 @@ services: interval: 5s retries: 3 environment: - LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} + LOGFLARE_API_KEY: ${LOGFLARE_API_KEY:-} command: [ "--config", "/etc/vector/vector.yml" ] security_opt: - "label=disable" From 48653c0a8dee0e849303f921a21e29fa797f67a5 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Tue, 15 Sep 2026 12:01:35 -0300 Subject: [PATCH 33/43] Update docker-compose.yml --- supabase/code/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index 00d4c17b3..4334b805e 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -373,7 +373,7 @@ services: interval: 5s retries: 3 environment: - LOGFLARE_API_KEY: ${LOGFLARE_API_KEY:-} + LOGFLARE_API_KEY: ${LOGFLARE_API_KEY:} command: [ "--config", "/etc/vector/vector.yml" ] security_opt: - "label=disable" From 9443b106dffd1c9c5b7381d6362202b26b94c289 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Tue, 15 Sep 2026 12:04:10 -0300 Subject: [PATCH 34/43] Update docker-compose.yml --- supabase/code/docker-compose.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index 4334b805e..ed580db01 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -39,8 +39,6 @@ services: SUPABASE_ANON_KEY: ${ANON_KEY} SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} AUTH_JWT_SECRET: ${JWT_SECRET} - - LOGFLARE_API_KEY: ${LOGFLARE_API_KEY:-your-super-secret-and-long-logflare-key} LOGFLARE_URL: http://analytics:4000 NEXT_PUBLIC_ENABLE_LOGS: true NEXT_ANALYTICS_BACKEND_PROVIDER: postgres @@ -372,8 +370,8 @@ services: timeout: 5s interval: 5s retries: 3 - environment: - LOGFLARE_API_KEY: ${LOGFLARE_API_KEY:} + environment: + LOGFLARE_API_KEY: "" command: [ "--config", "/etc/vector/vector.yml" ] security_opt: - "label=disable" From c3719f07debbf63ee3f303dc33f8b6703f4c7585 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Tue, 15 Sep 2026 12:05:41 -0300 Subject: [PATCH 35/43] Refactor vector service configuration in docker-compose Removed unnecessary configuration for vector service in docker-compose. --- supabase/code/docker-compose.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index ed580db01..7490835b0 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -351,12 +351,8 @@ services: "log_min_messages=fatal" ] - vector: + vector: image: timberio/vector:0.28.1-alpine - restart: unless-stopped - volumes: - - ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z - - ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro,z healthcheck: test: [ @@ -370,12 +366,11 @@ services: timeout: 5s interval: 5s retries: 3 - environment: + environment: LOGFLARE_API_KEY: "" command: [ "--config", "/etc/vector/vector.yml" ] security_opt: - "label=disable" - supavisor: image: supabase/supavisor:2.5.1 restart: unless-stopped From 972be7a393cce97763ed249e567eac3e1eed7be4 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Tue, 15 Sep 2026 12:15:33 -0300 Subject: [PATCH 36/43] Update docker-compose.yml --- supabase/code/docker-compose.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index 7490835b0..b06deace6 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -303,7 +303,7 @@ services: DB_PORT: ${POSTGRES_PORT} DB_PASSWORD: ${POSTGRES_PASSWORD} DB_SCHEMA: _analytics - LOGFLARE_API_KEY: ${LOGFLARE_API_KEY:-} + LOGFLARE_API_KEY: "" LOGFLARE_SINGLE_TENANT: true LOGFLARE_SUPABASE_MODE: true LOGFLARE_MIN_CLUSTER_SIZE: 1 @@ -351,8 +351,11 @@ services: "log_min_messages=fatal" ] - vector: + vector: image: timberio/vector:0.28.1-alpine + restart: unless-stopped + volumes: + - ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z healthcheck: test: [ @@ -371,6 +374,7 @@ services: command: [ "--config", "/etc/vector/vector.yml" ] security_opt: - "label=disable" + supavisor: image: supabase/supavisor:2.5.1 restart: unless-stopped From b137acb99b9709fefc59d4ba45891544c6f0a2d1 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Tue, 15 Sep 2026 12:19:40 -0300 Subject: [PATCH 37/43] Update docker-compose.yml --- supabase/code/docker-compose.yml | 906 +++++++++++++++++-------------- 1 file changed, 486 insertions(+), 420 deletions(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index b06deace6..caa0b7762 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -1,427 +1,493 @@ -# Usage -# Start: docker compose up -# With helpers: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml up -# Stop: docker compose down -# Destroy: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans -# Reset everything: ./reset.sh +Usage +Start: docker compose up +With helpers: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml up +Stop: docker compose down +Destroy: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans + +Reset everything: ./reset.sh name: supabase services: - studio: - image: supabase/studio:2025.05.19-sha-3487831 - restart: unless-stopped - healthcheck: - test: - [ - "CMD", - "node", - "-e", - "fetch('http://studio:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})" - ] - timeout: 10s - interval: 5s - retries: 3 - depends_on: - analytics: - condition: service_healthy - environment: - STUDIO_PG_META_URL: http://meta:8080 - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - - DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION} - DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT} - OPENAI_API_KEY: ${OPENAI_API_KEY:-} - - SUPABASE_URL: http://kong:8000 - SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} - SUPABASE_ANON_KEY: ${ANON_KEY} - SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} - AUTH_JWT_SECRET: ${JWT_SECRET} - LOGFLARE_URL: http://analytics:4000 - NEXT_PUBLIC_ENABLE_LOGS: true - NEXT_ANALYTICS_BACKEND_PROVIDER: postgres - - kong: - image: kong:2.8.1 - restart: unless-stopped - volumes: - - ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z - depends_on: - analytics: - condition: service_healthy - environment: - KONG_DATABASE: "off" - KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml - KONG_DNS_ORDER: LAST,A,CNAME - KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth - KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k - KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k - SUPABASE_ANON_KEY: ${ANON_KEY} - SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} - DASHBOARD_USERNAME: ${DASHBOARD_USERNAME} - DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD} - entrypoint: > - bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && - /docker-entrypoint.sh kong docker-start' - - auth: - image: supabase/gotrue:v2.172.1 - restart: unless-stopped - healthcheck: - test: - [ - "CMD", - "wget", - "--no-verbose", - "--tries=1", - "--spider", - "http://localhost:9999/health" - ] - timeout: 5s - interval: 5s - retries: 3 - depends_on: - db: - condition: service_healthy - analytics: - condition: service_healthy - environment: - GOTRUE_API_HOST: 0.0.0.0 - GOTRUE_API_PORT: 9999 - API_EXTERNAL_URL: ${API_EXTERNAL_URL} - - GOTRUE_DB_DRIVER: postgres - GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} - - GOTRUE_SITE_URL: ${SITE_URL} - GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS} - GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP} - - GOTRUE_JWT_ADMIN_ROLES: service_role - GOTRUE_JWT_AUD: authenticated - GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated - GOTRUE_JWT_EXP: ${JWT_EXPIRY} - GOTRUE_JWT_SECRET: ${JWT_SECRET} - - GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP} - GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS} - GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM} - - GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL} - GOTRUE_SMTP_HOST: ${SMTP_HOST} - GOTRUE_SMTP_PORT: ${SMTP_PORT} - GOTRUE_SMTP_USER: ${SMTP_USER} - GOTRUE_SMTP_PASS: ${SMTP_PASS} - GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME} - - GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE} - GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION} - GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY} - GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE} - - GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE: ${GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE:-} - GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE: ${GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE:-} - GOTRUE_MAILER_TEMPLATES_RECOVERY: ${GOTRUE_MAILER_TEMPLATES_RECOVERY:-} - GOTRUE_MAILER_SUBJECTS_RECOVERY: ${GOTRUE_MAILER_SUBJECTS_RECOVERY:-} - - GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP} - GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM} - - rest: - image: postgrest/postgrest:v12.2.12 - restart: unless-stopped - depends_on: - db: - condition: service_healthy - analytics: - condition: service_healthy - environment: - PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} - PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS} - PGRST_DB_ANON_ROLE: anon - PGRST_JWT_SECRET: ${JWT_SECRET} - PGRST_DB_USE_LEGACY_GUCS: "false" - PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET} - PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY} - command: [ "postgrest" ] - - realtime: - image: supabase/realtime:v2.34.47 - restart: unless-stopped - depends_on: - db: - condition: service_healthy - analytics: - condition: service_healthy - healthcheck: - test: - [ - "CMD", - "curl", - "-sSfL", - "--head", - "-o", - "/dev/null", - "-H", - "Authorization: Bearer ${ANON_KEY}", - "http://localhost:4000/api/tenants/realtime-dev/health" - ] - timeout: 5s - interval: 5s - retries: 3 - environment: - PORT: 4000 - DB_HOST: ${POSTGRES_HOST} - DB_PORT: ${POSTGRES_PORT} - DB_USER: supabase_admin - DB_PASSWORD: ${POSTGRES_PASSWORD} - DB_NAME: ${POSTGRES_DB} - DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' - DB_ENC_KEY: supabaserealtime - API_JWT_SECRET: ${JWT_SECRET} - SECRET_KEY_BASE: ${SECRET_KEY_BASE} - ERL_AFLAGS: -proto_dist inet_tcp - DNS_NODES: "''" - RLIMIT_NOFILE: "10000" - APP_NAME: realtime - SEED_SELF_HOST: true - RUN_JANITOR: true - - storage: - image: supabase/storage-api:v1.22.17 - restart: unless-stopped - volumes: - - ./volumes/storage:/var/lib/storage:z - healthcheck: - test: - [ - "CMD", - "wget", - "--no-verbose", - "--tries=1", - "--spider", - "http://storage:5000/status" - ] - timeout: 5s - interval: 5s - retries: 3 - depends_on: - db: - condition: service_healthy - rest: - condition: service_started - imgproxy: - condition: service_started - environment: - ANON_KEY: ${ANON_KEY} - SERVICE_KEY: ${SERVICE_ROLE_KEY} - POSTGREST_URL: http://rest:3000 - PGRST_JWT_SECRET: ${JWT_SECRET} - DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} - FILE_SIZE_LIMIT: 52428800 - STORAGE_BACKEND: file - FILE_STORAGE_BACKEND_PATH: /var/lib/storage - TENANT_ID: ${STORAGE_TENANT_ID} - REGION: stub - GLOBAL_S3_BUCKET: stub - ENABLE_IMAGE_TRANSFORMATION: "true" - IMGPROXY_URL: http://imgproxy:5001 - - imgproxy: - image: darthsim/imgproxy:v3.8.0 - restart: unless-stopped - volumes: - - ./volumes/storage:/var/lib/storage:z - healthcheck: - test: [ "CMD", "imgproxy", "health" ] - timeout: 5s - interval: 5s - retries: 3 - environment: - IMGPROXY_BIND: ":5001" - IMGPROXY_LOCAL_FILESYSTEM_ROOT: / - IMGPROXY_USE_ETAG: "true" - IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION:-true} - - meta: - image: supabase/postgres-meta:v0.89.0 - restart: unless-stopped - depends_on: - db: - condition: service_healthy - analytics: - condition: service_healthy - environment: - PG_META_PORT: 8080 - PG_META_DB_HOST: ${POSTGRES_HOST} - PG_META_DB_PORT: ${POSTGRES_PORT} - PG_META_DB_NAME: ${POSTGRES_DB} - PG_META_DB_USER: supabase_admin - PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} - - functions: - image: supabase/edge-runtime:v1.67.4 - restart: unless-stopped - volumes: - - ./volumes/functions:/home/deno/functions:Z - depends_on: - analytics: - condition: service_healthy - environment: - JWT_SECRET: ${JWT_SECRET} - SUPABASE_URL: http://kong:8000 - SUPABASE_ANON_KEY: ${ANON_KEY} - SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} - SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} - - # MERCADO PAGO - MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN} - - # Edge Functions - VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" - - command: [ "start", "--main-service", "/home/deno/functions/main" ] - - analytics: - image: supabase/logflare:1.12.0 - restart: unless-stopped - healthcheck: - test: [ "CMD", "curl", "http://localhost:4000/health" ] - timeout: 5s - interval: 5s - retries: 10 - depends_on: - db: - condition: service_healthy - environment: - LOGFLARE_NODE_HOST: 127.0.0.1 - DB_USERNAME: supabase_admin - DB_DATABASE: _supabase - DB_HOSTNAME: ${POSTGRES_HOST} - DB_PORT: ${POSTGRES_PORT} - DB_PASSWORD: ${POSTGRES_PASSWORD} - DB_SCHEMA: _analytics - LOGFLARE_API_KEY: "" - LOGFLARE_SINGLE_TENANT: true - LOGFLARE_SUPABASE_MODE: true - LOGFLARE_MIN_CLUSTER_SIZE: 1 - POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase - POSTGRES_BACKEND_SCHEMA: _analytics - LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true - - db: - image: supabase/postgres:15.8.1.060 - restart: unless-stopped - volumes: - - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z - - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z - - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z - - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z - - ./volumes/db/data:/var/lib/postgresql/data:Z - - ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z - - ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z - - ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z - - db-config:/etc/postgresql-custom - healthcheck: - test: [ "CMD", "pg_isready", "-U", "postgres", "-h", "localhost" ] - interval: 5s - timeout: 5s - retries: 10 - depends_on: - vector: - condition: service_healthy - environment: - POSTGRES_HOST: /var/run/postgresql - PGPORT: ${POSTGRES_PORT} - POSTGRES_PORT: ${POSTGRES_PORT} - PGPASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - PGDATABASE: ${POSTGRES_DB} - POSTGRES_DB: ${POSTGRES_DB} - JWT_SECRET: ${JWT_SECRET} - JWT_EXP: ${JWT_EXPIRY} - command: - [ - "postgres", - "-c", - "config_file=/etc/postgresql/postgresql.conf", - "-c", - "log_min_messages=fatal" - ] - - vector: - image: timberio/vector:0.28.1-alpine - restart: unless-stopped - volumes: - - ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z - healthcheck: - test: - [ - "CMD", - "wget", - "--no-verbose", - "--tries=1", - "--spider", - "http://vector:9001/health" - ] - timeout: 5s - interval: 5s - retries: 3 - environment: - LOGFLARE_API_KEY: "" - command: [ "--config", "/etc/vector/vector.yml" ] - security_opt: - - "label=disable" - - supavisor: - image: supabase/supavisor:2.5.1 - restart: unless-stopped - volumes: - - ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z - healthcheck: - test: - [ - "CMD", - "curl", - "-sSfL", - "--head", - "-o", - "/dev/null", - "http://127.0.0.1:4000/api/health" - ] - interval: 10s - timeout: 5s - retries: 5 - depends_on: - db: - condition: service_healthy - analytics: - condition: service_healthy - environment: - PORT: 4000 - POSTGRES_PORT: ${POSTGRES_PORT} - POSTGRES_DB: ${POSTGRES_DB} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/_supabase - CLUSTER_POSTGRES: true - SECRET_KEY_BASE: ${SECRET_KEY_BASE} - VAULT_ENC_KEY: ${VAULT_ENC_KEY} - API_JWT_SECRET: ${JWT_SECRET} - METRICS_JWT_SECRET: ${JWT_SECRET} - REGION: local - ERL_AFLAGS: -proto_dist inet_tcp - POOLER_TENANT_ID: ${POOLER_TENANT_ID} - POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE} - POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN} - POOLER_POOL_MODE: transaction - command: - [ - "/bin/sh", - "-c", - "/app/bin/migrate && /app/bin/supavisor eval \"$$(cat /etc/pooler/pooler.exs)\" && /app/bin/server" - ] +studio: +image: supabase/studio:2025.05.19-sha-3487831 +restart: unless-stopped +healthcheck: +test: +[ +"CMD", +"node", +"-e", +"fetch('http://studio:3000/api/platform/profile').then((r) => {if +(r.status !== 200) throw new Error(r.status)})" +] +timeout: 10s +interval: 5s +retries: 3 +depends_on: +analytics: +condition: service_healthy +environment: +STUDIO_PG_META_URL: http://meta:8080 +POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + + DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION} + DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + AUTH_JWT_SECRET: ${JWT_SECRET} + + LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} + LOGFLARE_URL: http://analytics:4000 + NEXT_PUBLIC_ENABLE_LOGS: true + # Comment to use Big Query backend for analytics + NEXT_ANALYTICS_BACKEND_PROVIDER: postgres + # Uncomment to use Big Query backend for analytics + # NEXT_ANALYTICS_BACKEND_PROVIDER: bigquery + +kong: +image: kong:2.8.1 +restart: unless-stopped +volumes: +# https://github.com/supabase/supabase/issues/12661 +- ./volumes/api/kong.yml:/home/kong/temp.yml,z +depends_on: +analytics: +condition: service_healthy +environment: +KONG_DATABASE: "off" +KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml +# https://github.com/supabase/cli/issues/14 +KONG_DNS_ORDER: LAST,A,CNAME +KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth +KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k +KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k +SUPABASE_ANON_KEY: ${ANON_KEY} +SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} +DASHBOARD_USERNAME: ${DASHBOARD_USERNAME} +DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD} +# https://unix.stackexchange.com/a/294837 +entrypoint: bash -c 'eval "echo "$$(cat ~/temp.yml)"" > ~/kong.yml && +/docker-entrypoint.sh kong docker-start' + +auth: +image: supabase/gotrue.172.1 +restart: unless-stopped +healthcheck: +test: +[ +"CMD", +"wget", +"--no-verbose", +"--tries=1", +"--spider", +"http://localhost:9999/health" +] +timeout: 5s +interval: 5s +retries: 3 +depends_on: +db: +# Disable this if you are using an external Postgres database +condition: service_healthy +analytics: +condition: service_healthy +environment: +GOTRUE_API_HOST: 0.0.0.0 +GOTRUE_API_PORT: 9999 +API_EXTERNAL_URL: ${API_EXTERNAL_URL} + + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + + GOTRUE_SITE_URL: ${SITE_URL} + GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS} + GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP} + + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: ${JWT_EXPIRY} + GOTRUE_JWT_SECRET: ${JWT_SECRET} + + GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP} + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS} + GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM} + + # Uncomment to bypass nonce check in ID Token flow. Commonly set to true when using Google Sign In on mobile. + # GOTRUE_EXTERNAL_SKIP_NONCE_CHECK: true + + # GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: true + # GOTRUE_SMTP_MAX_FREQUENCY: 1s + GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL} + GOTRUE_SMTP_HOST: ${SMTP_HOST} + GOTRUE_SMTP_PORT: ${SMTP_PORT} + GOTRUE_SMTP_USER: ${SMTP_USER} + GOTRUE_SMTP_PASS: ${SMTP_PASS} + GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME} + GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE} + GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION} + GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY} + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE} + GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE: ${GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE} + GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE: ${GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE} + GOTRUE_MAILER_TEMPLATES_RECOVERY: ${GOTRUE_MAILER_TEMPLATES_RECOVERY} + GOTRUE_MAILER_SUBJECTS_RECOVERY: ${GOTRUE_MAILER_SUBJECTS_RECOVERY} + + GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP} + GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM} + # Uncomment to enable custom access token hook. Please see: https://supabase.com/docs/guides/auth/auth-hooks for full list of hooks and additional details about custom_access_token_hook + + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true" + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI: "pg-functions://postgres/public/custom_access_token_hook" + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS: "" + + # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED: "true" + # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/mfa_verification_attempt" + + # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED: "true" + # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/password_verification_attempt" + + # GOTRUE_HOOK_SEND_SMS_ENABLED: "false" + # GOTRUE_HOOK_SEND_SMS_URI: "pg-functions://postgres/public/custom_access_token_hook" + # GOTRUE_HOOK_SEND_SMS_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + + # GOTRUE_HOOK_SEND_EMAIL_ENABLED: "false" + # GOTRUE_HOOK_SEND_EMAIL_URI: "http://host.docker.internal:54321/functions/v1/email_sender" + # GOTRUE_HOOK_SEND_EMAIL_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + +rest: +image: postgrest/postgrest.2.12 +restart: unless-stopped +depends_on: +db: +# Disable this if you are using an external Postgres database +condition: service_healthy +analytics: +condition: service_healthy +environment: +PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} +PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS} +PGRST_DB_ANON_ROLE: anon +PGRST_JWT_SECRET: ${JWT_SECRET} +PGRST_DB_USE_LEGACY_GUCS: "false" +PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET} +PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY} +command: [ "postgrest" ] + +realtime: +# This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain +image: supabase/realtime.34.47 +restart: unless-stopped +depends_on: +db: +# Disable this if you are using an external Postgres database +condition: service_healthy +analytics: +condition: service_healthy +healthcheck: +test: +[ +"CMD", +"curl", +"-sSfL", +"--head", +"-o", +"/dev/null", +"-H", +"Authorization: Bearer ${ANON_KEY}", +"http://localhost:4000/api/tenants/realtime-dev/health" +] +timeout: 5s +interval: 5s +retries: 3 +environment: +PORT: 4000 +DB_HOST: ${POSTGRES_HOST} +DB_PORT: ${POSTGRES_PORT} +DB_USER: supabase_admin +DB_PASSWORD: ${POSTGRES_PASSWORD} +DB_NAME: ${POSTGRES_DB} +DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' +DB_ENC_KEY: supabaserealtime +API_JWT_SECRET: ${JWT_SECRET} +SECRET_KEY_BASE: ${SECRET_KEY_BASE} +ERL_AFLAGS: -proto_dist inet_tcp +DNS_NODES: "''" +RLIMIT_NOFILE: "10000" +APP_NAME: realtime +SEED_SELF_HOST: true +RUN_JANITOR: true + +To use S3 backed storage: docker compose -f docker-compose.yml -f docker-compose.s3.yml up + +storage: +image: supabase/storage-api.22.17 +restart: unless-stopped +volumes: +- ./volumes/storage:/var/lib/storage +healthcheck: +test: +[ +"CMD", +"wget", +"--no-verbose", +"--tries=1", +"--spider", +"http://storage:5000/status" +] +timeout: 5s +interval: 5s +retries: 3 +depends_on: +db: +# Disable this if you are using an external Postgres database +condition: service_healthy +rest: +condition: service_started +imgproxy: +condition: service_started +environment: +ANON_KEY: ${ANON_KEY} +SERVICE_KEY: ${SERVICE_ROLE_KEY} +POSTGREST_URL: http://rest:3000 +PGRST_JWT_SECRET: ${JWT_SECRET} +DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} +FILE_SIZE_LIMIT: 52428800 +STORAGE_BACKEND: file +FILE_STORAGE_BACKEND_PATH: /var/lib/storage +TENANT_ID: ${STORAGE_TENANT_ID} +# TODO: https://github.com/supabase/storage-api/issues/55 +REGION: stub +GLOBAL_S3_BUCKET: stub +ENABLE_IMAGE_TRANSFORMATION: "true" +IMGPROXY_URL: http://imgproxy:5001 + +imgproxy: +image: darthsim/imgproxy.8.0 +restart: unless-stopped +volumes: +- ./volumes/storage:/var/lib/storage +healthcheck: +test: [ "CMD", "imgproxy", "health" ] +timeout: 5s +interval: 5s +retries: 3 +environment: +IMGPROXY_BIND: ":5001" +IMGPROXY_LOCAL_FILESYSTEM_ROOT: / +IMGPROXY_USE_ETAG: "true" +IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION} + +meta: +image: supabase/postgres-meta.89.0 +restart: unless-stopped +depends_on: +db: +# Disable this if you are using an external Postgres database +condition: service_healthy +analytics: +condition: service_healthy +environment: +PG_META_PORT: 8080 +PG_META_DB_HOST: ${POSTGRES_HOST} +PG_META_DB_PORT: ${POSTGRES_PORT} +PG_META_DB_NAME: ${POSTGRES_DB} +PG_META_DB_USER: supabase_admin +PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} + +functions: +image: supabase/edge-runtime.67.4 +restart: unless-stopped +volumes: +- ./volumes/functions:/home/deno/functions +depends_on: +analytics: +condition: service_healthy +environment: +JWT_SECRET: ${JWT_SECRET} +SUPABASE_URL: http://kong:8000 +SUPABASE_ANON_KEY: ${ANON_KEY} +SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} +MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN} +SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} +STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY} +STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} +STRIPE_BILLING_WEBHOOK_SECRET: ${STRIPE_BILLING_WEBHOOK_SECRET} +DASHBOARD_URL: ${DASHBOARD_URL} +# TODO: Allow configuring VERIFY_JWT per function. This PR might help: https://github.com/supabase/cli/pull/786 +VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" +command: [ "start", "--main-service", "/home/deno/functions/main" ] + +analytics: +image: supabase/logflare:1.12.0 +restart: unless-stopped +# Uncomment to use Big Query backend for analytics +# volumes: +# - type: bind +# source: ${PWD}/gcloud.json +# target: /opt/app/rel/logflare/bin/gcloud.json +# read_only: true +healthcheck: +test: [ "CMD", "curl", "http://localhost:4000/health" ] +timeout: 5s +interval: 5s +retries: 10 +depends_on: +db: +# Disable this if you are using an external Postgres database +condition: service_healthy +environment: +LOGFLARE_NODE_HOST: 127.0.0.1 +DB_USERNAME: supabase_admin +DB_DATABASE: _supabase +DB_HOSTNAME: ${POSTGRES_HOST} +DB_PORT: ${POSTGRES_PORT} +DB_PASSWORD: ${POSTGRES_PASSWORD} +DB_SCHEMA: _analytics +LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} +LOGFLARE_SINGLE_TENANT: true +LOGFLARE_SUPABASE_MODE: true +LOGFLARE_MIN_CLUSTER_SIZE: 1 + + # Comment variables to use Big Query backend for analytics + POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase + POSTGRES_BACKEND_SCHEMA: _analytics + LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true + # Uncomment to use Big Query backend for analytics + # GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID} + # GOOGLE_PROJECT_NUMBER: ${GOOGLE_PROJECT_NUMBER} + + # Comment out everything below this point if you are using an external Postgres database + +db: +image: supabase/postgres:15.8.1.060 +restart: unless-stopped +volumes: +- ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql +# Must be superuser to create event trigger +- ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql +# Must be superuser to alter reserved role +- ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql +# Initialize the database settings with JWT_SECRET and JWT_EXP +- ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql +# PGDATA directory is persisted between restarts +- ./volumes/db/data:/var/lib/postgresql/data +# Changes required for internal supabase data such as _analytics +- ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql +# Changes required for Analytics support +- ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql +# Changes required for Pooler support +- ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql +# Use named volume to persist pgsodium decryption key between restarts +- db-config:/etc/postgresql-custom +healthcheck: +test: [ "CMD", "pg_isready", "-U", "postgres", "-h", "localhost" ] +interval: 5s +timeout: 5s +retries: 10 +depends_on: +vector: +condition: service_healthy +environment: +POSTGRES_HOST: /var/run/postgresql +PGPORT: ${POSTGRES_PORT} +POSTGRES_PORT: ${POSTGRES_PORT} +PGPASSWORD: ${POSTGRES_PASSWORD} +POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} +PGDATABASE: ${POSTGRES_DB} +POSTGRES_DB: ${POSTGRES_DB} +JWT_SECRET: ${JWT_SECRET} +JWT_EXP: ${JWT_EXPIRY} +command: +[ +"postgres", +"-c", +"config_file=/etc/postgresql/postgresql.conf", +"-c", +"log_min_messages=fatal" # prevents Realtime polling queries from appearing in logs +] + +vector: +image: timberio/vector:0.28.1-alpine +restart: unless-stopped +volumes: +- ./volumes/logs/vector.yml:/etc/vector/vector.yml,z +- ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock,z +healthcheck: +test: +[ +"CMD", +"wget", +"--no-verbose", +"--tries=1", +"--spider", +"http://vector:9001/health" +] +timeout: 5s +interval: 5s +retries: 3 +environment: +LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} +command: [ "--config", "/etc/vector/vector.yml" ] +security_opt: +- "label=disable" + +Update the DATABASE_URL if you are using an external Postgres database + +supavisor: +image: supabase/supavisor:2.5.1 +restart: unless-stopped +volumes: +- ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs,z +healthcheck: +test: +[ +"CMD", +"curl", +"-sSfL", +"--head", +"-o", +"/dev/null", +"http://127.0.0.1:4000/api/health" +] +interval: 10s +timeout: 5s +retries: 5 +depends_on: +db: +condition: service_healthy +analytics: +condition: service_healthy +environment: +PORT: 4000 +POSTGRES_PORT: ${POSTGRES_PORT} +POSTGRES_DB: ${POSTGRES_DB} +POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} +DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/_supabase +CLUSTER_POSTGRES: true +SECRET_KEY_BASE: ${SECRET_KEY_BASE} +VAULT_ENC_KEY: ${VAULT_ENC_KEY} +API_JWT_SECRET: ${JWT_SECRET} +METRICS_JWT_SECRET: ${JWT_SECRET} +REGION: local +ERL_AFLAGS: -proto_dist inet_tcp +POOLER_TENANT_ID: ${POOLER_TENANT_ID} +POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE} +POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN} +POOLER_POOL_MODE: transaction +command: +[ +"/bin/sh", +"-c", +"/app/bin/migrate && /app/bin/supavisor eval "$$(cat +/etc/pooler/pooler.exs)" && /app/bin/server" +] volumes: - db-config: +db-config: From bdcc57c58aa38095d02931edff0006c327ed2c1c Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Tue, 15 Sep 2026 12:20:14 -0300 Subject: [PATCH 38/43] Update docker-compose.yml From fdbf550047ff342b46edfbcaecac432259cca5b7 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Tue, 15 Sep 2026 12:26:27 -0300 Subject: [PATCH 39/43] Update docker-compose.yml --- supabase/code/docker-compose.yml | 887 ++++++++++++++----------------- 1 file changed, 399 insertions(+), 488 deletions(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index caa0b7762..1ff776238 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -1,493 +1,404 @@ -Usage -Start: docker compose up -With helpers: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml up -Stop: docker compose down -Destroy: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans - -Reset everything: ./reset.sh - name: supabase services: - -studio: -image: supabase/studio:2025.05.19-sha-3487831 -restart: unless-stopped -healthcheck: -test: -[ -"CMD", -"node", -"-e", -"fetch('http://studio:3000/api/platform/profile').then((r) => {if -(r.status !== 200) throw new Error(r.status)})" -] -timeout: 10s -interval: 5s -retries: 3 -depends_on: -analytics: -condition: service_healthy -environment: -STUDIO_PG_META_URL: http://meta:8080 -POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - - DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION} - DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT} - OPENAI_API_KEY: ${OPENAI_API_KEY:-} - - SUPABASE_URL: http://kong:8000 - SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} - SUPABASE_ANON_KEY: ${ANON_KEY} - SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} - AUTH_JWT_SECRET: ${JWT_SECRET} - - LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} - LOGFLARE_URL: http://analytics:4000 - NEXT_PUBLIC_ENABLE_LOGS: true - # Comment to use Big Query backend for analytics - NEXT_ANALYTICS_BACKEND_PROVIDER: postgres - # Uncomment to use Big Query backend for analytics - # NEXT_ANALYTICS_BACKEND_PROVIDER: bigquery - -kong: -image: kong:2.8.1 -restart: unless-stopped -volumes: -# https://github.com/supabase/supabase/issues/12661 -- ./volumes/api/kong.yml:/home/kong/temp.yml,z -depends_on: -analytics: -condition: service_healthy -environment: -KONG_DATABASE: "off" -KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml -# https://github.com/supabase/cli/issues/14 -KONG_DNS_ORDER: LAST,A,CNAME -KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth -KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k -KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k -SUPABASE_ANON_KEY: ${ANON_KEY} -SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} -DASHBOARD_USERNAME: ${DASHBOARD_USERNAME} -DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD} -# https://unix.stackexchange.com/a/294837 -entrypoint: bash -c 'eval "echo "$$(cat ~/temp.yml)"" > ~/kong.yml && -/docker-entrypoint.sh kong docker-start' - -auth: -image: supabase/gotrue.172.1 -restart: unless-stopped -healthcheck: -test: -[ -"CMD", -"wget", -"--no-verbose", -"--tries=1", -"--spider", -"http://localhost:9999/health" -] -timeout: 5s -interval: 5s -retries: 3 -depends_on: -db: -# Disable this if you are using an external Postgres database -condition: service_healthy -analytics: -condition: service_healthy -environment: -GOTRUE_API_HOST: 0.0.0.0 -GOTRUE_API_PORT: 9999 -API_EXTERNAL_URL: ${API_EXTERNAL_URL} - - GOTRUE_DB_DRIVER: postgres - GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} - - GOTRUE_SITE_URL: ${SITE_URL} - GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS} - GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP} - - GOTRUE_JWT_ADMIN_ROLES: service_role - GOTRUE_JWT_AUD: authenticated - GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated - GOTRUE_JWT_EXP: ${JWT_EXPIRY} - GOTRUE_JWT_SECRET: ${JWT_SECRET} - - GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP} - GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS} - GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM} - - # Uncomment to bypass nonce check in ID Token flow. Commonly set to true when using Google Sign In on mobile. - # GOTRUE_EXTERNAL_SKIP_NONCE_CHECK: true - - # GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: true - # GOTRUE_SMTP_MAX_FREQUENCY: 1s - GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL} - GOTRUE_SMTP_HOST: ${SMTP_HOST} - GOTRUE_SMTP_PORT: ${SMTP_PORT} - GOTRUE_SMTP_USER: ${SMTP_USER} - GOTRUE_SMTP_PASS: ${SMTP_PASS} - GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME} - GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE} - GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION} - GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY} - GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE} - GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE: ${GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE} - GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE: ${GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE} - GOTRUE_MAILER_TEMPLATES_RECOVERY: ${GOTRUE_MAILER_TEMPLATES_RECOVERY} - GOTRUE_MAILER_SUBJECTS_RECOVERY: ${GOTRUE_MAILER_SUBJECTS_RECOVERY} - - GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP} - GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM} - # Uncomment to enable custom access token hook. Please see: https://supabase.com/docs/guides/auth/auth-hooks for full list of hooks and additional details about custom_access_token_hook - - # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true" - # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI: "pg-functions://postgres/public/custom_access_token_hook" - # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS: "" - - # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED: "true" - # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/mfa_verification_attempt" - - # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED: "true" - # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/password_verification_attempt" - - # GOTRUE_HOOK_SEND_SMS_ENABLED: "false" - # GOTRUE_HOOK_SEND_SMS_URI: "pg-functions://postgres/public/custom_access_token_hook" - # GOTRUE_HOOK_SEND_SMS_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" - - # GOTRUE_HOOK_SEND_EMAIL_ENABLED: "false" - # GOTRUE_HOOK_SEND_EMAIL_URI: "http://host.docker.internal:54321/functions/v1/email_sender" - # GOTRUE_HOOK_SEND_EMAIL_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" - -rest: -image: postgrest/postgrest.2.12 -restart: unless-stopped -depends_on: -db: -# Disable this if you are using an external Postgres database -condition: service_healthy -analytics: -condition: service_healthy -environment: -PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} -PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS} -PGRST_DB_ANON_ROLE: anon -PGRST_JWT_SECRET: ${JWT_SECRET} -PGRST_DB_USE_LEGACY_GUCS: "false" -PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET} -PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY} -command: [ "postgrest" ] - -realtime: -# This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain -image: supabase/realtime.34.47 -restart: unless-stopped -depends_on: -db: -# Disable this if you are using an external Postgres database -condition: service_healthy -analytics: -condition: service_healthy -healthcheck: -test: -[ -"CMD", -"curl", -"-sSfL", -"--head", -"-o", -"/dev/null", -"-H", -"Authorization: Bearer ${ANON_KEY}", -"http://localhost:4000/api/tenants/realtime-dev/health" -] -timeout: 5s -interval: 5s -retries: 3 -environment: -PORT: 4000 -DB_HOST: ${POSTGRES_HOST} -DB_PORT: ${POSTGRES_PORT} -DB_USER: supabase_admin -DB_PASSWORD: ${POSTGRES_PASSWORD} -DB_NAME: ${POSTGRES_DB} -DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' -DB_ENC_KEY: supabaserealtime -API_JWT_SECRET: ${JWT_SECRET} -SECRET_KEY_BASE: ${SECRET_KEY_BASE} -ERL_AFLAGS: -proto_dist inet_tcp -DNS_NODES: "''" -RLIMIT_NOFILE: "10000" -APP_NAME: realtime -SEED_SELF_HOST: true -RUN_JANITOR: true - -To use S3 backed storage: docker compose -f docker-compose.yml -f docker-compose.s3.yml up - -storage: -image: supabase/storage-api.22.17 -restart: unless-stopped -volumes: -- ./volumes/storage:/var/lib/storage -healthcheck: -test: -[ -"CMD", -"wget", -"--no-verbose", -"--tries=1", -"--spider", -"http://storage:5000/status" -] -timeout: 5s -interval: 5s -retries: 3 -depends_on: -db: -# Disable this if you are using an external Postgres database -condition: service_healthy -rest: -condition: service_started -imgproxy: -condition: service_started -environment: -ANON_KEY: ${ANON_KEY} -SERVICE_KEY: ${SERVICE_ROLE_KEY} -POSTGREST_URL: http://rest:3000 -PGRST_JWT_SECRET: ${JWT_SECRET} -DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} -FILE_SIZE_LIMIT: 52428800 -STORAGE_BACKEND: file -FILE_STORAGE_BACKEND_PATH: /var/lib/storage -TENANT_ID: ${STORAGE_TENANT_ID} -# TODO: https://github.com/supabase/storage-api/issues/55 -REGION: stub -GLOBAL_S3_BUCKET: stub -ENABLE_IMAGE_TRANSFORMATION: "true" -IMGPROXY_URL: http://imgproxy:5001 - -imgproxy: -image: darthsim/imgproxy.8.0 -restart: unless-stopped -volumes: -- ./volumes/storage:/var/lib/storage -healthcheck: -test: [ "CMD", "imgproxy", "health" ] -timeout: 5s -interval: 5s -retries: 3 -environment: -IMGPROXY_BIND: ":5001" -IMGPROXY_LOCAL_FILESYSTEM_ROOT: / -IMGPROXY_USE_ETAG: "true" -IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION} - -meta: -image: supabase/postgres-meta.89.0 -restart: unless-stopped -depends_on: -db: -# Disable this if you are using an external Postgres database -condition: service_healthy -analytics: -condition: service_healthy -environment: -PG_META_PORT: 8080 -PG_META_DB_HOST: ${POSTGRES_HOST} -PG_META_DB_PORT: ${POSTGRES_PORT} -PG_META_DB_NAME: ${POSTGRES_DB} -PG_META_DB_USER: supabase_admin -PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} - -functions: -image: supabase/edge-runtime.67.4 -restart: unless-stopped -volumes: -- ./volumes/functions:/home/deno/functions -depends_on: -analytics: -condition: service_healthy -environment: -JWT_SECRET: ${JWT_SECRET} -SUPABASE_URL: http://kong:8000 -SUPABASE_ANON_KEY: ${ANON_KEY} -SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} -MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN} -SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} -STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY} -STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} -STRIPE_BILLING_WEBHOOK_SECRET: ${STRIPE_BILLING_WEBHOOK_SECRET} -DASHBOARD_URL: ${DASHBOARD_URL} -# TODO: Allow configuring VERIFY_JWT per function. This PR might help: https://github.com/supabase/cli/pull/786 -VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" -command: [ "start", "--main-service", "/home/deno/functions/main" ] - -analytics: -image: supabase/logflare:1.12.0 -restart: unless-stopped -# Uncomment to use Big Query backend for analytics -# volumes: -# - type: bind -# source: ${PWD}/gcloud.json -# target: /opt/app/rel/logflare/bin/gcloud.json -# read_only: true -healthcheck: -test: [ "CMD", "curl", "http://localhost:4000/health" ] -timeout: 5s -interval: 5s -retries: 10 -depends_on: -db: -# Disable this if you are using an external Postgres database -condition: service_healthy -environment: -LOGFLARE_NODE_HOST: 127.0.0.1 -DB_USERNAME: supabase_admin -DB_DATABASE: _supabase -DB_HOSTNAME: ${POSTGRES_HOST} -DB_PORT: ${POSTGRES_PORT} -DB_PASSWORD: ${POSTGRES_PASSWORD} -DB_SCHEMA: _analytics -LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} -LOGFLARE_SINGLE_TENANT: true -LOGFLARE_SUPABASE_MODE: true -LOGFLARE_MIN_CLUSTER_SIZE: 1 - - # Comment variables to use Big Query backend for analytics - POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase - POSTGRES_BACKEND_SCHEMA: _analytics - LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true - # Uncomment to use Big Query backend for analytics - # GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID} - # GOOGLE_PROJECT_NUMBER: ${GOOGLE_PROJECT_NUMBER} - - # Comment out everything below this point if you are using an external Postgres database - -db: -image: supabase/postgres:15.8.1.060 -restart: unless-stopped -volumes: -- ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql -# Must be superuser to create event trigger -- ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql -# Must be superuser to alter reserved role -- ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql -# Initialize the database settings with JWT_SECRET and JWT_EXP -- ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql -# PGDATA directory is persisted between restarts -- ./volumes/db/data:/var/lib/postgresql/data -# Changes required for internal supabase data such as _analytics -- ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql -# Changes required for Analytics support -- ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql -# Changes required for Pooler support -- ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql -# Use named volume to persist pgsodium decryption key between restarts -- db-config:/etc/postgresql-custom -healthcheck: -test: [ "CMD", "pg_isready", "-U", "postgres", "-h", "localhost" ] -interval: 5s -timeout: 5s -retries: 10 -depends_on: -vector: -condition: service_healthy -environment: -POSTGRES_HOST: /var/run/postgresql -PGPORT: ${POSTGRES_PORT} -POSTGRES_PORT: ${POSTGRES_PORT} -PGPASSWORD: ${POSTGRES_PASSWORD} -POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} -PGDATABASE: ${POSTGRES_DB} -POSTGRES_DB: ${POSTGRES_DB} -JWT_SECRET: ${JWT_SECRET} -JWT_EXP: ${JWT_EXPIRY} -command: -[ -"postgres", -"-c", -"config_file=/etc/postgresql/postgresql.conf", -"-c", -"log_min_messages=fatal" # prevents Realtime polling queries from appearing in logs -] - -vector: -image: timberio/vector:0.28.1-alpine -restart: unless-stopped -volumes: -- ./volumes/logs/vector.yml:/etc/vector/vector.yml,z -- ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock,z -healthcheck: -test: -[ -"CMD", -"wget", -"--no-verbose", -"--tries=1", -"--spider", -"http://vector:9001/health" -] -timeout: 5s -interval: 5s -retries: 3 -environment: -LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} -command: [ "--config", "/etc/vector/vector.yml" ] -security_opt: -- "label=disable" - -Update the DATABASE_URL if you are using an external Postgres database - -supavisor: -image: supabase/supavisor:2.5.1 -restart: unless-stopped -volumes: -- ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs,z -healthcheck: -test: -[ -"CMD", -"curl", -"-sSfL", -"--head", -"-o", -"/dev/null", -"http://127.0.0.1:4000/api/health" -] -interval: 10s -timeout: 5s -retries: 5 -depends_on: -db: -condition: service_healthy -analytics: -condition: service_healthy -environment: -PORT: 4000 -POSTGRES_PORT: ${POSTGRES_PORT} -POSTGRES_DB: ${POSTGRES_DB} -POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} -DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/_supabase -CLUSTER_POSTGRES: true -SECRET_KEY_BASE: ${SECRET_KEY_BASE} -VAULT_ENC_KEY: ${VAULT_ENC_KEY} -API_JWT_SECRET: ${JWT_SECRET} -METRICS_JWT_SECRET: ${JWT_SECRET} -REGION: local -ERL_AFLAGS: -proto_dist inet_tcp -POOLER_TENANT_ID: ${POOLER_TENANT_ID} -POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE} -POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN} -POOLER_POOL_MODE: transaction -command: -[ -"/bin/sh", -"-c", -"/app/bin/migrate && /app/bin/supavisor eval "$$(cat -/etc/pooler/pooler.exs)" && /app/bin/server" -] + studio: + image: supabase/studio:2025.05.19-sha-3487831 + restart: unless-stopped + healthcheck: + test: + - CMD + - node + - -e + - "fetch('http://studio:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})" + timeout: 10s + interval: 5s + retries: 3 + depends_on: + analytics: + condition: service_healthy + environment: + STUDIO_PG_META_URL: http://meta:8080 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION} + DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + AUTH_JWT_SECRET: ${JWT_SECRET} + LOGFLARE_API_KEY: supabase-internal-logflare-key + LOGFLARE_URL: http://analytics:4000 + NEXT_PUBLIC_ENABLE_LOGS: "true" + NEXT_ANALYTICS_BACKEND_PROVIDER: postgres + + kong: + image: kong:2.8.1 + restart: unless-stopped + volumes: + - ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z + depends_on: + analytics: + condition: service_healthy + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml + KONG_DNS_ORDER: LAST,A,CNAME + KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth + KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k + KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + DASHBOARD_USERNAME: ${DASHBOARD_USERNAME} + DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD} + entrypoint: >- + bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && + /docker-entrypoint.sh kong docker-start' + + auth: + image: supabase/gotrue:v2.172.1 + restart: unless-stopped + healthcheck: + test: + - CMD + - wget + - --no-verbose + - --tries=1 + - --spider + - http://localhost:9999/health + timeout: 5s + interval: 5s + retries: 3 + depends_on: + db: + condition: service_healthy + analytics: + condition: service_healthy + environment: + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: 9999 + API_EXTERNAL_URL: ${API_EXTERNAL_URL} + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + GOTRUE_SITE_URL: ${SITE_URL} + GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS} + GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP} + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: ${JWT_EXPIRY} + GOTRUE_JWT_SECRET: ${JWT_SECRET} + GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP} + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS} + GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM} + GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL} + GOTRUE_SMTP_HOST: ${SMTP_HOST} + GOTRUE_SMTP_PORT: ${SMTP_PORT} + GOTRUE_SMTP_USER: ${SMTP_USER} + GOTRUE_SMTP_PASS: ${SMTP_PASS} + GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME} + GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE} + GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION} + GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY} + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE} + GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP} + GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM} + + rest: + image: postgrest/postgrest:v12.2.12 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + analytics: + condition: service_healthy + environment: + PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS} + PGRST_DB_ANON_ROLE: anon + PGRST_JWT_SECRET: ${JWT_SECRET} + PGRST_DB_USE_LEGACY_GUCS: "false" + PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET} + PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY} + command: + - postgrest + + realtime: + image: supabase/realtime:v2.34.47 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + analytics: + condition: service_healthy + healthcheck: + test: + - CMD + - curl + - -sSfL + - --head + - -o + - /dev/null + - -H + - "Authorization: Bearer ${ANON_KEY}" + - http://localhost:4000/api/tenants/realtime-dev/health + timeout: 5s + interval: 5s + retries: 3 + environment: + PORT: 4000 + DB_HOST: ${POSTGRES_HOST} + DB_PORT: ${POSTGRES_PORT} + DB_USER: supabase_admin + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_NAME: ${POSTGRES_DB} + DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime" + DB_ENC_KEY: supabaserealtime + API_JWT_SECRET: ${JWT_SECRET} + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + ERL_AFLAGS: -proto_dist inet_tcp + DNS_NODES: "''" + RLIMIT_NOFILE: "10000" + APP_NAME: realtime + SEED_SELF_HOST: "true" + RUN_JANITOR: "true" + + storage: + image: supabase/storage-api:v1.22.17 + restart: unless-stopped + volumes: + - ./volumes/storage:/var/lib/storage + healthcheck: + test: + - CMD + - wget + - --no-verbose + - --tries=1 + - --spider + - http://storage:5000/status + timeout: 5s + interval: 5s + retries: 3 + depends_on: + db: + condition: service_healthy + rest: + condition: service_started + imgproxy: + condition: service_started + environment: + ANON_KEY: ${ANON_KEY} + SERVICE_KEY: ${SERVICE_ROLE_KEY} + POSTGREST_URL: http://rest:3000 + PGRST_JWT_SECRET: ${JWT_SECRET} + DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + FILE_SIZE_LIMIT: "52428800" + STORAGE_BACKEND: file + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: ${STORAGE_TENANT_ID} + REGION: stub + GLOBAL_S3_BUCKET: stub + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:5001 + + imgproxy: + image: darthsim/imgproxy:v3.8.0 + restart: unless-stopped + volumes: + - ./volumes/storage:/var/lib/storage + healthcheck: + test: + - CMD + - imgproxy + - health + timeout: 5s + interval: 5s + retries: 3 + environment: + IMGPROXY_BIND: ":5001" + IMGPROXY_LOCAL_FILESYSTEM_ROOT: / + IMGPROXY_USE_ETAG: "true" + IMGPROXY_ENABLE_WEBP_DETECTION: "true" + + meta: + image: supabase/postgres-meta:v0.89.0 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + analytics: + condition: service_healthy + environment: + PG_META_PORT: 8080 + PG_META_DB_HOST: ${POSTGRES_HOST} + PG_META_DB_PORT: ${POSTGRES_PORT} + PG_META_DB_NAME: ${POSTGRES_DB} + PG_META_DB_USER: supabase_admin + PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} + + functions: + image: supabase/edge-runtime:v1.67.4 + restart: unless-stopped + volumes: + - ./volumes/functions:/home/deno/functions + depends_on: + analytics: + condition: service_healthy + environment: + JWT_SECRET: ${JWT_SECRET} + SUPABASE_URL: http://kong:8000 + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} + MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN} + SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" + command: + - start + - --main-service + - /home/deno/functions/main + + analytics: + image: supabase/logflare:1.12.0 + restart: unless-stopped + healthcheck: + test: + - CMD + - curl + - http://localhost:4000/health + timeout: 5s + interval: 5s + retries: 10 + depends_on: + db: + condition: service_healthy + environment: + LOGFLARE_NODE_HOST: 127.0.0.1 + DB_USERNAME: supabase_admin + DB_DATABASE: _supabase + DB_HOSTNAME: ${POSTGRES_HOST} + DB_PORT: ${POSTGRES_PORT} + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_SCHEMA: _analytics + LOGFLARE_API_KEY: supabase-internal-logflare-key + LOGFLARE_SINGLE_TENANT: "true" + LOGFLARE_SUPABASE_MODE: "true" + LOGFLARE_MIN_CLUSTER_SIZE: "1" + POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase + POSTGRES_BACKEND_SCHEMA: _analytics + LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true + + db: + image: supabase/postgres:15.8.1.060 + restart: unless-stopped + volumes: + - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql + - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql + - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql + - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql + - ./volumes/db/data:/var/lib/postgresql/data + - ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql + - ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql + - ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql + - db-config:/etc/postgresql-custom + healthcheck: + test: + - CMD + - pg_isready + - -U + - postgres + - -h + - localhost + interval: 5s + timeout: 5s + retries: 10 + depends_on: + vector: + condition: service_healthy + environment: + POSTGRES_HOST: /var/run/postgresql + PGPORT: ${POSTGRES_PORT} + POSTGRES_PORT: ${POSTGRES_PORT} + PGPASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + PGDATABASE: ${POSTGRES_DB} + POSTGRES_DB: ${POSTGRES_DB} + JWT_SECRET: ${JWT_SECRET} + JWT_EXP: ${JWT_EXPIRY} + command: + - postgres + - -c + - config_file=/etc/postgresql/postgresql.conf + - -c + - log_min_messages=fatal + + vector: + image: timberio/vector:0.28.1-alpine + restart: unless-stopped + volumes: + - ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z + - ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:z + healthcheck: + test: + - CMD + - wget + - --no-verbose + - --tries=1 + - --spider + - http://vector:9001/health + timeout: 5s + interval: 5s + retries: 3 + environment: + LOGFLARE_API_KEY: supabase-internal-logflare-key + command: + - --config + - /etc/vector/vector.yml + security_opt: + - label=disable + + supavisor: + image: supabase/supavisor:2.5.1 + restart: unless-stopped + volumes: + - ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z + healthcheck: + test: + - CMD + - curl + - -sSfL + - --head + - -o + - /dev/null + - http://127.0.0.1:4000/api/health + interval: 10s + timeout: 5s + retries: 5 + depends_on: + db: + condition: service_healthy + analytics: + condition: service_healthy + environment: + PORT: 4000 + POSTGRES_PORT: ${POSTGRES_PORT} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/_supabase + CLUSTER_POSTGRES: "true" + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + VAULT_ENC_KEY: ${VAULT_ENC_KEY} + API_JWT_SECRET: ${JWT_SECRET} + METRICS_JWT_SECRET: ${JWT_SECRET} + REGION: local + ERL_AFLAGS: -proto_dist inet_tcp + POOLER_TENANT_ID: ${POOLER_TENANT_ID} + POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE} + POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN} + POOLER_POOL_MODE: transaction + command: + - /bin/sh + - -c + - /app/bin/migrate && /app/bin/supavisor eval "$$(cat /etc/pooler/pooler.exs)" && /app/bin/server volumes: -db-config: + db-config: From ab37ccb269b10f6187cba4ae66edea90e4e958b7 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Wed, 16 Sep 2026 10:39:38 -0300 Subject: [PATCH 40/43] Create index.ts --- .../code/volumes/functions/criar-pix/index.ts | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 supabase/code/volumes/functions/criar-pix/index.ts diff --git a/supabase/code/volumes/functions/criar-pix/index.ts b/supabase/code/volumes/functions/criar-pix/index.ts new file mode 100644 index 000000000..c26727eda --- /dev/null +++ b/supabase/code/volumes/functions/criar-pix/index.ts @@ -0,0 +1,130 @@ +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"); + + if (!WOOVI_APP_ID) { + throw new Error("WOOVI_APP_ID não configurado"); + } + + 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", + }, + } + ); + } + + // Woovi trabalha em centavos + const valorCentavos = Math.round(valorNumero * 100); + + // Identificador único da cobrança + const correlationID = crypto.randomUUID(); + + const payload = { + correlationID, + value: valorCentavos, + comment: nome + ? `Doação - ${nome}` + : "Doação - Campanha de arrecadação", + }; + + const response = 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(payload), + } + ); + + const data = await response.json(); + + if (!response.ok) { + console.error("Erro Woovi:", data); + + return new Response( + JSON.stringify({ + erro: "Não foi possível gerar o PIX.", + detalhes: data, + }), + { + status: response.status, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + const charge = data.charge; + + if (!charge?.brCode) { + console.error("Resposta inesperada Woovi:", data); + throw new Error("Woovi não retornou o código PIX."); + } + + return new Response( + JSON.stringify({ + payment_id: correlationID, + + // Mantemos estes nomes porque seu HTML atual já espera isso. + qr_code: charge.brCode, + qr_code_base64: null, + + // Dados adicionais da Woovi + qr_code_image: charge.qrCodeImage, + payment_link: charge.paymentLinkUrl, + status: charge.status, + correlation_id: correlationID, + }), + { + 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", + }, + } + ); + } +}); From 3d7b73dc352656ddb60f84a51199a46ba3d5b52c Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Wed, 16 Sep 2026 10:43:41 -0300 Subject: [PATCH 41/43] Update docker-compose.yml --- supabase/code/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index 1ff776238..cc830c97b 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -252,6 +252,7 @@ services: MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN} SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" + WOOVI_APP_ID: ${WOOVI_APP_ID} command: - start - --main-service From d7b71c99a6ff52cc2a734b9ac3c34722b5a94fb6 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Wed, 16 Sep 2026 10:48:33 -0300 Subject: [PATCH 42/43] Create index.ts --- .../volumes/functions/status-pix/index.ts | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 supabase/code/volumes/functions/status-pix/index.ts diff --git a/supabase/code/volumes/functions/status-pix/index.ts b/supabase/code/volumes/functions/status-pix/index.ts new file mode 100644 index 000000000..f12b4c69d --- /dev/null +++ b/supabase/code/volumes/functions/status-pix/index.ts @@ -0,0 +1,136 @@ +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": "GET, POST, OPTIONS", + }; + + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + try { + const WOOVI_APP_ID = Deno.env.get("WOOVI_APP_ID"); + + if (!WOOVI_APP_ID) { + throw new Error("WOOVI_APP_ID não configurado"); + } + + let paymentId = ""; + + // Aceita tanto POST com JSON quanto GET com query string + if (req.method === "POST") { + const body = await req.json(); + + paymentId = + body.payment_id || + body.correlation_id || + body.correlationID || + ""; + } else { + const url = new URL(req.url); + + paymentId = + url.searchParams.get("payment_id") || + url.searchParams.get("correlation_id") || + ""; + } + + if (!paymentId) { + return new Response( + JSON.stringify({ + erro: "payment_id não informado", + }), + { + status: 400, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + const response = await fetch( + `https://api.woovi.com/api/v1/charge/${encodeURIComponent(paymentId)}`, + { + method: "GET", + headers: { + Authorization: WOOVI_APP_ID, + Accept: "application/json", + }, + } + ); + + const data = await response.json(); + + if (!response.ok) { + console.error("Erro consulta Woovi:", data); + + return new Response( + JSON.stringify({ + erro: "Não foi possível consultar o PIX.", + detalhes: data, + }), + { + status: response.status, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + const charge = data.charge; + + if (!charge) { + throw new Error("Cobrança não encontrada na resposta da Woovi."); + } + + const statusWoovi = String(charge.status || "").toUpperCase(); + + // Mantemos os nomes que o seu HTML já utilizava com Mercado Pago + let status = "pending"; + + if (statusWoovi === "COMPLETED") { + status = "approved"; + } else if (statusWoovi === "EXPIRED") { + status = "expired"; + } + + return new Response( + JSON.stringify({ + status, + status_woovi: statusWoovi, + payment_id: paymentId, + correlation_id: charge.correlationID || paymentId, + paid_at: charge.paidAt || null, + }), + { + status: 200, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } catch (error) { + console.error("Erro status-pix:", error); + + return new Response( + JSON.stringify({ + erro: "Erro interno ao consultar PIX.", + detalhes: String(error), + }), + { + status: 500, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } +}); From f946fb47952ab60271f1bcf113990a9e6e8f3c86 Mon Sep 17 00:00:00 2001 From: Tailan Fontenele Date: Wed, 16 Sep 2026 10:55:49 -0300 Subject: [PATCH 43/43] Add Supabase configuration and error handling --- .../code/volumes/functions/criar-pix/index.ts | 123 ++++++++++++++---- 1 file changed, 97 insertions(+), 26 deletions(-) diff --git a/supabase/code/volumes/functions/criar-pix/index.ts b/supabase/code/volumes/functions/criar-pix/index.ts index c26727eda..ead7dd93b 100644 --- a/supabase/code/volumes/functions/criar-pix/index.ts +++ b/supabase/code/volumes/functions/criar-pix/index.ts @@ -12,18 +12,26 @@ Deno.serve(async (req) => { 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." }), + JSON.stringify({ + erro: "Valor da doação inválido.", + }), { status: 400, headers: { @@ -34,21 +42,14 @@ Deno.serve(async (req) => { ); } - // Woovi trabalha em centavos const valorCentavos = Math.round(valorNumero * 100); - - // Identificador único da cobrança const correlationID = crypto.randomUUID(); - const payload = { - correlationID, - value: valorCentavos, - comment: nome - ? `Doação - ${nome}` - : "Doação - Campanha de arrecadação", - }; + // ============================ + // CRIAR COBRANÇA NA WOOVI + // ============================ - const response = await fetch( + const wooviResponse = await fetch( "https://api.woovi.com/api/v1/charge", { method: "POST", @@ -57,22 +58,28 @@ Deno.serve(async (req) => { "Content-Type": "application/json", Accept: "application/json", }, - body: JSON.stringify(payload), + body: JSON.stringify({ + correlationID, + value: valorCentavos, + comment: nome + ? `Doação - ${nome}` + : "Doação - Campanha de arrecadação", + }), } ); - const data = await response.json(); + const wooviData = await wooviResponse.json(); - if (!response.ok) { - console.error("Erro Woovi:", data); + if (!wooviResponse.ok) { + console.error("Erro Woovi:", wooviData); return new Response( JSON.stringify({ erro: "Não foi possível gerar o PIX.", - detalhes: data, + detalhes: wooviData, }), { - status: response.status, + status: wooviResponse.status, headers: { ...corsHeaders, "Content-Type": "application/json", @@ -81,26 +88,90 @@ Deno.serve(async (req) => { ); } - const charge = data.charge; + const charge = wooviData.charge; if (!charge?.brCode) { - console.error("Resposta inesperada Woovi:", data); + 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, - // Mantemos estes nomes porque seu HTML atual já espera isso. qr_code: charge.brCode, + qr_code_base64: null, - // Dados adicionais da Woovi - qr_code_image: charge.qrCodeImage, - payment_link: charge.paymentLinkUrl, - status: charge.status, - correlation_id: correlationID, + qr_code_image: + charge.qrCodeImage || null, + + payment_link: + charge.paymentLinkUrl || null, + + status: "pending", + status_woovi: + String(charge.status || "").toUpperCase(), }), { status: 200,