From c34d5d95295c1e6de26233c88f930f138735f54d Mon Sep 17 00:00:00 2001 From: v0 Date: Wed, 11 Mar 2026 08:05:53 +0000 Subject: [PATCH 1/3] fix: correct Supabase insert fields for secrets and area leads Fix missing 'label' in secrets insert and correct area leads field names. Co-authored-by: Moheeb Zara <1184415+virgilvox@users.noreply.github.com> --- app/(app)/ops/ops-client.tsx | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/app/(app)/ops/ops-client.tsx b/app/(app)/ops/ops-client.tsx index 41b78b0..9b36f8d 100644 --- a/app/(app)/ops/ops-client.tsx +++ b/app/(app)/ops/ops-client.tsx @@ -25,9 +25,9 @@ interface KbEntry { interface AreaLead { id: string area_name: string - member_name: string - member_id?: string - contact_info?: string + lead_handle: string + lead_id?: string + description?: string } interface Secret { @@ -225,6 +225,7 @@ function AddSecretModal({ const { data, error } = await supabase.from('secrets').insert({ space_id: spaceId, title: title.trim(), + label: title.trim(), area: area.trim() || null, value: value.trim(), }).select('id, title, area, created_at').single() @@ -299,8 +300,8 @@ function AreaLeadModal({ }) { const isEdit = !!lead const [areaName, setAreaName] = useState(lead?.area_name ?? '') - const [memberName, setMemberName] = useState(lead?.member_name ?? '') - const [contactInfo, setContactInfo] = useState(lead?.contact_info ?? '') + const [memberName, setMemberName] = useState(lead?.lead_handle ?? '') + const [contactInfo, setContactInfo] = useState(lead?.description ?? '') const [saving, setSaving] = useState(false) async function handleSave(e: React.FormEvent) { @@ -312,15 +313,15 @@ function AreaLeadModal({ if (isEdit) { result = await supabase.from('area_leads').update({ area_name: areaName.trim(), - member_name: memberName.trim(), - contact_info: contactInfo.trim() || null, + lead_handle: memberName.trim(), + description: contactInfo.trim() || null, }).eq('id', lead.id).select('*').single() } else { result = await supabase.from('area_leads').insert({ space_id: spaceId, area_name: areaName.trim(), - member_name: memberName.trim(), - contact_info: contactInfo.trim() || null, + lead_handle: memberName.trim(), + description: contactInfo.trim() || null, }).select('*').single() } setSaving(false) @@ -717,9 +718,9 @@ export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: init

{lead.area_name}

-

{lead.member_name}

- {lead.contact_info && ( -

{lead.contact_info}

+

{lead.lead_handle}

+ {lead.description && ( +

{lead.description}

)}
From 2e136b5f555af8aa8f827d559b84909cb445a3c3 Mon Sep 17 00:00:00 2001 From: v0 Date: Wed, 11 Mar 2026 08:12:39 +0000 Subject: [PATCH 2/3] refactor: generate and use Supabase TypeScript types Co-authored-by: Moheeb Zara <1184415+virgilvox@users.noreply.github.com> --- app/(app)/comms/comms-client.tsx | 14 +- app/(app)/contacts/contacts-client.tsx | 14 +- app/(app)/dashboard/page.tsx | 9 +- app/(app)/import/import-client.tsx | 11 +- app/(app)/members/members-client.tsx | 19 +- app/(app)/ops/[id]/kb-entry-editor.tsx | 5 +- app/(app)/ops/ops-client.tsx | 35 +- app/(app)/payments/payments-client.tsx | 15 +- app/(app)/projects/projects-client.tsx | 13 +- app/(app)/settings/settings-client.tsx | 21 +- app/(app)/tasks/tasks-client.tsx | 27 +- components/app-shell.tsx | 5 +- lib/actions.ts | 5 +- lib/supabase/admin.ts | 3 +- lib/supabase/client.ts | 3 +- lib/supabase/server.ts | 3 +- lib/types.ts | 238 +----- types/database.ts | 1037 ++++++++++++++++++++++++ 18 files changed, 1135 insertions(+), 342 deletions(-) create mode 100644 types/database.ts diff --git a/app/(app)/comms/comms-client.tsx b/app/(app)/comms/comms-client.tsx index f57d184..c1518aa 100644 --- a/app/(app)/comms/comms-client.tsx +++ b/app/(app)/comms/comms-client.tsx @@ -3,16 +3,20 @@ import { useState, useEffect, useRef } from 'react' import { createClient } from '@/lib/supabase/client' import { Hash, Users2, Send, ChevronLeft } from 'lucide-react' +import type { Tables } from '@/types/database' + +type Channel = Tables<'comms_channels'> +type Message = Tables<'comms_messages'> interface Props { - member: any - space: any - channels: any[] + member: Tables<'space_members'> + space: Tables<'spaces'> + channels: Channel[] } export default function CommsClient({ member, space, channels }: Props) { - const [selectedChannel, setSelectedChannel] = useState(channels[0] || null) - const [messages, setMessages] = useState([]) + const [selectedChannel, setSelectedChannel] = useState(channels[0] || null) + const [messages, setMessages] = useState([]) const [newMessage, setNewMessage] = useState('') const [sending, setSending] = useState(false) const [showChannelList, setShowChannelList] = useState(true) diff --git a/app/(app)/contacts/contacts-client.tsx b/app/(app)/contacts/contacts-client.tsx index a17bd02..d1a30e5 100644 --- a/app/(app)/contacts/contacts-client.tsx +++ b/app/(app)/contacts/contacts-client.tsx @@ -3,19 +3,9 @@ import { useState } from 'react' import { Plus, X, Pencil } from 'lucide-react' import { createContact, updateContact, deleteContact } from '@/lib/actions' +import type { Tables } from '@/types/database' -interface Contact { - id: string - name: string - contact_type: string - email?: string - phone?: string - details?: string - note?: string - group_label?: string - tags?: string[] - code?: string -} +type Contact = Tables<'contacts'> const CATEGORY_COLORS: Record = { vendor: 'text-primary border-primary/30 bg-primary/5', diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index f8607c0..765600c 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import Link from 'next/link' import { redirect } from 'next/navigation' +import type { Tables } from '@/types/database' function IcoUsers({ className }: { className?: string }) { return @@ -50,7 +51,7 @@ export default async function DashboardPage() { supabase.from('activity_log').select('*').eq('space_id', spaceId).order('created_at', { ascending: false }).limit(6), ]) - const overdueTasks = (tasks ?? []).filter((t: any) => t.due_date && new Date(t.due_date) < new Date()) + const overdueTasks = (tasks ?? []).filter((t) => t.due_date && new Date(t.due_date) < new Date()) const stats = [ { label: 'Active Members', value: activeMembers ?? 0, Ico: IcoUsers, sub: 'in your space', warn: false }, @@ -91,7 +92,7 @@ export default async function DashboardPage() { All tasks
- {(tasks ?? []).length > 0 ? (tasks ?? []).map((task: any) => { + {(tasks ?? []).length > 0 ? (tasks ?? []).map((task) => { const isOverdue = task.due_date && new Date(task.due_date) < new Date() const isDueToday = task.due_date && new Date(task.due_date).toDateString() === new Date().toDateString() return ( @@ -127,7 +128,7 @@ export default async function DashboardPage() { All projects
- {(projects ?? []).length > 0 ? (projects ?? []).map((project: any) => ( + {(projects ?? []).length > 0 ? (projects ?? []).map((project) => (

{project.title}

@@ -167,7 +168,7 @@ export default async function DashboardPage() {

Recent Activity

- {(activity ?? []).length > 0 ? (activity ?? []).map((item: any) => ( + {(activity ?? []).length > 0 ? (activity ?? []).map((item) => (
diff --git a/app/(app)/import/import-client.tsx b/app/(app)/import/import-client.tsx index 311e18f..c9d3229 100644 --- a/app/(app)/import/import-client.tsx +++ b/app/(app)/import/import-client.tsx @@ -246,13 +246,22 @@ export default function ImportClient({ spaceId, role }: Props) { const last_paid_at = obj.last_paid_at ? new Date(obj.last_paid_at).toISOString() : null const has_card_access = /yes|true|1|y/i.test(obj.has_card_access ?? '') + // Map tier value to valid enum or default to 'basic' + const tierMap: Record = { + plus: 'plus', premium: 'plus', full: 'plus', + basic: 'basic', member: 'basic', standard: 'basic', + associate: 'associate', visitor: 'associate', guest: 'associate', + } + const tier = tierMap[(obj.tier || '').toLowerCase()] || 'basic' + const { error } = await supabase.from('space_members').insert({ space_id: spaceId, + user_id: crypto.randomUUID(), display_name: obj.display_name, email: obj.email, phone: obj.phone || null, handle: obj.handle || null, - tier: obj.tier || 'member', + tier, role: 'member', status: 'current', approved: true, diff --git a/app/(app)/members/members-client.tsx b/app/(app)/members/members-client.tsx index e44ff3c..6c5afb5 100644 --- a/app/(app)/members/members-client.tsx +++ b/app/(app)/members/members-client.tsx @@ -3,24 +3,9 @@ import { useState } from 'react' import { Plus, X, ChevronDown } from 'lucide-react' import { addMember, updateMember, approveMember, removeMember } from '@/lib/actions' +import type { Tables } from '@/types/database' -interface Member { - id: string - display_name: string - email: string - phone?: string - handle?: string - tier: string - role: string - status: string - joined_at?: string - last_paid_at?: string - last_payment_at?: string - payment_status?: string - payment_note?: string - has_card_access?: boolean - approved?: boolean -} +type Member = Tables<'space_members'> interface Props { members: Member[] diff --git a/app/(app)/ops/[id]/kb-entry-editor.tsx b/app/(app)/ops/[id]/kb-entry-editor.tsx index 66713bb..f747ad5 100644 --- a/app/(app)/ops/[id]/kb-entry-editor.tsx +++ b/app/(app)/ops/[id]/kb-entry-editor.tsx @@ -6,10 +6,11 @@ import Link from 'next/link' import { ArrowLeft, Pin, Lock } from 'lucide-react' import { createKbEntry, updateKbEntry } from '@/lib/actions' import { toast } from 'sonner' +import type { Tables } from '@/types/database' interface Props { - entry?: any - member: any + entry?: Tables<'knowledge_base'> + member: Tables<'space_members'> } export default function KbEntryEditor({ entry, member }: Props) { diff --git a/app/(app)/ops/ops-client.tsx b/app/(app)/ops/ops-client.tsx index 9b36f8d..0950494 100644 --- a/app/(app)/ops/ops-client.tsx +++ b/app/(app)/ops/ops-client.tsx @@ -6,39 +6,14 @@ import { Plus, Search, Lock, Pin, Eye, EyeOff, Pencil, Trash2, X, ChevronDown, U import { createKbEntry, updateKbEntry, deleteKbEntry } from '@/lib/actions' import { createClient } from '@/lib/supabase/client' import { toast } from 'sonner' +import type { Tables, TablesInsert } from '@/types/database' -interface KbEntry { - id: string - title: string - content: string - description?: string - area?: string - visibility: string - is_pinned: boolean - tags?: string[] - icon?: string - updated_by_name?: string - updated_at: string - created_at: string -} - -interface AreaLead { - id: string - area_name: string - lead_handle: string - lead_id?: string - description?: string -} - -interface Secret { - id: string - title: string - area?: string - created_at: string -} +type KbEntry = Tables<'knowledge_base'> +type AreaLead = Tables<'area_leads'> +type Secret = Tables<'secrets'> interface Props { - member: any + member: Tables<'space_members'> spaceId: string kbEntries: KbEntry[] areaLeads: AreaLead[] diff --git a/app/(app)/payments/payments-client.tsx b/app/(app)/payments/payments-client.tsx index ac92cc6..fcb65aa 100644 --- a/app/(app)/payments/payments-client.tsx +++ b/app/(app)/payments/payments-client.tsx @@ -4,21 +4,14 @@ import { useState } from 'react' import { X, RefreshCcw } from 'lucide-react' import { toast } from 'sonner' import { logCashPayment, linkPaymentToMember } from '@/lib/actions' +import type { Tables } from '@/types/database' -interface Payment { - id: string - platform: string - amount: number - from_identifier: string - from_note?: string - transaction_date: string - link_status: string - member_id?: string +type Payment = Tables<'payments'> & { space_members?: { display_name: string } | null } -interface Member { id: string; display_name: string; email: string } -interface Integration { platform: string; is_connected: boolean; config?: Record } +type Member = Pick, 'id' | 'display_name' | 'email'> +type Integration = Pick, 'platform' | 'is_connected' | 'config'> interface Props { payments: Payment[] diff --git a/app/(app)/projects/projects-client.tsx b/app/(app)/projects/projects-client.tsx index dd8ef9a..a0cec24 100644 --- a/app/(app)/projects/projects-client.tsx +++ b/app/(app)/projects/projects-client.tsx @@ -3,18 +3,9 @@ import { useState } from 'react' import { Plus, X } from 'lucide-react' import { createProject, updateProjectStatus, deleteProject } from '@/lib/actions' +import type { Tables } from '@/types/database' -interface Project { - id: string - title: string - description?: string - status: string - area?: string - tags?: string[] - progress?: number - due_date?: string - created_at: string -} +type Project = Tables<'projects'> const STATUS_COLS = [ { key: 'backlog', label: 'BACKLOG', color: 'text-muted-foreground', dot: 'bg-muted-foreground' }, diff --git a/app/(app)/settings/settings-client.tsx b/app/(app)/settings/settings-client.tsx index cdfae09..8763415 100644 --- a/app/(app)/settings/settings-client.tsx +++ b/app/(app)/settings/settings-client.tsx @@ -3,25 +3,10 @@ import { useState } from 'react' import { X, Check, Copy, Eye, EyeOff, RotateCcw } from 'lucide-react' import { updateSpaceSettings, saveIntegration, disconnectIntegration, rotateWebhookSecret } from '@/lib/actions' +import type { Tables } from '@/types/database' -interface Space { - id: string - name: string - slug: string - city?: string - invite_code?: string - require_approval?: boolean - public_member_directory?: boolean - webhook_secret?: string -} - -interface Integration { - id: string - platform: string - name?: string - is_connected: boolean - config?: Record -} +type Space = Tables<'spaces'> +type Integration = Tables<'integrations'> interface Props { space: Space diff --git a/app/(app)/tasks/tasks-client.tsx b/app/(app)/tasks/tasks-client.tsx index 2a918bd..828a40e 100644 --- a/app/(app)/tasks/tasks-client.tsx +++ b/app/(app)/tasks/tasks-client.tsx @@ -3,30 +3,13 @@ import { useState, useTransition } from 'react' import { Plus, X, CheckCircle2 } from 'lucide-react' import { createTask, claimTask, completeTask, deleteTask } from '@/lib/actions' +import type { Tables } from '@/types/database' -interface Task { - id: string - title: string - description?: string - type?: string - task_type?: string - status: string - area?: string - recurrence?: string - due_date?: string - claimed_by?: string - claimed_by_name?: string - assigned_to?: string - assigned_to_name?: string - progress?: number - subtask_completed?: number - subtask_total?: number - created_at: string -} +type Task = Tables<'tasks'> interface Props { tasks: Task[] - members: { id: string; display_name: string; user_id: string }[] + members: Pick, 'id' | 'display_name' | 'user_id'>[] currentUserId: string spaceId: string } @@ -57,8 +40,8 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space // Chores = chore type OR regular tasks without recurrence (the general open pool) // Mine = tasks claimed/assigned to me // Done = completed - const isDone = (t: any) => t.status === 'done' || t.status === 'completed' - const isOpen = (t: any) => !isDone(t) + const isDone = (t: Task) => t.status === 'done' || t.status === 'completed' + const isOpen = (t: Task) => !isDone(t) const chores = tasks.filter(t => isOpen(t) && (!t.recurrence || t.recurrence === 'none')) const ongoing = tasks.filter(t => t.recurrence && t.recurrence !== 'none' && isOpen(t)) const mine = tasks.filter(t => (t.claimed_by === currentUserId || t.assigned_to === currentUserId) && isOpen(t)) diff --git a/components/app-shell.tsx b/components/app-shell.tsx index 19a23f6..f7f1198 100644 --- a/components/app-shell.tsx +++ b/components/app-shell.tsx @@ -8,6 +8,7 @@ import { LayoutDashboard, ListChecks, FolderKanban, Settings2, MessageSquare, Users, CreditCard, BookUser, Download, LogOut, ChevronDown } from 'lucide-react' +import type { Tables } from '@/types/database' const NAV = [ { @@ -38,8 +39,8 @@ const NAV = [ ] interface Props { - member: any - space: any + member: Tables<'space_members'> + space: Tables<'spaces'> children: React.ReactNode } diff --git a/lib/actions.ts b/lib/actions.ts index fc855d6..362ffa8 100644 --- a/lib/actions.ts +++ b/lib/actions.ts @@ -3,6 +3,8 @@ import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' import { revalidatePath } from 'next/cache' +import type { SupabaseClient } from '@supabase/supabase-js' +import type { Database } from '@/types/database' // ─── Auth ──────────────────────────────────────────────────────────────────── @@ -26,7 +28,7 @@ export async function getUser() { } // ─── Helper: get current member (any non-inactive status) ──────────────────── -async function getMember(supabase: any, userId: string) { +async function getMember(supabase: SupabaseClient, userId: string) { const { data } = await supabase .from('space_members') .select('space_id, role, display_name') @@ -670,6 +672,7 @@ export async function createSecret(formData: { const { data, error } = await supabase.from('secrets').insert({ space_id: member.space_id, title: formData.title, + label: formData.title, value: formData.value, description: formData.description, area: formData.area, diff --git a/lib/supabase/admin.ts b/lib/supabase/admin.ts index e3c5f8f..29556dd 100644 --- a/lib/supabase/admin.ts +++ b/lib/supabase/admin.ts @@ -1,4 +1,5 @@ import { createClient } from '@supabase/supabase-js' +import type { Database } from '@/types/database' /** * Admin client using the service role key. @@ -6,7 +7,7 @@ import { createClient } from '@supabase/supabase-js' * NEVER expose this client to the browser. */ export function createAdminClient() { - return createClient( + return createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, { diff --git a/lib/supabase/client.ts b/lib/supabase/client.ts index c48a435..ace8e20 100644 --- a/lib/supabase/client.ts +++ b/lib/supabase/client.ts @@ -1,7 +1,8 @@ import { createBrowserClient } from '@supabase/ssr' +import type { Database } from '@/types/database' export function createClient() { - return createBrowserClient( + return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, ) diff --git a/lib/supabase/server.ts b/lib/supabase/server.ts index aa01a96..1e9629c 100644 --- a/lib/supabase/server.ts +++ b/lib/supabase/server.ts @@ -1,5 +1,6 @@ import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' +import type { Database } from '@/types/database' /** * Especially important if using Fluid compute: Don't put this client in a @@ -9,7 +10,7 @@ import { cookies } from 'next/headers' export async function createClient() { const cookieStore = await cookies() - return createServerClient( + return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { diff --git a/lib/types.ts b/lib/types.ts index cb60f00..8c8382c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,206 +1,38 @@ -// 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' +/** + * Re-export types from the generated database types. + * These types are auto-generated from the Supabase schema. + * + * To regenerate: Use the supabase_generate_typescript_types tool + */ -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 -} +import type { Tables, Enums } from '@/types/database' -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 -} +// ─── Table Row Types ─────────────────────────────────────────────────────────── +export type Space = Tables<'spaces'> +export type SpaceMember = Tables<'space_members'> +export type Task = Tables<'tasks'> +export type Project = Tables<'projects'> +export type KnowledgeBase = Tables<'knowledge_base'> +export type Secret = Tables<'secrets'> +export type AreaLead = Tables<'area_leads'> +export type Contact = Tables<'contacts'> +export type Payment = Tables<'payments'> +export type CommsChannel = Tables<'comms_channels'> +export type CommsMessage = Tables<'comms_messages'> +export type Integration = Tables<'integrations'> +export type ActivityLog = Tables<'activity_log'> -export interface Task { - id: string - space_id: string - task_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 -} +// ─── Enum Types ──────────────────────────────────────────────────────────────── +export type MemberTier = Enums<'member_tier'> +export type MemberRole = Enums<'member_role'> +export type MemberStatus = Enums<'member_status'> +export type TaskType = Enums<'task_type'> +export type TaskStatus = Enums<'task_status'> +export type TaskRecurrence = Enums<'recurrence_type'> +export type ProjectStatus = Enums<'project_status'> +export type KBVisibility = Enums<'kb_visibility'> +export type ContactType = Enums<'contact_type'> +export type PaymentPlatform = Enums<'payment_platform'> +export type PaymentLinkStatus = Enums<'payment_link_status'> +export type ChannelType = Enums<'channel_type'> +export type AreaLeadStatus = Enums<'area_lead_status'> diff --git a/types/database.ts b/types/database.ts new file mode 100644 index 0000000..27a1a17 --- /dev/null +++ b/types/database.ts @@ -0,0 +1,1037 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + +export type Database = { + // Allows to automatically instantiate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: "14.1" + } + public: { + Tables: { + activity_log: { + Row: { + action: string + created_at: string + details: string | null + display_name: string | null + entity_id: string | null + entity_type: string | null + id: string + metadata: Json | null + space_id: string + user_id: string | null + } + Insert: { + action: string + created_at?: string + details?: string | null + display_name?: string | null + entity_id?: string | null + entity_type?: string | null + id?: string + metadata?: Json | null + space_id: string + user_id?: string | null + } + Update: { + action?: string + created_at?: string + details?: string | null + display_name?: string | null + entity_id?: string | null + entity_type?: string | null + id?: string + metadata?: Json | null + space_id?: string + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "activity_log_space_id_fkey" + columns: ["space_id"] + isOneToOne: false + referencedRelation: "spaces" + referencedColumns: ["id"] + }, + ] + } + area_leads: { + Row: { + area_code: string | null + area_name: string + created_at: string + description: string | null + id: string + lead_handle: string | null + lead_id: string | null + notes: string | null + space_id: string + status: Database["public"]["Enums"]["area_lead_status"] + updated_at: string + } + Insert: { + area_code?: string | null + area_name: string + created_at?: string + description?: string | null + id?: string + lead_handle?: string | null + lead_id?: string | null + notes?: string | null + space_id: string + status?: Database["public"]["Enums"]["area_lead_status"] + updated_at?: string + } + Update: { + area_code?: string | null + area_name?: string + created_at?: string + description?: string | null + id?: string + lead_handle?: string | null + lead_id?: string | null + notes?: string | null + space_id?: string + status?: Database["public"]["Enums"]["area_lead_status"] + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "area_leads_lead_id_fkey" + columns: ["lead_id"] + isOneToOne: false + referencedRelation: "space_members" + referencedColumns: ["id"] + }, + { + foreignKeyName: "area_leads_space_id_fkey" + columns: ["space_id"] + isOneToOne: false + referencedRelation: "spaces" + referencedColumns: ["id"] + }, + ] + } + comms_channels: { + Row: { + area_reference: string | null + channel_type: Database["public"]["Enums"]["channel_type"] + created_at: string + created_by: string | null + description: string | null + icon: string | null + id: string + is_archived: boolean + is_default: boolean + member_count: number + name: string + space_id: string + unread_count: number + updated_at: string + } + Insert: { + area_reference?: string | null + channel_type?: Database["public"]["Enums"]["channel_type"] + created_at?: string + created_by?: string | null + description?: string | null + icon?: string | null + id?: string + is_archived?: boolean + is_default?: boolean + member_count?: number + name: string + space_id: string + unread_count?: number + updated_at?: string + } + Update: { + area_reference?: string | null + channel_type?: Database["public"]["Enums"]["channel_type"] + created_at?: string + created_by?: string | null + description?: string | null + icon?: string | null + id?: string + is_archived?: boolean + is_default?: boolean + member_count?: number + name?: string + space_id?: string + unread_count?: number + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "comms_channels_space_id_fkey" + columns: ["space_id"] + isOneToOne: false + referencedRelation: "spaces" + referencedColumns: ["id"] + }, + ] + } + comms_messages: { + Row: { + channel_id: string + content: string + created_at: string + deleted: boolean + display_name: string | null + edited: boolean + handle: string | null + id: string + reply_to: string | null + space_id: string + updated_at: string + user_id: string + } + Insert: { + channel_id: string + content: string + created_at?: string + deleted?: boolean + display_name?: string | null + edited?: boolean + handle?: string | null + id?: string + reply_to?: string | null + space_id: string + updated_at?: string + user_id: string + } + Update: { + channel_id?: string + content?: string + created_at?: string + deleted?: boolean + display_name?: string | null + edited?: boolean + handle?: string | null + id?: string + reply_to?: string | null + space_id?: string + updated_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "comms_messages_channel_id_fkey" + columns: ["channel_id"] + isOneToOne: false + referencedRelation: "comms_channels" + referencedColumns: ["id"] + }, + { + foreignKeyName: "comms_messages_reply_to_fkey" + columns: ["reply_to"] + isOneToOne: false + referencedRelation: "comms_messages" + referencedColumns: ["id"] + }, + { + foreignKeyName: "comms_messages_space_id_fkey" + columns: ["space_id"] + isOneToOne: false + referencedRelation: "spaces" + referencedColumns: ["id"] + }, + ] + } + contacts: { + Row: { + address: string | null + code: string | null + contact_type: Database["public"]["Enums"]["contact_type"] + created_at: string + created_by: string | null + details: string | null + email: string | null + group_label: string | null + id: string + name: string + note: string | null + notes: string | null + phone: string | null + space_id: string + tags: string[] | null + updated_at: string + } + Insert: { + address?: string | null + code?: string | null + contact_type?: Database["public"]["Enums"]["contact_type"] + created_at?: string + created_by?: string | null + details?: string | null + email?: string | null + group_label?: string | null + id?: string + name: string + note?: string | null + notes?: string | null + phone?: string | null + space_id: string + tags?: string[] | null + updated_at?: string + } + Update: { + address?: string | null + code?: string | null + contact_type?: Database["public"]["Enums"]["contact_type"] + created_at?: string + created_by?: string | null + details?: string | null + email?: string | null + group_label?: string | null + id?: string + name?: string + note?: string | null + notes?: string | null + phone?: string | null + space_id?: string + tags?: string[] | null + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "contacts_space_id_fkey" + columns: ["space_id"] + isOneToOne: false + referencedRelation: "spaces" + referencedColumns: ["id"] + }, + ] + } + integrations: { + Row: { + config: Json | null + created_at: string + credentials: Json | null + description: string | null + id: string + is_connected: boolean + last_sync_at: string | null + name: string | null + platform: string + settings: Json | null + space_id: string + updated_at: string + } + Insert: { + config?: Json | null + created_at?: string + credentials?: Json | null + description?: string | null + id?: string + is_connected?: boolean + last_sync_at?: string | null + name?: string | null + platform: string + settings?: Json | null + space_id: string + updated_at?: string + } + Update: { + config?: Json | null + created_at?: string + credentials?: Json | null + description?: string | null + id?: string + is_connected?: boolean + last_sync_at?: string | null + name?: string | null + platform?: string + settings?: Json | null + space_id?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "integrations_space_id_fkey" + columns: ["space_id"] + isOneToOne: false + referencedRelation: "spaces" + referencedColumns: ["id"] + }, + ] + } + knowledge_base: { + Row: { + access_level: string | null + area: string | null + category: string | null + content: string | null + created_at: string + created_by: string | null + icon: string | null + id: string + is_pinned: boolean + pinned: boolean + space_id: string + tags: string[] | null + title: string + updated_at: string + updated_by: string | null + updated_by_id: string | null + updated_by_name: string | null + visibility: Database["public"]["Enums"]["kb_visibility"] + } + Insert: { + access_level?: string | null + area?: string | null + category?: string | null + content?: string | null + created_at?: string + created_by?: string | null + icon?: string | null + id?: string + is_pinned?: boolean + pinned?: boolean + space_id: string + tags?: string[] | null + title: string + updated_at?: string + updated_by?: string | null + updated_by_id?: string | null + updated_by_name?: string | null + visibility?: Database["public"]["Enums"]["kb_visibility"] + } + Update: { + access_level?: string | null + area?: string | null + category?: string | null + content?: string | null + created_at?: string + created_by?: string | null + icon?: string | null + id?: string + is_pinned?: boolean + pinned?: boolean + space_id?: string + tags?: string[] | null + title?: string + updated_at?: string + updated_by?: string | null + updated_by_id?: string | null + updated_by_name?: string | null + visibility?: Database["public"]["Enums"]["kb_visibility"] + } + Relationships: [ + { + foreignKeyName: "knowledge_base_space_id_fkey" + columns: ["space_id"] + isOneToOne: false + referencedRelation: "spaces" + referencedColumns: ["id"] + }, + { + foreignKeyName: "knowledge_base_updated_by_id_fkey" + columns: ["updated_by_id"] + isOneToOne: false + referencedRelation: "space_members" + referencedColumns: ["id"] + }, + ] + } + payments: { + Row: { + amount: number + created_at: string + currency: string + description: string | null + external_id: string | null + from_identifier: string | null + from_note: string | null + id: string + link_status: Database["public"]["Enums"]["payment_link_status"] + member_id: string | null + member_name: string | null + payer_email: string | null + payer_name: string | null + payment_date: string | null + platform: Database["public"]["Enums"]["payment_platform"] + raw_data: Json | null + space_id: string + status: Database["public"]["Enums"]["payment_link_status"] + transaction_date: string | null + updated_at: string + } + Insert: { + amount: number + created_at?: string + currency?: string + description?: string | null + external_id?: string | null + from_identifier?: string | null + from_note?: string | null + id?: string + link_status?: Database["public"]["Enums"]["payment_link_status"] + member_id?: string | null + member_name?: string | null + payer_email?: string | null + payer_name?: string | null + payment_date?: string | null + platform: Database["public"]["Enums"]["payment_platform"] + raw_data?: Json | null + space_id: string + status?: Database["public"]["Enums"]["payment_link_status"] + transaction_date?: string | null + updated_at?: string + } + Update: { + amount?: number + created_at?: string + currency?: string + description?: string | null + external_id?: string | null + from_identifier?: string | null + from_note?: string | null + id?: string + link_status?: Database["public"]["Enums"]["payment_link_status"] + member_id?: string | null + member_name?: string | null + payer_email?: string | null + payer_name?: string | null + payment_date?: string | null + platform?: Database["public"]["Enums"]["payment_platform"] + raw_data?: Json | null + space_id?: string + status?: Database["public"]["Enums"]["payment_link_status"] + transaction_date?: string | null + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "payments_member_id_fkey" + columns: ["member_id"] + isOneToOne: false + referencedRelation: "space_members" + referencedColumns: ["id"] + }, + { + foreignKeyName: "payments_space_id_fkey" + columns: ["space_id"] + isOneToOne: false + referencedRelation: "spaces" + referencedColumns: ["id"] + }, + ] + } + projects: { + Row: { + area: string | null + assignee_names: string[] | null + assignees: string | null + category: string | null + created_at: string + created_by: string | null + description: string | null + due_date: string | null + id: string + lead_id: string | null + name: string | null + progress: number + space_id: string + status: Database["public"]["Enums"]["project_status"] + tags: string[] | null + task_count: number + tasks_completed: number + title: string + updated_at: string + } + Insert: { + area?: string | null + assignee_names?: string[] | null + assignees?: string | null + category?: string | null + created_at?: string + created_by?: string | null + description?: string | null + due_date?: string | null + id?: string + lead_id?: string | null + name?: string | null + progress?: number + space_id: string + status?: Database["public"]["Enums"]["project_status"] + tags?: string[] | null + task_count?: number + tasks_completed?: number + title: string + updated_at?: string + } + Update: { + area?: string | null + assignee_names?: string[] | null + assignees?: string | null + category?: string | null + created_at?: string + created_by?: string | null + description?: string | null + due_date?: string | null + id?: string + lead_id?: string | null + name?: string | null + progress?: number + space_id?: string + status?: Database["public"]["Enums"]["project_status"] + tags?: string[] | null + task_count?: number + tasks_completed?: number + title?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "projects_space_id_fkey" + columns: ["space_id"] + isOneToOne: false + referencedRelation: "spaces" + referencedColumns: ["id"] + }, + ] + } + secrets: { + Row: { + area: string | null + category: string | null + created_at: string + created_by: string | null + description: string | null + icon: string | null + id: string + label: string + notes: string | null + space_id: string + title: string | null + updated_at: string + value: string + } + Insert: { + area?: string | null + category?: string | null + created_at?: string + created_by?: string | null + description?: string | null + icon?: string | null + id?: string + label: string + notes?: string | null + space_id: string + title?: string | null + updated_at?: string + value: string + } + Update: { + area?: string | null + category?: string | null + created_at?: string + created_by?: string | null + description?: string | null + icon?: string | null + id?: string + label?: string + notes?: string | null + space_id?: string + title?: string | null + updated_at?: string + value?: string + } + Relationships: [ + { + foreignKeyName: "secrets_space_id_fkey" + columns: ["space_id"] + isOneToOne: false + referencedRelation: "spaces" + referencedColumns: ["id"] + }, + ] + } + space_members: { + Row: { + approved: boolean + avatar_url: string | null + bio: string | null + created_at: string + display_name: string | null + dues_paid_until: string | null + email: string | null + handle: string | null + has_card_access: boolean + id: string + joined_at: string | null + last_paid_at: string | null + last_payment_at: string | null + payment_note: string | null + payment_status: string | null + phone: string | null + role: Database["public"]["Enums"]["member_role"] + space_id: string + status: Database["public"]["Enums"]["member_status"] + stripe_customer_id: string | null + tier: Database["public"]["Enums"]["member_tier"] + updated_at: string + user_id: string + } + Insert: { + approved?: boolean + avatar_url?: string | null + bio?: string | null + created_at?: string + display_name?: string | null + dues_paid_until?: string | null + email?: string | null + handle?: string | null + has_card_access?: boolean + id?: string + joined_at?: string | null + last_paid_at?: string | null + last_payment_at?: string | null + payment_note?: string | null + payment_status?: string | null + phone?: string | null + role?: Database["public"]["Enums"]["member_role"] + space_id: string + status?: Database["public"]["Enums"]["member_status"] + stripe_customer_id?: string | null + tier?: Database["public"]["Enums"]["member_tier"] + updated_at?: string + user_id: string + } + Update: { + approved?: boolean + avatar_url?: string | null + bio?: string | null + created_at?: string + display_name?: string | null + dues_paid_until?: string | null + email?: string | null + handle?: string | null + has_card_access?: boolean + id?: string + joined_at?: string | null + last_paid_at?: string | null + last_payment_at?: string | null + payment_note?: string | null + payment_status?: string | null + phone?: string | null + role?: Database["public"]["Enums"]["member_role"] + space_id?: string + status?: Database["public"]["Enums"]["member_status"] + stripe_customer_id?: string | null + tier?: Database["public"]["Enums"]["member_tier"] + updated_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "space_members_space_id_fkey" + columns: ["space_id"] + isOneToOne: false + referencedRelation: "spaces" + referencedColumns: ["id"] + }, + ] + } + spaces: { + Row: { + address: string | null + city: string | null + created_at: string + description: string | null + id: string + invite_code: string | null + logo_url: string | null + name: string + public_member_directory: boolean | null + require_approval: boolean | null + settings: Json | null + slug: string + timezone: string | null + updated_at: string + webhook_secret: string | null + } + Insert: { + address?: string | null + city?: string | null + created_at?: string + description?: string | null + id?: string + invite_code?: string | null + logo_url?: string | null + name: string + public_member_directory?: boolean | null + require_approval?: boolean | null + settings?: Json | null + slug: string + timezone?: string | null + updated_at?: string + webhook_secret?: string | null + } + Update: { + address?: string | null + city?: string | null + created_at?: string + description?: string | null + id?: string + invite_code?: string | null + logo_url?: string | null + name?: string + public_member_directory?: boolean | null + require_approval?: boolean | null + settings?: Json | null + slug?: string + timezone?: string | null + updated_at?: string + webhook_secret?: string | null + } + Relationships: [] + } + tasks: { + Row: { + area: string | null + assigned_to: string | null + assigned_to_name: string | null + claimed_by: string | null + claimed_by_name: string | null + completed_at: string | null + created_at: string + created_by: string | null + description: string | null + due_date: string | null + id: string + last_done_at: string | null + priority: string | null + progress: number + project_id: string | null + recurrence: Database["public"]["Enums"]["recurrence_type"] | null + requested_by: string | null + requested_by_name: string | null + space_id: string + status: Database["public"]["Enums"]["task_status"] + subtask_completed: number + subtask_total: number + tags: string[] | null + task_type: Database["public"]["Enums"]["task_type"] + title: string + updated_at: string + } + Insert: { + area?: string | null + assigned_to?: string | null + assigned_to_name?: string | null + claimed_by?: string | null + claimed_by_name?: string | null + completed_at?: string | null + created_at?: string + created_by?: string | null + description?: string | null + due_date?: string | null + id?: string + last_done_at?: string | null + priority?: string | null + progress?: number + project_id?: string | null + recurrence?: Database["public"]["Enums"]["recurrence_type"] | null + requested_by?: string | null + requested_by_name?: string | null + space_id: string + status?: Database["public"]["Enums"]["task_status"] + subtask_completed?: number + subtask_total?: number + tags?: string[] | null + task_type?: Database["public"]["Enums"]["task_type"] + title: string + updated_at?: string + } + Update: { + area?: string | null + assigned_to?: string | null + assigned_to_name?: string | null + claimed_by?: string | null + claimed_by_name?: string | null + completed_at?: string | null + created_at?: string + created_by?: string | null + description?: string | null + due_date?: string | null + id?: string + last_done_at?: string | null + priority?: string | null + progress?: number + project_id?: string | null + recurrence?: Database["public"]["Enums"]["recurrence_type"] | null + requested_by?: string | null + requested_by_name?: string | null + space_id?: string + status?: Database["public"]["Enums"]["task_status"] + subtask_completed?: number + subtask_total?: number + tags?: string[] | null + task_type?: Database["public"]["Enums"]["task_type"] + title?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "tasks_project_id_fkey" + columns: ["project_id"] + isOneToOne: false + referencedRelation: "projects" + referencedColumns: ["id"] + }, + { + foreignKeyName: "tasks_space_id_fkey" + columns: ["space_id"] + isOneToOne: false + referencedRelation: "spaces" + referencedColumns: ["id"] + }, + ] + } + } + Views: { + [_ in never]: never + } + Functions: { + get_user_space_ids: { Args: { uid: string }; Returns: string[] } + user_has_role_in_space: { + Args: { allowed_roles: string[]; sid: string; uid: string } + Returns: boolean + } + } + Enums: { + area_lead_status: "active" | "vacant" | "handoff" + channel_type: "general" | "area" | "ops" | "project" + contact_type: "vendor" | "supplier" | "partner" | "landlord" | "city" + kb_visibility: "all_members" | "board" | "admin_only" + member_role: "admin" | "board" | "treasurer" | "member" | "associate" + member_status: "current" | "late" | "inactive" | "unverified" + member_tier: "plus" | "basic" | "associate" + payment_link_status: "linked" | "unlinked" + payment_platform: "paypal" | "zeffy" | "venmo" | "cash" + project_status: "backlog" | "in_progress" | "review" | "done" | "blocked" + recurrence_type: "daily" | "weekly" | "biweekly" | "monthly" | "none" + task_status: + | "open" + | "claimed" + | "in_progress" + | "overdue" + | "due_today" + | "completed" + | "done" + | "blocked" + task_type: "chore" | "task" + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never From f5697f577febf5c08dcf09f8d85774bfd5801991 Mon Sep 17 00:00:00 2001 From: v0 Date: Wed, 11 Mar 2026 08:18:57 +0000 Subject: [PATCH 3/3] feat: comprehensive application audit and fixes Audited codebase, fixed input validation, OAuth, error handling, type casts, and security hardening Co-authored-by: Moheeb Zara <1184415+virgilvox@users.noreply.github.com> --- app/(app)/error.tsx | 61 +++++++++++++ app/(app)/layout.tsx | 2 +- app/(app)/loading.tsx | 12 +++ app/(app)/ops/page.tsx | 9 +- app/error.tsx | 60 +++++++++++++ app/global-error.tsx | 62 +++++++++++++ app/loading.tsx | 18 ++++ app/login/page.tsx | 35 +++++++- app/not-found.tsx | 42 +++++++++ components/app-sidebar.tsx | 7 +- lib/actions.ts | 43 ++++++++-- lib/auth-actions.ts | 33 +++++-- lib/security.ts | 172 +++++++++++++++++++++++++++++++++++++ lib/validations.ts | 171 ++++++++++++++++++++++++++++++++++++ 14 files changed, 704 insertions(+), 23 deletions(-) create mode 100644 app/(app)/error.tsx create mode 100644 app/(app)/loading.tsx create mode 100644 app/error.tsx create mode 100644 app/global-error.tsx create mode 100644 app/loading.tsx create mode 100644 app/not-found.tsx create mode 100644 lib/security.ts create mode 100644 lib/validations.ts diff --git a/app/(app)/error.tsx b/app/(app)/error.tsx new file mode 100644 index 0000000..6dcc1c2 --- /dev/null +++ b/app/(app)/error.tsx @@ -0,0 +1,61 @@ +'use client' + +import { useEffect } from 'react' +import { AlertTriangle, RefreshCw, ArrowLeft } from 'lucide-react' +import Link from 'next/link' + +export default function AppError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error('[App Section Error]', error) + }, [error]) + + return ( +
+
+
+
+ +
+ +

+ Something went wrong +

+ +

+ We encountered an error while loading this page. +

+ + {error.digest && ( +

+ {error.digest} +

+ )} + +
+ + + + + Dashboard + +
+
+
+
+ ) +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index e4849d5..eabb738 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -41,7 +41,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod return (
diff --git a/app/(app)/loading.tsx b/app/(app)/loading.tsx new file mode 100644 index 0000000..9ad3dff --- /dev/null +++ b/app/(app)/loading.tsx @@ -0,0 +1,12 @@ +import { Loader2 } from 'lucide-react' + +export default function AppLoading() { + return ( +
+
+ +

Loading...

+
+
+ ) +} diff --git a/app/(app)/ops/page.tsx b/app/(app)/ops/page.tsx index 8c8a2c8..236d8ce 100644 --- a/app/(app)/ops/page.tsx +++ b/app/(app)/ops/page.tsx @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { OpsClient } from './ops-client' +import type { Tables } from '@/types/database' export default async function OpsPage() { const supabase = await createClient() @@ -16,17 +17,17 @@ export default async function OpsPage() { supabase.from('knowledge_base').select('*').eq('space_id', member.space_id).order('is_pinned', { ascending: false }).order('created_at', { ascending: false }), supabase.from('area_leads').select('*').eq('space_id', member.space_id).order('area_name'), canSeeSecrets - ? supabase.from('secrets').select('id, title, area, created_at').eq('space_id', member.space_id) - : Promise.resolve({ data: [] }), + ? supabase.from('secrets').select('id, title, area, created_at, label, value, description, icon, space_id, created_by, updated_at, category, notes').eq('space_id', member.space_id) + : Promise.resolve({ data: [] as Tables<'secrets'>[] }), ]) return ( } spaceId={member.space_id} kbEntries={kbEntries ?? []} areaLeads={areaLeads ?? []} - secrets={(secretsResult as any)?.data ?? []} + secrets={secretsResult.data ?? []} canSeeSecrets={canSeeSecrets} /> ) diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..51da99f --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,60 @@ +'use client' + +import { useEffect } from 'react' +import { AlertTriangle, RefreshCw, Home } from 'lucide-react' +import Link from 'next/link' + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + // Log the error to an error reporting service + console.error('[App Error]', error) + }, [error]) + + return ( +
+
+
+ +
+ +

+ Something went wrong +

+ +

+ An unexpected error occurred. Please try again or contact support if the problem persists. +

+ + {error.digest && ( +

+ Error ID: {error.digest} +

+ )} + +
+ + + + + Go to Dashboard + +
+
+
+ ) +} diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 0000000..e9df911 --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,62 @@ +'use client' + +import { useEffect } from 'react' + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error('[Global Error]', error) + }, [error]) + + return ( + + +
+
+
+ + + +
+ +

+ Critical Error +

+ +

+ A critical error occurred. Please refresh the page or try again later. +

+ + {error.digest && ( +

+ Error ID: {error.digest} +

+ )} + + +
+
+ + + ) +} diff --git a/app/loading.tsx b/app/loading.tsx new file mode 100644 index 0000000..08d57e9 --- /dev/null +++ b/app/loading.tsx @@ -0,0 +1,18 @@ +export default function Loading() { + return ( +
+
+
+ + {'{'}hs{'}'} + +
+
+
+
+
+
+
+
+ ) +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 5076feb..4762c04 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -11,6 +11,7 @@ export default function LoginPage() { const [password, setPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) + const [oauthLoading, setOauthLoading] = useState<'github' | 'google' | null>(null) async function handleSubmit(e: React.FormEvent) { e.preventDefault() @@ -27,6 +28,22 @@ export default function LoginPage() { } } + async function handleOAuthLogin(provider: 'github' | 'google') { + setOauthLoading(provider) + setError('') + const supabase = createClient() + const { error } = await supabase.auth.signInWithOAuth({ + provider, + options: { + redirectTo: `${window.location.origin}/auth/callback`, + }, + }) + if (error) { + setError(error.message) + setOauthLoading(null) + } + } + return (
{/* Grid background */} @@ -102,20 +119,30 @@ export default function LoginPage() {
- -
diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..4cb2ec2 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,42 @@ +import Link from 'next/link' +import { FileQuestion, Home, ArrowLeft } from 'lucide-react' + +export default function NotFound() { + return ( +
+
+
+ +
+ +

404

+ +

+ Page not found +

+ +

+ The page you're looking for doesn't exist or has been moved. +

+ +
+ + + Go to Dashboard + + + + + Back to Home + +
+
+
+ ) +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index a663213..95daa3b 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -5,7 +5,10 @@ import { usePathname } from 'next/navigation' import { useState } from 'react' import { cn } from '@/lib/utils' import { signOut } from '@/lib/auth-actions' -import type { SpaceMember, Space } from '@/lib/types' +import type { Tables } from '@/types/database' + +type SpaceMember = Tables<'space_members'> +type Space = Tables<'spaces'> import { Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2, MessageSquare, Users, CreditCard, BookUser, Download, LogOut, @@ -46,7 +49,7 @@ function NavLink({ href, label, icon: Icon, badge, active, onClick }: NavLinkPro } interface AppSidebarProps { - member: SpaceMember & { spaces: Space } + member: SpaceMember & { spaces: Space | null } taskBadge?: number commsBadge?: number paymentBadge?: number diff --git a/lib/actions.ts b/lib/actions.ts index 362ffa8..5831da0 100644 --- a/lib/actions.ts +++ b/lib/actions.ts @@ -5,6 +5,25 @@ import { redirect } from 'next/navigation' import { revalidatePath } from 'next/cache' import type { SupabaseClient } from '@supabase/supabase-js' import type { Database } from '@/types/database' +import { + createTaskSchema, + taskIdSchema, + createProjectSchema, + updateProjectStatusSchema, + addMemberSchema, + updateMemberSchema, + createContactSchema, + updateContactSchema, + createKbEntrySchema, + updateKbEntrySchema, + createSecretSchema, + logCashPaymentSchema, + linkPaymentSchema, + updateSpaceSettingsSchema, + saveIntegrationSchema, + upsertAreaLeadSchema, + uuidSchema, +} from '@/lib/validations' // ─── Auth ──────────────────────────────────────────────────────────────────── @@ -61,6 +80,13 @@ export async function createTask(formData: { recurrence?: string due_date?: string }) { + // Validate input + const parsed = createTaskSchema.safeParse(formData) + if (!parsed.success) { + return { error: parsed.error.errors[0]?.message || 'Invalid input' } + } + const input = parsed.data + const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) return { error: 'Not authenticated' } @@ -70,12 +96,12 @@ export async function createTask(formData: { const { data, error } = await supabase.from('tasks').insert({ space_id: member.space_id, - title: formData.title, - description: formData.description, - task_type: formData.type || 'task', - area: formData.area, - recurrence: formData.recurrence || 'none', - due_date: formData.due_date || null, + title: input.title, + description: input.description, + task_type: input.type || 'task', + area: input.area, + recurrence: input.recurrence || 'none', + due_date: input.due_date || null, status: 'open', requested_by: user.id, requested_by_name: member.display_name, @@ -100,6 +126,9 @@ export async function createTask(formData: { } export async function claimTask(taskId: string) { + const idResult = taskIdSchema.safeParse(taskId) + if (!idResult.success) return { error: 'Invalid task ID' } + const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) return { error: 'Not authenticated' } @@ -111,7 +140,7 @@ export async function claimTask(taskId: string) { claimed_by: user.id, claimed_by_name: member.display_name, status: 'claimed', - }).eq('id', taskId).eq('space_id', member.space_id) + }).eq('id', idResult.data).eq('space_id', member.space_id) if (error) return { error: error.message } diff --git a/lib/auth-actions.ts b/lib/auth-actions.ts index 3eae4ae..44991b6 100644 --- a/lib/auth-actions.ts +++ b/lib/auth-actions.ts @@ -3,6 +3,7 @@ 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' @@ -24,6 +25,22 @@ export async function createSpace(formData: { 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() @@ -31,7 +48,7 @@ export async function createSpace(formData: { const { data: existingSpace } = await admin .from('spaces') .select('id') - .eq('slug', formData.spaceSlug.toLowerCase().trim()) + .eq('slug', spaceSlug) .maybeSingle() if (existingSpace) { @@ -42,9 +59,9 @@ export async function createSpace(formData: { const { data: space, error: spaceErr } = await admin .from('spaces') .insert({ - name: formData.spaceName, - slug: formData.spaceSlug.toLowerCase().trim(), - city: formData.spaceCity || null, + name: spaceName, + slug: spaceSlug, + city: spaceCity, invite_code: generateInviteCode(), }) .select('id') @@ -63,7 +80,7 @@ export async function createSpace(formData: { .insert({ space_id: space.id, user_id: user.id, - display_name: formData.displayName || user.email, + display_name: displayName || user.email, email: user.email, role: 'admin', tier: 'plus', @@ -117,6 +134,12 @@ export async function joinSpace(formData: { } 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({ diff --git a/lib/security.ts b/lib/security.ts new file mode 100644 index 0000000..d500287 --- /dev/null +++ b/lib/security.ts @@ -0,0 +1,172 @@ +/** + * Security utilities for input sanitization and validation + */ + +/** + * Sanitize a string by removing potentially dangerous characters + * Prevents XSS and injection attacks when displaying user input + */ +export function sanitizeString(input: string): string { + if (typeof input !== 'string') return '' + return input + .replace(/[<>]/g, '') // Remove HTML brackets + .replace(/javascript:/gi, '') // Remove javascript: protocol + .replace(/on\w+=/gi, '') // Remove event handlers + .trim() +} + +/** + * Sanitize HTML content - strips all HTML tags + * Use when you need plain text from user input + */ +export function stripHtml(input: string): string { + if (typeof input !== 'string') return '' + return input.replace(/<[^>]*>/g, '').trim() +} + +/** + * Escape HTML entities for safe display + * Use when you need to display user input in HTML context + */ +export function escapeHtml(input: string): string { + if (typeof input !== 'string') return '' + const htmlEntities: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + } + return input.replace(/[&<>"'/]/g, char => htmlEntities[char] || char) +} + +/** + * Validate and sanitize email address + */ +export function sanitizeEmail(email: string): string | null { + if (typeof email !== 'string') return null + const sanitized = email.toLowerCase().trim() + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(sanitized) ? sanitized : null +} + +/** + * Sanitize URL - only allow http/https protocols + */ +export function sanitizeUrl(url: string): string | null { + if (typeof url !== 'string') return null + try { + const parsed = new URL(url.trim()) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null + } + return parsed.href + } catch { + return null + } +} + +/** + * Sanitize slug - only allow lowercase alphanumeric and hyphens + */ +export function sanitizeSlug(input: string): string { + if (typeof input !== 'string') return '' + return input + .toLowerCase() + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 50) +} + +/** + * Check if input contains potential SQL injection patterns + * Note: Always use parameterized queries - this is an additional safety layer + */ +export function hasSqlInjectionPatterns(input: string): boolean { + if (typeof input !== 'string') return false + const patterns = [ + /(\b(SELECT|INSERT|UPDATE|DELETE|DROP|UNION|ALTER|CREATE|TRUNCATE)\b)/i, + /(--|;|\/\*|\*\/)/, + /(\b(OR|AND)\b\s+\d+\s*=\s*\d+)/i, + ] + return patterns.some(pattern => pattern.test(input)) +} + +/** + * Validate UUID format + */ +export function isValidUuid(id: string): boolean { + if (typeof id !== 'string') return false + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + return uuidRegex.test(id) +} + +/** + * Truncate string to max length with ellipsis + */ +export function truncate(input: string, maxLength: number): string { + if (typeof input !== 'string') return '' + if (input.length <= maxLength) return input + return input.slice(0, maxLength - 3) + '...' +} + +/** + * Simple in-memory rate limiter for server actions + * For production, use Redis-based rate limiting + */ +const rateLimitStore = new Map() + +export function checkRateLimit( + identifier: string, + maxRequests: number = 10, + windowMs: number = 60000 +): { allowed: boolean; remaining: number; resetIn: number } { + const now = Date.now() + const entry = rateLimitStore.get(identifier) + + if (!entry || now > entry.resetTime) { + rateLimitStore.set(identifier, { count: 1, resetTime: now + windowMs }) + return { allowed: true, remaining: maxRequests - 1, resetIn: windowMs } + } + + if (entry.count >= maxRequests) { + return { allowed: false, remaining: 0, resetIn: entry.resetTime - now } + } + + entry.count++ + return { allowed: true, remaining: maxRequests - entry.count, resetIn: entry.resetTime - now } +} + +/** + * Clear expired rate limit entries (call periodically) + */ +export function cleanupRateLimits(): void { + const now = Date.now() + for (const [key, entry] of rateLimitStore.entries()) { + if (now > entry.resetTime) { + rateLimitStore.delete(key) + } + } +} + +/** + * Validate content length + */ +export function validateContentLength( + content: string, + minLength: number = 0, + maxLength: number = 10000 +): { valid: boolean; error?: string } { + if (typeof content !== 'string') { + return { valid: false, error: 'Content must be a string' } + } + if (content.length < minLength) { + return { valid: false, error: `Content must be at least ${minLength} characters` } + } + if (content.length > maxLength) { + return { valid: false, error: `Content must be less than ${maxLength} characters` } + } + return { valid: true } +} diff --git a/lib/validations.ts b/lib/validations.ts new file mode 100644 index 0000000..192a9bb --- /dev/null +++ b/lib/validations.ts @@ -0,0 +1,171 @@ +import { z } from 'zod' + +// ─── Auth ──────────────────────────────────────────────────────────────────── + +export const signInSchema = z.object({ + email: z.string().email('Invalid email address'), + password: z.string().min(1, 'Password is required'), +}) + +export const signUpSchema = z.object({ + email: z.string().email('Invalid email address'), + password: z.string().min(8, 'Password must be at least 8 characters'), + fullName: z.string().min(1, 'Full name is required').max(100, 'Name is too long'), + action: z.enum(['create', 'join']), + spaceName: z.string().max(100).optional(), + spaceSlug: z.string().regex(/^[a-z0-9]+$/, 'Slug must be lowercase letters and numbers only').max(50).optional(), + city: z.string().max(100).optional(), + inviteCode: z.string().max(20).optional(), +}) + +export const createSpaceSchema = z.object({ + spaceName: z.string().min(1, 'Space name is required').max(100, 'Name is too long'), + spaceSlug: z.string().min(1, 'Slug is required').max(50).regex(/^[a-z0-9]+$/, 'Slug must be lowercase letters and numbers only'), + spaceCity: z.string().max(100).optional(), + displayName: z.string().min(1, 'Display name is required').max(100), +}) + +export const joinSpaceSchema = z.object({ + inviteCode: z.string().min(1, 'Invite code is required').max(20), + displayName: z.string().min(1, 'Display name is required').max(100), +}) + +// ─── Tasks ─────────────────────────────────────────────────────────────────── + +export const createTaskSchema = z.object({ + title: z.string().min(1, 'Title is required').max(200, 'Title is too long'), + description: z.string().max(2000, 'Description is too long').optional(), + type: z.string().max(50).default('task'), + area: z.string().max(100).optional(), + recurrence: z.enum(['none', 'daily', 'weekly', 'monthly']).default('none'), + due_date: z.string().datetime().optional().nullable(), +}) + +export const taskIdSchema = z.string().uuid('Invalid task ID') + +// ─── Projects ──────────────────────────────────────────────────────────────── + +export const createProjectSchema = z.object({ + title: z.string().min(1, 'Title is required').max(200), + description: z.string().max(2000).optional(), + area: z.string().max(100).optional(), + due_date: z.string().datetime().optional().nullable(), +}) + +export const updateProjectStatusSchema = z.object({ + projectId: z.string().uuid('Invalid project ID'), + status: z.enum(['planning', 'active', 'paused', 'completed', 'cancelled']), +}) + +// ─── Members ───────────────────────────────────────────────────────────────── + +export const addMemberSchema = z.object({ + email: z.string().email('Invalid email address'), + display_name: z.string().min(1, 'Display name is required').max(100), + role: z.enum(['admin', 'board', 'treasurer', 'member']).default('member'), + tier: z.enum(['plus', 'basic', 'associate']).default('basic'), +}) + +export const updateMemberSchema = z.object({ + memberId: z.string().uuid('Invalid member ID'), + display_name: z.string().min(1).max(100).optional(), + email: z.string().email().optional(), + phone: z.string().max(20).optional().nullable(), + handle: z.string().max(50).optional().nullable(), + role: z.enum(['admin', 'board', 'treasurer', 'member']).optional(), + tier: z.enum(['plus', 'basic', 'associate']).optional(), + status: z.enum(['current', 'unverified', 'late', 'inactive']).optional(), + has_card_access: z.boolean().optional(), +}) + +// ─── Contacts ──────────────────────────────────────────────────────────────── + +export const createContactSchema = z.object({ + name: z.string().min(1, 'Name is required').max(200), + contact_type: z.enum(['vendor', 'sponsor', 'partner', 'city', 'media', 'other']), + email: z.string().email().optional().nullable(), + phone: z.string().max(20).optional().nullable(), + details: z.string().max(500).optional().nullable(), + note: z.string().max(2000).optional().nullable(), + group_label: z.string().max(100).optional().nullable(), + tags: z.array(z.string().max(50)).max(10).optional(), + code: z.string().max(50).optional().nullable(), +}) + +export const updateContactSchema = createContactSchema.partial().extend({ + contactId: z.string().uuid('Invalid contact ID'), +}) + +// ─── Knowledge Base ────────────────────────────────────────────────────────── + +export const createKbEntrySchema = z.object({ + title: z.string().min(1, 'Title is required').max(200), + content: z.string().min(1, 'Content is required').max(50000), + area: z.string().max(100).optional().nullable(), + visibility: z.enum(['public', 'members', 'board', 'admin']).default('members'), + is_pinned: z.boolean().default(false), + tags: z.array(z.string().max(50)).max(20).optional(), + icon: z.string().max(10).optional().nullable(), +}) + +export const updateKbEntrySchema = createKbEntrySchema.partial().extend({ + entryId: z.string().uuid('Invalid entry ID'), +}) + +// ─── Secrets ───────────────────────────────────────────────────────────────── + +export const createSecretSchema = z.object({ + title: z.string().min(1, 'Title is required').max(200), + value: z.string().min(1, 'Value is required').max(5000), + description: z.string().max(500).optional().nullable(), + area: z.string().max(100).optional().nullable(), + icon: z.string().max(10).optional().nullable(), +}) + +// ─── Payments ──────────────────────────────────────────────────────────────── + +export const logCashPaymentSchema = z.object({ + amount: z.number().positive('Amount must be positive'), + from_identifier: z.string().min(1, 'Payer name is required').max(200), + from_note: z.string().max(500).optional().nullable(), + transaction_date: z.string().datetime().optional(), +}) + +export const linkPaymentSchema = z.object({ + paymentId: z.string().uuid('Invalid payment ID'), + memberId: z.string().uuid('Invalid member ID'), +}) + +// ─── Settings ──────────────────────────────────────────────────────────────── + +export const updateSpaceSettingsSchema = z.object({ + name: z.string().min(1).max(100).optional(), + city: z.string().max(100).optional().nullable(), + require_approval: z.boolean().optional(), + public_member_directory: z.boolean().optional(), +}) + +export const saveIntegrationSchema = z.object({ + platform: z.string().min(1).max(50), + config: z.record(z.string().max(500)), +}) + +// ─── Area Leads ────────────────────────────────────────────────────────────── + +export const upsertAreaLeadSchema = z.object({ + id: z.string().uuid().optional(), + area_name: z.string().min(1, 'Area name is required').max(100), + lead_handle: z.string().min(1, 'Lead handle is required').max(100), + description: z.string().max(500).optional().nullable(), +}) + +// ─── Generic ID schemas ────────────────────────────────────────────────────── + +export const uuidSchema = z.string().uuid('Invalid ID format') + +// Helper type exports +export type CreateTaskInput = z.infer +export type CreateProjectInput = z.infer +export type CreateContactInput = z.infer +export type CreateKbEntryInput = z.infer +export type CreateSecretInput = z.infer