Initial commit from v0

This commit is contained in:
v0
2026-03-11 04:44:48 +00:00
commit 296c5dd78d
123 changed files with 21242 additions and 0 deletions
+874
View File
@@ -0,0 +1,874 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
// ─── Auth ────────────────────────────────────────────────────────────────────
export async function signIn(email: string, password: string) {
const supabase = await createClient()
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) return { error: error.message }
return { success: true }
}
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 } } = await supabase.auth.getUser()
return user
}
export async function getCurrentMember() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return null
const { data } = await supabase
.from('space_members')
.select('*, spaces(*)')
.eq('user_id', user.id)
.eq('status', 'current')
.single()
return data
}
// ─── Tasks ───────────────────────────────────────────────────────────────────
export async function createTask(formData: {
title: string
description?: string
type: string
area?: string
recurrence?: string
due_date?: string
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, display_name').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { data, error } = await supabase.from('tasks').insert({
space_id: member.space_id,
title: formData.title,
description: formData.description,
type: formData.type || 'task',
area: formData.area,
recurrence: formData.recurrence || 'none',
due_date: formData.due_date || null,
status: 'open',
requested_by: user.id,
requested_by_name: member.display_name,
}).select().single()
if (error) return { error: error.message }
// Log activity
await supabase.from('activity_log').insert({
space_id: member.space_id,
user_id: user.id,
display_name: member.display_name,
action: 'created',
entity_type: 'task',
entity_id: data.id,
details: formData.title,
})
revalidatePath('/tasks')
revalidatePath('/dashboard')
return { data }
}
export async function claimTask(taskId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, display_name').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { error } = await supabase.from('tasks').update({
claimed_by: user.id,
claimed_by_name: member.display_name,
status: 'claimed',
}).eq('id', taskId).eq('space_id', member.space_id)
if (error) return { error: error.message }
await supabase.from('activity_log').insert({
space_id: member.space_id,
user_id: user.id,
display_name: member.display_name,
action: 'claimed',
entity_type: 'task',
entity_id: taskId,
})
revalidatePath('/tasks')
revalidatePath('/dashboard')
return { success: true }
}
export async function completeTask(taskId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, display_name').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { error } = await supabase.from('tasks').update({
status: 'completed',
completed_at: new Date().toISOString(),
last_done_at: new Date().toISOString(),
}).eq('id', taskId).eq('space_id', member.space_id)
if (error) return { error: error.message }
await supabase.from('activity_log').insert({
space_id: member.space_id,
user_id: user.id,
display_name: member.display_name,
action: 'completed',
entity_type: 'task',
entity_id: taskId,
})
revalidatePath('/tasks')
revalidatePath('/dashboard')
return { success: true }
}
export async function deleteTask(taskId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { error } = await supabase.from('tasks').delete()
.eq('id', taskId).eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/tasks')
return { success: true }
}
// ─── Projects ────────────────────────────────────────────────────────────────
export async function createProject(formData: {
title: string
description?: string
area?: string
tags?: string[]
due_date?: string
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, display_name').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { data, error } = await supabase.from('projects').insert({
space_id: member.space_id,
title: formData.title,
description: formData.description,
area: formData.area,
tags: formData.tags ?? [],
due_date: formData.due_date || null,
status: 'backlog',
}).select().single()
if (error) return { error: error.message }
await supabase.from('activity_log').insert({
space_id: member.space_id,
user_id: user.id,
display_name: member.display_name,
action: 'created',
entity_type: 'project',
entity_id: data.id,
details: formData.title,
})
revalidatePath('/projects')
return { data }
}
export async function updateProjectStatus(projectId: string, status: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { error } = await supabase.from('projects').update({ status, updated_at: new Date().toISOString() })
.eq('id', projectId).eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/projects')
revalidatePath('/dashboard')
return { success: true }
}
export async function deleteProject(projectId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { error } = await supabase.from('projects').delete()
.eq('id', projectId).eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/projects')
return { success: true }
}
// ─── Members ─────────────────────────────────────────────────────────────────
export async function addMember(formData: {
display_name: string
email: string
phone?: string
handle?: string
tier: string
role: string
joined_at?: string
has_card_access?: boolean
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: self } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!self || (self.role !== 'admin' && self.role !== 'board')) return { error: 'Admin access required' }
const { data, error } = await supabase.from('space_members').insert({
space_id: self.space_id,
display_name: formData.display_name,
email: formData.email,
phone: formData.phone,
handle: formData.handle,
tier: formData.tier,
role: formData.role,
status: 'current',
approved: true,
joined_at: formData.joined_at || new Date().toISOString(),
has_card_access: formData.has_card_access ?? false,
}).select().single()
if (error) return { error: error.message }
revalidatePath('/members')
return { data }
}
export async function updateMember(memberId: string, updates: {
display_name?: string
email?: string
phone?: string
handle?: string
tier?: string
role?: string
status?: string
has_card_access?: boolean
payment_status?: string
payment_note?: string
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: self } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!self || (self.role !== 'admin' && self.role !== 'board')) return { error: 'Admin access required' }
const { error } = await supabase.from('space_members').update(updates)
.eq('id', memberId).eq('space_id', self.space_id)
if (error) return { error: error.message }
revalidatePath('/members')
return { success: true }
}
export async function approveMember(memberId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: self } = await supabase
.from('space_members').select('space_id, role, display_name').eq('user_id', user.id).eq('status', 'current').single()
if (!self || (self.role !== 'admin' && self.role !== 'board')) return { error: 'Admin access required' }
const { error } = await supabase.from('space_members').update({ status: 'current', approved: true })
.eq('id', memberId).eq('space_id', self.space_id)
if (error) return { error: error.message }
await supabase.from('activity_log').insert({
space_id: self.space_id,
user_id: user.id,
display_name: self.display_name,
action: 'approved',
entity_type: 'member',
entity_id: memberId,
})
revalidatePath('/members')
return { success: true }
}
export async function removeMember(memberId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: self } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!self || self.role !== 'admin') return { error: 'Admin access required' }
const { error } = await supabase.from('space_members').delete()
.eq('id', memberId).eq('space_id', self.space_id)
if (error) return { error: error.message }
revalidatePath('/members')
return { success: true }
}
// ─── Contacts ────────────────────────────────────────────────────────────────
export async function createContact(formData: {
name: string
contact_type: string
email?: string
phone?: string
details?: string
note?: string
group_label?: string
tags?: string[]
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const code = formData.name.slice(0, 3).toUpperCase() + Math.floor(Math.random() * 900 + 100)
const { data, error } = await supabase.from('contacts').insert({
space_id: member.space_id,
name: formData.name,
contact_type: formData.contact_type,
email: formData.email,
phone: formData.phone,
details: formData.details || formData.note,
note: formData.note,
group_label: formData.group_label,
tags: formData.tags ?? [],
code,
}).select().single()
if (error) return { error: error.message }
revalidatePath('/contacts')
return { data }
}
export async function updateContact(contactId: string, updates: {
name?: string
contact_type?: string
email?: string
phone?: string
details?: string
note?: string
group_label?: string
tags?: string[]
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { error } = await supabase.from('contacts').update({ ...updates, updated_at: new Date().toISOString() })
.eq('id', contactId).eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/contacts')
return { success: true }
}
export async function deleteContact(contactId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { error } = await supabase.from('contacts').delete()
.eq('id', contactId).eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/contacts')
return { success: true }
}
// ─── Payments ────────────────────────────────────────────────────────────────
export async function logCashPayment(formData: {
amount: number
from_note: string
member_id?: string
transaction_date?: string
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role, display_name').eq('user_id', user.id).eq('status', 'current').single()
if (!member || (member.role !== 'admin' && member.role !== 'board' && member.role !== 'treasurer')) {
return { error: 'Treasurer access required' }
}
const { data, error } = await supabase.from('payments').insert({
space_id: member.space_id,
platform: 'cash',
amount: formData.amount,
from_identifier: 'Cash',
from_note: formData.from_note,
member_id: formData.member_id || null,
link_status: formData.member_id ? 'linked' : 'unlinked',
transaction_date: formData.transaction_date || new Date().toISOString(),
}).select().single()
if (error) return { error: error.message }
// Update member's last_paid_at if linked
if (formData.member_id) {
await supabase.from('space_members').update({
last_paid_at: formData.transaction_date || new Date().toISOString(),
payment_status: 'current',
}).eq('id', formData.member_id).eq('space_id', member.space_id)
}
await supabase.from('activity_log').insert({
space_id: member.space_id,
user_id: user.id,
display_name: member.display_name,
action: 'logged',
entity_type: 'payment',
entity_id: data.id,
details: `$${formData.amount} cash — ${formData.from_note}`,
})
revalidatePath('/payments')
revalidatePath('/dashboard')
return { data }
}
export async function linkPaymentToMember(paymentId: string, memberId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: self } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!self || (self.role !== 'admin' && self.role !== 'board' && self.role !== 'treasurer')) {
return { error: 'Treasurer access required' }
}
// Get payment to find the amount and date
const { data: payment } = await supabase
.from('payments').select('amount, transaction_date').eq('id', paymentId).single()
const { error } = await supabase.from('payments').update({
member_id: memberId,
link_status: 'linked',
}).eq('id', paymentId).eq('space_id', self.space_id)
if (error) return { error: error.message }
// Update member payment status
if (payment) {
await supabase.from('space_members').update({
last_paid_at: payment.transaction_date,
payment_status: 'current',
}).eq('id', memberId).eq('space_id', self.space_id)
}
revalidatePath('/payments')
revalidatePath('/members')
revalidatePath('/dashboard')
return { success: true }
}
export async function importPaymentsCsv(rows: Array<{
platform: string
amount: number
from_identifier: string
from_note?: string
transaction_date: string
}>) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member || (member.role !== 'admin' && member.role !== 'board' && member.role !== 'treasurer')) {
return { error: 'Treasurer access required' }
}
const inserts = rows.map(r => ({
space_id: member.space_id,
platform: r.platform as any,
amount: r.amount,
from_identifier: r.from_identifier,
from_note: r.from_note,
link_status: 'unlinked' as const,
transaction_date: r.transaction_date,
}))
const { data, error } = await supabase.from('payments').insert(inserts).select()
if (error) return { error: error.message }
revalidatePath('/payments')
revalidatePath('/dashboard')
return { data, count: data.length }
}
// ─── Knowledge Base ───────────────────────────────────────────────────────────
export async function createKbEntry(formData: {
title: string
content: string
description?: string
area?: string
visibility?: string
is_pinned?: boolean
tags?: string[]
icon?: string
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, display_name, handle').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { data, error } = await supabase.from('knowledge_base').insert({
space_id: member.space_id,
title: formData.title,
content: formData.content,
area: formData.area,
visibility: formData.visibility || 'all_members',
is_pinned: formData.is_pinned ?? false,
tags: formData.tags ?? [],
icon: formData.icon,
updated_by_id: user.id,
updated_by_name: member.display_name,
}).select().single()
if (error) return { error: error.message }
revalidatePath('/ops')
return { data }
}
export async function updateKbEntry(entryId: string, updates: {
title?: string
content?: string
area?: string
visibility?: string
is_pinned?: boolean
tags?: string[]
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, display_name').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
// Only pass columns that actually exist in the DB
const { is_pinned, ...rest } = updates
const { error } = await supabase.from('knowledge_base').update({
...rest,
...(is_pinned !== undefined ? { is_pinned } : {}),
updated_by_id: user.id,
updated_by_name: member.display_name,
updated_at: new Date().toISOString(),
}).eq('id', entryId).eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/ops')
return { success: true }
}
export async function deleteKbEntry(entryId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
if (!member) return { error: 'No active membership' }
const { error } = await supabase.from('knowledge_base').delete()
.eq('id', entryId).eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/ops')
return { success: true }
}
// ─── Secrets ─────────────────────────────────────────────────────────────────
export async function createSecret(formData: {
title: string
value: string
description?: string
area?: string
icon?: string
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member || (member.role !== 'admin' && member.role !== 'board')) return { error: 'Admin access required' }
const { data, error } = await supabase.from('secrets').insert({
space_id: member.space_id,
title: formData.title,
value: formData.value,
description: formData.description,
area: formData.area,
icon: formData.icon,
}).select().single()
if (error) return { error: error.message }
revalidatePath('/ops')
return { data }
}
export async function deleteSecret(secretId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
const { error } = await supabase.from('secrets').delete()
.eq('id', secretId).eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/ops')
return { success: true }
}
// ─── Area Leads ───────────────────────────────────────────────────────────────
export async function upsertAreaLead(formData: {
area_code: string
area_name: string
lead_id?: string
lead_handle?: string
status?: string
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member || (member.role !== 'admin' && member.role !== 'board')) return { error: 'Admin access required' }
const { data, error } = await supabase.from('area_leads').upsert({
space_id: member.space_id,
area_code: formData.area_code,
area_name: formData.area_name,
lead_id: formData.lead_id || null,
lead_handle: formData.lead_handle || null,
status: formData.status || 'active',
updated_at: new Date().toISOString(),
}, { onConflict: 'space_id,area_code' }).select().single()
if (error) return { error: error.message }
revalidatePath('/ops')
return { data }
}
// ─── Settings ────────────────────────────────────────────────────────────────
export async function updateSpaceSettings(updates: {
name?: string
slug?: string
city?: string
require_approval?: boolean
public_member_directory?: boolean
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
const { error } = await supabase.from('spaces').update({
...updates,
updated_at: new Date().toISOString(),
}).eq('id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/settings')
return { success: true }
}
export async function rotateWebhookSecret() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
// Generate a new webhook secret
const newSecret = 'whsec_' + Array.from(crypto.getRandomValues(new Uint8Array(24)))
.map(b => b.toString(16).padStart(2, '0')).join('')
const { error } = await supabase.from('spaces').update({ webhook_secret: newSecret, updated_at: new Date().toISOString() })
.eq('id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/settings')
return { secret: newSecret }
}
export async function saveIntegration(platform: string, config: Record<string, string>) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
// Mask sensitive values — store but indicate they're set
const safeConfig: Record<string, string> = {}
for (const [key, value] of Object.entries(config)) {
safeConfig[key] = value ? value : ''
safeConfig[`${key}_set`] = value ? 'true' : 'false'
}
const { error } = await supabase.from('integrations').upsert({
space_id: member.space_id,
platform,
name: platform,
is_connected: Object.values(config).some(v => v.length > 0),
config: safeConfig,
updated_at: new Date().toISOString(),
}, { onConflict: 'space_id,platform' })
if (error) return { error: error.message }
revalidatePath('/settings')
return { success: true }
}
export async function disconnectIntegration(platform: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
const { error } = await supabase.from('integrations').update({
is_connected: false,
config: {},
updated_at: new Date().toISOString(),
}).eq('space_id', member.space_id).eq('platform', platform)
if (error) return { error: error.message }
revalidatePath('/settings')
return { success: true }
}
// ─── Import Members ───────────────────────────────────────────────────────────
export async function importMembers(rows: Array<{
display_name: string
email: string
phone?: string
tier?: string
joined_at?: string
last_paid_at?: string
has_card_access?: boolean
}>) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
if (!member || (member.role !== 'admin' && member.role !== 'board')) return { error: 'Admin access required' }
const inserts = rows.map(r => ({
space_id: member.space_id,
display_name: r.display_name,
email: r.email,
phone: r.phone,
tier: r.tier || 'basic',
role: 'member',
status: 'current',
approved: true,
joined_at: r.joined_at || new Date().toISOString(),
last_paid_at: r.last_paid_at || null,
has_card_access: r.has_card_access ?? false,
}))
// Upsert by email to avoid duplicates
const { data, error } = await supabase.from('space_members').upsert(inserts, {
onConflict: 'space_id,email',
ignoreDuplicates: false,
}).select()
if (error) return { error: error.message }
revalidatePath('/members')
return { data, count: data.length }
}
+201
View File
@@ -0,0 +1,201 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { redirect } from 'next/navigation'
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' }
// 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', formData.spaceSlug.toLowerCase().trim())
.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: formData.spaceName,
slug: formData.spaceSlug.toLowerCase().trim(),
city: formData.spaceCity || null,
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
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: 'admin',
tier: 'plus',
status: 'current',
approved: true,
})
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 { data: space, error: lookupErr } = await admin
.from('spaces')
.select('id, require_approval')
.eq('invite_code', formData.inviteCode.trim().toUpperCase())
.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 }
return { success: true }
}
export async function signIn(email: string, password: string) {
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
}
+19
View File
@@ -0,0 +1,19 @@
import { createClient } from '@supabase/supabase-js'
/**
* Admin client using the service role key.
* This bypasses RLS - use only for trusted server-side operations.
* NEVER expose this client to the browser.
*/
export function createAdminClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
)
}
+8
View File
@@ -0,0 +1,8 @@
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
)
}
+69
View File
@@ -0,0 +1,69 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
// With Fluid compute, don't put this client in a global environment
// variable. Always create a new one on each request.
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value),
)
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options),
)
},
},
},
)
// Do not run code between createServerClient and
// supabase.auth.getUser(). A simple mistake could make it very hard to debug
// issues with users being randomly logged out.
// IMPORTANT: If you remove getUser() and you use server-side rendering
// with the Supabase client, your users may be randomly logged out.
const {
data: { user },
} = await supabase.auth.getUser()
if (
// if the user is not logged in and the app path, in this case, /protected, is accessed, redirect to the login page
request.nextUrl.pathname.startsWith('/protected') &&
!user
) {
// no user, potentially respond by redirecting the user to the login page
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
// IMPORTANT: You *must* return the supabaseResponse object as it is.
// If you're creating a new response object with NextResponse.next() make sure to:
// 1. Pass the request in it, like so:
// const myNewResponse = NextResponse.next({ request })
// 2. Copy over the cookies, like so:
// myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())
// 3. Change the myNewResponse object to fit your needs, but avoid changing
// the cookies!
// 4. Finally:
// return myNewResponse
// If this is not done, you may be causing the browser and server to go out
// of sync and terminate the user's session prematurely!
return supabaseResponse
}
+34
View File
@@ -0,0 +1,34 @@
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
/**
* Especially important if using Fluid compute: Don't put this client in a
* global variable. Always create a new client within each function when using
* it.
*/
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
)
} catch {
// The "setAll" method was called from a Server Component.
// This can be ignored if you have proxy refreshing
// user sessions.
}
},
},
},
)
}
+206
View File
@@ -0,0 +1,206 @@
// Enum values match live DB exactly
export type MemberTier = 'plus' | 'basic' | 'associate'
export type MemberRole = 'admin' | 'board' | 'treasurer' | 'member' | 'associate'
export type MemberStatus = 'current' | 'late' | 'inactive' | 'unverified'
export type TaskType = 'chore' | 'task'
export type TaskStatus = 'open' | 'claimed' | 'in_progress' | 'overdue' | 'due_today' | 'completed' | 'done' | 'blocked'
export type TaskRecurrence = 'none' | 'daily' | 'weekly' | 'biweekly' | 'monthly'
export type ProjectStatus = 'backlog' | 'in_progress' | 'review' | 'done' | 'blocked'
export type KBVisibility = 'all_members' | 'board' | 'admin_only'
export type ContactType = 'vendor' | 'supplier' | 'partner' | 'landlord' | 'city'
export type PaymentPlatform = 'paypal' | 'zeffy' | 'venmo' | 'cash'
export type PaymentLinkStatus = 'linked' | 'unlinked'
export type ChannelType = 'general' | 'area' | 'ops' | 'project'
export type AreaLeadStatus = 'active' | 'vacant' | 'handoff'
export interface Space {
id: string
name: string
slug: string
city?: string
invite_code: string
require_approval: boolean
public_member_directory: boolean
webhook_secret?: string
created_at: string
updated_at: string
}
export interface SpaceMember {
id: string
space_id: string
user_id: string
display_name: string
handle?: string
email?: string
phone?: string
tier: MemberTier
role: MemberRole
status: MemberStatus
joined_at: string
approved: boolean
has_card_access: boolean
last_payment_at?: string
last_paid_at?: string
payment_status?: string
payment_note?: string
}
export interface Task {
id: string
space_id: string
type: TaskType
title: string
description?: string
status: TaskStatus
area?: string
assigned_to?: string
assigned_to_name?: string
claimed_by?: string
claimed_by_name?: string
requested_by?: string
requested_by_name?: string
due_date?: string
recurrence: TaskRecurrence
last_done_at?: string
subtask_completed?: number
subtask_total?: number
completed_at?: string
created_at: string
updated_at: string
}
export interface Project {
id: string
space_id: string
title: string
description?: string
status: ProjectStatus
area?: string
tags?: string[]
assignee_names?: string[]
task_count?: number
tasks_completed?: number
due_date?: string
created_at: string
updated_at: string
}
export interface KnowledgeBase {
id: string
space_id: string
title: string
content?: string
description?: string
area?: string
tags?: string[]
visibility: KBVisibility
access_level?: string
is_pinned: boolean
pinned?: boolean
icon?: string
updated_by_id?: string
updated_by_name?: string
updated_by?: string
created_at: string
updated_at: string
}
export interface Secret {
id: string
space_id: string
title: string
description?: string
value: string
area?: string
icon?: string
created_at: string
updated_at: string
}
export interface AreaLead {
id: string
space_id: string
area_code: string
area_name: string
lead_id?: string
lead_handle?: string
status: AreaLeadStatus
created_at: string
updated_at: string
}
export interface Contact {
id: string
space_id: string
contact_type: ContactType
code: string
name: string
email?: string
phone?: string
details?: string
tags?: string[]
created_at: string
updated_at: string
}
export interface Payment {
id: string
space_id: string
platform: PaymentPlatform
amount: number
from_identifier: string
from_note?: string
member_id?: string
member_name?: string
link_status: PaymentLinkStatus
transaction_date: string
created_at: string
}
export interface CommsChannel {
id: string
space_id: string
name: string
channel_type: ChannelType
icon?: string
area_reference?: string
project_id?: string
member_count?: number
created_at: string
}
export interface CommsMessage {
id: string
space_id: string
channel_id: string
user_id: string
display_name: string
handle?: string
content: string
created_at: string
}
export interface Integration {
id: string
space_id: string
platform: string
name: string
description?: string
is_connected: boolean
config?: any
created_at: string
updated_at: string
}
export interface ActivityLog {
id: string
space_id: string
user_id: string
display_name: string
action: string
entity_type: string
entity_id: string
details?: string
created_at: string
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}