diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index ba7e5bb..00f29fb 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -14,16 +14,470 @@ export type Database = { } public: { Tables: { - [_ in never]: never + cases: { + Row: { + assigned_attorney_id: string | null + case_number: string + client_id: string + closed_at: string | null + created_at: string + created_by: string | null + default_hourly_rate: number | null + description: string | null + id: string + opened_at: string + practice_area: string | null + status: Database["public"]["Enums"]["case_status"] + title: string + updated_at: string + } + Insert: { + assigned_attorney_id?: string | null + case_number: string + client_id: string + closed_at?: string | null + created_at?: string + created_by?: string | null + default_hourly_rate?: number | null + description?: string | null + id?: string + opened_at?: string + practice_area?: string | null + status?: Database["public"]["Enums"]["case_status"] + title: string + updated_at?: string + } + Update: { + assigned_attorney_id?: string | null + case_number?: string + client_id?: string + closed_at?: string | null + created_at?: string + created_by?: string | null + default_hourly_rate?: number | null + description?: string | null + id?: string + opened_at?: string + practice_area?: string | null + status?: Database["public"]["Enums"]["case_status"] + title?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "cases_client_id_fkey" + columns: ["client_id"] + isOneToOne: false + referencedRelation: "clients" + referencedColumns: ["id"] + }, + ] + } + clients: { + Row: { + address_line1: string | null + address_line2: string | null + board_members: Json + city: string | null + client_type: Database["public"]["Enums"]["client_type"] + created_at: string + created_by: string | null + id: string + management_company: string | null + name: string + notes: string | null + num_units: number | null + postal_code: string | null + primary_contact_email: string | null + primary_contact_name: string | null + primary_contact_phone: string | null + state: string | null + updated_at: string + } + Insert: { + address_line1?: string | null + address_line2?: string | null + board_members?: Json + city?: string | null + client_type?: Database["public"]["Enums"]["client_type"] + created_at?: string + created_by?: string | null + id?: string + management_company?: string | null + name: string + notes?: string | null + num_units?: number | null + postal_code?: string | null + primary_contact_email?: string | null + primary_contact_name?: string | null + primary_contact_phone?: string | null + state?: string | null + updated_at?: string + } + Update: { + address_line1?: string | null + address_line2?: string | null + board_members?: Json + city?: string | null + client_type?: Database["public"]["Enums"]["client_type"] + created_at?: string + created_by?: string | null + id?: string + management_company?: string | null + name?: string + notes?: string | null + num_units?: number | null + postal_code?: string | null + primary_contact_email?: string | null + primary_contact_name?: string | null + primary_contact_phone?: string | null + state?: string | null + updated_at?: string + } + Relationships: [] + } + documents: { + Row: { + case_id: string + created_at: string + description: string | null + id: string + mime_type: string | null + name: string + size_bytes: number | null + storage_path: string + uploaded_by: string | null + } + Insert: { + case_id: string + created_at?: string + description?: string | null + id?: string + mime_type?: string | null + name: string + size_bytes?: number | null + storage_path: string + uploaded_by?: string | null + } + Update: { + case_id?: string + created_at?: string + description?: string | null + id?: string + mime_type?: string | null + name?: string + size_bytes?: number | null + storage_path?: string + uploaded_by?: string | null + } + Relationships: [ + { + foreignKeyName: "documents_case_id_fkey" + columns: ["case_id"] + isOneToOne: false + referencedRelation: "cases" + referencedColumns: ["id"] + }, + ] + } + expenses: { + Row: { + amount: number + billable: boolean + case_id: string + created_at: string + description: string + expense_date: string + id: string + invoice_id: string | null + receipt_storage_path: string | null + updated_at: string + user_id: string + } + Insert: { + amount: number + billable?: boolean + case_id: string + created_at?: string + description: string + expense_date?: string + id?: string + invoice_id?: string | null + receipt_storage_path?: string | null + updated_at?: string + user_id: string + } + Update: { + amount?: number + billable?: boolean + case_id?: string + created_at?: string + description?: string + expense_date?: string + id?: string + invoice_id?: string | null + receipt_storage_path?: string | null + updated_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "expenses_case_id_fkey" + columns: ["case_id"] + isOneToOne: false + referencedRelation: "cases" + referencedColumns: ["id"] + }, + { + foreignKeyName: "expenses_invoice_fk" + columns: ["invoice_id"] + isOneToOne: false + referencedRelation: "invoices" + referencedColumns: ["id"] + }, + ] + } + invoices: { + Row: { + amount_paid: number + case_id: string | null + client_id: string + created_at: string + created_by: string | null + due_date: string | null + id: string + invoice_number: string + issue_date: string + notes: string | null + paid_at: string | null + status: Database["public"]["Enums"]["invoice_status"] + subtotal: number + tax: number + total: number + updated_at: string + } + Insert: { + amount_paid?: number + case_id?: string | null + client_id: string + created_at?: string + created_by?: string | null + due_date?: string | null + id?: string + invoice_number: string + issue_date?: string + notes?: string | null + paid_at?: string | null + status?: Database["public"]["Enums"]["invoice_status"] + subtotal?: number + tax?: number + total?: number + updated_at?: string + } + Update: { + amount_paid?: number + case_id?: string | null + client_id?: string + created_at?: string + created_by?: string | null + due_date?: string | null + id?: string + invoice_number?: string + issue_date?: string + notes?: string | null + paid_at?: string | null + status?: Database["public"]["Enums"]["invoice_status"] + subtotal?: number + tax?: number + total?: number + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "invoices_case_id_fkey" + columns: ["case_id"] + isOneToOne: false + referencedRelation: "cases" + referencedColumns: ["id"] + }, + { + foreignKeyName: "invoices_client_id_fkey" + columns: ["client_id"] + isOneToOne: false + referencedRelation: "clients" + referencedColumns: ["id"] + }, + ] + } + profiles: { + Row: { + created_at: string + email: string + full_name: string + id: string + updated_at: string + } + Insert: { + created_at?: string + email?: string + full_name?: string + id: string + updated_at?: string + } + Update: { + created_at?: string + email?: string + full_name?: string + id?: string + updated_at?: string + } + Relationships: [] + } + status_updates: { + Row: { + body: string + case_id: string + created_at: string + created_by: string | null + id: string + report_storage_path: string | null + title: string + } + Insert: { + body: string + case_id: string + created_at?: string + created_by?: string | null + id?: string + report_storage_path?: string | null + title: string + } + Update: { + body?: string + case_id?: string + created_at?: string + created_by?: string | null + id?: string + report_storage_path?: string | null + title?: string + } + Relationships: [ + { + foreignKeyName: "status_updates_case_id_fkey" + columns: ["case_id"] + isOneToOne: false + referencedRelation: "cases" + referencedColumns: ["id"] + }, + ] + } + time_entries: { + Row: { + billable: boolean + case_id: string + created_at: string + description: string + hourly_rate: number + hours: number + id: string + invoice_id: string | null + updated_at: string + user_id: string + work_date: string + } + Insert: { + billable?: boolean + case_id: string + created_at?: string + description: string + hourly_rate?: number + hours: number + id?: string + invoice_id?: string | null + updated_at?: string + user_id: string + work_date?: string + } + Update: { + billable?: boolean + case_id?: string + created_at?: string + description?: string + hourly_rate?: number + hours?: number + id?: string + invoice_id?: string | null + updated_at?: string + user_id?: string + work_date?: string + } + Relationships: [ + { + foreignKeyName: "time_entries_case_id_fkey" + columns: ["case_id"] + isOneToOne: false + referencedRelation: "cases" + referencedColumns: ["id"] + }, + { + foreignKeyName: "time_entries_invoice_fk" + columns: ["invoice_id"] + isOneToOne: false + referencedRelation: "invoices" + referencedColumns: ["id"] + }, + ] + } + user_roles: { + Row: { + created_at: string + id: string + role: Database["public"]["Enums"]["app_role"] + user_id: string + } + Insert: { + created_at?: string + id?: string + role: Database["public"]["Enums"]["app_role"] + user_id: string + } + Update: { + created_at?: string + id?: string + role?: Database["public"]["Enums"]["app_role"] + user_id?: string + } + Relationships: [] + } } Views: { [_ in never]: never } Functions: { - [_ in never]: never + can_access_case: { + Args: { _case_id: string; _user_id: string } + Returns: boolean + } + has_role: { + Args: { + _role: Database["public"]["Enums"]["app_role"] + _user_id: string + } + Returns: boolean + } + is_admin: { Args: { _user_id: string }; Returns: boolean } } Enums: { - [_ in never]: never + app_role: "admin" | "attorney" | "staff" + case_status: + | "intake" + | "active" + | "on_hold" + | "closed_won" + | "closed_lost" + | "closed" + client_type: "hoa" | "individual" | "business" + invoice_status: "draft" | "sent" | "paid" | "overdue" | "void" } CompositeTypes: { [_ in never]: never @@ -150,6 +604,18 @@ export type CompositeTypes< export const Constants = { public: { - Enums: {}, + Enums: { + app_role: ["admin", "attorney", "staff"], + case_status: [ + "intake", + "active", + "on_hold", + "closed_won", + "closed_lost", + "closed", + ], + client_type: ["hoa", "individual", "business"], + invoice_status: ["draft", "sent", "paid", "overdue", "void"], + }, }, } as const diff --git a/supabase/migrations/20260416224551_467073c0-09ae-4595-8850-8072a75114c1.sql b/supabase/migrations/20260416224551_467073c0-09ae-4595-8850-8072a75114c1.sql new file mode 100644 index 0000000..4605ead --- /dev/null +++ b/supabase/migrations/20260416224551_467073c0-09ae-4595-8850-8072a75114c1.sql @@ -0,0 +1,366 @@ +-- ========================================= +-- ENUMS +-- ========================================= +CREATE TYPE public.app_role AS ENUM ('admin', 'attorney', 'staff'); +CREATE TYPE public.client_type AS ENUM ('hoa', 'individual', 'business'); +CREATE TYPE public.case_status AS ENUM ('intake', 'active', 'on_hold', 'closed_won', 'closed_lost', 'closed'); +CREATE TYPE public.invoice_status AS ENUM ('draft', 'sent', 'paid', 'overdue', 'void'); + +-- ========================================= +-- UPDATED-AT TRIGGER FN +-- ========================================= +CREATE OR REPLACE FUNCTION public.tg_set_updated_at() +RETURNS TRIGGER LANGUAGE plpgsql SET search_path = public AS $$ +BEGIN NEW.updated_at = now(); RETURN NEW; END; +$$; + +-- ========================================= +-- PROFILES (display info for auth users) +-- ========================================= +CREATE TABLE public.profiles ( + id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + full_name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; + +CREATE TRIGGER profiles_updated_at BEFORE UPDATE ON public.profiles +FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); + +-- Auto-create profile on signup +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +BEGIN + INSERT INTO public.profiles (id, email, full_name) + VALUES (NEW.id, NEW.email, COALESCE(NEW.raw_user_meta_data->>'full_name', '')); + -- First-ever user becomes admin + IF (SELECT count(*) FROM public.user_roles) = 0 THEN + INSERT INTO public.user_roles (user_id, role) VALUES (NEW.id, 'admin'); + ELSE + INSERT INTO public.user_roles (user_id, role) VALUES (NEW.id, 'staff'); + END IF; + RETURN NEW; +END; +$$; + +-- ========================================= +-- USER ROLES +-- ========================================= +CREATE TABLE public.user_roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + role public.app_role NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, role) +); +ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY; + +CREATE OR REPLACE FUNCTION public.has_role(_user_id UUID, _role public.app_role) +RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT EXISTS (SELECT 1 FROM public.user_roles WHERE user_id = _user_id AND role = _role) +$$; + +CREATE OR REPLACE FUNCTION public.is_admin(_user_id UUID) +RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT EXISTS (SELECT 1 FROM public.user_roles WHERE user_id = _user_id AND role = 'admin') +$$; + +-- Now create the trigger (after user_roles exists) +CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users +FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); + +-- ========================================= +-- CLIENTS +-- ========================================= +CREATE TABLE public.clients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_type public.client_type NOT NULL DEFAULT 'hoa', + name TEXT NOT NULL, + primary_contact_name TEXT, + primary_contact_email TEXT, + primary_contact_phone TEXT, + address_line1 TEXT, + address_line2 TEXT, + city TEXT, + state TEXT, + postal_code TEXT, + notes TEXT, + -- HOA-specific + management_company TEXT, + num_units INTEGER, + board_members JSONB NOT NULL DEFAULT '[]'::jsonb, + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE public.clients ENABLE ROW LEVEL SECURITY; +CREATE TRIGGER clients_updated_at BEFORE UPDATE ON public.clients +FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); + +-- ========================================= +-- CASES +-- ========================================= +CREATE TABLE public.cases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_id UUID NOT NULL REFERENCES public.clients(id) ON DELETE RESTRICT, + case_number TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + description TEXT, + practice_area TEXT, + status public.case_status NOT NULL DEFAULT 'intake', + assigned_attorney_id UUID REFERENCES auth.users(id), + opened_at DATE NOT NULL DEFAULT CURRENT_DATE, + closed_at DATE, + default_hourly_rate NUMERIC(10,2), + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE public.cases ENABLE ROW LEVEL SECURITY; +CREATE TRIGGER cases_updated_at BEFORE UPDATE ON public.cases +FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); +CREATE INDEX idx_cases_client ON public.cases(client_id); +CREATE INDEX idx_cases_attorney ON public.cases(assigned_attorney_id); + +-- Helper: can current user access this case? +CREATE OR REPLACE FUNCTION public.can_access_case(_case_id UUID, _user_id UUID) +RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT + public.has_role(_user_id, 'admin') + OR EXISTS ( + SELECT 1 FROM public.cases c + WHERE c.id = _case_id + AND (c.assigned_attorney_id = _user_id OR c.created_by = _user_id) + ) +$$; + +-- ========================================= +-- DOCUMENTS +-- ========================================= +CREATE TABLE public.documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + case_id UUID NOT NULL REFERENCES public.cases(id) ON DELETE CASCADE, + name TEXT NOT NULL, + storage_path TEXT NOT NULL, + mime_type TEXT, + size_bytes BIGINT, + description TEXT, + uploaded_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY; +CREATE INDEX idx_documents_case ON public.documents(case_id); + +-- ========================================= +-- STATUS UPDATES (case log entries) +-- ========================================= +CREATE TABLE public.status_updates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + case_id UUID NOT NULL REFERENCES public.cases(id) ON DELETE CASCADE, + title TEXT NOT NULL, + body TEXT NOT NULL, + report_storage_path TEXT, + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE public.status_updates ENABLE ROW LEVEL SECURITY; +CREATE INDEX idx_status_updates_case ON public.status_updates(case_id); + +-- ========================================= +-- TIME ENTRIES +-- ========================================= +CREATE TABLE public.time_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + case_id UUID NOT NULL REFERENCES public.cases(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id), + work_date DATE NOT NULL DEFAULT CURRENT_DATE, + hours NUMERIC(6,2) NOT NULL CHECK (hours > 0), + hourly_rate NUMERIC(10,2) NOT NULL DEFAULT 0, + description TEXT NOT NULL, + billable BOOLEAN NOT NULL DEFAULT true, + invoice_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE public.time_entries ENABLE ROW LEVEL SECURITY; +CREATE TRIGGER time_entries_updated_at BEFORE UPDATE ON public.time_entries +FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); +CREATE INDEX idx_time_case ON public.time_entries(case_id); +CREATE INDEX idx_time_user ON public.time_entries(user_id); +CREATE INDEX idx_time_invoice ON public.time_entries(invoice_id); + +-- ========================================= +-- EXPENSES +-- ========================================= +CREATE TABLE public.expenses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + case_id UUID NOT NULL REFERENCES public.cases(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id), + expense_date DATE NOT NULL DEFAULT CURRENT_DATE, + description TEXT NOT NULL, + amount NUMERIC(10,2) NOT NULL CHECK (amount >= 0), + billable BOOLEAN NOT NULL DEFAULT true, + receipt_storage_path TEXT, + invoice_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE public.expenses ENABLE ROW LEVEL SECURITY; +CREATE TRIGGER expenses_updated_at BEFORE UPDATE ON public.expenses +FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); +CREATE INDEX idx_expenses_case ON public.expenses(case_id); +CREATE INDEX idx_expenses_invoice ON public.expenses(invoice_id); + +-- ========================================= +-- INVOICES +-- ========================================= +CREATE TABLE public.invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + invoice_number TEXT NOT NULL UNIQUE, + client_id UUID NOT NULL REFERENCES public.clients(id) ON DELETE RESTRICT, + case_id UUID REFERENCES public.cases(id) ON DELETE SET NULL, + status public.invoice_status NOT NULL DEFAULT 'draft', + issue_date DATE NOT NULL DEFAULT CURRENT_DATE, + due_date DATE, + subtotal NUMERIC(12,2) NOT NULL DEFAULT 0, + tax NUMERIC(12,2) NOT NULL DEFAULT 0, + total NUMERIC(12,2) NOT NULL DEFAULT 0, + amount_paid NUMERIC(12,2) NOT NULL DEFAULT 0, + notes TEXT, + paid_at TIMESTAMPTZ, + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE public.invoices ENABLE ROW LEVEL SECURITY; +CREATE TRIGGER invoices_updated_at BEFORE UPDATE ON public.invoices +FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); +CREATE INDEX idx_invoices_client ON public.invoices(client_id); +CREATE INDEX idx_invoices_case ON public.invoices(case_id); + +ALTER TABLE public.time_entries + ADD CONSTRAINT time_entries_invoice_fk FOREIGN KEY (invoice_id) REFERENCES public.invoices(id) ON DELETE SET NULL; +ALTER TABLE public.expenses + ADD CONSTRAINT expenses_invoice_fk FOREIGN KEY (invoice_id) REFERENCES public.invoices(id) ON DELETE SET NULL; + +-- ========================================= +-- RLS POLICIES +-- ========================================= + +-- profiles: any authenticated user can read names; users can update their own +CREATE POLICY "profiles_select_auth" ON public.profiles FOR SELECT TO authenticated USING (true); +CREATE POLICY "profiles_update_own" ON public.profiles FOR UPDATE TO authenticated USING (auth.uid() = id); + +-- user_roles: users can see their own roles; admins see all; only admins manage +CREATE POLICY "user_roles_select_self" ON public.user_roles FOR SELECT TO authenticated + USING (user_id = auth.uid() OR public.is_admin(auth.uid())); +CREATE POLICY "user_roles_admin_insert" ON public.user_roles FOR INSERT TO authenticated + WITH CHECK (public.is_admin(auth.uid())); +CREATE POLICY "user_roles_admin_update" ON public.user_roles FOR UPDATE TO authenticated + USING (public.is_admin(auth.uid())); +CREATE POLICY "user_roles_admin_delete" ON public.user_roles FOR DELETE TO authenticated + USING (public.is_admin(auth.uid())); + +-- clients: any authenticated firm user can view clients (firm-wide). +CREATE POLICY "clients_select_auth" ON public.clients FOR SELECT TO authenticated USING (true); +CREATE POLICY "clients_insert_auth" ON public.clients FOR INSERT TO authenticated + WITH CHECK (auth.uid() IS NOT NULL); +CREATE POLICY "clients_update_auth" ON public.clients FOR UPDATE TO authenticated + USING (public.is_admin(auth.uid()) OR created_by = auth.uid()); +CREATE POLICY "clients_delete_admin" ON public.clients FOR DELETE TO authenticated + USING (public.is_admin(auth.uid())); + +-- cases: only admin or assigned attorney/creator can see +CREATE POLICY "cases_select_assigned" ON public.cases FOR SELECT TO authenticated + USING (public.is_admin(auth.uid()) OR assigned_attorney_id = auth.uid() OR created_by = auth.uid()); +CREATE POLICY "cases_insert_auth" ON public.cases FOR INSERT TO authenticated + WITH CHECK (auth.uid() IS NOT NULL); +CREATE POLICY "cases_update_assigned" ON public.cases FOR UPDATE TO authenticated + USING (public.is_admin(auth.uid()) OR assigned_attorney_id = auth.uid() OR created_by = auth.uid()); +CREATE POLICY "cases_delete_admin" ON public.cases FOR DELETE TO authenticated + USING (public.is_admin(auth.uid())); + +-- documents +CREATE POLICY "documents_select_case" ON public.documents FOR SELECT TO authenticated + USING (public.can_access_case(case_id, auth.uid())); +CREATE POLICY "documents_insert_case" ON public.documents FOR INSERT TO authenticated + WITH CHECK (public.can_access_case(case_id, auth.uid())); +CREATE POLICY "documents_delete_case" ON public.documents FOR DELETE TO authenticated + USING (public.can_access_case(case_id, auth.uid())); + +-- status updates +CREATE POLICY "status_select_case" ON public.status_updates FOR SELECT TO authenticated + USING (public.can_access_case(case_id, auth.uid())); +CREATE POLICY "status_insert_case" ON public.status_updates FOR INSERT TO authenticated + WITH CHECK (public.can_access_case(case_id, auth.uid())); +CREATE POLICY "status_update_owner" ON public.status_updates FOR UPDATE TO authenticated + USING (public.is_admin(auth.uid()) OR created_by = auth.uid()); +CREATE POLICY "status_delete_owner" ON public.status_updates FOR DELETE TO authenticated + USING (public.is_admin(auth.uid()) OR created_by = auth.uid()); + +-- time entries +CREATE POLICY "time_select_case" ON public.time_entries FOR SELECT TO authenticated + USING (public.can_access_case(case_id, auth.uid())); +CREATE POLICY "time_insert_case" ON public.time_entries FOR INSERT TO authenticated + WITH CHECK (public.can_access_case(case_id, auth.uid()) AND user_id = auth.uid()); +CREATE POLICY "time_update_owner" ON public.time_entries FOR UPDATE TO authenticated + USING (public.is_admin(auth.uid()) OR user_id = auth.uid()); +CREATE POLICY "time_delete_owner" ON public.time_entries FOR DELETE TO authenticated + USING (public.is_admin(auth.uid()) OR user_id = auth.uid()); + +-- expenses +CREATE POLICY "exp_select_case" ON public.expenses FOR SELECT TO authenticated + USING (public.can_access_case(case_id, auth.uid())); +CREATE POLICY "exp_insert_case" ON public.expenses FOR INSERT TO authenticated + WITH CHECK (public.can_access_case(case_id, auth.uid()) AND user_id = auth.uid()); +CREATE POLICY "exp_update_owner" ON public.expenses FOR UPDATE TO authenticated + USING (public.is_admin(auth.uid()) OR user_id = auth.uid()); +CREATE POLICY "exp_delete_owner" ON public.expenses FOR DELETE TO authenticated + USING (public.is_admin(auth.uid()) OR user_id = auth.uid()); + +-- invoices +CREATE POLICY "inv_select" ON public.invoices FOR SELECT TO authenticated + USING ( + public.is_admin(auth.uid()) + OR created_by = auth.uid() + OR (case_id IS NOT NULL AND public.can_access_case(case_id, auth.uid())) + ); +CREATE POLICY "inv_insert" ON public.invoices FOR INSERT TO authenticated + WITH CHECK (auth.uid() IS NOT NULL); +CREATE POLICY "inv_update" ON public.invoices FOR UPDATE TO authenticated + USING ( + public.is_admin(auth.uid()) + OR created_by = auth.uid() + OR (case_id IS NOT NULL AND public.can_access_case(case_id, auth.uid())) + ); +CREATE POLICY "inv_delete_admin" ON public.invoices FOR DELETE TO authenticated + USING (public.is_admin(auth.uid())); + +-- ========================================= +-- STORAGE BUCKETS +-- ========================================= +INSERT INTO storage.buckets (id, name, public) VALUES ('case-documents', 'case-documents', false) + ON CONFLICT (id) DO NOTHING; +INSERT INTO storage.buckets (id, name, public) VALUES ('case-receipts', 'case-receipts', false) + ON CONFLICT (id) DO NOTHING; +INSERT INTO storage.buckets (id, name, public) VALUES ('case-reports', 'case-reports', false) + ON CONFLICT (id) DO NOTHING; + +-- Storage policies: file path must start with case-id/ that the user can access +CREATE POLICY "case_docs_select" ON storage.objects FOR SELECT TO authenticated + USING ( + bucket_id IN ('case-documents','case-receipts','case-reports') + AND public.can_access_case(((storage.foldername(name))[1])::uuid, auth.uid()) + ); +CREATE POLICY "case_docs_insert" ON storage.objects FOR INSERT TO authenticated + WITH CHECK ( + bucket_id IN ('case-documents','case-receipts','case-reports') + AND public.can_access_case(((storage.foldername(name))[1])::uuid, auth.uid()) + ); +CREATE POLICY "case_docs_delete" ON storage.objects FOR DELETE TO authenticated + USING ( + bucket_id IN ('case-documents','case-receipts','case-reports') + AND public.can_access_case(((storage.foldername(name))[1])::uuid, auth.uid()) + );