Board-member upload permission for documents & bids/quotes

Add a "Allow document & bid/quote uploads" toggle on board member profiles
(board_members.can_upload). When enabled, that board member can upload
association documents and create/manage bids & quotes for their association(s);
otherwise the board portal stays read-only for them.

- Migration (prod): board_members.can_upload column; tighten the documents
  insert + storage 'files' upload policies to require can_upload; add a
  bids_quotes board policy gated on can_upload.
- BoardMembersPage: permission switch (load/save).
- BoardAssociationContext: expose canUpload for the selected association.
- DocumentsPage: board upload gated by the flag (was always-on for board).
- BidsQuotesPage: permitted board members can add/manage bids (was hidden);
  board inserts target the board's association.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 23:33:09 -04:00
parent 2fd311c8a2
commit a3a0b706a1
7 changed files with 105 additions and 21 deletions
+26 -3
View File
@@ -14,6 +14,10 @@ interface BoardAssociationContextType {
/** For backward compat: returns array with just the selected ID, or all if none selected */ /** For backward compat: returns array with just the selected ID, or all if none selected */
associationIds: string[]; associationIds: string[];
loading: boolean; loading: boolean;
/** Whether this board member may upload docs / bids for the selected association. */
canUpload: boolean;
/** Per-association upload permission map. */
canUploadByAssociation: Record<string, boolean>;
} }
const BoardAssociationContext = createContext<BoardAssociationContextType>({ const BoardAssociationContext = createContext<BoardAssociationContextType>({
@@ -22,18 +26,22 @@ const BoardAssociationContext = createContext<BoardAssociationContextType>({
setSelectedAssociationId: () => {}, setSelectedAssociationId: () => {},
associationIds: [], associationIds: [],
loading: true, loading: true,
canUpload: false,
canUploadByAssociation: {},
}); });
export function BoardAssociationProvider({ children }: { children: React.ReactNode }) { export function BoardAssociationProvider({ children }: { children: React.ReactNode }) {
const { user } = useAuth(); const { user } = useAuth();
const [associations, setAssociations] = useState<BoardAssociation[]>([]); const [associations, setAssociations] = useState<BoardAssociation[]>([]);
const [selectedAssociationId, setSelectedAssociationId] = useState<string | null>(null); const [selectedAssociationId, setSelectedAssociationId] = useState<string | null>(null);
const [uploadByAssoc, setUploadByAssoc] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
if (!user) { if (!user) {
setAssociations([]); setAssociations([]);
setSelectedAssociationId(null); setSelectedAssociationId(null);
setUploadByAssoc({});
setLoading(false); setLoading(false);
return; return;
} }
@@ -45,12 +53,12 @@ export function BoardAssociationProvider({ children }: { children: React.ReactNo
const [bmByIdRes, bmByEmailRes, mbaRes] = await Promise.all([ const [bmByIdRes, bmByEmailRes, mbaRes] = await Promise.all([
supabase supabase
.from("board_members") .from("board_members")
.select("association_id, associations(name)") .select("association_id, associations(name), can_upload")
.eq("user_id", user.id), .eq("user_id", user.id),
email email
? supabase ? supabase
.from("board_members") .from("board_members")
.select("association_id, associations(name), email") .select("association_id, associations(name), email, can_upload")
.ilike("email", email) .ilike("email", email)
: Promise.resolve({ data: [] as any[] }), : Promise.resolve({ data: [] as any[] }),
(supabase as any) (supabase as any)
@@ -76,6 +84,18 @@ export function BoardAssociationProvider({ children }: { children: React.ReactNo
const items = Array.from(merged.values()).sort((a, b) => a.name.localeCompare(b.name)); const items = Array.from(merged.values()).sort((a, b) => a.name.localeCompare(b.name));
setAssociations(items); setAssociations(items);
// Per-association upload permission (board_members.can_upload). Master-board
// assignments are elevated and always permitted.
const uploadMap: Record<string, boolean> = {};
for (const r of [...((bmByIdRes as any).data || []), ...((bmByEmailRes as any).data || [])]) {
if (!r?.association_id) continue;
uploadMap[r.association_id] = uploadMap[r.association_id] || !!r.can_upload;
}
for (const r of ((mbaRes as any).data || [])) {
if (r?.association_id) uploadMap[r.association_id] = true;
}
setUploadByAssoc(uploadMap);
const savedId = localStorage.getItem("board_selected_association"); const savedId = localStorage.getItem("board_selected_association");
if (savedId && items.some((a) => a.id === savedId)) { if (savedId && items.some((a) => a.id === savedId)) {
setSelectedAssociationId(savedId); setSelectedAssociationId(savedId);
@@ -95,10 +115,13 @@ export function BoardAssociationProvider({ children }: { children: React.ReactNo
// Filter to just the selected association // Filter to just the selected association
const associationIds = selectedAssociationId ? [selectedAssociationId] : associations.map((a) => a.id); const associationIds = selectedAssociationId ? [selectedAssociationId] : associations.map((a) => a.id);
const canUpload = selectedAssociationId
? !!uploadByAssoc[selectedAssociationId]
: Object.values(uploadByAssoc).some(Boolean);
return ( return (
<BoardAssociationContext.Provider <BoardAssociationContext.Provider
value={{ associations, selectedAssociationId, setSelectedAssociationId: handleSelect, associationIds, loading }} value={{ associations, selectedAssociationId, setSelectedAssociationId: handleSelect, associationIds, loading, canUpload, canUploadByAssociation: uploadByAssoc }}
> >
{children} {children}
</BoardAssociationContext.Provider> </BoardAssociationContext.Provider>
+17 -7
View File
@@ -17,7 +17,7 @@ import VotingAndComments from "@/components/shared/VotingAndComments";
const statusColors: Record<string, string> = { pending: "bg-amber-100 text-amber-700", accepted: "bg-emerald-100 text-emerald-700", rejected: "bg-red-100 text-red-700", expired: "bg-muted text-muted-foreground" }; const statusColors: Record<string, string> = { pending: "bg-amber-100 text-amber-700", accepted: "bg-emerald-100 text-emerald-700", rejected: "bg-red-100 text-red-700", expired: "bg-muted text-muted-foreground" };
export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociationIds?: string[] } = {}) { export default function BidsQuotesPage({ boardAssociationIds, boardCanManage = false }: { boardAssociationIds?: string[]; boardCanManage?: boolean } = {}) {
const { toast } = useToast(); const { toast } = useToast();
const [bids, setBids] = useState<any[]>([]); const [bids, setBids] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -28,6 +28,8 @@ export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociati
const [form, setForm] = useState({ vendor_name: "", amount: "", description: "", status: "pending", received_date: "", expiry_date: "" }); const [form, setForm] = useState({ vendor_name: "", amount: "", description: "", status: "pending", received_date: "", expiry_date: "" });
const isBoardView = !!boardAssociationIds?.length; const isBoardView = !!boardAssociationIds?.length;
// Staff always manage; board members only when granted the upload permission.
const canManage = !isBoardView || boardCanManage;
const fetchData = async () => { setLoading(true); let query = supabase.from("bids_quotes").select("*, associations(name)").order("created_at", { ascending: false }); if (isBoardView) query = query.in("association_id", boardAssociationIds!); const { data } = await query; setBids(data || []); setLoading(false); }; const fetchData = async () => { setLoading(true); let query = supabase.from("bids_quotes").select("*, associations(name)").order("created_at", { ascending: false }); if (isBoardView) query = query.in("association_id", boardAssociationIds!); const { data } = await query; setBids(data || []); setLoading(false); };
useEffect(() => { fetchData(); }, []); useEffect(() => { fetchData(); }, []);
const openNew = () => { setEditing(null); setForm({ vendor_name: "", amount: "", description: "", status: "pending", received_date: "", expiry_date: "" }); setDialogOpen(true); }; const openNew = () => { setEditing(null); setForm({ vendor_name: "", amount: "", description: "", status: "pending", received_date: "", expiry_date: "" }); setDialogOpen(true); };
@@ -35,7 +37,14 @@ export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociati
const handleSave = async () => { const handleSave = async () => {
const payload = { ...form, amount: parseFloat(form.amount) || 0, received_date: form.received_date || null, expiry_date: form.expiry_date || null }; const payload = { ...form, amount: parseFloat(form.amount) || 0, received_date: form.received_date || null, expiry_date: form.expiry_date || null };
if (editing) { await supabase.from("bids_quotes").update(payload).eq("id", editing.id); toast({ title: "Updated" }); } if (editing) { await supabase.from("bids_quotes").update(payload).eq("id", editing.id); toast({ title: "Updated" }); }
else { const { data: a } = await supabase.from("associations").select("id").eq("status", "active").limit(1); if (!a?.length) return; await supabase.from("bids_quotes").insert({ ...payload, association_id: a[0].id }); toast({ title: "Bid created" }); } else {
let targetAssoc = boardAssociationIds?.[0];
if (!targetAssoc) { const { data: a } = await supabase.from("associations").select("id").eq("status", "active").limit(1); targetAssoc = a?.[0]?.id; }
if (!targetAssoc) return;
const { error } = await supabase.from("bids_quotes").insert({ ...payload, association_id: targetAssoc });
if (error) { toast({ variant: "destructive", title: "Error", description: error.message }); return; }
toast({ title: "Bid created" });
}
setDialogOpen(false); fetchData(); setDialogOpen(false); fetchData();
}; };
const handleDelete = async (id: string) => { await supabase.from("bids_quotes").delete().eq("id", id); toast({ title: "Deleted" }); fetchData(); }; const handleDelete = async (id: string) => { await supabase.from("bids_quotes").delete().eq("id", id); toast({ title: "Deleted" }); fetchData(); };
@@ -45,7 +54,7 @@ export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociati
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div><h1 className="text-2xl font-bold text-foreground flex items-center gap-2"><Scale className="h-6 w-6 text-primary" /> Bids & Quotes</h1><p className="text-sm text-muted-foreground mt-1">Manage vendor bids and quotes.</p></div> <div><h1 className="text-2xl font-bold text-foreground flex items-center gap-2"><Scale className="h-6 w-6 text-primary" /> Bids & Quotes</h1><p className="text-sm text-muted-foreground mt-1">Manage vendor bids and quotes.</p></div>
{!isBoardView && <div className="flex gap-2"> {canManage && <div className="flex gap-2">
<RecordImportButton <RecordImportButton
title="Import Bids & Quotes" title="Import Bids & Quotes"
description="Upload a CSV or Excel file with bid/quote records." description="Upload a CSV or Excel file with bid/quote records."
@@ -58,8 +67,9 @@ export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociati
{ key: "expiry_date", label: "Expiry Date" }, { key: "expiry_date", label: "Expiry Date" },
]} ]}
onImport={async (rows) => { onImport={async (rows) => {
const { data: a } = await supabase.from("associations").select("id").eq("status", "active").limit(1); let targetAssoc = boardAssociationIds?.[0];
if (!a?.length) throw new Error("Create an association first"); if (!targetAssoc) { const { data: a } = await supabase.from("associations").select("id").eq("status", "active").limit(1); targetAssoc = a?.[0]?.id; }
if (!targetAssoc) throw new Error("Create an association first");
const payload = rows.map(r => ({ const payload = rows.map(r => ({
vendor_name: r.vendor_name || "Unknown", vendor_name: r.vendor_name || "Unknown",
amount: parseFloat(r.amount) || 0, amount: parseFloat(r.amount) || 0,
@@ -67,7 +77,7 @@ export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociati
status: r.status || "pending", status: r.status || "pending",
received_date: r.received_date || null, received_date: r.received_date || null,
expiry_date: r.expiry_date || null, expiry_date: r.expiry_date || null,
association_id: a[0].id, association_id: targetAssoc,
})); }));
const { error } = await supabase.from("bids_quotes").insert(payload); const { error } = await supabase.from("bids_quotes").insert(payload);
if (error) throw error; if (error) throw error;
@@ -82,7 +92,7 @@ export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociati
<div className="relative max-w-md"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="Search..." className="pl-10" value={search} onChange={(e) => setSearch(e.target.value)} /></div> <div className="relative max-w-md"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="Search..." className="pl-10" value={search} onChange={(e) => setSearch(e.target.value)} /></div>
{loading ? <div className="flex justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div> : filtered.length === 0 ? <Card><CardContent className="py-12 text-center text-muted-foreground">No bids found.</CardContent></Card> : ( {loading ? <div className="flex justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div> : filtered.length === 0 ? <Card><CardContent className="py-12 text-center text-muted-foreground">No bids found.</CardContent></Card> : (
<Card><Table><TableHeader><TableRow><TableHead>Vendor</TableHead><TableHead>Amount</TableHead><TableHead>Status</TableHead><TableHead>Received</TableHead><TableHead>Expires</TableHead><TableHead className="w-12" /></TableRow></TableHeader><TableBody> <Card><Table><TableHeader><TableRow><TableHead>Vendor</TableHead><TableHead>Amount</TableHead><TableHead>Status</TableHead><TableHead>Received</TableHead><TableHead>Expires</TableHead><TableHead className="w-12" /></TableRow></TableHeader><TableBody>
{filtered.map((b) => (<TableRow key={b.id} className="cursor-pointer" onClick={() => setSelectedBid(b)}><TableCell className="font-medium">{b.vendor_name}</TableCell><TableCell>${Number(b.amount).toLocaleString()}</TableCell><TableCell><Badge className={statusColors[b.status] || "bg-muted text-muted-foreground"}>{b.status}</Badge></TableCell><TableCell>{b.received_date || "—"}</TableCell><TableCell>{b.expiry_date || "—"}</TableCell>{!isBoardView && <TableCell> {filtered.map((b) => (<TableRow key={b.id} className="cursor-pointer" onClick={() => setSelectedBid(b)}><TableCell className="font-medium">{b.vendor_name}</TableCell><TableCell>${Number(b.amount).toLocaleString()}</TableCell><TableCell><Badge className={statusColors[b.status] || "bg-muted text-muted-foreground"}>{b.status}</Badge></TableCell><TableCell>{b.received_date || "—"}</TableCell><TableCell>{b.expiry_date || "—"}</TableCell>{canManage && <TableCell>
<DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end"><DropdownMenuItem onClick={(e) => { e.stopPropagation(); openEdit(b); }}><Edit className="h-4 w-4 mr-2" /> Edit</DropdownMenuItem><DropdownMenuItem className="text-destructive" onClick={(e) => { e.stopPropagation(); handleDelete(b.id); }}><Trash2 className="h-4 w-4 mr-2" /> Delete</DropdownMenuItem></DropdownMenuContent></DropdownMenu></TableCell>}</TableRow>))}</TableBody></Table></Card> <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end"><DropdownMenuItem onClick={(e) => { e.stopPropagation(); openEdit(b); }}><Edit className="h-4 w-4 mr-2" /> Edit</DropdownMenuItem><DropdownMenuItem className="text-destructive" onClick={(e) => { e.stopPropagation(); handleDelete(b.id); }}><Trash2 className="h-4 w-4 mr-2" /> Delete</DropdownMenuItem></DropdownMenuContent></DropdownMenu></TableCell>}</TableRow>))}</TableBody></Table></Card>
)} )}
+10 -5
View File
@@ -16,11 +16,11 @@ import type { Tables } from "@/integrations/supabase/types";
type BoardMember = { type BoardMember = {
id: string; association_id: string; member_name: string; member_email: string | null; id: string; association_id: string; member_name: string; member_email: string | null;
phone: string | null; role: string | null; approval_authority: boolean; phone: string | null; role: string | null; approval_authority: boolean; can_upload: boolean;
created_at: string; associations?: { name: string } | null; created_at: string; associations?: { name: string } | null;
}; };
const emptyForm = { member_name: "", member_email: "", phone: "", role: "Member", approval_authority: false, association_id: "" }; const emptyForm = { member_name: "", member_email: "", phone: "", role: "Member", approval_authority: false, can_upload: false, association_id: "" };
export default function BoardMembersPage() { export default function BoardMembersPage() {
const { toast } = useToast(); const { toast } = useToast();
@@ -55,7 +55,7 @@ export default function BoardMembersPage() {
const openAdd = () => { setEditingId(null); setForm({ ...emptyForm }); setFormOpen(true); }; const openAdd = () => { setEditingId(null); setForm({ ...emptyForm }); setFormOpen(true); };
const openEdit = (m: BoardMember) => { const openEdit = (m: BoardMember) => {
setEditingId(m.id); setEditingId(m.id);
setForm({ member_name: m.member_name, member_email: m.member_email || "", phone: m.phone || "", role: m.role || "Member", approval_authority: m.approval_authority, association_id: m.association_id }); setForm({ member_name: m.member_name, member_email: m.member_email || "", phone: m.phone || "", role: m.role || "Member", approval_authority: m.approval_authority, can_upload: m.can_upload ?? false, association_id: m.association_id });
setFormOpen(true); setFormOpen(true);
}; };
@@ -68,10 +68,11 @@ export default function BoardMembersPage() {
association_id: form.association_id, member_name: form.member_name, association_id: form.association_id, member_name: form.member_name,
member_email: form.member_email || null, phone: form.phone || null, member_email: form.member_email || null, phone: form.phone || null,
role: form.role || "Member", approval_authority: form.approval_authority, role: form.role || "Member", approval_authority: form.approval_authority,
can_upload: form.can_upload,
}; };
const { error } = editingId const { error } = editingId
? await supabase.from("board_members").update(payload).eq("id", editingId) ? await supabase.from("board_members").update(payload as any).eq("id", editingId)
: await supabase.from("board_members").insert(payload); : await supabase.from("board_members").insert(payload as any);
if (error) toast({ variant: "destructive", title: "Error", description: error.message }); if (error) toast({ variant: "destructive", title: "Error", description: error.message });
else { toast({ title: editingId ? "Member updated" : "Member added" }); setFormOpen(false); fetchData(); } else { toast({ title: editingId ? "Member updated" : "Member added" }); setFormOpen(false); fetchData(); }
setSaving(false); setSaving(false);
@@ -208,6 +209,10 @@ export default function BoardMembersPage() {
<Switch checked={form.approval_authority} onCheckedChange={v => setForm({ ...form, approval_authority: v })} /> <Switch checked={form.approval_authority} onCheckedChange={v => setForm({ ...form, approval_authority: v })} />
<Label>Approval Authority</Label> <Label>Approval Authority</Label>
</div> </div>
<div className="flex items-center gap-3">
<Switch checked={form.can_upload} onCheckedChange={v => setForm({ ...form, can_upload: v })} />
<Label>Allow document & bid/quote uploads</Label>
</div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setFormOpen(false)}>Cancel</Button> <Button variant="outline" onClick={() => setFormOpen(false)}>Cancel</Button>
+6 -2
View File
@@ -236,9 +236,12 @@ function FolderSidebar({
export default function DocumentsPage({ export default function DocumentsPage({
boardAssociationIds, boardAssociationIds,
viewerRole = "admin", viewerRole = "admin",
boardCanUpload = false,
}: { }: {
boardAssociationIds?: string[]; boardAssociationIds?: string[];
viewerRole?: "admin" | "board" | "homeowner"; viewerRole?: "admin" | "board" | "homeowner";
/** For board viewers: whether this board member is permitted to upload. */
boardCanUpload?: boolean;
} = {}) { } = {}) {
const { toast } = useToast(); const { toast } = useToast();
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
@@ -372,8 +375,9 @@ export default function DocumentsPage({
const isBoardView = viewerRole === "board" || (!!boardAssociationIds?.length && viewerRole !== "homeowner"); const isBoardView = viewerRole === "board" || (!!boardAssociationIds?.length && viewerRole !== "homeowner");
const isHomeownerView = viewerRole === "homeowner"; const isHomeownerView = viewerRole === "homeowner";
const isReadOnlyView = isBoardView || isHomeownerView; const isReadOnlyView = isBoardView || isHomeownerView;
// Board members can upload documents within their association(s); homeowners remain read-only. // Board members can upload only when granted the per-member permission;
const canUpload = !isHomeownerView; // homeowners are always read-only; staff can always upload.
const canUpload = isHomeownerView ? false : isBoardView ? boardCanUpload : true;
const getFolderAssociation = (folder: string | null) => { const getFolderAssociation = (folder: string | null) => {
if (!folder) return null; if (!folder) return null;
+2 -2
View File
@@ -1,7 +1,7 @@
import { useBoardAssociations } from "@/contexts/BoardAssociationContext"; import { useBoardAssociations } from "@/contexts/BoardAssociationContext";
import BidsQuotesPage from "@/pages/BidsQuotesPage"; import BidsQuotesPage from "@/pages/BidsQuotesPage";
export default function BoardBidsQuotesPage() { export default function BoardBidsQuotesPage() {
const { associationIds, loading } = useBoardAssociations(); const { associationIds, loading, canUpload } = useBoardAssociations();
if (loading) return <div className="flex justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div>; if (loading) return <div className="flex justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div>;
return <BidsQuotesPage boardAssociationIds={associationIds} />; return <BidsQuotesPage boardAssociationIds={associationIds} boardCanManage={canUpload} />;
} }
+2 -2
View File
@@ -1,7 +1,7 @@
import { useBoardAssociations } from "@/contexts/BoardAssociationContext"; import { useBoardAssociations } from "@/contexts/BoardAssociationContext";
import DocumentsPage from "@/pages/DocumentsPage"; import DocumentsPage from "@/pages/DocumentsPage";
export default function BoardDocumentsPage() { export default function BoardDocumentsPage() {
const { associationIds, loading } = useBoardAssociations(); const { associationIds, loading, canUpload } = useBoardAssociations();
if (loading) return <div className="flex justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div>; if (loading) return <div className="flex justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div>;
return <DocumentsPage boardAssociationIds={associationIds} viewerRole="board" />; return <DocumentsPage boardAssociationIds={associationIds} viewerRole="board" boardCanUpload={canUpload} />;
} }
@@ -0,0 +1,42 @@
-- Per-board-member "can upload" permission. When enabled, that board member may
-- upload association documents (files bucket + documents table) and create/manage
-- bids & quotes for their association(s). Default off.
alter table public.board_members
add column if not exists can_upload boolean not null default false;
-- Documents: tighten the existing board insert policy to require the flag.
alter policy "Board members can insert association documents" on public.documents
with check (
association_id in (
select bm.association_id from public.board_members bm
where bm.user_id = auth.uid() and bm.can_upload
)
);
-- Storage (files bucket): same gate on the board upload policy.
alter policy "Board members can upload association files" on storage.objects
with check (
bucket_id = 'files'
and ((storage.foldername(name))[1])::uuid in (
select bm.association_id from public.board_members bm
where bm.user_id = auth.uid() and bm.can_upload
)
);
-- Bids & Quotes: allow permitted board members to manage their association's bids.
drop policy if exists "Board members manage association bids" on public.bids_quotes;
create policy "Board members manage association bids" on public.bids_quotes
for all
using (
association_id in (
select bm.association_id from public.board_members bm
where bm.user_id = auth.uid() and bm.can_upload
)
)
with check (
association_id in (
select bm.association_id from public.board_members bm
where bm.user_id = auth.uid() and bm.can_upload
)
);