sojorn/_legacy/supabase/functions/signup/index.ts
2026-02-15 00:33:24 -06:00

196 lines
5.6 KiB
TypeScript

/**
* 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' },
});
}
});