/** * GET /profile/:handle - Get user profile by handle * GET /profile/me - Get own profile * PATCH /profile - Update own profile * * Design intent: * - Profiles are public (unless blocked) * - Shows harmony tier (not score) * - Minimal public metrics */ 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'; import { trySignR2Url } from '../_shared/r2_signer.ts'; const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || '*'; const corsHeaders = { 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, 'Access-Control-Allow-Methods': 'GET, PATCH', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', }; serve(async (req) => { if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders, }); } try { const authHeader = req.headers.get('Authorization'); if (!authHeader) { return new Response(JSON.stringify({ error: 'Missing authorization header' }), { status: 401, headers: { ...corsHeaders, '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: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } const url = new URL(req.url); const handle = url.searchParams.get('handle'); // GET /profile/me or /profile?handle=username if (req.method === 'GET') { let profileQuery; let isOwnProfile = false; if (handle) { // Get profile by handle profileQuery = supabase .from('profiles') .select( ` id, handle, display_name, bio, location, website, interests, avatar_url, cover_url, origin_country, is_private, is_official, created_at, trust_state ( tier ) ` ) .eq('handle', handle) .single(); } else { // Get own profile isOwnProfile = true; profileQuery = supabase .from('profiles') .select( ` id, handle, display_name, bio, location, website, interests, avatar_url, cover_url, origin_country, is_private, is_official, created_at, updated_at, trust_state ( harmony_score, tier, posts_today ) ` ) .eq('id', user.id) .single(); } const { data: profile, error: profileError } = await profileQuery; if (profileError || !profile) { return new Response(JSON.stringify({ error: 'Profile not found' }), { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } // Check if viewing own profile if (profile.id === user.id) { isOwnProfile = true; } // Get privacy settings for this profile const { data: privacySettings } = await supabase .from('profile_privacy_settings') .select('*') .eq('user_id', profile.id) .maybeSingle(); const profileVisibility = privacySettings?.profile_visibility || 'public'; // Apply privacy filtering based on viewer relationship if (!isOwnProfile && profileVisibility !== 'public') { // Check if viewer is following the profile const { data: followData } = await supabase .from('follows') .select('status') .eq('follower_id', user.id) .eq('following_id', profile.id) .maybeSingle(); const followStatus = followData?.status as string | null; const isFollowing = followStatus === 'accepted'; let isFollowedBy = false; if (user.id !== profile.id) { const { data: reverseFollow } = await supabase .from('follows') .select('status') .eq('follower_id', profile.id) .eq('following_id', user.id) .maybeSingle(); isFollowedBy = reverseFollow?.status === 'accepted'; } const isFriend = isFollowing && isFollowedBy; // Check privacy visibility if (profile.is_private || profileVisibility === 'private') { // Private profiles show minimal info to non-followers if (!isFollowing) { return new Response( JSON.stringify({ profile: { id: profile.id, handle: profile.handle ?? 'unknown', display_name: profile.display_name ?? 'Anonymous', avatar_url: profile.avatar_url, created_at: profile.created_at, trust_state: profile.trust_state, }, stats: { posts: 0, followers: 0, following: 0, }, is_following: false, is_followed_by: isFollowedBy, is_friend: isFriend, follow_status: followStatus, is_private: true, }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, } ); } } else if (profileVisibility === 'followers') { // Followers-only profiles hide details from non-followers if (!isFollowing) { profile.bio = null; profile.location = null; profile.website = null; profile.interests = null; } } } // Coalesce null values for older clients const safeProfile = { ...profile, handle: profile.handle ?? 'unknown', display_name: profile.display_name ?? 'Anonymous', }; if (safeProfile.avatar_url) { safeProfile.avatar_url = await trySignR2Url(safeProfile.avatar_url); } if (safeProfile.cover_url) { safeProfile.cover_url = await trySignR2Url(safeProfile.cover_url); } // Get post count const { count: postCount } = await supabase .from('posts') .select('*', { count: 'exact', head: true }) .eq('author_id', safeProfile.id); // Get follower/following counts const { count: followerCount } = await supabase .from('follows') .select('*', { count: 'exact', head: true }) .eq('following_id', safeProfile.id) .eq('status', 'accepted'); const { count: followingCount } = await supabase .from('follows') .select('*', { count: 'exact', head: true }) .eq('follower_id', safeProfile.id) .eq('status', 'accepted'); // Check if current user is following this profile let isFollowing = false; let isFollowedBy = false; let isFriend = false; let followStatus: string | null = null; if (user && user.id !== safeProfile.id) { const { data: followData } = await supabase .from('follows') .select('status') .eq('follower_id', user.id) .eq('following_id', safeProfile.id) .maybeSingle(); followStatus = followData?.status as string | null; isFollowing = followStatus === 'accepted'; const { data: reverseFollow } = await supabase .from('follows') .select('status') .eq('follower_id', safeProfile.id) .eq('following_id', user.id) .maybeSingle(); isFollowedBy = reverseFollow?.status === 'accepted'; isFriend = isFollowing && isFollowedBy; } const isPrivateForViewer = (profile.is_private ?? false) && !isFollowing && !isOwnProfile; return new Response( JSON.stringify({ profile: safeProfile, stats: { posts: postCount || 0, followers: followerCount || 0, following: followingCount || 0, }, is_following: isFollowing, is_followed_by: isFollowedBy, is_friend: isFriend, follow_status: followStatus, is_private: isPrivateForViewer, }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, } ); } // PATCH /profile - Update own profile if (req.method === 'PATCH') { const { handle, display_name, bio, location, website, interests, avatar_url, cover_url } = await req.json(); const updates: any = {}; // Handle username changes with 30-day limit if (handle !== undefined) { if (handle.trim().length < 3 || handle.length > 20) { throw new ValidationError('Username must be 3-20 characters', 'handle'); } if (!/^[a-zA-Z0-9_]+$/.test(handle)) { throw new ValidationError('Username can only contain letters, numbers, and underscores', 'handle'); } // Check if handle is already taken const { data: existingProfile } = await supabase .from('profiles') .select('id') .eq('handle', handle) .neq('id', user.id) .maybeSingle(); if (existingProfile) { throw new ValidationError('Username is already taken', 'handle'); } // Check 30-day limit const { data: canChange, error: canChangeError } = await supabase .rpc('can_change_handle', { p_user_id: user.id }); if (canChangeError || !canChange) { throw new ValidationError('You can only change your username once every 30 days', 'handle'); } updates.handle = handle; } if (display_name !== undefined) { if (display_name.trim().length === 0 || display_name.length > 50) { throw new ValidationError('Display name must be 1-50 characters', 'display_name'); } updates.display_name = display_name; } if (bio !== undefined) { if (bio && bio.length > 300) { throw new ValidationError('Bio must be 300 characters or less', 'bio'); } updates.bio = bio || null; } if (location !== undefined) { if (location && location.length > 100) { throw new ValidationError('Location must be 100 characters or less', 'location'); } updates.location = location || null; } if (website !== undefined) { if (website) { if (website.length > 200) { throw new ValidationError('Website must be 200 characters or less', 'website'); } // Validate URL format and scheme try { const url = new URL(website.startsWith('http') ? website : `https://${website}`); if (!['http:', 'https:'].includes(url.protocol)) { throw new ValidationError('Website must be a valid HTTP or HTTPS URL', 'website'); } } catch (error) { throw new ValidationError('Website must be a valid URL', 'website'); } } updates.website = website || null; } if (interests !== undefined) { if (Array.isArray(interests)) { updates.interests = interests; } else { throw new ValidationError('Interests must be an array', 'interests'); } } if (avatar_url !== undefined) { updates.avatar_url = avatar_url || null; } if (cover_url !== undefined) { updates.cover_url = cover_url || null; } if (Object.keys(updates).length === 0) { return new Response(JSON.stringify({ error: 'No fields to update' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } updates.updated_at = new Date().toISOString(); const { data: profile, error: updateError } = await supabase .from('profiles') .update(updates) .eq('id', user.id) .select() .single(); if (updateError) { console.error('Error updating profile:', updateError); return new Response(JSON.stringify({ error: 'Failed to update profile', details: updateError.message, code: updateError.code }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } return new Response( JSON.stringify({ profile, message: 'Profile updated', }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, } ); } return new Response(JSON.stringify({ error: 'Method not allowed' }), { status: 405, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } catch (error) { if (error instanceof ValidationError) { return new Response( JSON.stringify({ error: 'Validation error', message: error.message }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } console.error('Unexpected error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } });