Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
f1b1cc8f6d
commit
dc787df0aa
@@ -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())
|
||||
);
|
||||
Reference in New Issue
Block a user