'use server' import { createClient } from '@/lib/supabase/server' import { createAdminClient } from '@/lib/supabase/admin' import { redirect } from 'next/navigation' import { checkRateLimit, sanitizeString, sanitizeSlug } from '@/lib/security' function generateInviteCode() { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' const prefix = 'HSL' const year = new Date().getFullYear() let code = '' for (let i = 0; i < 4; i++) code += chars.charAt(Math.floor(Math.random() * chars.length)) return `${prefix}-${year}-${code}` } export async function createSpace(formData: { spaceName: string spaceSlug: string spaceCity?: string displayName: string }) { // Verify user is authenticated using regular client const supabase = await createClient() const { data: { user }, error: userErr } = await supabase.auth.getUser() if (userErr || !user) return { error: 'Not authenticated' } // Rate limit space creation const rateLimit = checkRateLimit(`createspace:${user.id}`, 3, 3600000) // 3 per hour if (!rateLimit.allowed) { return { error: 'Too many space creation attempts. Please try again later.' } } // Sanitize inputs const spaceName = sanitizeString(formData.spaceName).slice(0, 100) const spaceSlug = sanitizeSlug(formData.spaceSlug) const spaceCity = formData.spaceCity ? sanitizeString(formData.spaceCity).slice(0, 100) : null const displayName = sanitizeString(formData.displayName).slice(0, 100) if (!spaceName || !spaceSlug || !displayName) { return { error: 'Invalid input. Please check your entries.' } } // Use admin client for inserts (bypasses RLS) const admin = createAdminClient() // Check if slug already exists const { data: existingSpace } = await admin .from('spaces') .select('id') .eq('slug', spaceSlug) .maybeSingle() if (existingSpace) { return { error: 'A space with this URL slug already exists. Please choose a different one.' } } // Create the space const { data: space, error: spaceErr } = await admin .from('spaces') .insert({ name: spaceName, slug: spaceSlug, city: spaceCity, invite_code: generateInviteCode(), }) .select('id') .single() if (spaceErr || !space) { if (spaceErr?.code === '23505') { return { error: 'A space with this URL slug already exists. Please choose a different one.' } } return { error: spaceErr?.message ?? 'Failed to create space' } } // Add user as admin. The founder configures the space; they do not go // through the member onboarding flow, so mark it complete up front. const { error: memberErr } = await admin .from('space_members') .insert({ space_id: space.id, user_id: user.id, display_name: displayName || user.email, email: user.email, role: 'admin', tier: 'plus', status: 'current', approved: true, onboarding_completed_at: new Date().toISOString(), }) if (memberErr) { return { error: memberErr.message } } return { success: true } } export async function joinSpace(formData: { inviteCode: string displayName: string }) { // Verify user is authenticated const supabase = await createClient() const { data: { user }, error: userErr } = await supabase.auth.getUser() if (userErr || !user) return { error: 'Not authenticated' } // Use admin client for operations const admin = createAdminClient() const code = formData.inviteCode.trim().toUpperCase() // Try the new multi-code invites table first. Falls back to the legacy // spaces.invite_code (a permanent default) if the code isn't found there. let invite: { id: string; space_id: string; max_uses: number | null; uses_count: number; expires_at: string | null; is_enabled: boolean } | null = null const { data: inviteRow } = await admin .from('space_invites') .select('id, space_id, max_uses, uses_count, expires_at, is_enabled') .eq('code', code) .maybeSingle() if (inviteRow) invite = inviteRow as typeof invite let spaceId: string | null = null if (invite) { if (!invite.is_enabled) return { error: 'This invite is disabled.' } if (invite.expires_at && new Date(invite.expires_at) < new Date()) return { error: 'This invite has expired.' } if (invite.max_uses !== null && invite.uses_count >= invite.max_uses) return { error: 'This invite has reached its use cap.' } spaceId = invite.space_id } else { const { data: legacy } = await admin .from('spaces') .select('id') .eq('invite_code', code) .maybeSingle() if (!legacy) return { error: 'Invalid invite code' } spaceId = legacy.id } const { data: space, error: lookupErr } = await admin .from('spaces') .select('id, require_approval') .eq('id', spaceId) .single() if (lookupErr || !space) return { error: 'Invalid invite code' } const { error: memberErr } = await admin .from('space_members') .insert({ space_id: space.id, user_id: user.id, display_name: formData.displayName || user.email, email: user.email, role: 'member', tier: 'basic', status: space.require_approval ? 'unverified' : 'current', approved: true, }) if (memberErr) return { error: memberErr.message } // Increment invite usage counter for the multi-code path. if (invite) { await admin .from('space_invites') .update({ uses_count: invite.uses_count + 1 }) .eq('id', invite.id) } return { success: true } } export async function signIn(email: string, password: string) { // Rate limit by email to prevent brute force const rateLimit = checkRateLimit(`signin:${email.toLowerCase()}`, 5, 60000) if (!rateLimit.allowed) { return { error: 'Too many login attempts. Please try again later.' } } const supabase = await createClient() const { data, error } = await supabase.auth.signInWithPassword({ email, password, }) if (error) { return { error: error.message } } return { data } } export async function signUp(formData: { email: string password: string fullName: string action: 'create' | 'join' spaceName?: string spaceSlug?: string city?: string inviteCode?: string }) { const supabase = await createClient() const { data: authData, error: authError } = await supabase.auth.signUp({ email: formData.email, password: formData.password, options: { data: { full_name: formData.fullName, action: formData.action, space_name: formData.spaceName, space_slug: formData.spaceSlug, city: formData.city, invite_code: formData.inviteCode, }, }, }) if (authError) { return { error: authError.message } } return { data: authData } } export async function signOut() { const supabase = await createClient() await supabase.auth.signOut() redirect('/login') } export async function getUser() { const supabase = await createClient() const { data: { user }, error } = await supabase.auth.getUser() if (error || !user) { return null } return user } export async function getCurrentMembership() { const supabase = await createClient() const user = await getUser() if (!user) return null const { data, error } = await supabase .from('space_members') .select('*, spaces(*)') .eq('user_id', user.id) .in('status', ['current', 'unverified', 'late']) .maybeSingle() if (error || !data) return null return data }