diff --git a/proservice-mobile-enterprise/README.md b/proservice-mobile-enterprise/README.md new file mode 100644 index 0000000..2a297c7 --- /dev/null +++ b/proservice-mobile-enterprise/README.md @@ -0,0 +1,100 @@ +# ProService Mobile – Secure Donation Flow + +This document describes how to run and deploy the secure donation flow that integrates Stripe payments with Supabase Edge Functions for the Solar Concept initiative. + +## Overview + +- Mobile app uses `@supabase/supabase-js` to fetch projects and invoke secure Edge Functions. +- Stripe Payment Intents are created server-side via Supabase functions to keep secrets outside the client. +- A hardened error handler surfaces end-user friendly messages while preserving observability in logs. +- Supabase Row Level Security (RLS) enforces access control for donation data. + +## Environment Variables + +Configure the following variables before building the mobile app or deploying the backend: + +| Variable | Purpose | Where to set | +|----------|---------|--------------| +| `EXPO_PUBLIC_SUPABASE_URL` or `SUPABASE_URL` | Supabase project URL | Mobile app | +| `EXPO_PUBLIC_SUPABASE_ANON_KEY` or `SUPABASE_ANON_KEY` | Supabase anonymous key | Mobile app | +| `STRIPE_PUBLISHABLE_KEY` or `EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Stripe publishable key used by the mobile client | Mobile app | +| `STRIPE_SECRET_KEY` | Secret key used by Supabase Edge Functions to talk to Stripe | Supabase secrets | +| `APP_URL` | Public URL of your frontend for Stripe redirects | Supabase secrets | +| `STRIPE_TEST_PAYMENT_METHOD` (optional) | Test payment method ID (`pm_card_visa`) for automated confirmations during QA | Supabase secrets | + +> ℹ️ **React Native / Expo** – make sure environment variables are available at build time (e.g. `app.config.js`, `expo-config`, or `react-native-config`). + +## Supabase Edge Functions + +The repository ships with two Edge Functions under `supabase/functions`: + +- `create-payment-intent`: validates donation payloads and returns a Stripe Payment Intent client secret. +- `confirm-payment`: retrieves or finalises a Payment Intent to confirm the donation outcome. + +### Deploy + +```bash +supabase functions deploy create-payment-intent +supabase functions deploy confirm-payment +``` + +### Configure Secrets + +```bash +supabase secrets set STRIPE_SECRET_KEY=sk_test_... +supabase secrets set APP_URL=https://your-app.com +supabase secrets set STRIPE_TEST_PAYMENT_METHOD=pm_card_visa # optional +``` + +## Database Security + +Apply the recommended RLS policies in the Supabase SQL editor: + +```sql +ALTER TABLE solar_projects ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Public can view projects" ON solar_projects + FOR SELECT USING (true); + +CREATE POLICY "Authenticated users can donate" ON donations + FOR INSERT TO authenticated WITH CHECK (true); + +CREATE POLICY "Users see own donations" ON donations + FOR SELECT USING (auth.uid() = user_id); +``` + +## Mobile Integration + +- Use `SecureDonationButton` from `src/components/SecureDonationButton.tsx` to trigger a protected donation flow. +- `PaymentService` (under `src/services`) abstracts Supabase function calls and amount sanitisation. +- `ErrorHandler` unifies network and payment error messaging. + +### Quick Usage + +```tsx +import SecureDonationButton from '../components/SecureDonationButton'; + + { + console.log('Donation confirmed', amount, paymentIntentId); + }} +/>; +``` + +## Testing Checklist + +- [ ] Run `npm install` (or `yarn`) inside `proservice-mobile-enterprise/`. +- [ ] Provide environment variables via `.env`, `app.config.js`, or native build tooling. +- [ ] Use Stripe test cards to validate the flow (`4242 4242 4242 4242`). +- [ ] Monitor Supabase Edge Function logs (`supabase functions logs `). + +## Deployment Recap + +1. Configure Supabase secrets (`STRIPE_SECRET_KEY`, `APP_URL`, optional test payment method). +2. Deploy `create-payment-intent` and `confirm-payment`. +3. Apply database RLS policies. +4. Rebuild the mobile app with publishable keys and Supabase credentials. +5. Verify transactions in the Stripe dashboard (test mode first, then live). diff --git a/proservice-mobile-enterprise/package.json b/proservice-mobile-enterprise/package.json index b96c1e0..0117f64 100644 --- a/proservice-mobile-enterprise/package.json +++ b/proservice-mobile-enterprise/package.json @@ -23,6 +23,7 @@ "react-native": "0.73.0", "@tanstack/react-query": "^5.0.0", "@reduxjs/toolkit": "^2.0.0", + "@supabase/supabase-js": "^2.45.4", "react-redux": "^8.1.0", "react-native-maps": "1.7.1", "@stripe/stripe-react-native": "0.26.0", diff --git a/proservice-mobile-enterprise/src/components/SecureDonationButton.tsx b/proservice-mobile-enterprise/src/components/SecureDonationButton.tsx new file mode 100644 index 0000000..34c73f9 --- /dev/null +++ b/proservice-mobile-enterprise/src/components/SecureDonationButton.tsx @@ -0,0 +1,260 @@ +import React, { useMemo, useState } from 'react'; +import { + Alert, + StyleSheet, + StyleProp, + Text, + TextInput, + TouchableOpacity, + View, + ViewStyle, + TextStyle +} from 'react-native'; +import { PaymentService } from '../services/paymentService'; +import { ErrorHandler } from '../services/errorHandler'; + +interface SecureDonationButtonProps { + projectId: string; + projectName: string; + userEmail: string; + defaultAmounts?: number[]; + containerStyle?: StyleProp; + titleStyle?: StyleProp; + onSuccess?: (payload: { amount: number; paymentIntentId: string }) => void; +} + +const DEFAULT_AMOUNTS = [10, 25, 50, 100]; + +export const SecureDonationButton: React.FC = ({ + projectId, + projectName, + userEmail, + defaultAmounts = DEFAULT_AMOUNTS, + containerStyle, + titleStyle, + onSuccess +}) => { + const [selectedAmount, setSelectedAmount] = useState(null); + const [customAmount, setCustomAmount] = useState(''); + const [loading, setLoading] = useState(false); + + const formattedAmounts = useMemo(() => { + return defaultAmounts + .filter((amount) => Number.isFinite(amount) && amount > 0) + .map((amount) => Math.round(amount)); + }, [defaultAmounts]); + + const resolveAmount = (): number | null => { + if (selectedAmount && selectedAmount > 0) { + return selectedAmount; + } + + if (customAmount.trim().length === 0) { + return null; + } + + const value = parseFloat(customAmount.replace(',', '.')); + if (!Number.isFinite(value) || value <= 0) { + return null; + } + + return Number(value.toFixed(2)); + }; + + const resetState = () => { + setSelectedAmount(null); + setCustomAmount(''); + }; + + const handleSecureDonation = async () => { + const amount = resolveAmount(); + + if (!amount) { + Alert.alert('Erreur', 'Veuillez saisir un montant valide'); + return; + } + + if (!userEmail) { + Alert.alert('Erreur', 'Adresse e-mail utilisateur manquante'); + return; + } + + setLoading(true); + + try { + const paymentData = await ErrorHandler.safeAPIcall( + () => PaymentService.createDonationIntent(amount, projectId, userEmail), + 'create-donation-intent' + ); + + console.log('Paiement sécurisé initié:', paymentData); + + await new Promise((resolve) => setTimeout(resolve, 1500)); + + const confirmation = await ErrorHandler.safeAPIcall( + () => PaymentService.confirmDonation(paymentData.id), + 'confirm-donation' + ); + + Alert.alert( + 'Don sécurisé réussi !', + `Merci pour votre don de ${amount}€ pour ${projectName}` + ); + + if (onSuccess) { + onSuccess({ amount, paymentIntentId: confirmation.id }); + } + + resetState(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Erreur de sécurité'; + Alert.alert('Erreur de sécurité', message); + } finally { + setLoading(false); + } + }; + + const resolvedAmountLabel = resolveAmount(); + + return ( + + Faire un don sécurisé + + + {formattedAmounts.map((amount) => { + const isSelected = selectedAmount === amount; + return ( + { + setSelectedAmount(amount); + setCustomAmount(''); + }} + disabled={loading} + > + + {amount}€ + + + ); + })} + + + Ou montant personnalisé : + { + const sanitized = text.replace(/[^\d.,]/g, ''); + setCustomAmount(sanitized); + setSelectedAmount(null); + }} + keyboardType="decimal-pad" + maxLength={8} + editable={!loading} + /> + + + + {loading + ? 'Traitement sécurisé...' + : resolvedAmountLabel + ? `Donner ${resolvedAmountLabel}€` + : 'Sélectionnez un montant'} + + + + 🔒 Paiement 100% sécurisé + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#0f172a', + borderRadius: 16, + padding: 20, + borderWidth: 1, + borderColor: '#1e293b' + }, + title: { + color: '#f8fafc', + fontSize: 18, + fontWeight: '600', + marginBottom: 16, + textAlign: 'center' + }, + amounts: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + marginBottom: 12 + }, + amountButton: { + width: '48%', + paddingVertical: 12, + borderRadius: 12, + borderWidth: 1, + borderColor: '#1e293b', + backgroundColor: '#1f2937', + marginBottom: 8, + alignItems: 'center' + }, + amountButtonSelected: { + backgroundColor: '#2563eb', + borderColor: '#3b82f6' + }, + amountText: { + color: '#f8fafc', + fontSize: 16, + fontWeight: '500' + }, + amountTextSelected: { + color: '#ffffff' + }, + customLabel: { + color: '#94a3b8', + fontSize: 14, + marginBottom: 8 + }, + customInput: { + backgroundColor: '#1f2937', + borderColor: '#1e293b', + borderWidth: 1, + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 12, + color: '#f8fafc', + fontSize: 16, + marginBottom: 16 + }, + donateButton: { + backgroundColor: '#22c55e', + borderRadius: 12, + paddingVertical: 14, + alignItems: 'center' + }, + donateButtonDisabled: { + backgroundColor: '#16a34a88' + }, + donateButtonText: { + color: '#0f172a', + fontSize: 16, + fontWeight: '700' + }, + securityNote: { + color: '#4ade80', + fontSize: 12, + textAlign: 'center', + marginTop: 12 + } +}); + +export default SecureDonationButton; diff --git a/proservice-mobile-enterprise/src/services/errorHandler.ts b/proservice-mobile-enterprise/src/services/errorHandler.ts new file mode 100644 index 0000000..3f9e25f --- /dev/null +++ b/proservice-mobile-enterprise/src/services/errorHandler.ts @@ -0,0 +1,51 @@ +type ErrorCategory = 'network' | 'payment' | 'auth' | 'default'; + +const DEFAULT_ERROR_MAP: Record = { + network: 'Erreur réseau. Vérifiez votre connexion.', + payment: 'Erreur de paiement. Réessayez ou contactez le support.', + auth: "Erreur d'authentification. Reconnectez-vous.", + default: 'Une erreur est survenue. Réessayez.' +}; + +const resolveErrorType = (error: unknown): ErrorCategory => { + if (!error || typeof error !== 'object') { + return 'default'; + } + + if ('type' in error && typeof (error as { type?: string }).type === 'string') { + const type = (error as { type?: string }).type as ErrorCategory; + if (type === 'network' || type === 'payment' || type === 'auth') { + return type; + } + } + + if (error instanceof Error && error.message.toLowerCase().includes('paiement')) { + return 'payment'; + } + + return 'default'; +}; + +export class ErrorHandler { + static handleAPIError(error: unknown, context: string): string { + console.error(`[${context}] Error:`, error); + + const type = resolveErrorType(error); + const message = DEFAULT_ERROR_MAP[type] ?? DEFAULT_ERROR_MAP.default; + + if (type === 'default' && error instanceof Error && error.message) { + return error.message; + } + + return message; + } + + static async safeAPIcall(apiCall: () => Promise, context: string): Promise { + try { + return await apiCall(); + } catch (error) { + const userMessage = this.handleAPIError(error, context); + throw new Error(userMessage); + } + } +} diff --git a/proservice-mobile-enterprise/src/services/paymentService.ts b/proservice-mobile-enterprise/src/services/paymentService.ts new file mode 100644 index 0000000..6f3c4c1 --- /dev/null +++ b/proservice-mobile-enterprise/src/services/paymentService.ts @@ -0,0 +1,87 @@ +import { supabase } from './supabase'; + +interface SupabaseFunctionResponse { + data: T | null; + error: { message: string } | null; +} + +export interface DonationIntent { + id: string; + clientSecret?: string; + url?: string; +} + +const normalizeAmount = (amount: number): number => { + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error('Montant de don invalide'); + } + + return Math.round(amount * 100); +}; + +const propagateError = (error: unknown, fallbackMessage: string): never => { + if (error instanceof Error && error.message) { + throw new Error(error.message); + } + + if (error && typeof error === 'object' && 'message' in error) { + const message = String((error as { message?: string }).message ?? fallbackMessage); + throw new Error(message); + } + + throw new Error(fallbackMessage); +}; + +export class PaymentService { + static async createDonationIntent(amount: number, projectId: string, userEmail: string): Promise { + try { + const normalizedAmount = normalizeAmount(amount); + + const response = (await supabase.functions.invoke('create-payment-intent', { + body: { + amount: normalizedAmount, + projectId, + userEmail + } + })) as SupabaseFunctionResponse; + + if (response.error) { + propagateError(response.error, 'Erreur de traitement du paiement'); + } + + if (!response.data) { + throw new Error('Réponse paiement invalide'); + } + + return response.data; + } catch (error) { + console.error('[PaymentService.createDonationIntent] error:', error); + propagateError(error, 'Erreur de traitement du paiement'); + } + } + + static async confirmDonation(paymentIntentId: string): Promise { + try { + if (!paymentIntentId) { + throw new Error('Identifiant de paiement manquant'); + } + + const response = (await supabase.functions.invoke('confirm-payment', { + body: { paymentIntentId } + })) as SupabaseFunctionResponse; + + if (response.error) { + propagateError(response.error, 'Erreur de confirmation du paiement'); + } + + if (!response.data) { + throw new Error('Réponse de confirmation invalide'); + } + + return response.data; + } catch (error) { + console.error('[PaymentService.confirmDonation] error:', error); + propagateError(error, 'Erreur de confirmation du paiement'); + } + } +} diff --git a/proservice-mobile-enterprise/src/services/supabase.ts b/proservice-mobile-enterprise/src/services/supabase.ts new file mode 100644 index 0000000..5e8736b --- /dev/null +++ b/proservice-mobile-enterprise/src/services/supabase.ts @@ -0,0 +1,112 @@ +import { createClient, SupabaseClient, SupabaseRealtimePayload } from '@supabase/supabase-js'; +import type { RealtimeChannel } from '@supabase/supabase-js'; + +type EnvSource = Record; +type EnvContainer = { + process?: { env?: EnvSource }; + __ENV__?: EnvSource; + env?: EnvSource; +}; + +const globalEnv: EnvContainer = + (typeof globalThis !== 'undefined' ? (globalThis as EnvContainer) : {}) ?? {}; + +const envSources: EnvSource[] = [ + globalEnv.process?.env ?? {}, + globalEnv.__ENV__ ?? {}, + globalEnv.env ?? {} +]; + +const resolveEnvVar = (...keys: string[]): string => { + for (const key of keys) { + for (const source of envSources) { + const value = source?.[key]; + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + } + } + + throw new Error( + `Missing environment variable. Tried keys: ${keys.join(', ')}. Ensure your mobile app exposes these values at build time.` + ); +}; + +const supabaseUrl = resolveEnvVar('EXPO_PUBLIC_SUPABASE_URL', 'SUPABASE_URL'); +const supabaseAnonKey = resolveEnvVar('EXPO_PUBLIC_SUPABASE_ANON_KEY', 'SUPABASE_ANON_KEY'); + +export const supabase: SupabaseClient = createClient(supabaseUrl, supabaseAnonKey, { + auth: { + persistSession: false + }, + realtime: { + params: { + eventsPerSecond: 5 + } + } +}); + +export interface Project { + id: string; + name: string; + summary?: string | null; + goal_amount?: number | null; + collected_amount?: number | null; + latitude?: number | null; + longitude?: number | null; + thumbnail_url?: string | null; + updated_at?: string | null; + created_at?: string | null; +} + +export const getProjects = async (): Promise => { + const { data, error } = await supabase + .from('solar_projects') + .select('*') + .order('updated_at', { ascending: false, nullsFirst: false }); + + if (error) { + const typedError = new Error(error.message); + (typedError as Error & { type?: string }).type = 'network'; + throw typedError; + } + + return data ?? []; +}; + +export const subscribeToProjects = ( + callback: (payload: SupabaseRealtimePayload) => void +): RealtimeChannel => { + const channel = supabase + .channel('solar-projects-feed') + .on>( + 'postgres_changes', + { event: '*', schema: 'public', table: 'solar_projects' }, + (payload) => { + try { + callback(payload); + } catch (error) { + console.error('[subscribeToProjects] Listener error:', error); + } + } + ) + .subscribe((status) => { + if (status === 'SUBSCRIBED') { + console.log('✅ Realtime subscription established for solar_projects'); + } else if (status === 'CHANNEL_ERROR') { + console.warn('⚠️ Realtime subscription error for solar_projects'); + } + }); + + return channel; +}; + +export interface DonationPayload { + projectId: string; + amount: number; + userEmail: string; +} + +export interface DonationConfirmation { + paymentIntentId: string; +} diff --git a/proservice-mobile-enterprise/supabase/functions/confirm-payment/index.ts b/proservice-mobile-enterprise/supabase/functions/confirm-payment/index.ts new file mode 100644 index 0000000..d1dcfcc --- /dev/null +++ b/proservice-mobile-enterprise/supabase/functions/confirm-payment/index.ts @@ -0,0 +1,64 @@ +import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; +import { corsHeaders, jsonResponse, parseJson } from "../shared/utils.ts"; +import { stripe, STRIPE_TEST_PAYMENT_METHOD } from "../shared/stripe.ts"; + +interface ConfirmPaymentPayload { + paymentIntentId: string; +} + +const sanitizePaymentIntentId = (paymentIntentId: unknown): string => { + if (typeof paymentIntentId !== "string" || paymentIntentId.trim().length === 0) { + throw new Error("Missing payment intent identifier"); + } + + return paymentIntentId.trim(); +}; + +serve(async (req: Request): Promise => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders(req) }); + } + + if (req.method !== "POST") { + return jsonResponse(req, { error: "Method not allowed" }, 405); + } + + try { + const payload = await parseJson(req); + const paymentIntentId = sanitizePaymentIntentId(payload.paymentIntentId); + + let paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId, { + expand: ["latest_charge"], + }); + + if ( + paymentIntent.status === "requires_confirmation" && + STRIPE_TEST_PAYMENT_METHOD + ) { + paymentIntent = await stripe.paymentIntents.confirm(paymentIntentId, { + payment_method: STRIPE_TEST_PAYMENT_METHOD, + }); + } + + if ( + paymentIntent.status !== "succeeded" && + paymentIntent.status !== "processing" && + paymentIntent.status !== "requires_capture" + ) { + throw new Error(`Payment intent ${paymentIntentId} is not completed`); + } + + return jsonResponse(req, { + id: paymentIntent.id, + status: paymentIntent.status, + amountReceived: paymentIntent.amount_received, + currency: paymentIntent.currency, + latestCharge: paymentIntent.latest_charge, + }); + } catch (error) { + console.error("[confirm-payment] error", error); + + const message = error instanceof Error ? error.message : "Une erreur est survenue"; + return jsonResponse(req, { error: message }, 500); + } +}); diff --git a/proservice-mobile-enterprise/supabase/functions/create-payment-intent/index.ts b/proservice-mobile-enterprise/supabase/functions/create-payment-intent/index.ts new file mode 100644 index 0000000..dc92f0d --- /dev/null +++ b/proservice-mobile-enterprise/supabase/functions/create-payment-intent/index.ts @@ -0,0 +1,105 @@ +import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; +import { corsHeaders, jsonResponse, parseJson } from "../shared/utils.ts"; +import { stripe, DEFAULT_CANCEL_URL, DEFAULT_SUCCESS_URL } from "../shared/stripe.ts"; + +interface CreatePaymentIntentPayload { + amount: number; + projectId: string; + userEmail?: string; + currency?: string; +} + +const sanitizeAmount = (amount: unknown): number => { + if (typeof amount !== "number" || !Number.isFinite(amount) || amount <= 0) { + throw new Error("Invalid donation amount"); + } + + if (!Number.isInteger(amount)) { + throw new Error("Amount must be expressed in the smallest currency unit (integer)"); + } + + return amount; +}; + +const sanitizeProjectId = (projectId: unknown): string => { + if (typeof projectId !== "string" || projectId.trim().length === 0) { + throw new Error("Invalid project identifier"); + } + return projectId.trim(); +}; + +const sanitizeEmail = (email: unknown): string | undefined => { + if (typeof email === "undefined" || email === null) { + return undefined; + } + + if (typeof email !== "string") { + throw new Error("Invalid email address"); + } + + const normalized = email.trim().toLowerCase(); + if ( + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized) + ) { + throw new Error("Invalid email address"); + } + + return normalized; +}; + +const sanitizeCurrency = (currency: unknown): string => { + if (typeof currency === "string" && /^[a-zA-Z]{3}$/.test(currency)) { + return currency.toLowerCase(); + } + return "eur"; +}; + +serve(async (req: Request): Promise => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders(req) }); + } + + if (req.method !== "POST") { + return jsonResponse(req, { error: "Method not allowed" }, 405); + } + + try { + const payload = await parseJson(req); + + const amount = sanitizeAmount(payload.amount); + const projectId = sanitizeProjectId(payload.projectId); + const userEmail = sanitizeEmail(payload.userEmail); + const currency = sanitizeCurrency(payload.currency); + + const paymentIntent = await stripe.paymentIntents.create({ + amount, + currency, + metadata: { + projectId, + source: "mobile-app", + }, + receipt_email: userEmail, + automatic_payment_methods: { + enabled: true, + }, + }); + + return jsonResponse(req, { + id: paymentIntent.id, + clientSecret: paymentIntent.client_secret, + status: paymentIntent.status, + nextAction: paymentIntent.next_action, + amount, + currency, + successUrl: DEFAULT_SUCCESS_URL, + cancelUrl: DEFAULT_CANCEL_URL, + }); + } catch (error) { + console.error("[create-payment-intent] error", error); + + const message = + error instanceof Error ? error.message : "Une erreur est survenue"; + + return jsonResponse(req, { error: message }, 500); + } +}); diff --git a/proservice-mobile-enterprise/supabase/functions/shared/stripe.ts b/proservice-mobile-enterprise/supabase/functions/shared/stripe.ts new file mode 100644 index 0000000..ee40f1b --- /dev/null +++ b/proservice-mobile-enterprise/supabase/functions/shared/stripe.ts @@ -0,0 +1,14 @@ +import Stripe from "https://esm.sh/stripe@13.11.0?target=deno&deno-std=0.190.0"; + +const apiVersion = "2023-10-16"; +const stripeSecret = Deno.env.get("STRIPE_SECRET_KEY"); + +if (!stripeSecret) { + throw new Error("Missing STRIPE_SECRET_KEY environment variable"); +} + +export const stripe = new Stripe(stripeSecret, { apiVersion }); +export const APP_URL = Deno.env.get("APP_URL") ?? ""; +export const DEFAULT_SUCCESS_URL = APP_URL ? `${APP_URL}/success` : "https://example.com/success"; +export const DEFAULT_CANCEL_URL = APP_URL ? `${APP_URL}/cancel` : "https://example.com/cancel"; +export const STRIPE_TEST_PAYMENT_METHOD = Deno.env.get("STRIPE_TEST_PAYMENT_METHOD") ?? ""; diff --git a/proservice-mobile-enterprise/supabase/functions/shared/utils.ts b/proservice-mobile-enterprise/supabase/functions/shared/utils.ts new file mode 100644 index 0000000..a68e294 --- /dev/null +++ b/proservice-mobile-enterprise/supabase/functions/shared/utils.ts @@ -0,0 +1,66 @@ +const baseHeaders = { + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Content-Type": "application/json", +} as const; + +const allowedOrigins = ((): string[] => { + const origins = Deno.env.get("ALLOWED_ORIGINS") ?? Deno.env.get("APP_URL") ?? "*"; + return origins + .split(",") + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); +})(); + +const defaultOrigin = allowedOrigins[0] ?? "*"; + +const resolveOrigin = (requestOrigin: string | null): string => { + if (!requestOrigin || requestOrigin.length === 0) { + return allowedOrigins.includes("*") ? "*" : defaultOrigin; + } + + if (allowedOrigins.includes("*") || allowedOrigins.includes(requestOrigin)) { + return requestOrigin; + } + + return defaultOrigin; +}; + +export const corsHeaders = ( + req: Request, + extra?: Record, +): Record => { + const origin = resolveOrigin(req.headers.get("origin")); + + return { + ...baseHeaders, + "Access-Control-Allow-Origin": origin, + ...extra, + }; +}; + +export const jsonResponse = ( + req: Request, + data: unknown, + status = 200, + extraHeaders?: Record, +): Response => { + const headers = corsHeaders(req, extraHeaders); + return new Response(JSON.stringify(data), { + status, + headers, + }); +}; + +export const parseJson = async (req: Request): Promise => { + const contentType = req.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) { + throw new Error("Unsupported content type"); + } + + try { + return (await req.json()) as T; + } catch { + throw new Error("Invalid JSON payload"); + } +};