196 lines
5.6 KiB
TypeScript
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' },
|
|
});
|
|
}
|
|
});
|