/** * POST /signup * * User registration and profile creation * Creates profile + initializes trust_state * * Flow: * 1. User signs up via Supabase Auth (handled by client) * 2. Client calls this function with profile details * 3. Create profile record * 4. Trust state is auto-initialized by trigger * 5. Return profile data */ import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; import { createSupabaseClient } from '../_shared/supabase-client.ts'; import { ValidationError } from '../_shared/validation.ts'; interface SignupRequest { handle: string; display_name: string; bio?: string; } serve(async (req) => { if (req.method === 'OPTIONS') { return new Response(null, { headers: { 'Access-Control-Allow-Origin': Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net', 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', }, }); } try { // 1. Validate auth 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 supabase = createSupabaseClient(authHeader); const { data: { user }, error: authError, } = await supabase.auth.getUser(); if (authError || !user) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } // 2. Parse request const { handle, display_name, bio } = (await req.json()) as SignupRequest; // 3. Validate inputs if (!handle || !handle.match(/^[a-z0-9_]{3,20}$/)) { throw new ValidationError( 'Handle must be 3-20 characters, lowercase letters, numbers, and underscores only', 'handle' ); } if (!display_name || display_name.trim().length === 0 || display_name.length > 50) { throw new ValidationError('Display name must be 1-50 characters', 'display_name'); } if (bio && bio.length > 300) { throw new ValidationError('Bio must be 300 characters or less', 'bio'); } // 3b. Get origin country from IP geolocation // Uses ipinfo.io to look up country from client IP address let originCountry: string | null = null; const clientIp = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); const ipinfoToken = Deno.env.get('IPINFO_TOKEN'); if (clientIp && ipinfoToken) { try { const geoRes = await fetch(`https://api.ipinfo.io/lite/${clientIp}?token=${ipinfoToken}`); if (geoRes.ok) { const geoData = await geoRes.json(); // ipinfo.io returns country as ISO 3166-1 alpha-2 code (e.g., 'US', 'GB') if (geoData.country && /^[A-Z]{2}$/.test(geoData.country)) { originCountry = geoData.country; } } } catch (geoError) { // Geolocation is optional - don't fail signup if it errors console.warn('Geolocation lookup failed:', geoError); } } // 4. Check if profile already exists const { data: existingProfile } = await supabase .from('profiles') .select('id') .eq('id', user.id) .single(); if (existingProfile) { return new Response( JSON.stringify({ error: 'Profile already exists', message: 'You have already completed signup', }), { status: 400, headers: { 'Content-Type': 'application/json' }, } ); } // 5. Create profile (trust_state will be auto-created by trigger) const { data: profile, error: profileError } = await supabase .from('profiles') .insert({ id: user.id, handle, display_name, bio: bio || null, origin_country: originCountry, }) .select() .single(); if (profileError) { // Check for duplicate handle if (profileError.code === '23505') { return new Response( JSON.stringify({ error: 'Handle taken', message: 'This handle is already in use. Please choose another.', }), { status: 400, headers: { 'Content-Type': 'application/json' }, } ); } console.error('Error creating profile:', profileError); return new Response(JSON.stringify({ error: 'Failed to create profile' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } // 6. Get the auto-created trust state const { data: trustState } = await supabase .from('trust_state') .select('harmony_score, tier') .eq('user_id', user.id) .single(); // 7. Return profile data return new Response( JSON.stringify({ profile, trust_state: trustState, message: 'Welcome to sojorn. Your journey begins quietly.', }), { status: 201, headers: { 'Content-Type': 'application/json' }, } ); } catch (error) { if (error instanceof ValidationError) { return new Response( JSON.stringify({ error: 'Validation error', message: error.message, field: error.field, }), { status: 400, headers: { 'Content-Type': 'application/json' }, } ); } console.error('Unexpected error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } });