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
+118 -72
View File
@@ -6,6 +6,11 @@ import { serviceTypes } from '../../data/mockData';
import { supabase } from '../../lib/supabase';
import { sendEmail } from '../../lib/email';
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() {
const { currentUser } = useAuth();
@@ -17,24 +22,28 @@ export default function NewRequest() {
const [submitted, setSubmitted] = useState(false);
const [saving, setSaving] = useState(false);
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 [customProjects, setCustomProjects] = useState([]);
const [isTypingProject, setIsTypingProject] = useState(false);
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(() => {
async function load() {
if (!currentUser.company?.id) return;
if (!selectedCompanyId) return;
const { data: p } = await supabase
.from('projects')
.select('id, name')
.eq('company_id', currentUser.company.id)
.eq('company_id', selectedCompanyId)
.order('created_at', { ascending: false });
setExistingProjects((p || []).map(pr => ({ id: pr.id, name: pr.name })));
}
load();
}, [currentUser.company?.id]);
}, [selectedCompanyId]);
const allProjectNames = [
...existingProjects.map(p => p.name),
@@ -65,89 +74,96 @@ export default function NewRequest() {
const handleSubmit = async (e) => {
e.preventDefault();
if (!currentUser.company?.id) {
if (saving) return;
if (!selectedCompanyId) {
alert('Your account is not yet assigned to a company. Please contact support.');
return;
}
setSaving(true);
setError(null);
// Find existing project by name within this company, or create new one
let projectId;
const existing = existingProjects.find(p => p.name === form.project);
if (existing) {
projectId = existing.id;
} 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;
const projectName = form.project.trim();
if (!projectName) {
setError('Please select or create a project before submitting this request.');
setSaving(false);
return;
}
if (!projectId) { setSaving(false); return; }
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;
// Create task
const { data: task } = await supabase.from('tasks').insert({
project_id: projectId,
title: form.title.trim() || form.serviceType,
status: 'not_started',
current_version: 0,
}).select().single();
if (!projectId) { setSaving(false); return; }
if (!task) { setSaving(false); return; }
const { task } = await createTaskForRequest({
projectId,
title: form.title.trim() || form.serviceType,
requestKey,
});
// Create submission
const { data: submission } = await supabase.from('submissions').insert({
task_id: task.id,
version_number: 1,
type: 'initial',
service_type: form.serviceType,
deadline: form.deadline || null,
description: form.description,
submitted_by: currentUser.id,
submitted_by_name: currentUser.name,
}).select().single();
if (!task) { setSaving(false); return; }
// Upload files
if (submission && files.length > 0) {
for (const file of files) {
const path = `${task.id}/${Date.now()}_${file.name}`;
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
if (uploadError) {
setError(`Failed to upload "${file.name}": ${uploadError.message}`);
setSaving(false);
return;
}
if (uploaded) {
await supabase.from('submission_files').insert({
submission_id: submission.id,
name: file.name,
storage_path: path,
size: file.size,
});
const { submission } = await createInitialSubmissionForRequest({
taskId: task.id,
requestKey,
isHot: form.isHot,
serviceType: form.serviceType,
deadline: form.deadline,
description: form.description,
submittedBy: currentUser.id,
submittedByName: currentUser.name,
});
// Upload files — rollback task on any failure so no orphaned records
if (submission && files.length > 0) {
for (const file of files) {
const path = `${task.id}/${Date.now()}_${file.name}`;
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
if (uploadError) {
await supabase.from('tasks').delete().eq('id', task.id);
throw new Error(`Failed to upload "${file.name}": ${uploadError.message}`);
}
if (uploaded) {
const { error: fileRecordError } = await supabase.from('submission_files').insert({
submission_id: submission.id,
name: file.name,
storage_path: path,
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}`);
}
}
}
}
sendEmail('new_request', 'hello@fourgebranding.com', {
clientName: currentUser.name,
clientEmail: currentUser.email,
company: selectedCompany?.name || '',
serviceType: form.serviceType,
projectName,
deadline: form.deadline,
description: form.description,
taskId: task.id,
}).catch((emailError) => {
console.error('New request email failed:', emailError);
});
setSaving(false);
setSubmitted(true);
} catch (err) {
console.error('Request submission failed:', err);
setError(err.message || 'Something went wrong. Please try again.');
setSaving(false);
}
sendEmail('new_request', 'hello@fourgebranding.com', {
clientName: currentUser.name,
clientEmail: currentUser.email,
company: currentUser.company?.name || '',
serviceType: form.serviceType,
projectName: form.project,
deadline: form.deadline,
description: form.description,
taskId: task.id,
});
setSaving(false);
setSubmitted(true);
};
if (!currentUser.company?.id) {
if (!companyOptions.length) {
return (
<Layout>
<div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}>
@@ -174,7 +190,7 @@ export default function NewRequest() {
</p>
<div className="action-buttons" style={{ justifyContent: 'center' }}>
<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
</button>
</div>
@@ -194,6 +210,25 @@ export default function NewRequest() {
<div className="card" style={{ maxWidth: 600 }}>
<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">
<label>Project *</label>
{isTypingProject ? (
@@ -233,6 +268,17 @@ export default function NewRequest() {
</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">
<label>
Request Title
@@ -266,7 +312,7 @@ export default function NewRequest() {
)}
<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>
<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)
)
);