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:
+118
-72
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user