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 (
+
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 (
+
+ )
+}
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