Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-16 22:45:54 +00:00
co-authored by renee-png
parent f1b1cc8f6d
commit dc787df0aa
2 changed files with 836 additions and 4 deletions
@@ -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())
);