Fix client storage RLS + rollback task on upload failure

Storage policies for submissions read/insert and deliveries read were using
get_my_company_id() (single company) instead of has_company_access() — blocked
multi-company clients from uploading or viewing files.

NewRequest: delete task+submission if any file upload fails so no orphaned
records are left behind.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-05-13 11:41:10 -04:00
parent 2bf29f5699
commit 3a1cde64e6
2 changed files with 163 additions and 72 deletions
+90 -44
View File
@@ -6,6 +6,11 @@ import { serviceTypes } from '../../data/mockData';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { sendEmail } from '../../lib/email'; import { sendEmail } from '../../lib/email';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../../lib/dates';
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../../lib/requestSubmission';
const defaultRequestDeadline = () => addDaysToDateOnly(getTodayDateOnlyEST(), 3);
const emptyForm = (project = '') => ({ project, serviceType: '', title: '', deadline: defaultRequestDeadline(), description: '', isHot: false });
export default function NewRequest() { export default function NewRequest() {
const { currentUser } = useAuth(); const { currentUser } = useAuth();
@@ -17,24 +22,28 @@ export default function NewRequest() {
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [form, setForm] = useState({ project: preselectedProject, serviceType: '', title: '', deadline: '', description: '' }); const [form, setForm] = useState(() => emptyForm(preselectedProject));
const [requestKey, setRequestKey] = useState(() => crypto.randomUUID());
const [files, setFiles] = useState([]); const [files, setFiles] = useState([]);
const [customProjects, setCustomProjects] = useState([]); const [customProjects, setCustomProjects] = useState([]);
const [isTypingProject, setIsTypingProject] = useState(false); const [isTypingProject, setIsTypingProject] = useState(false);
const [newProjectName, setNewProjectName] = useState(''); const [newProjectName, setNewProjectName] = useState('');
const companyOptions = currentUser.companies?.length ? currentUser.companies : (currentUser.company ? [currentUser.company] : []);
const [selectedCompanyId, setSelectedCompanyId] = useState(companyOptions[0]?.id || '');
const selectedCompany = companyOptions.find(company => company.id === selectedCompanyId) || companyOptions[0];
useEffect(() => { useEffect(() => {
async function load() { async function load() {
if (!currentUser.company?.id) return; if (!selectedCompanyId) return;
const { data: p } = await supabase const { data: p } = await supabase
.from('projects') .from('projects')
.select('id, name') .select('id, name')
.eq('company_id', currentUser.company.id) .eq('company_id', selectedCompanyId)
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
setExistingProjects((p || []).map(pr => ({ id: pr.id, name: pr.name }))); setExistingProjects((p || []).map(pr => ({ id: pr.id, name: pr.name })));
} }
load(); load();
}, [currentUser.company?.id]); }, [selectedCompanyId]);
const allProjectNames = [ const allProjectNames = [
...existingProjects.map(p => p.name), ...existingProjects.map(p => p.name),
@@ -65,69 +74,69 @@ export default function NewRequest() {
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
if (!currentUser.company?.id) { if (saving) return;
if (!selectedCompanyId) {
alert('Your account is not yet assigned to a company. Please contact support.'); alert('Your account is not yet assigned to a company. Please contact support.');
return; return;
} }
setSaving(true); setSaving(true);
setError(null); setError(null);
// Find existing project by name within this company, or create new one const projectName = form.project.trim();
let projectId; if (!projectName) {
const existing = existingProjects.find(p => p.name === form.project); setError('Please select or create a project before submitting this request.');
if (existing) { setSaving(false);
projectId = existing.id; return;
} else {
const { data: newProject, error: projectError } = await supabase.from('projects').insert({
company_id: currentUser.company.id,
name: form.project,
status: 'active',
}).select().single();
if (projectError || !newProject) { setError('Failed to create project.'); setSaving(false); return; }
projectId = newProject.id;
} }
try {
const resolvedProject = await findOrCreateProject(selectedCompanyId, projectName, existingProjects);
if (!existingProjects.some(project => project.id === resolvedProject.id)) {
setExistingProjects(prev => [{ id: resolvedProject.id, name: resolvedProject.name }, ...prev]);
}
const projectId = resolvedProject.id;
if (!projectId) { setSaving(false); return; } if (!projectId) { setSaving(false); return; }
// Create task const { task } = await createTaskForRequest({
const { data: task } = await supabase.from('tasks').insert({ projectId,
project_id: projectId,
title: form.title.trim() || form.serviceType, title: form.title.trim() || form.serviceType,
status: 'not_started', requestKey,
current_version: 0, });
}).select().single();
if (!task) { setSaving(false); return; } if (!task) { setSaving(false); return; }
// Create submission const { submission } = await createInitialSubmissionForRequest({
const { data: submission } = await supabase.from('submissions').insert({ taskId: task.id,
task_id: task.id, requestKey,
version_number: 1, isHot: form.isHot,
type: 'initial', serviceType: form.serviceType,
service_type: form.serviceType, deadline: form.deadline,
deadline: form.deadline || null,
description: form.description, description: form.description,
submitted_by: currentUser.id, submittedBy: currentUser.id,
submitted_by_name: currentUser.name, submittedByName: currentUser.name,
}).select().single(); });
// Upload files // Upload files — rollback task on any failure so no orphaned records
if (submission && files.length > 0) { if (submission && files.length > 0) {
for (const file of files) { for (const file of files) {
const path = `${task.id}/${Date.now()}_${file.name}`; const path = `${task.id}/${Date.now()}_${file.name}`;
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file); const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
if (uploadError) { if (uploadError) {
setError(`Failed to upload "${file.name}": ${uploadError.message}`); await supabase.from('tasks').delete().eq('id', task.id);
setSaving(false); throw new Error(`Failed to upload "${file.name}": ${uploadError.message}`);
return;
} }
if (uploaded) { if (uploaded) {
await supabase.from('submission_files').insert({ const { error: fileRecordError } = await supabase.from('submission_files').insert({
submission_id: submission.id, submission_id: submission.id,
name: file.name, name: file.name,
storage_path: path, storage_path: path,
size: file.size, size: file.size,
}); });
if (fileRecordError) {
await supabase.from('tasks').delete().eq('id', task.id);
throw new Error(`Failed to save file record for "${file.name}": ${fileRecordError.message}`);
}
} }
} }
} }
@@ -135,19 +144,26 @@ export default function NewRequest() {
sendEmail('new_request', 'hello@fourgebranding.com', { sendEmail('new_request', 'hello@fourgebranding.com', {
clientName: currentUser.name, clientName: currentUser.name,
clientEmail: currentUser.email, clientEmail: currentUser.email,
company: currentUser.company?.name || '', company: selectedCompany?.name || '',
serviceType: form.serviceType, serviceType: form.serviceType,
projectName: form.project, projectName,
deadline: form.deadline, deadline: form.deadline,
description: form.description, description: form.description,
taskId: task.id, taskId: task.id,
}).catch((emailError) => {
console.error('New request email failed:', emailError);
}); });
setSaving(false); setSaving(false);
setSubmitted(true); setSubmitted(true);
} catch (err) {
console.error('Request submission failed:', err);
setError(err.message || 'Something went wrong. Please try again.');
setSaving(false);
}
}; };
if (!currentUser.company?.id) { if (!companyOptions.length) {
return ( return (
<Layout> <Layout>
<div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}> <div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}>
@@ -174,7 +190,7 @@ export default function NewRequest() {
</p> </p>
<div className="action-buttons" style={{ justifyContent: 'center' }}> <div className="action-buttons" style={{ justifyContent: 'center' }}>
<button className="btn btn-primary" onClick={() => navigate('/my-projects')}>View Projects</button> <button className="btn btn-primary" onClick={() => navigate('/my-projects')}>View Projects</button>
<button className="btn btn-outline" onClick={() => { setSubmitted(false); setForm({ project: '', serviceType: '', title: '', deadline: '', description: '' }); setFiles([]); }}> <button className="btn btn-outline" onClick={() => { setSubmitted(false); setForm(emptyForm()); setFiles([]); setRequestKey(crypto.randomUUID()); }}>
Submit Another Submit Another
</button> </button>
</div> </div>
@@ -194,6 +210,25 @@ export default function NewRequest() {
<div className="card" style={{ maxWidth: 600 }}> <div className="card" style={{ maxWidth: 600 }}>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{companyOptions.length > 1 && (
<div className="form-group">
<label>Company *</label>
<select
value={selectedCompanyId}
onChange={e => {
setSelectedCompanyId(e.target.value);
setForm(f => ({ ...f, project: '' }));
setCustomProjects([]);
setIsTypingProject(false);
setNewProjectName('');
}}
required
>
{companyOptions.map(company => <option key={company.id} value={company.id}>{company.name}</option>)}
</select>
</div>
)}
<div className="form-group"> <div className="form-group">
<label>Project *</label> <label>Project *</label>
{isTypingProject ? ( {isTypingProject ? (
@@ -233,6 +268,17 @@ export default function NewRequest() {
</div> </div>
</div> </div>
<div className="form-group" style={{ marginTop: -4 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
<input
type="checkbox"
checked={form.isHot}
onChange={e => setForm(f => ({ ...f, isHot: e.target.checked }))}
/>
<span>Mark as Hot</span>
</label>
</div>
<div className="form-group"> <div className="form-group">
<label> <label>
Request Title Request Title
@@ -266,7 +312,7 @@ export default function NewRequest() {
)} )}
<div className="notification notification-info" style={{ marginBottom: 16 }}> <div className="notification notification-info" style={{ marginBottom: 16 }}>
Submitting as <strong>{currentUser?.name}</strong> · {currentUser?.company?.name} Submitting as <strong>{currentUser?.name}</strong> · {selectedCompany?.name}
</div> </div>
<button type="submit" className="btn btn-primary btn-lg" disabled={saving}> <button type="submit" className="btn btn-primary btn-lg" disabled={saving}>
@@ -0,0 +1,45 @@
-- Fix client storage policies to use has_company_access() instead of get_my_company_id().
-- Previously, clients tied to multiple companies via company_members could not upload
-- or read files for their non-primary company.
drop policy if exists "Client reads submissions storage" on storage.objects;
create policy "Client reads submissions storage" on storage.objects
for select to authenticated
using (
bucket_id = 'submissions'
and get_my_role() = 'client'
and split_part(name, '/', 1) in (
select t.id::text
from public.tasks t
join public.projects p on p.id = t.project_id
where has_company_access(p.company_id)
)
);
drop policy if exists "Client inserts submissions storage" on storage.objects;
create policy "Client inserts submissions storage" on storage.objects
for insert to authenticated
with check (
bucket_id = 'submissions'
and get_my_role() = 'client'
and split_part(name, '/', 1) in (
select t.id::text
from public.tasks t
join public.projects p on p.id = t.project_id
where has_company_access(p.company_id)
)
);
drop policy if exists "Client reads deliveries storage" on storage.objects;
create policy "Client reads deliveries storage" on storage.objects
for select to authenticated
using (
bucket_id = 'deliveries'
and get_my_role() = 'client'
and split_part(name, '/', 1) in (
select t.id::text
from public.tasks t
join public.projects p on p.id = t.project_id
where has_company_access(p.company_id)
)
);