From 990117cf74275b61eae879d0dd68a37308937f29 Mon Sep 17 00:00:00 2001 From: Josue Sanchez Date: Wed, 5 Aug 2026 11:22:37 -0400 Subject: [PATCH 1/4] Add edge functions from Supabase project laxdgacdiuygemctnewg Copia literal de las 14 edge functions activas del proyecto hosted a volumes/functions/, una carpeta por funcion. El nombre de cada carpeta es el slug de la funcion, que es lo que resuelve el router main/index.ts del edge-runtime self-hosted. Nota: create-event corresponde a la funcion cuyo nombre visible en el dashboard es send-event-notification. Co-Authored-By: Claude Opus 5 (1M context) --- .../functions/clip-all-coupons/index.ts | 59 +++ .../code/volumes/functions/clip-all/index.ts | 69 ++++ .../volumes/functions/clip-coupon/index.ts | 86 +++++ .../volumes/functions/create-event/index.ts | 159 +++++++++ .../volumes/functions/delete-account/index.ts | 67 ++++ .../generate-google-wallet-pass/index.ts | 236 ++++++++++++ .../functions/generate-wallet-pass/index.ts | 292 +++++++++++++++ .../code/volumes/functions/get-turn/index.ts | 103 ++++++ .../functions/loyalty-barcodes/index.ts | 122 +++++++ .../functions/notify-queue-position/index.ts | 173 +++++++++ .../volumes/functions/queue-admin/index.ts | 336 ++++++++++++++++++ .../volumes/functions/scrape-coupons/index.ts | 59 +++ .../functions/send-notification/index.ts | 150 ++++++++ .../code/volumes/functions/take-turn/index.ts | 99 ++++++ 14 files changed, 2010 insertions(+) create mode 100644 supabase/code/volumes/functions/clip-all-coupons/index.ts create mode 100644 supabase/code/volumes/functions/clip-all/index.ts create mode 100644 supabase/code/volumes/functions/clip-coupon/index.ts create mode 100644 supabase/code/volumes/functions/create-event/index.ts create mode 100644 supabase/code/volumes/functions/delete-account/index.ts create mode 100644 supabase/code/volumes/functions/generate-google-wallet-pass/index.ts create mode 100644 supabase/code/volumes/functions/generate-wallet-pass/index.ts create mode 100644 supabase/code/volumes/functions/get-turn/index.ts create mode 100644 supabase/code/volumes/functions/loyalty-barcodes/index.ts create mode 100644 supabase/code/volumes/functions/notify-queue-position/index.ts create mode 100644 supabase/code/volumes/functions/queue-admin/index.ts create mode 100644 supabase/code/volumes/functions/scrape-coupons/index.ts create mode 100644 supabase/code/volumes/functions/send-notification/index.ts create mode 100644 supabase/code/volumes/functions/take-turn/index.ts diff --git a/supabase/code/volumes/functions/clip-all-coupons/index.ts b/supabase/code/volumes/functions/clip-all-coupons/index.ts new file mode 100644 index 000000000..a5ccb509d --- /dev/null +++ b/supabase/code/volumes/functions/clip-all-coupons/index.ts @@ -0,0 +1,59 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +const API_BASE_URL = 'https://food-universe-api-scrap.onrender.com'; + +serve(async (req) => { + // Handle CORS preflight requests + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const { store_id, loyalty_card } = await req.json(); + + if (!store_id || !loyalty_card) { + return new Response( + JSON.stringify({ error: 'Missing required parameters: store_id, loyalty_card' }), + { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + } + ); + } + + console.log(`Clipping all coupons for loyalty card ${loyalty_card}`); + + const response = await fetch( + `${API_BASE_URL}/api/clipOffers?store_id=${store_id}&loyalty_card=${loyalty_card}`, + { method: "GET" } + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + console.log('Clip all coupons result:', result); + + return new Response(JSON.stringify(result), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } catch (error) { + console.error('Error clipping all coupons:', error); + return new Response( + JSON.stringify({ + error: error.message || 'Failed to clip all coupons', + success: false + }), + { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } +}); diff --git a/supabase/code/volumes/functions/clip-all/index.ts b/supabase/code/volumes/functions/clip-all/index.ts new file mode 100644 index 000000000..87a685df5 --- /dev/null +++ b/supabase/code/volumes/functions/clip-all/index.ts @@ -0,0 +1,69 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +const API_BASE_URL = 'https://xcirculars-coupons-api.7panny.easypanel.host'; + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const { store_id, phone_number } = await req.json(); + + if (!store_id || !phone_number) { + return new Response( + JSON.stringify({ error: 'Missing required parameters: store_id, phone_number' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + console.log(`Clipping all coupons: store_id=${store_id}`); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 110000); + + let response; + try { + response = await fetch(`${API_BASE_URL}/clip`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ store_id, phone_number }), + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + const text = await response.text(); + let result; + try { + result = JSON.parse(text); + } catch { + result = { error: text, success: false }; + } + + console.log('clip-all response:', JSON.stringify(result)); + + return new Response(JSON.stringify(result), { + status: response.ok ? 200 : response.status, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } catch (error) { + const isTimeout = error.name === 'AbortError'; + console.error('Error clipping all coupons:', error); + return new Response( + JSON.stringify({ + error: isTimeout + ? 'External API timeout — service may be starting up, try again' + : (error.message || 'Failed to clip all coupons'), + success: false, + }), + { status: isTimeout ? 504 : 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } +}); diff --git a/supabase/code/volumes/functions/clip-coupon/index.ts b/supabase/code/volumes/functions/clip-coupon/index.ts new file mode 100644 index 000000000..5f26fefec --- /dev/null +++ b/supabase/code/volumes/functions/clip-coupon/index.ts @@ -0,0 +1,86 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +const API_BASE_URL = 'https://xcirculars-coupons-api.7panny.easypanel.host'; + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const { store_id, offer_id, phone_number, job_id, otp_code, first_name, last_name, email } = await req.json(); + + if (!store_id || !offer_id || !job_id) { + return new Response( + JSON.stringify({ error: 'Missing required parameters: store_id, offer_id, job_id' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + if (!phone_number) { + return new Response( + JSON.stringify({ error: 'Missing required parameter: phone_number' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + console.log(`Clipping coupon: offer_id=${offer_id}, store_id=${store_id}, job_id=${job_id}, has_otp=${!!otp_code}`); + + const payload: Record = { store_id, offer_id, phone_number, job_id }; + if (otp_code) { + payload.otp_code = otp_code; + if (first_name) payload.first_name = first_name; + if (last_name) payload.last_name = last_name; + if (email) payload.email = email; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 110000); + + let response; + try { + response = await fetch(`${API_BASE_URL}/clip-single`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + const text = await response.text(); + let result; + try { + result = JSON.parse(text); + } catch { + result = { error: text, success: false }; + } + + console.log('clip-single response:', JSON.stringify(result)); + + const status = result.auth_required ? 200 : (response.ok ? 200 : response.status); + + return new Response(JSON.stringify({ job_id, ...result }), { + status, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } catch (error) { + const isTimeout = error.name === 'AbortError'; + console.error('Error clipping coupon:', error); + return new Response( + JSON.stringify({ + error: isTimeout + ? 'External API timeout — service may be starting up, try again' + : (error.message || 'Failed to clip coupon'), + success: false, + }), + { status: isTimeout ? 504 : 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } +}); diff --git a/supabase/code/volumes/functions/create-event/index.ts b/supabase/code/volumes/functions/create-event/index.ts new file mode 100644 index 000000000..44a56335a --- /dev/null +++ b/supabase/code/volumes/functions/create-event/index.ts @@ -0,0 +1,159 @@ +import { createClient } from "npm:@supabase/supabase-js@2.47.10"; +// ✅ Variables de entorno +const SUPABASE_URL = Deno.env.get("SUPABASE_URL"); +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); +const EVENTS_FUNCTION_API_KEY = Deno.env.get("EVENTS_FUNCTION_API_KEY"); +// ✅ Cliente admin +const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); +// ✅ CORS headers +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "X-Api-Key, Content-Type, Accept, Origin", + "Access-Control-Allow-Methods": "POST, OPTIONS" +}; +// ✅ Enviar notificación a Expo usando fetch en vez del SDK +async function sendExpoNotification(event, pushTokens) { + if (!pushTokens || pushTokens.length === 0) return; + // Validar tokens (Expo tokens suelen empezar con ExponentPushToken o ExpoPushToken) + const validTokens = pushTokens.filter((token)=>typeof token === "string" && (token.startsWith("ExponentPushToken") || token.startsWith("ExpoPushToken"))); + if (validTokens.length === 0) return; + const messages = validTokens.map((token)=>({ + to: token, + sound: "default", + title: "🎉 New Event Alert!", + body: `${event.event_name} is coming up on ${new Date(event.event_date).toLocaleDateString()}. Don’t miss out — tap to see details!`, + data: { + event_id: event.id, + event_image: event.event_image, + screen: "events" + } + })); + const chunkSize = 100; // Expo recomienda enviar hasta 100 mensajes por petición + const tickets = []; + for(let i = 0; i < messages.length; i += chunkSize){ + const chunk = messages.slice(i, i + chunkSize); + try { + const res = await fetch("https://exp.host/--/api/v2/push/send", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer dcI8i3Hjor9dmwSU_RKIwGNMZf0Ow6ljQVWtkUKU" + }, + body: JSON.stringify(chunk) + }); + const text = await res.text(); + try { + const json = text ? JSON.parse(text) : null; + if (json) { + if (Array.isArray(json)) tickets.push(...json); + else tickets.push(json); + } else { + console.warn("⚠️ Expo response could not be parsed as JSON:", text); + } + } catch (parseErr) { + console.error("❌ Error parsing Expo response:", parseErr, "raw:", text); + } + if (!res.ok) { + console.error("❌ Expo API returned non-OK status:", res.status); + } + } catch (err) { + console.error("❌ Error al enviar notificación:", err); + } + } + console.log("🎫 Tickets generados:", tickets); +} +// ✅ Edge function principal +Deno.serve(async (req)=>{ + // CORS preflight + if (req.method === "OPTIONS") { + return new Response("ok", { + headers: corsHeaders + }); + } + // Solo POST + if (req.method !== "POST") { + return new Response(JSON.stringify({ + error: "Method Not Allowed" + }), { + status: 405, + headers: { + ...corsHeaders, + "Content-Type": "application/json" + } + }); + } + // Validación API Key + const apiKey = (req.headers.get("X-Api-Key") || "").trim(); + console.info("🔑 X-Api-Key recibida:", apiKey); + console.info("🔐 EVENTS_FUNCTION_API_KEY esperada:", (EVENTS_FUNCTION_API_KEY || "").trim()); + if (!apiKey || apiKey !== (EVENTS_FUNCTION_API_KEY || "").trim()) { + return new Response(JSON.stringify({ + error: "Unauthorized", + message: "Invalid or missing X-Api-Key" + }), { + status: 401, + headers: { + ...corsHeaders, + "Content-Type": "application/json" + } + }); + } + try { + // Parsear body + const { id } = await req.json(); + if (!id) { + return new Response(JSON.stringify({ + error: "Missing event id" + }), { + status: 400, + headers: { + ...corsHeaders, + "Content-Type": "application/json" + } + }); + } + // Buscar información del evento + const { data: event, error: eventErr } = await supabase.from("events").select("*").eq("id", id).single(); + if (eventErr || !event) { + return new Response(JSON.stringify({ + error: "Event not found", + details: eventErr?.message + }), { + status: 404, + headers: { + ...corsHeaders, + "Content-Type": "application/json" + } + }); + } + // Obtener push tokens de usuarios + const { data: users, error: usersErr } = await supabase.from("profiles").select("push_token").not("push_token", "is", null).neq("push_token", ""); + if (usersErr) throw usersErr; + const pushTokens = users.map((u)=>u.push_token).filter(Boolean); + // Enviar notificación + await sendExpoNotification(event, pushTokens); + return new Response(JSON.stringify({ + success: true, + event_id: id, + notified_users: pushTokens.length + }), { + status: 200, + headers: { + ...corsHeaders, + "Content-Type": "application/json" + } + }); + } catch (err) { + console.error("❌ Error interno:", err); + return new Response(JSON.stringify({ + error: "Internal Server Error", + message: err.message + }), { + status: 500, + headers: { + ...corsHeaders, + "Content-Type": "application/json" + } + }); + } +}); diff --git a/supabase/code/volumes/functions/delete-account/index.ts b/supabase/code/volumes/functions/delete-account/index.ts new file mode 100644 index 000000000..166026b2c --- /dev/null +++ b/supabase/code/volumes/functions/delete-account/index.ts @@ -0,0 +1,67 @@ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; + +Deno.serve(async (req: Request) => { + try { + const authHeader = req.headers.get("Authorization"); + if (!authHeader) { + return new Response(JSON.stringify({ error: "Missing Authorization header" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const supabaseClient = (await import("jsr:@supabase/supabase-js@2")) as any; + + // Create a client with the JWT so RLS applies to this user + const url = Deno.env.get("SUPABASE_URL")!; + const anon = Deno.env.get("SUPABASE_ANON_KEY")!; + const service = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + + const jwt = authHeader.replace(/^Bearer\s+/i, ""); + + // Client with user JWT for RLS deletes on public tables + const { createClient } = supabaseClient; + const userClient = createClient(url, anon, { + global: { headers: { Authorization: `Bearer ${jwt}` } }, + }); + + // Service client for deleting auth user + const serviceClient = createClient(url, service); + + // Identify current user + const { data: userRes, error: userErr } = await userClient.auth.getUser(); + if (userErr || !userRes?.user) { + return new Response(JSON.stringify({ error: userErr?.message || "No user" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const userId = userRes.user.id; + + // Delete dependent data first + await Promise.allSettled([ + userClient.from("user_coupon_clipping").delete().eq("user_id", userId), + userClient.from("loyalty_cards").delete().eq("user_id", userId), + ]); + + // Delete profile row + const { error: profileDeleteError } = await userClient + .from("profiles") + .delete() + .eq("id", userId); + + if (profileDeleteError) { + return new Response(JSON.stringify({ error: profileDeleteError.message }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + // Delete auth user (requires service role) + const { error: adminDeleteErr } = await serviceClient.auth.admin.deleteUser(userId); + if (adminDeleteErr) { + return new Response(JSON.stringify({ error: adminDeleteErr.message }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + return new Response(JSON.stringify({ success: true }), { + headers: { "Content-Type": "application/json" }, + }); + } catch (e: any) { + return new Response(JSON.stringify({ error: e?.message || "Unknown error" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } +}); diff --git a/supabase/code/volumes/functions/generate-google-wallet-pass/index.ts b/supabase/code/volumes/functions/generate-google-wallet-pass/index.ts new file mode 100644 index 000000000..37a671573 --- /dev/null +++ b/supabase/code/volumes/functions/generate-google-wallet-pass/index.ts @@ -0,0 +1,236 @@ +import { createClient } from "npm:@supabase/supabase-js@2"; + +function base64urlEncode(data: string | Uint8Array): string { + const bytes = + typeof data === "string" ? new TextEncoder().encode(data) : data; + const CHUNK = 0x8000; + let binary = ""; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode( + ...Array.from(bytes.subarray(i, Math.min(i + CHUNK, bytes.length))), + ); + } + return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); +} + +async function createSignedJWT( + claims: Record, + privateKeyPem: string, +): Promise { + const cleanPem = privateKeyPem + .replace(/\\n/g, "\n") + .replace(/-----BEGIN PRIVATE KEY-----/g, "") + .replace(/-----END PRIVATE KEY-----/g, "") + .replace(/\s+/g, ""); + + const keyDer = Uint8Array.from(atob(cleanPem), (c) => c.charCodeAt(0)); + const cryptoKey = await crypto.subtle.importKey( + "pkcs8", + keyDer, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["sign"], + ); + + const headerB64 = base64urlEncode( + JSON.stringify({ alg: "RS256", typ: "JWT" }), + ); + const payloadB64 = base64urlEncode(JSON.stringify(claims)); + const signingInput = `${headerB64}.${payloadB64}`; + const sigBytes = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + cryptoKey, + new TextEncoder().encode(signingInput), + ); + return `${signingInput}.${base64urlEncode(new Uint8Array(sigBytes))}`; +} + +// Gets an OAuth2 access token using the service account credentials +async function getAccessToken( + serviceAccountEmail: string, + privateKeyPem: string, +): Promise { + const now = Math.floor(Date.now() / 1000); + const assertion = await createSignedJWT( + { + iss: serviceAccountEmail, + scope: "https://www.googleapis.com/auth/wallet_object.issuer", + aud: "https://oauth2.googleapis.com/token", + iat: now, + exp: now + 3600, + }, + privateKeyPem, + ); + + const res = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${assertion}`, + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`OAuth2 token request failed (${res.status}): ${body}`); + } + + const { access_token } = await res.json(); + return access_token; +} + +// Creates the loyalty class via REST API if it doesn't exist yet +async function ensureLoyaltyClass( + accessToken: string, + issuerId: string, + classSuffix: string, + logoUrl: string, +): Promise { + const classId = `${issuerId}.${classSuffix}`; + const apiBase = "https://walletobjects.googleapis.com/walletobjects/v1"; + + const checkRes = await fetch( + `${apiBase}/loyaltyClass/${encodeURIComponent(classId)}`, + { headers: { Authorization: `Bearer ${accessToken}` } }, + ); + + if (checkRes.ok) return; + + if (checkRes.status !== 404) { + const body = await checkRes.text(); + throw new Error(`Error checking loyalty class (${checkRes.status}): ${body}`); + } + + const createRes = await fetch(`${apiBase}/loyaltyClass`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + id: classId, + issuerName: "Food Universe", + programName: "Food Universe Loyalty", + programLogo: { + sourceUri: { uri: logoUrl }, + contentDescription: { + defaultValue: { language: "en-US", value: "Food Universe Logo" }, + }, + }, + reviewStatus: "UNDER_REVIEW", + }), + }); + + if (!createRes.ok) { + const body = await createRes.text(); + throw new Error(`Failed to create loyalty class (${createRes.status}): ${body}`); + } +} + +Deno.serve(async (req) => { + if (req.method !== "GET" && req.method !== "POST") { + return new Response("Method Not Allowed", { status: 405 }); + } + + const authHeader = req.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return new Response("Unauthorized", { status: 401 }); + } + const token = authHeader.slice(7); + + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_ANON_KEY")!, + { global: { headers: { Authorization: `Bearer ${token}` } } }, + ); + + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + if (authError || !user) { + return new Response("Unauthorized", { status: 401 }); + } + + const { data: card, error: cardError } = await supabase + .from("loyalty_cards") + .select("card_number, points") + .eq("user_id", user.id) + .maybeSingle(); + + if (cardError || !card) { + return new Response("Loyalty card not found", { status: 404 }); + } + + const profileRes = await supabase + .from("profiles") + .select("first_name, last_name") + .eq("id", user.id) + .maybeSingle(); + + const profile = profileRes.data as { + first_name?: string; + last_name?: string; + } | null; + + const issuerId = Deno.env.get("GOOGLE_WALLET_ISSUER_ID")!; + const classSuffix = + Deno.env.get("GOOGLE_WALLET_CLASS_ID") ?? "loyalty_class"; + const serviceAccountEmail = Deno.env.get("GOOGLE_SERVICE_ACCOUNT_EMAIL")!; + const privateKeyPem = Deno.env.get("GOOGLE_PRIVATE_KEY")!; + const logoUrl = + Deno.env.get("GOOGLE_WALLET_LOGO_URL") ?? + "https://storage.googleapis.com/wallet-lab-tools-codelab-artifacts-public/pass_google_logo.jpg"; + + const memberName = + [profile?.first_name, profile?.last_name].filter(Boolean).join(" ") || + user.email || + ""; + + const objectSuffix = `loyalty_${card.card_number.replace(/[^a-zA-Z0-9_-]/g, "_")}`; + + try { + const accessToken = await getAccessToken(serviceAccountEmail, privateKeyPem); + await ensureLoyaltyClass(accessToken, issuerId, classSuffix, logoUrl); + + const jwtClaims = { + iss: serviceAccountEmail, + aud: "google", + typ: "savetowallet", + iat: Math.floor(Date.now() / 1000), + origins: [], + payload: { + loyaltyObjects: [ + { + id: `${issuerId}.${objectSuffix}`, + classId: `${issuerId}.${classSuffix}`, + state: "ACTIVE", + accountId: card.card_number, + accountName: memberName, + loyaltyPoints: { + label: "Points", + balance: { int: card.points ?? 0 }, + }, + barcode: { + type: "CODE_128", + value: card.card_number, + alternateText: card.card_number, + }, + }, + ], + }, + }; + + const jwt = await createSignedJWT(jwtClaims, privateKeyPem); + return new Response(JSON.stringify({ jwt }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + console.error("Google Wallet pass generation failed:", message, stack); + return new Response(JSON.stringify({ error: message, stack }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } +}); diff --git a/supabase/code/volumes/functions/generate-wallet-pass/index.ts b/supabase/code/volumes/functions/generate-wallet-pass/index.ts new file mode 100644 index 000000000..96b099638 --- /dev/null +++ b/supabase/code/volumes/functions/generate-wallet-pass/index.ts @@ -0,0 +1,292 @@ +import { createClient } from "npm:@supabase/supabase-js@2"; +import forge from "npm:node-forge@1.3.1"; +import JSZip from "npm:jszip@3.10.1"; + +const APPLE_WWDR_G4_PEM = `-----BEGIN CERTIFICATE----- +MIIEVTCCAz2gAwIBAgIUE9x3lVJx5T3GMujM/+Uh88zFztIwDQYJKoZIhvcNAQEL +BQAwYjELMAkGA1UEBhMCVVMxEzARBgNVBAoTCkFwcGxlIEluYy4xJjAkBgNVBAsT +HUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRYwFAYDVQQDEw1BcHBsZSBS +b290IENBMB4XDTIwMTIxNjE5MzYwNFoXDTMwMTIxMDAwMDAwMFowdTFEMEIGA1UE +Aww7QXBwbGUgV29ybGR3aWRlIERldmVsb3BlciBSZWxhdGlvbnMgQ2VydGlmaWNh +dGlvbiBBdXRob3JpdHkxCzAJBgNVBAsMAkc0MRMwEQYDVQQKDApBcHBsZSBJbmMu +MQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANAf +eKp6JzKwRl/nF3bYoJ0OKY6tPTKlxGs3yeRBkWq3eXFdDDQEYHX3rkOPR8SGHgjo +v9Y5Ui8eZ/xx8YJtPH4GUnadLLzVQ+mxtLxAOnhRXVGhJeG+bJGdayFZGEHVD41t +QSo5SiHgkJ9OE0/QjJoyuNdqkh4laqQyziIZhQVg3AJK8lrrd3kCfcCXVGySjnYB +5kaP5eYq+6KwrRitbTOFOCOL6oqW7Z+uZk+jDEAnbZXQYojZQykn/e2kv1MukBVl +PNkuYmQzHWxq3Y4hqqRfFcYw7V/mjDaSlLfcOQIA+2SM1AyB8j/VNJeHdSbCb64D +YyEMe9QbsWLFApy9/a8CAwEAAaOB7zCB7DASBgNVHRMBAf8ECDAGAQH/AgEAMB8G +A1UdIwQYMBaAFCvQaUeUdgn+9GuNLkCm90dNfwheMEQGCCsGAQUFBwEBBDgwNjA0 +BggrBgEFBQcwAYYoaHR0cDovL29jc3AuYXBwbGUuY29tL29jc3AwMy1hcHBsZXJv +b3RjYTAuBgNVHR8EJzAlMCOgIaAfhh1odHRwOi8vY3JsLmFwcGxlLmNvbS9yb290 +LmNybDAdBgNVHQ4EFgQUW9n6HeeaGgujmXYiUIY+kchbd6gwDgYDVR0PAQH/BAQD +AgEGMBAGCiqGSIb3Y2QGAgEEAgUAMA0GCSqGSIb3DQEBCwUAA4IBAQA/Vj2e5bbD +eeZFIGi9v3OLLBKeAuOugCKMBB7DUshwgKj7zqew1UJEggOCTwb8O0kU+9h0UoWv +p50h5wESA5/NQFjQAde/MoMrU1goPO6cn1R2PWQnxn6NHThNLa6B5rmluJyJlPef +x4elUWY0GzlxOSTjh2fvpbFoe4zuPfeutnvi0v/fYcZqdUmVIkSoBPyUuAsuORFJ +EtHlgepZAE9bPFo22noicwkJac3AfOriJP6YRLj477JxPxpd1F1+M02cHSS+APCQ +A1iZQT0xWmJArzmoUUOSqwSonMJNsUvSq3xKX+udO7xPiEAGE/+QF4oIRynoYpgp +pU8RBWk6z/Kf +-----END CERTIFICATE-----`; + +const PLACEHOLDER_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQAABjE+ibYAAAAASUVORK5CYII="; + +async function sha1Hex(data: Uint8Array | string): Promise { + const bytes = + typeof data === "string" ? new TextEncoder().encode(data) : data; + const hash = await crypto.subtle.digest("SHA-1", bytes); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function base64ToBytes(b64: string): Uint8Array { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +function hexToRgb(hex: string): string { + const h = hex.replace("#", ""); + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + return `rgb(${r}, ${g}, ${b})`; +} + +async function fetchImageBytes(url: string, fallback: Uint8Array): Promise { + try { + const res = await fetch(url); + if (!res.ok) return fallback; + const buf = await res.arrayBuffer(); + return new Uint8Array(buf); + } catch { + return fallback; + } +} + +Deno.serve(async (req) => { + if (req.method !== "GET" && req.method !== "POST") { + return new Response("Method Not Allowed", { status: 405 }); + } + + const authHeader = req.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return new Response("Unauthorized", { status: 401 }); + } + const token = authHeader.slice(7); + + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_ANON_KEY")!, + { global: { headers: { Authorization: `Bearer ${token}` } } }, + ); + + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return new Response("Unauthorized", { status: 401 }); + } + + const { data: card, error: cardError } = await supabase + .from("loyalty_cards") + .select("card_number, points, created_at, id_store") + .eq("user_id", user.id) + .maybeSingle(); + + if (cardError || !card) { + return new Response("Loyalty card not found", { status: 404 }); + } + + const [storeRes, profileRes] = await Promise.all([ + card.id_store + ? supabase + .from("stores") + .select("name, colors, logo_header_url") + .eq("id", card.id_store) + .maybeSingle() + : Promise.resolve({ data: null }), + supabase + .from("profiles") + .select("first_name, last_name") + .eq("id", user.id) + .maybeSingle(), + ]); + + const store = storeRes.data as { + name?: string; + colors?: string[]; + logo_header_url?: string; + } | null; + const profile = profileRes.data as { + first_name?: string; + last_name?: string; + } | null; + + const orgName = store?.name ?? Deno.env.get("ORG_NAME") ?? "Food Universe"; + const primaryColor = store?.colors?.[0] + ? hexToRgb(store.colors[0]) + : "rgb(43, 127, 255)"; + const memberName = + [profile?.first_name, profile?.last_name].filter(Boolean).join(" ") || ""; + const memberEmail = user.email ?? ""; + const points = card.points ?? 0; + + const starCount = Math.min(5, Math.max(1, Math.ceil(points / 100))); + const stars = "★".repeat(starCount) + "☆".repeat(5 - starCount); + + try { + const passTypeIdentifier = Deno.env.get("PASS_TYPE_IDENTIFIER")!; + const teamIdentifier = Deno.env.get("TEAM_IDENTIFIER")!; + const placeholderBytes = base64ToBytes(PLACEHOLDER_PNG_BASE64); + + const logoUrl = store?.logo_header_url ?? Deno.env.get("ORG_LOGO_URL"); + const logoBytes = logoUrl + ? await fetchImageBytes(logoUrl, placeholderBytes) + : placeholderBytes; + + const passJson = { + formatVersion: 1, + passTypeIdentifier, + serialNumber: card.card_number, + teamIdentifier, + organizationName: orgName, + description: `${orgName} Loyalty Card`, + foregroundColor: "rgb(255, 255, 255)", + backgroundColor: primaryColor, + labelColor: "rgb(200, 230, 255)", + logoText: orgName, + storeCard: { + headerFields: [ + { + key: "points", + label: "POINTS", + value: String(points), + }, + ], + primaryFields: [ + { + key: "member_name", + label: `${orgName.toUpperCase()} MARKETPLACE`, + value: memberName || orgName, + }, + ], + secondaryFields: [ + { + key: "stars", + label: "", + value: stars, + }, + ], + auxiliaryFields: [ + { + key: "member_email", + label: "", + value: memberEmail, + }, + { + key: "card_number", + label: "", + value: `#${card.card_number}`, + }, + ], + }, + barcodes: [ + { + message: card.card_number, + format: "PKBarcodeFormatCode128", + messageEncoding: "iso-8859-1", + altText: card.card_number, + }, + ], + barcode: { + message: card.card_number, + format: "PKBarcodeFormatCode128", + messageEncoding: "iso-8859-1", + altText: card.card_number, + }, + }; + + const passJsonStr = JSON.stringify(passJson); + + const manifest = { + "pass.json": await sha1Hex(passJsonStr), + "icon.png": await sha1Hex(placeholderBytes), + "icon@2x.png": await sha1Hex(placeholderBytes), + "logo.png": await sha1Hex(logoBytes), + "logo@2x.png": await sha1Hex(logoBytes), + }; + const manifestStr = JSON.stringify(manifest); + + const p12Base64 = Deno.env.get("PASS_CERTIFICATE_P12_BASE64")!; + const p12Password = Deno.env.get("PASS_CERTIFICATE_PASSWORD") ?? ""; + + const p12Der = forge.util.decode64(p12Base64); + const p12Asn1 = forge.asn1.fromDer(p12Der); + const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, false, p12Password); + + const certBags = p12.getBags({ bagType: forge.pki.oids.certBag }); + const keyBags = p12.getBags({ + bagType: forge.pki.oids.pkcs8ShroudedKeyBag, + }); + + const leafCert = certBags[forge.pki.oids.certBag]![0].cert!; + const privateKey = + keyBags[forge.pki.oids.pkcs8ShroudedKeyBag]![0].key!; + const wwdrCert = forge.pki.certificateFromPem(APPLE_WWDR_G4_PEM); + + const p7 = forge.pkcs7.createSignedData(); + p7.content = forge.util.createBuffer(manifestStr); + p7.addCertificate(leafCert); + p7.addCertificate(wwdrCert); + p7.addSigner({ + key: privateKey, + certificate: leafCert, + digestAlgorithm: forge.pki.oids.sha1, + authenticatedAttributes: [ + { type: forge.pki.oids.contentType, value: forge.pki.oids.data }, + { type: forge.pki.oids.messageDigest }, + { type: forge.pki.oids.signingTime, value: new Date() }, + ], + }); + p7.sign({ detached: true }); + + const p7Asn1 = p7.toAsn1(); + const signedData = p7Asn1.value[1].value[0]; + const encapContentInfo = signedData.value[2]; + encapContentInfo.value = [encapContentInfo.value[0]]; + + const sigDer = forge.asn1.toDer(p7Asn1).getBytes(); + const sigBytes = new Uint8Array(sigDer.length); + for (let i = 0; i < sigDer.length; i++) + sigBytes[i] = sigDer.charCodeAt(i); + + const zip = new JSZip(); + zip.file("pass.json", passJsonStr); + zip.file("manifest.json", manifestStr); + zip.file("signature", sigBytes); + zip.file("icon.png", placeholderBytes); + zip.file("icon@2x.png", placeholderBytes); + zip.file("logo.png", logoBytes); + zip.file("logo@2x.png", logoBytes); + + const pkpass = await zip.generateAsync({ type: "uint8array" }); + + return new Response(pkpass, { + status: 200, + headers: { + "Content-Type": "application/vnd.apple.pkpass", + "Content-Disposition": 'attachment; filename="loyalty.pkpass"', + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + console.error("Pass generation failed:", message, stack); + return new Response( + JSON.stringify({ error: message, stack }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); + } +}); diff --git a/supabase/code/volumes/functions/get-turn/index.ts b/supabase/code/volumes/functions/get-turn/index.ts new file mode 100644 index 000000000..4f38a38af --- /dev/null +++ b/supabase/code/volumes/functions/get-turn/index.ts @@ -0,0 +1,103 @@ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, GET, OPTIONS", +}; + +Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + try { + const url = new URL(req.url); + let session_id: string | null = null; + let device_fingerprint: string | null = null; + + if (req.method === "GET") { + session_id = url.searchParams.get("session_id"); + device_fingerprint = url.searchParams.get("device_fingerprint"); + } else { + const body = await req.json(); + session_id = body.session_id; + device_fingerprint = body.device_fingerprint; + } + + if (!session_id) { + return new Response( + JSON.stringify({ success: false, error: "missing_params", message: "session_id is required" }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? ""; + const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; + const supabase = createClient(supabaseUrl, supabaseServiceKey); + + // Get session info + const { data: session, error: sessionError } = await supabase + .from("queue_sessions") + .select("*") + .eq("id", session_id) + .single(); + + if (sessionError || !session) { + return new Response( + JSON.stringify({ success: false, error: "session_not_found", message: "Queue session not found" }), + { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // Get all turns for this session + const { data: turns, error: turnsError } = await supabase + .from("queue_turns") + .select("*") + .eq("session_id", session_id) + .order("turn_number", { ascending: true }); + + if (turnsError) { + return new Response( + JSON.stringify({ success: false, error: "turns_error", message: turnsError.message }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // If device_fingerprint provided, find the specific turn + let myTurn = null; + if (device_fingerprint) { + myTurn = (turns || []).find((t: any) => t.device_fingerprint === device_fingerprint) || null; + } + + // Current serving turn + const servingTurn = (turns || []).find((t: any) => t.status === "serving") || null; + const waitingTurns = (turns || []).filter((t: any) => t.status === "waiting"); + + return new Response( + JSON.stringify({ + success: true, + session: { + id: session.id, + title: session.title, + description: session.description, + is_active: session.is_active, + current_turn: session.current_turn, + created_at: session.created_at, + }, + turns: turns || [], + my_turn: myTurn, + currently_serving: servingTurn, + waiting_count: waitingTurns.length, + total_turns: (turns || []).length, + }), + { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } catch (err) { + return new Response( + JSON.stringify({ success: false, error: "server_error", message: String(err) }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } +}); diff --git a/supabase/code/volumes/functions/loyalty-barcodes/index.ts b/supabase/code/volumes/functions/loyalty-barcodes/index.ts new file mode 100644 index 000000000..53631ba1b --- /dev/null +++ b/supabase/code/volumes/functions/loyalty-barcodes/index.ts @@ -0,0 +1,122 @@ +import { createClient } from "npm:@supabase/supabase-js@2.47.10"; +const SUPABASE_URL = Deno.env.get("SUPABASE_URL"); +const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY"); +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); +const BUCKET = "barcodes"; +const MAX_BYTES = 10 * 1024 * 1024; // 10 MB +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Authorization, Content-Type, Accept, Origin", + "Access-Control-Allow-Methods": "POST, OPTIONS" +}; +function json(data, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { + ...corsHeaders, + "Content-Type": "application/json" + } + }); +} +function sanitizeFilename(name) { + return name.replace(/[^\w.\-]/g, "_"); +} +function inferMimeFromName(name) { + const lower = name.toLowerCase(); + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + if (lower.endsWith(".pdf")) return "application/pdf"; + return "application/octet-stream"; +} +Deno.serve(async (req)=>{ + if (req.method === "OPTIONS") return new Response("ok", { + headers: corsHeaders + }); + if (req.method !== "POST") return json({ + error: "method_not_allowed" + }, 405); + const authHeader = req.headers.get("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return json({ + error: "missing_bearer" + }, 401); + } + const contentType = (req.headers.get("content-type") || "").toLowerCase(); + if (!contentType.includes("multipart/form-data")) { + return json({ + error: "expected_multipart_form_data" + }, 400); + } + try { + const form = await req.formData(); + const file = form.get("file"); + let loyalty_card_id = form.get("loyalty_card_id") ? String(form.get("loyalty_card_id")).trim() : undefined; + const event_id = form.get("event_id") ? String(form.get("event_id")) : undefined; + const timestamp = String(form.get("timestamp") || Date.now()); + if (!(file instanceof File)) return json({ + error: "invalid_file" + }, 400); + const supabaseUser = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + global: { + headers: { + Authorization: authHeader + } + } + }); + const supabaseAdmin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); + const { data: userData, error: userErr } = await supabaseUser.auth.getUser(); + if (userErr || !userData?.user) return json({ + error: "invalid_token" + }, 401); + const userIdFromToken = userData.user.id; + const origName = sanitizeFilename(file.name || "file"); + const arrayBuf = await file.arrayBuffer(); + const size = arrayBuf.byteLength; + if (size > MAX_BYTES) return json({ + error: "file_too_large", + max_bytes: MAX_BYTES + }, 413); + const mime = file.type || inferMimeFromName(origName); + // Si falta loyalty_card_id, intentamos inferirlo del nombre del archivo + if (!loyalty_card_id) { + const m = origName.match(/barcode_numeric_(\d+)_/); + if (m && m[1]) loyalty_card_id = m[1]; + } + const key = `${userIdFromToken}/${timestamp}_${origName}`; + const { error: uploadErr } = await supabaseUser.storage.from(BUCKET).upload(key, arrayBuf, { + contentType: mime, + upsert: false + }); + if (uploadErr) return json({ + error: "upload_failed", + message: uploadErr.message + }, 400); + const { data: pub } = supabaseUser.storage.from(BUCKET).getPublicUrl(key); + const fullUrl = pub?.publicUrl ?? `${SUPABASE_URL}/storage/v1/object/public/${BUCKET}/${key}`; + if (event_id) { + const { error: updateEventErr } = await supabaseAdmin.from("events").update({ + event_image: fullUrl + }).eq("id", event_id); + if (updateEventErr) console.error("DB update (events) error:", updateEventErr); + } + if (loyalty_card_id) { + const { error: updateLoyaltyErr } = await supabaseAdmin.from("loyalty_cards").update({ + barcode_url: fullUrl + }).eq("user_id", userIdFromToken).eq("card_number", loyalty_card_id); + if (updateLoyaltyErr) console.error("DB update (loyalty_cards) error:", updateLoyaltyErr); + } + return json({ + url: fullUrl, + key, + bucket: BUCKET + }, 201); + } catch (e) { + console.error("server_error", e); + return json({ + error: "server_error", + message: e?.message || String(e) + }, 500); + } +}); diff --git a/supabase/code/volumes/functions/notify-queue-position/index.ts b/supabase/code/volumes/functions/notify-queue-position/index.ts new file mode 100644 index 000000000..960629c45 --- /dev/null +++ b/supabase/code/volumes/functions/notify-queue-position/index.ts @@ -0,0 +1,173 @@ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "npm:@supabase/supabase-js@2.47.10"; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL"); +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + +if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) { + console.error("Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY"); +} + +const supabase = createClient(SUPABASE_URL!, SUPABASE_SERVICE_ROLE_KEY!); + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Content-Type": "application/json", +}; + +/** + * Send Expo push notifications to a list of push tokens. + */ +async function sendExpoNotification( + pushTokens: string[], + notif: { title: string; body: string; data: Record }, +) { + const validTokens = pushTokens.filter( + (token) => + typeof token === "string" && + (token.startsWith("ExponentPushToken") || token.startsWith("ExpoPushToken")), + ); + if (validTokens.length === 0) return; + + const messages = validTokens.map((token) => ({ + to: token, + sound: "default", + title: notif.title, + body: notif.body, + data: notif.data, + })); + + try { + const res = await fetch("https://exp.host/--/api/v2/push/send", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${Deno.env.get("EXPO_ACCESS_TOKEN") ?? ""}`, + }, + body: JSON.stringify(messages), + }); + const text = await res.text(); + if (!res.ok) { + console.error("Expo API error", res.status, text); + } else { + console.log("Expo push sent successfully to", validTokens.length, "tokens"); + } + } catch (err) { + console.error("Error sending Expo push:", err); + } +} + +Deno.serve(async (req: Request) => { + // CORS preflight + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + if (req.method !== "POST") { + return new Response(JSON.stringify({ error: "Method Not Allowed" }), { + status: 405, + headers: corsHeaders, + }); + } + + try { + const { session_id } = await req.json(); + + if (!session_id) { + return new Response( + JSON.stringify({ error: "Missing session_id" }), + { status: 400, headers: corsHeaders }, + ); + } + + // Get all waiting turns for this session, ordered by turn_number + const { data: waitingTurns, error: turnsErr } = await supabase + .from("queue_turns") + .select("id, turn_number, user_id, user_name, device_fingerprint") + .eq("session_id", session_id) + .eq("status", "waiting") + .order("turn_number", { ascending: true }); + + if (turnsErr) { + console.error("Error fetching waiting turns:", turnsErr); + return new Response( + JSON.stringify({ error: "Database error", message: turnsErr.message }), + { status: 500, headers: corsHeaders }, + ); + } + + // Position 3 = index 2 (2 people ahead) + // We notify the person at index 2 (third in line) + const NOTIFY_POSITION_INDEX = 2; + + if (!waitingTurns || waitingTurns.length <= NOTIFY_POSITION_INDEX) { + // Not enough people in the queue to notify position 3 + return new Response( + JSON.stringify({ success: true, notified: false, reason: "queue_too_short" }), + { status: 200, headers: corsHeaders }, + ); + } + + const turnToNotify = waitingTurns[NOTIFY_POSITION_INDEX]; + + // Only send push notification if the user is registered (has user_id) + if (!turnToNotify.user_id) { + return new Response( + JSON.stringify({ success: true, notified: false, reason: "anonymous_user" }), + { status: 200, headers: corsHeaders }, + ); + } + + // Look up the push token + const { data: profile, error: profileErr } = await supabase + .from("profiles") + .select("push_token, prefered_language") + .eq("id", turnToNotify.user_id) + .single(); + + if (profileErr || !profile?.push_token) { + return new Response( + JSON.stringify({ success: true, notified: false, reason: "no_push_token" }), + { status: 200, headers: corsHeaders }, + ); + } + + // Send the notification + const isSpanish = profile.prefered_language === "es"; + const title = isSpanish + ? "¡Tu turno se acerca!" + : "Your turn is coming up!"; + const body = isSpanish + ? `Solo quedan 2 personas delante de ti. Tu turno es #${turnToNotify.turn_number}.` + : `Only 2 people ahead of you. Your turn is #${turnToNotify.turn_number}.`; + + await sendExpoNotification([profile.push_token], { + title, + body, + data: { + screen: "queue", + turn_id: turnToNotify.id, + turn_number: String(turnToNotify.turn_number), + type: "queue_position", + }, + }); + + return new Response( + JSON.stringify({ + success: true, + notified: true, + turn_number: turnToNotify.turn_number, + user_name: turnToNotify.user_name, + }), + { status: 200, headers: corsHeaders }, + ); + } catch (err: any) { + console.error("Internal error:", err); + return new Response( + JSON.stringify({ error: "Internal Server Error", message: err?.message ?? String(err) }), + { status: 500, headers: corsHeaders }, + ); + } +}); diff --git a/supabase/code/volumes/functions/queue-admin/index.ts b/supabase/code/volumes/functions/queue-admin/index.ts new file mode 100644 index 000000000..086e0e26d --- /dev/null +++ b/supabase/code/volumes/functions/queue-admin/index.ts @@ -0,0 +1,336 @@ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "GET, OPTIONS", +}; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""; +const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY") ?? ""; + +function renderPage(dept: string): string { + return ` + + + + +Queue Admin${dept ? ' - ' + dept.toUpperCase() : ''} + + + + +
+ + + + +`; +} + +Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + const url = new URL(req.url); + const dept = (url.searchParams.get("dept") || "").replace(/[^a-zA-Z0-9_-]/g, ""); + + return new Response(renderPage(dept), { + status: 200, + headers: { + ...corsHeaders, + "Content-Type": "text/html; charset=utf-8", + }, + }); +}); diff --git a/supabase/code/volumes/functions/scrape-coupons/index.ts b/supabase/code/volumes/functions/scrape-coupons/index.ts new file mode 100644 index 000000000..a1b101671 --- /dev/null +++ b/supabase/code/volumes/functions/scrape-coupons/index.ts @@ -0,0 +1,59 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +const API_BASE_URL = 'https://food-universe-api-scrap.onrender.com'; + +serve(async (req) => { + // Handle CORS preflight requests + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const { store_id, loyalty_card } = await req.json(); + + if (!store_id || !loyalty_card) { + return new Response( + JSON.stringify({ error: 'Missing required parameters: store_id, loyalty_card' }), + { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + } + ); + } + + console.log(`Scraping coupons for loyalty card ${loyalty_card}`); + + const response = await fetch( + `${API_BASE_URL}/api/scrape?store_id=${store_id}&loyalty_card=${loyalty_card}`, + { method: "GET" } + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + console.log('Scrape coupons result:', result); + + return new Response(JSON.stringify(result), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } catch (error) { + console.error('Error scraping coupons:', error); + return new Response( + JSON.stringify({ + error: error.message || 'Failed to scrape coupons', + success: false + }), + { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } +}); diff --git a/supabase/code/volumes/functions/send-notification/index.ts b/supabase/code/volumes/functions/send-notification/index.ts new file mode 100644 index 000000000..f75c44d36 --- /dev/null +++ b/supabase/code/volumes/functions/send-notification/index.ts @@ -0,0 +1,150 @@ +// Setup type definitions for built-in Supabase Runtime APIs +// Edge Function para enviar notificaciones personalizadas vía Expo en Supabase + +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "npm:@supabase/supabase-js@2.47.10"; + +// Configuración y variables de entorno +const SUPABASE_URL = Deno.env.get("SUPABASE_URL"); +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); +const EVENTS_FUNCTION_API_KEY = Deno.env.get("EVENTS_FUNCTION_API_KEY"); + +if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) { + console.error("Faltan variables SUPABASE_URL o SUPABASE_SERVICE_ROLE_KEY"); +} + +const supabase = createClient(SUPABASE_URL!, SUPABASE_SERVICE_ROLE_KEY!); + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "X-Api-Key, Content-Type, Accept, Origin", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Content-Type": "application/json", +}; + +// Utilidad para enviar notificaciones a través de Expo +async function sendExpoNotification( + pushTokens: string[], + notif: { title: string; body: string; data: any }, +) { + if (!pushTokens || pushTokens.length === 0) return; + + // Filtrar tokens válidos (deben comenzar con ExponentPushToken o ExpoPushToken) + const validTokens = pushTokens.filter( + (token) => + typeof token === "string" && + (token.startsWith("ExponentPushToken") || + token.startsWith("ExpoPushToken")), + ); + if (validTokens.length === 0) return; + + // Construir los mensajes + const messages = validTokens.map((token) => ({ + to: token, + sound: "default", + title: notif.title || "🔔 Notificación personalizada", + body: notif.body || "", + data: notif.data || {}, + })); + + // Enviar notificaciones en chunks recomendados + const chunkSize = 100; + for (let i = 0; i < messages.length; i += chunkSize) { + const chunk = messages.slice(i, i + chunkSize); + try { + const res = await fetch("https://exp.host/--/api/v2/push/send", { + method: "POST", + headers: { + "Content-Type": "application/json", + // Nota: siempre usa un token de servidor seguro. Evita hardcodear; usa secretos si es necesario. + Authorization: `Bearer ${Deno.env.get("EXPO_ACCESS_TOKEN") ?? ""}`, + }, + body: JSON.stringify(chunk), + }); + const text = await res.text(); + try { + const json = text ? JSON.parse(text) : null; + if (!res.ok) { + console.error("❌ Expo API error", res.status, json); + } + } catch (parseErr) { + console.error("❌ Error parsing Expo response:", parseErr, "raw:", text); + } + } catch (err) { + console.error("❌ Error al enviar notificación:", err); + } + } +} + +Deno.serve(async (req: Request) => { + // CORS preflight + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + // Solo permitir POST + if (req.method !== "POST") { + return new Response(JSON.stringify({ error: "Method Not Allowed" }), { + status: 405, + headers: corsHeaders, + }); + } + + // Validación API Key + const apiKey = (req.headers.get("X-Api-Key") || "").trim(); + if (!apiKey || apiKey !== (EVENTS_FUNCTION_API_KEY || "").trim()) { + return new Response( + JSON.stringify({ error: "Unauthorized", message: "Invalid or missing X-Api-Key" }), + { + status: 401, + headers: corsHeaders, + }, + ); + } + + try { + // Esperar un body { message: string, data: object, title?: string } + const { message, data, title } = await req.json(); + + if (!message || typeof message !== "string") { + return new Response(JSON.stringify({ error: "Missing or invalid message" }), { + status: 400, + headers: corsHeaders, + }); + } + + // Obtener todos los push tokens desde profiles.push_token (no nulos, no vacíos) + const { data: users, error: usersErr } = await supabase + .from("profiles") + .select("push_token") + .not("push_token", "is", null) + .neq("push_token", ""); + if (usersErr) throw usersErr; + + const pushTokens = (users ?? []).map((u: any) => u.push_token).filter(Boolean); + + // Enviar notificaciones + await sendExpoNotification(pushTokens, { + title: title || "🔔 Notificación personalizada", + body: message, + data: data || {}, + }); + + return new Response( + JSON.stringify({ success: true, notified_count: pushTokens.length }), + { + status: 200, + headers: corsHeaders, + }, + ); + } catch (err: any) { + console.error("❌ Error interno:", err); + return new Response( + JSON.stringify({ error: "Internal Server Error", message: err?.message ?? String(err) }), + { + status: 500, + headers: corsHeaders, + }, + ); + } +}); diff --git a/supabase/code/volumes/functions/take-turn/index.ts b/supabase/code/volumes/functions/take-turn/index.ts new file mode 100644 index 000000000..73bb6ed73 --- /dev/null +++ b/supabase/code/volumes/functions/take-turn/index.ts @@ -0,0 +1,99 @@ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +Deno.serve(async (req: Request) => { + // Handle CORS preflight + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + try { + // Support dept from query string (QR scan) or from JSON body + const url = new URL(req.url); + let dept: string | null = url.searchParams.get("dept"); + let device_fingerprint: string | null = null; + let user_id: string | null = null; + let user_name: string | null = null; + let user_email: string | null = null; + let user_phone: string | null = null; + // Also support legacy session_id + let session_id: string | null = url.searchParams.get("session_id"); + + if (req.method === "POST") { + const body = await req.json(); + dept = dept || body.dept || null; + session_id = session_id || body.session_id || null; + device_fingerprint = body.device_fingerprint || null; + user_id = body.user_id || null; + user_name = body.user_name || null; + user_email = body.user_email || null; + user_phone = body.user_phone || null; + } + + if (!device_fingerprint) { + return new Response( + JSON.stringify({ success: false, error: "missing_params", message: "device_fingerprint is required" }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + if (!dept && !session_id) { + return new Response( + JSON.stringify({ success: false, error: "missing_params", message: "dept or session_id is required" }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // Use service role to bypass RLS for the atomic function + const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? ""; + const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; + const supabase = createClient(supabaseUrl, supabaseServiceKey); + + let data, error; + + if (dept) { + // New flow: by department code + ({ data, error } = await supabase.rpc("take_turn_by_dept", { + p_department_code: dept, + p_device_fingerprint: device_fingerprint, + p_user_id: user_id || null, + p_user_name: user_name || "Anonymous", + p_user_email: user_email || null, + p_user_phone: user_phone || null, + })); + } else { + // Legacy flow: by session_id + ({ data, error } = await supabase.rpc("take_turn", { + p_session_id: session_id, + p_device_fingerprint: device_fingerprint, + p_user_id: user_id || null, + p_user_name: user_name || "Anonymous", + p_user_email: user_email || null, + p_user_phone: user_phone || null, + })); + } + + if (error) { + return new Response( + JSON.stringify({ success: false, error: "rpc_error", message: error.message }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + return new Response( + JSON.stringify(data), + { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } catch (err) { + return new Response( + JSON.stringify({ success: false, error: "server_error", message: String(err) }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } +}); From 5165273c7ddac30d2a1b6b70a5c16d0313a71a24 Mon Sep 17 00:00:00 2001 From: Josue Sanchez Date: Wed, 5 Aug 2026 12:58:10 -0400 Subject: [PATCH 2/4] Habilita proveedores OAuth Google y Apple en GoTrue El template solo traia Google comentado y no incluia Apple. Se activan via variables de entorno porque Studio self-hosted no expone la UI de proveedores del dashboard hosted. Todas las variables llevan default vacio/false, asi que el servicio auth arranca igual si no se definen en el .env. --- supabase/code/docker-compose.yml | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index a5ffd2f5e..3fc039ae3 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -165,8 +165,9 @@ services: 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" + # Necesario para Google Sign In nativo en movil (flujo signInWithIdToken). + # Ponlo en "true" en el .env solo si usas login nativo, no con redirect. + GOTRUE_EXTERNAL_SKIP_NONCE_CHECK: ${SKIP_NONCE_CHECK:-false} # GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: "true" # GOTRUE_SMTP_MAX_FREQUENCY: 1s @@ -183,11 +184,23 @@ services: GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP} GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM} - # Uncomment to enable OAuth / social login providers. - # GOTRUE_EXTERNAL_GOOGLE_ENABLED: ${GOOGLE_ENABLED} - # GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} - # GOTRUE_EXTERNAL_GOOGLE_SECRET: ${GOOGLE_SECRET} - # GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback + # --- OAuth / social login --- + # Google. CLIENT_ID admite varios IDs separados por coma: el de Web + # (necesario para el flujo con redirect) y los nativos de iOS/Android + # (necesarios para signInWithIdToken). + GOTRUE_EXTERNAL_GOOGLE_ENABLED: ${GOOGLE_ENABLED:-false} + GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + GOTRUE_EXTERNAL_GOOGLE_SECRET: ${GOOGLE_SECRET:-} + GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback + + # Apple. En APPLE_CLIENT_ID va el Services ID (flujo web) y, si usas + # Sign in with Apple nativo, tambien el bundle ID separado por coma. + # APPLE_SECRET NO es una contrasena: es un JWT que se genera desde la + # clave .p8 y caduca a los 6 meses como maximo (hay que regenerarlo). + GOTRUE_EXTERNAL_APPLE_ENABLED: ${APPLE_ENABLED:-false} + GOTRUE_EXTERNAL_APPLE_CLIENT_ID: ${APPLE_CLIENT_ID:-} + GOTRUE_EXTERNAL_APPLE_SECRET: ${APPLE_SECRET:-} + GOTRUE_EXTERNAL_APPLE_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback # GOTRUE_EXTERNAL_GITHUB_ENABLED: ${GITHUB_ENABLED} # GOTRUE_EXTERNAL_GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID} From 6654f383da6f3b4a6b7b4b1fa102cdc5661305dc Mon Sep 17 00:00:00 2001 From: Josue Sanchez Date: Thu, 6 Aug 2026 10:55:28 -0400 Subject: [PATCH 3/4] Update docker-compose.yml --- supabase/code/docker-compose.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index 3fc039ae3..122b1d8f4 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -76,6 +76,7 @@ services: kong: image: kong/kong:3.9.1 + container_name: supabase-kong restart: unless-stopped networks: default: @@ -119,6 +120,7 @@ services: auth: image: supabase/gotrue:v2.186.0 + container_name: supabase-auth restart: unless-stopped healthcheck: test: @@ -266,6 +268,7 @@ services: rest: image: postgrest/postgrest:v14.8 + container_name: supabase-rest restart: unless-stopped depends_on: db: @@ -291,6 +294,7 @@ services: realtime: # This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain image: supabase/realtime:v2.76.5 + container_name: realtime-dev.supabase-realtime restart: unless-stopped depends_on: db: @@ -337,6 +341,7 @@ services: # To use S3 backed storage: docker compose -f docker-compose.yml -f docker-compose.s3.yml up storage: image: supabase/storage-api:v1.48.26 + container_name: supabase-storage restart: unless-stopped depends_on: db: @@ -432,6 +437,7 @@ services: functions: image: supabase/edge-runtime:v1.71.2 + container_name: supabase-edge-functions restart: unless-stopped volumes: - ./volumes/functions:/home/deno/functions:Z @@ -457,6 +463,7 @@ services: analytics: image: supabase/logflare:1.36.1 + container_name: supabase-analytics restart: unless-stopped # ports: # - 4000:4000 @@ -499,6 +506,7 @@ services: # Comment out everything below this point if you are using an external Postgres database db: image: supabase/postgres:15.8.1.085 + container_name: supabase-db restart: unless-stopped volumes: - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z @@ -544,6 +552,7 @@ services: vector: image: timberio/vector:0.53.0-alpine + container_name: supabase-vector restart: unless-stopped volumes: - ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z From a1c9ec40b3a366bc1867bc40fe504c81d0178c74 Mon Sep 17 00:00:00 2001 From: Josue Sanchez Date: Thu, 6 Aug 2026 11:03:23 -0400 Subject: [PATCH 4/4] , --- supabase/code/.env.example | 8 +++++ supabase/code/docker-compose.yml | 22 ++++++++------ supabase/code/volumes/logs/vector.yml | 44 +++++++++++++++++++++------ 3 files changed, 54 insertions(+), 20 deletions(-) diff --git a/supabase/code/.env.example b/supabase/code/.env.example index 03a1a67a1..403ad7815 100644 --- a/supabase/code/.env.example +++ b/supabase/code/.env.example @@ -297,6 +297,14 @@ DOCKER_SOCKET_LOCATION=/var/run/docker.sock # For Podman use the following: # DOCKER_SOCKET_LOCATION=/run/podman/podman.sock +# Nombre del proyecto/stack tal como aparece en Docker. Vector solo recoge los +# logs de contenedores etiquetados con este valor, por lo que puedes desplegar +# varios stacks de Supabase en el mismo host sin que se mezclen los logs. +# Con `docker compose` es el valor de `name:` del docker-compose.yml (supabase). +# En Easypanel es el nombre del proyecto; verifícalo con: +# docker ps --format '{{.Label "com.docker.stack.namespace"}} {{.Names}}' +LOGS_STACK_NAME=supabase + # Google Cloud Project details GOOGLE_PROJECT_ID=GOOGLE_PROJECT_ID GOOGLE_PROJECT_NUMBER=GOOGLE_PROJECT_NUMBER diff --git a/supabase/code/docker-compose.yml b/supabase/code/docker-compose.yml index 122b1d8f4..03eea513d 100644 --- a/supabase/code/docker-compose.yml +++ b/supabase/code/docker-compose.yml @@ -76,7 +76,6 @@ services: kong: image: kong/kong:3.9.1 - container_name: supabase-kong restart: unless-stopped networks: default: @@ -120,7 +119,6 @@ services: auth: image: supabase/gotrue:v2.186.0 - container_name: supabase-auth restart: unless-stopped healthcheck: test: @@ -268,7 +266,6 @@ services: rest: image: postgrest/postgrest:v14.8 - container_name: supabase-rest restart: unless-stopped depends_on: db: @@ -292,10 +289,16 @@ services: command: [ "postgrest" ] realtime: - # This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain + # El hostname parece inconsistente pero es correcto: Realtime deriva el + # tenant id parseando el subdominio del host, y Kong lo llama por este + # nombre (ver volumes/api/kong.yml). Usamos un alias de red en vez de + # container_name porque el alias esta aislado por proyecto y no colisiona. image: supabase/realtime:v2.76.5 - container_name: realtime-dev.supabase-realtime restart: unless-stopped + networks: + default: + aliases: + - realtime-dev.supabase-realtime depends_on: db: # Disable this if you are using an external Postgres database @@ -341,7 +344,6 @@ services: # To use S3 backed storage: docker compose -f docker-compose.yml -f docker-compose.s3.yml up storage: image: supabase/storage-api:v1.48.26 - container_name: supabase-storage restart: unless-stopped depends_on: db: @@ -437,7 +439,6 @@ services: functions: image: supabase/edge-runtime:v1.71.2 - container_name: supabase-edge-functions restart: unless-stopped volumes: - ./volumes/functions:/home/deno/functions:Z @@ -463,7 +464,6 @@ services: analytics: image: supabase/logflare:1.36.1 - container_name: supabase-analytics restart: unless-stopped # ports: # - 4000:4000 @@ -506,7 +506,6 @@ services: # Comment out everything below this point if you are using an external Postgres database db: image: supabase/postgres:15.8.1.085 - container_name: supabase-db restart: unless-stopped volumes: - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z @@ -552,7 +551,6 @@ services: vector: image: timberio/vector:0.53.0-alpine - container_name: supabase-vector restart: unless-stopped volumes: - ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z @@ -572,6 +570,10 @@ services: retries: 3 environment: LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN} + # Nombre del proyecto/stack en Docker. Vector solo recoge logs de los + # contenedores etiquetados con este valor, para no mezclar los logs de + # otros stacks desplegados en el mismo host. + LOGS_STACK_NAME: ${LOGS_STACK_NAME:-supabase} command: [ "--config", "/etc/vector/vector.yml" ] security_opt: - "label=disable" diff --git a/supabase/code/volumes/logs/vector.yml b/supabase/code/volumes/logs/vector.yml index f63bfd8ad..b434d6fc1 100644 --- a/supabase/code/volumes/logs/vector.yml +++ b/supabase/code/volumes/logs/vector.yml @@ -5,8 +5,17 @@ api: sources: docker_host: type: docker_logs - exclude_containers: - - supabase-vector + # Filtramos por las labels que Docker pone automaticamente en cada + # contenedor en lugar de por container_name, para que este stack no lea + # los logs de otros proyectos que convivan en el mismo host (Easypanel). + # Compose usa com.docker.compose.*, Swarm usa com.docker.stack.namespace. + include_labels: + - "com.docker.compose.project=${LOGS_STACK_NAME:-supabase}" + - "com.docker.stack.namespace=${LOGS_STACK_NAME:-supabase}" + # Evita que Vector procese sus propios logs (bucle de realimentacion) + exclude_labels: + - "com.docker.compose.service=vector" + - "com.docker.swarm.service.name=${LOGS_STACK_NAME:-supabase}_vector" transforms: project_logs: @@ -16,7 +25,22 @@ transforms: source: |- .project = "default" .event_message = del(.message) - .appname = del(.container_name) + + # .appname pasa a ser el NOMBRE DEL SERVICIO en compose (auth, rest, db...), + # no el nombre del contenedor. Compose lo expone directamente; Swarm solo + # publica "_", asi que le quitamos el prefijo del stack. + compose_service = .label."com.docker.compose.service" || "" + swarm_service = .label."com.docker.swarm.service.name" || "" + stack = .label."com.docker.stack.namespace" || "" + if compose_service != "" { + .appname = compose_service + } else if stack != "" { + .appname = replace(string!(swarm_service), string!(stack) + "_", "", count: 1) + } else { + .appname = .container_name + } + + del(.container_name) del(.container_created_at) del(.container_id) del(.source_type) @@ -30,13 +54,13 @@ transforms: inputs: - project_logs route: - kong: '.appname == "supabase-kong" || .appname == "supabase-envoy"' - auth: '.appname == "supabase-auth"' - rest: '.appname == "supabase-rest"' - realtime: '.appname == "realtime-dev.supabase-realtime"' - storage: '.appname == "supabase-storage"' - functions: '.appname == "supabase-edge-functions"' - db: '.appname == "supabase-db"' + kong: '.appname == "kong" || .appname == "envoy"' + auth: '.appname == "auth"' + rest: '.appname == "rest"' + realtime: '.appname == "realtime"' + storage: '.appname == "storage"' + functions: '.appname == "functions"' + db: '.appname == "db"' # Ignores non nginx errors since they are related with kong booting up kong_logs: type: remap