diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index a8d8584d9..cc830c97b 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -1,26 +1,15 @@ -# 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)})" - ] + - 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 @@ -30,30 +19,23 @@ services: 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_API_KEY: supabase-internal-logflare-key LOGFLARE_URL: http://analytics:4000 - NEXT_PUBLIC_ENABLE_LOGS: true - # Comment to use Big Query backend for analytics + 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: @@ -61,7 +43,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 @@ -70,8 +51,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: @@ -79,20 +60,17 @@ services: restart: unless-stopped healthcheck: test: - [ - "CMD", - "wget", - "--no-verbose", - "--tries=1", - "--spider", - "http://localhost:9999/health" - ] + - 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 @@ -100,29 +78,19 @@ services: 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} @@ -133,35 +101,14 @@ 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_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: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 @@ -173,31 +120,28 @@ services: PGRST_DB_USE_LEGACY_GUCS: "false" PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET} PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY} - command: [ "postgrest" ] + 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 healthcheck: test: - [ - "CMD", - "curl", - "-sSfL", - "--head", - "-o", - "/dev/null", - "-H", - "Authorization: Bearer ${ANON_KEY}", - "http://localhost:4000/api/tenants/realtime-dev/health" - ] + - 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 @@ -208,7 +152,7 @@ services: DB_USER: supabase_admin DB_PASSWORD: ${POSTGRES_PASSWORD} DB_NAME: ${POSTGRES_DB} - DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' + DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime" DB_ENC_KEY: supabaserealtime API_JWT_SECRET: ${JWT_SECRET} SECRET_KEY_BASE: ${SECRET_KEY_BASE} @@ -216,31 +160,27 @@ services: DNS_NODES: "''" RLIMIT_NOFILE: "10000" APP_NAME: realtime - SEED_SELF_HOST: true - RUN_JANITOR: true + 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 volumes: - - ./volumes/storage:/var/lib/storage:z + - ./volumes/storage:/var/lib/storage healthcheck: test: - [ - "CMD", - "wget", - "--no-verbose", - "--tries=1", - "--spider", - "http://storage:5000/status" - ] + - 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 @@ -252,11 +192,10 @@ services: 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 + FILE_SIZE_LIMIT: "52428800" STORAGE_BACKEND: file FILE_STORAGE_BACKEND_PATH: /var/lib/storage - TENANT_ID: stub - # TODO: https://github.com/supabase/storage-api/issues/55 + TENANT_ID: ${STORAGE_TENANT_ID} REGION: stub GLOBAL_S3_BUCKET: stub ENABLE_IMAGE_TRANSFORMATION: "true" @@ -266,9 +205,12 @@ services: image: darthsim/imgproxy:v3.8.0 restart: unless-stopped volumes: - - ./volumes/storage:/var/lib/storage:z + - ./volumes/storage:/var/lib/storage healthcheck: - test: [ "CMD", "imgproxy", "health" ] + test: + - CMD + - imgproxy + - health timeout: 5s interval: 5s retries: 3 @@ -276,14 +218,13 @@ services: IMGPROXY_BIND: ":5001" IMGPROXY_LOCAL_FILESYSTEM_ROOT: / IMGPROXY_USE_ETAG: "true" - IMGPROXY_ENABLE_WEBP_DETECTION: ${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 @@ -299,7 +240,7 @@ services: image: supabase/edge-runtime:v1.67.4 restart: unless-stopped volumes: - - ./volumes/functions:/home/deno/functions:Z + - ./volumes/functions:/home/deno/functions depends_on: analytics: condition: service_healthy @@ -308,28 +249,28 @@ 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 VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" - command: [ "start", "--main-service", "/home/deno/functions/main" ] + WOOVI_APP_ID: ${WOOVI_APP_ID} + 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" ] + 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 @@ -339,43 +280,35 @@ 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 + 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 - # 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 + - ./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" ] + test: + - CMD + - pg_isready + - -U + - postgres + - -h + - localhost interval: 5s timeout: 5s retries: 10 @@ -393,40 +326,37 @@ services: 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 - ] + - 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:ro,z + - ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:z healthcheck: test: - [ - "CMD", - "wget", - "--no-verbose", - "--tries=1", - "--spider", - "http://vector:9001/health" - ] + - 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" ] + LOGFLARE_API_KEY: supabase-internal-logflare-key + command: + - --config + - /etc/vector/vector.yml security_opt: - - "label=disable" + - label=disable - # Update the DATABASE_URL if you are using an external Postgres database supavisor: image: supabase/supavisor:2.5.1 restart: unless-stopped @@ -434,15 +364,13 @@ services: - ./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" - ] + - CMD + - curl + - -sSfL + - --head + - -o + - /dev/null + - http://127.0.0.1:4000/api/health interval: 10s timeout: 5s retries: 5 @@ -457,7 +385,7 @@ services: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/_supabase - CLUSTER_POSTGRES: true + CLUSTER_POSTGRES: "true" SECRET_KEY_BASE: ${SECRET_KEY_BASE} VAULT_ENC_KEY: ${VAULT_ENC_KEY} API_JWT_SECRET: ${JWT_SECRET} @@ -469,12 +397,9 @@ services: 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" - ] + - /bin/sh + - -c + - /app/bin/migrate && /app/bin/supavisor eval "$$(cat /etc/pooler/pooler.exs)" && /app/bin/server volumes: db-config: 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(''); 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]; 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/criar-pix/index.ts b/supabase/code/volumes/functions/criar-pix/index.ts new file mode 100644 index 000000000..ead7dd93b --- /dev/null +++ b/supabase/code/volumes/functions/criar-pix/index.ts @@ -0,0 +1,201 @@ +Deno.serve(async (req) => { + const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", + }; + + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + try { + const WOOVI_APP_ID = Deno.env.get("WOOVI_APP_ID"); + const SUPABASE_URL = Deno.env.get("SUPABASE_URL"); + const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + + if (!WOOVI_APP_ID) { + throw new Error("WOOVI_APP_ID não configurado"); + } + + if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { + throw new Error("Configuração interna do Supabase não encontrada"); + } + + const { valor, nome } = await req.json(); + + const valorNumero = Number(valor); + + if (!valorNumero || valorNumero <= 0) { + return new Response( + JSON.stringify({ + erro: "Valor da doação inválido.", + }), + { + status: 400, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + const valorCentavos = Math.round(valorNumero * 100); + const correlationID = crypto.randomUUID(); + + // ============================ + // CRIAR COBRANÇA NA WOOVI + // ============================ + + const wooviResponse = await fetch( + "https://api.woovi.com/api/v1/charge", + { + method: "POST", + headers: { + Authorization: WOOVI_APP_ID, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + correlationID, + value: valorCentavos, + comment: nome + ? `Doação - ${nome}` + : "Doação - Campanha de arrecadação", + }), + } + ); + + const wooviData = await wooviResponse.json(); + + if (!wooviResponse.ok) { + console.error("Erro Woovi:", wooviData); + + return new Response( + JSON.stringify({ + erro: "Não foi possível gerar o PIX.", + detalhes: wooviData, + }), + { + status: wooviResponse.status, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + const charge = wooviData.charge; + + if (!charge?.brCode) { + console.error("Resposta inesperada Woovi:", wooviData); + throw new Error("Woovi não retornou o código PIX."); + } + + // ============================ + // SALVAR NA TABELA DOACOES + // ============================ + + const bancoResponse = await fetch( + `${SUPABASE_URL}/rest/v1/doacoes`, + { + method: "POST", + headers: { + apikey: SERVICE_ROLE_KEY, + Authorization: `Bearer ${SERVICE_ROLE_KEY}`, + "Content-Type": "application/json", + Prefer: "return=representation", + }, + body: JSON.stringify({ + mp_payment_id: correlationID, + valor: valorNumero, + status: "pending", + nome_doador: nome || null, + email_doador: null, + qr_code: charge.brCode, + qr_code_base64: null, + }), + } + ); + + const bancoTexto = await bancoResponse.text(); + + if (!bancoResponse.ok) { + console.error( + "PIX criado, mas erro ao salvar no banco:", + bancoTexto + ); + + return new Response( + JSON.stringify({ + erro: "PIX criado, mas não foi possível registrar a doação.", + detalhes: bancoTexto, + payment_id: correlationID, + }), + { + status: 500, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + console.log( + "Cobrança criada e registrada:", + correlationID + ); + + // ============================ + // RETORNO PARA O SITE + // ============================ + + return new Response( + JSON.stringify({ + payment_id: correlationID, + correlation_id: correlationID, + + qr_code: charge.brCode, + + qr_code_base64: null, + + qr_code_image: + charge.qrCodeImage || null, + + payment_link: + charge.paymentLinkUrl || null, + + status: "pending", + status_woovi: + String(charge.status || "").toUpperCase(), + }), + { + status: 200, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } catch (error) { + console.error("Erro criar-pix:", error); + + return new Response( + JSON.stringify({ + erro: "Erro interno ao gerar PIX.", + detalhes: String(error), + }), + { + status: 500, + headers: { + "Access-Control-Allow-Origin": "*", + "Content-Type": "application/json", + }, + } + ); + } +}); 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..e2648d82d --- /dev/null +++ b/supabase/code/volumes/functions/saas-stripe-create-checkout/index.ts @@ -0,0 +1,79 @@ +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 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 {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 publishableKey = secret('STRIPE_PUBLISHABLE_KEY'); + if (!publishableKey) throw new Error('STRIPE_PUBLISHABLE_KEY ausente'); + const body = new URLSearchParams(); + body.set('mode','subscription'); + 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); + 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)); + + // 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); + 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); + } +}); 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);} +}); + 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..ed7a360e2 --- /dev/null +++ b/supabase/code/volumes/functions/saas-stripe-webhook/index.ts @@ -0,0 +1,89 @@ +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 + : 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){ + 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); + 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 + }; + 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); + } + } + } + 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); + } +}); 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..04ef6d406 --- /dev/null +++ b/supabase/code/volumes/functions/send-renewal-reminders/index.ts @@ -0,0 +1,164 @@ +// 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"; +import { internationalPhone } from "../_shared/wa-common.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(); +} + +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, 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), 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, 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 || sub.trial_ends_at); + if (!relevantDate) continue; + const dueDate = new Date(relevantDate); + 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; + 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), + sub.companies?.country_code || "BR", + ); + 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, billing: billingResult || null, billing_error: billingError?.message || null }); + } 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/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", + }, + } + ); + } +}); 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);} +});