diff --git a/app/(app)/ops/components/area-lead-modal.tsx b/app/(app)/ops/components/area-lead-modal.tsx index 5933929..df0f3cb 100644 --- a/app/(app)/ops/components/area-lead-modal.tsx +++ b/app/(app)/ops/components/area-lead-modal.tsx @@ -1,9 +1,8 @@ 'use client' import { useState } from 'react' -import { createClient } from '@/lib/supabase/client' +import { createAreaLead, updateAreaLead } from '@/lib/actions' import { toast } from 'sonner' -import type { TablesInsert, TablesUpdate } from '@/types/database' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import type { AreaLead } from '../types' @@ -12,12 +11,10 @@ export function AreaLeadModal({ lead, onClose, onSaved, - spaceId, }: { lead: AreaLead | null onClose: () => void onSaved: (l: AreaLead) => void - spaceId: string }) { const isEdit = !!lead const [areaName, setAreaName] = useState(lead?.area_name ?? '') @@ -29,26 +26,16 @@ export function AreaLeadModal({ e.preventDefault() if (!areaName.trim() || !memberName.trim()) return setSaving(true) - const supabase = createClient() - let result - if (isEdit) { - result = await supabase.from('area_leads').update({ - area_name: areaName.trim(), - lead_handle: memberName.trim(), - description: contactInfo.trim() || null, - // TODO(types): remove `as never` after regenerating types/database.ts (missing FK relationship metadata) - } satisfies TablesUpdate<'area_leads'> as never).eq('id', lead.id).select('*').single() - } else { - result = await supabase.from('area_leads').insert({ - space_id: spaceId, - area_name: areaName.trim(), - lead_handle: memberName.trim(), - description: contactInfo.trim() || null, - // TODO(types): remove `as never` after regenerating types/database.ts (missing FK relationship metadata) - } satisfies TablesInsert<'area_leads'> as never).select('*').single() + const fields = { + area_name: areaName.trim(), + lead_handle: memberName.trim(), + description: contactInfo.trim() || null, } + const result = isEdit + ? await updateAreaLead(lead.id, fields) + : await createAreaLead(fields) setSaving(false) - if (result.error) { toast.error(result.error.message); return } + if ('error' in result) { toast.error(result.error); return } toast.success(isEdit ? 'Area lead updated' : 'Area lead added') onSaved(result.data as AreaLead) onClose() diff --git a/app/(app)/ops/ops-client.tsx b/app/(app)/ops/ops-client.tsx index 68e65aa..8b407da 100644 --- a/app/(app)/ops/ops-client.tsx +++ b/app/(app)/ops/ops-client.tsx @@ -2,7 +2,7 @@ import { useState, useMemo } from 'react' import { Plus, Search, Lock, Users2 } from 'lucide-react' -import { createClient } from '@/lib/supabase/client' +import { deleteAreaLead } from '@/lib/actions' import { toast } from 'sonner' import { PageTitle } from '@/components/ui/page-title' import { KbModal } from './components/kb-modal' @@ -15,7 +15,7 @@ import { TABS } from './types' import type { OpsClientProps, Tab, KbEntry, AreaLead, Secret } from './types' // ─── Main Component ──────────────────────────────────────────────────────────── -export function OpsClient({ spaceId, kbEntries: initial, areaLeads: initialLeads, secrets: initialSecrets, canSeeSecrets, canManageAcl = false, aclRoleOptions = [], aclByEntity = {} }: OpsClientProps) { +export function OpsClient({ kbEntries: initial, areaLeads: initialLeads, secrets: initialSecrets, canSeeSecrets, canManageAcl = false, aclRoleOptions = [], aclByEntity = {} }: OpsClientProps) { const [activeTab, setActiveTab] = useState('kb') const [search, setSearch] = useState('') const [kbEntries, setKbEntries] = useState(initial) @@ -64,9 +64,8 @@ export function OpsClient({ spaceId, kbEntries: initial, areaLeads: initialLeads }, [areaLeads, search]) async function deleteLeadFn(id: string) { - const supabase = createClient() - const { error } = await supabase.from('area_leads').delete().eq('id', id) - if (error) { toast.error(error.message); return } + const result = await deleteAreaLead(id) + if ('error' in result) { toast.error(result.error); return } setAreaLeads(prev => prev.filter(l => l.id !== id)) toast.success('Area lead removed') } @@ -221,7 +220,6 @@ export function OpsClient({ spaceId, kbEntries: initial, areaLeads: initialLeads : [...prev, saved].sort((a, b) => a.area_name.localeCompare(b.area_name)), ) }} - spaceId={spaceId} /> )} diff --git a/lib/actions/area-leads.ts b/lib/actions/area-leads.ts index 0338d04..fab7267 100644 --- a/lib/actions/area-leads.ts +++ b/lib/actions/area-leads.ts @@ -2,9 +2,9 @@ import { revalidatePath } from 'next/cache' import { createClient } from '@/lib/supabase/server' -import { requireMemberWithRole, parseInput } from '@/lib/auth-helpers' +import { requireMemberWithRole, parseInput, logActivity } from '@/lib/auth-helpers' import { ADMIN_ROLES } from '@/lib/permissions' -import { upsertAreaLeadSchema } from '@/lib/validations' +import { upsertAreaLeadSchema, createAreaLeadSchema, updateAreaLeadSchema, uuidSchema } from '@/lib/validations' export async function upsertAreaLead(formData: { area_code: string @@ -62,3 +62,94 @@ export async function upsertAreaLead(formData: { revalidatePath('/ops') return { data } } + +// ─── Ops area-lead roster (area_name-keyed) ────────────────────────────────── +// The Ops UI manages a plain roster of "who leads which area". These are a +// separate concern from upsertAreaLead's area_code-keyed role interface above; +// area_code is left null here. + +export async function createAreaLead(formData: { + area_name: string + lead_handle: string + description?: string | null +}) { + const v = parseInput(createAreaLeadSchema, formData) + if (!v.ok) return { error: v.error } + + const supabase = await createClient() + const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required') + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const { data, error } = await supabase + .from('area_leads') + .insert({ + space_id: member.space_id, + area_name: v.data.area_name, + lead_handle: v.data.lead_handle, + description: v.data.description ?? null, + }) + .select() + .single() + + if (error) return { error: error.message } + + await logActivity(supabase, member, 'created', 'area_lead', data.id as string) + revalidatePath('/ops') + return { data } +} + +export async function updateAreaLead( + id: string, + updates: { area_name: string; lead_handle: string; description?: string | null }, +) { + const v = parseInput(updateAreaLeadSchema, { id, ...updates }) + if (!v.ok) return { error: v.error } + + const supabase = await createClient() + const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required') + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const { data, error } = await supabase + .from('area_leads') + .update({ + area_name: v.data.area_name, + lead_handle: v.data.lead_handle, + description: v.data.description ?? null, + }) + .eq('id', v.data.id) + .eq('space_id', member.space_id) + .select() + .single() + + if (error) return { error: error.message } + if (!data) return { error: 'Area lead not found' } + + await logActivity(supabase, member, 'updated', 'area_lead', v.data.id) + revalidatePath('/ops') + return { data } +} + +export async function deleteAreaLead(id: string) { + const v = parseInput(uuidSchema, id) + if (!v.ok) return { error: 'Invalid area lead ID' } + + const supabase = await createClient() + const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required') + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const { error, count } = await supabase + .from('area_leads') + .delete({ count: 'exact' }) + .eq('id', v.data) + .eq('space_id', member.space_id) + + if (error) return { error: error.message } + if (count === 0) return { error: 'Area lead not found' } + + await logActivity(supabase, member, 'deleted', 'area_lead', v.data) + revalidatePath('/ops') + return { success: true as const } +} diff --git a/lib/validations/permissions.ts b/lib/validations/permissions.ts index bbf302c..a82a145 100644 --- a/lib/validations/permissions.ts +++ b/lib/validations/permissions.ts @@ -28,3 +28,15 @@ export const assignAreaLeadSchema = z.object({ area_lead_role_id: z.string().uuid('Invalid role ID'), member_id: z.string().uuid('Invalid member ID'), }) + +// The ops-managed area-lead roster (area_name-keyed, distinct from the +// area_code-keyed upsertAreaLead role interface above). +export const createAreaLeadSchema = z.object({ + area_name: z.string().min(1).max(100), + lead_handle: z.string().min(1).max(100), + description: z.string().max(2000).optional().nullable(), +}) + +export const updateAreaLeadSchema = createAreaLeadSchema.extend({ + id: z.string().uuid('Invalid area lead ID'), +})