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:
@@ -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)
|
||||||
|
)
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user