04e0911e9f
Bug fixes: - TeamInvoices: add useAuth import/currentUser (invoice-create crash) - TeamInvoices: setChartYear -> setExportYear (year-dropdown crash) - TeamDashboard: drop redundant setState-in-effect (cascading renders) - Companies: delete dead _UnusedClientCompanies (illegal hook calls) - annotate intentional empty PDF-fallback catches Load speed: - preconnect/dns-prefetch to Supabase origin - lazy-load heic-to in Converters: page chunk 2737KB -> 9KB - split recharts into its own 'charts' vendor chunk Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
412 lines
15 KiB
JavaScript
412 lines
15 KiB
JavaScript
import { createClient } from '@supabase/supabase-js';
|
|
|
|
const FB_SOURCE = 'srv';
|
|
const PROJECT_DEFAULT_SUBFOLDERS = ['00 Project Files'];
|
|
const REQUEST_DEFAULT_SUBFOLDERS = ['Old Books', 'Working Files', 'Survey'];
|
|
|
|
export const config = { api: { bodyParser: false, sizeLimit: '50mb' } };
|
|
|
|
function json(res, status, body) {
|
|
res.status(status).setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.send(JSON.stringify(body));
|
|
}
|
|
|
|
function normalizePath(path) {
|
|
const raw = String(path || '/').trim();
|
|
const parts = raw.split('/').filter(Boolean);
|
|
const clean = [];
|
|
for (const part of parts) {
|
|
if (part === '.') continue;
|
|
if (part === '..') throw new Error('Invalid path: path traversal not allowed');
|
|
clean.push(part);
|
|
}
|
|
return `/${clean.join('/')}`;
|
|
}
|
|
|
|
function joinPath(...parts) {
|
|
return normalizePath(parts.join('/'));
|
|
}
|
|
|
|
function safeName(value, fallback = '') {
|
|
const cleaned = String(value || '')
|
|
.trim()
|
|
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
|
.replace(/\s+/g, ' ')
|
|
.replace(/^-+|-+$/g, '');
|
|
return cleaned || fallback;
|
|
}
|
|
|
|
function parseBody(body) {
|
|
if (!body) return {};
|
|
if (typeof body === 'string') {
|
|
try {
|
|
return JSON.parse(body);
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
return body;
|
|
}
|
|
|
|
async function readBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks = [];
|
|
req.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
function getConfig() {
|
|
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
|
|
return {
|
|
url,
|
|
token: process.env.FILEBROWSER_TOKEN || '',
|
|
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
|
|
subsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/team'),
|
|
configured: Boolean(url),
|
|
};
|
|
}
|
|
|
|
function getToken(config) {
|
|
const token = String(config.token || '').trim();
|
|
if (!token) throw new Error('FILEBROWSER_TOKEN not configured');
|
|
return token;
|
|
}
|
|
|
|
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
|
|
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
|
const url = `${config.url}${endpoint}?${qs}`;
|
|
const token = getToken(config);
|
|
const response = await fetch(url, {
|
|
method,
|
|
headers: { Authorization: `Bearer ${token}`, ...headers },
|
|
body,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => '');
|
|
const error = new Error(text || `FileBrowser ${response.status}`);
|
|
error.status = response.status;
|
|
throw error;
|
|
}
|
|
|
|
const text = await response.text();
|
|
try {
|
|
return text ? JSON.parse(text) : null;
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|
|
|
|
async function createCallerClient(authHeader) {
|
|
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
|
const supabaseAnonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
|
|
if (!supabaseUrl || !supabaseAnonKey) throw new Error('Supabase env not configured');
|
|
return createClient(supabaseUrl, supabaseAnonKey, {
|
|
auth: { persistSession: false, autoRefreshToken: false },
|
|
global: { headers: { Authorization: authHeader } },
|
|
});
|
|
}
|
|
|
|
async function requirePortalUser(authHeader) {
|
|
const callerClient = await createCallerClient(authHeader);
|
|
const { data: userData, error: userError } = await callerClient.auth.getUser();
|
|
if (userError || !userData?.user) return { ok: false, status: 401, message: 'Unauthorized' };
|
|
|
|
const { data: profile, error: profileError } = await callerClient
|
|
.from('profiles')
|
|
.select('id, role, company:companies(id, name)')
|
|
.eq('id', userData.user.id)
|
|
.single();
|
|
|
|
if (profileError) return { ok: false, status: 500, message: profileError.message };
|
|
if (!['team', 'client'].includes(profile?.role)) {
|
|
return { ok: false, status: 403, message: 'Forbidden' };
|
|
}
|
|
|
|
const clientCompanies = [];
|
|
if (profile.role === 'client') {
|
|
const seen = new Set();
|
|
if (profile.company?.id) {
|
|
clientCompanies.push(profile.company);
|
|
seen.add(profile.company.id);
|
|
}
|
|
const { data: memberships } = await callerClient
|
|
.from('company_members')
|
|
.select('company:companies(id, name)')
|
|
.eq('profile_id', userData.user.id);
|
|
for (const row of memberships || []) {
|
|
if (row.company?.id && !seen.has(row.company.id)) {
|
|
seen.add(row.company.id);
|
|
clientCompanies.push(row.company);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
profile: { ...profile, clientCompanies },
|
|
};
|
|
}
|
|
|
|
function createAdminClient() {
|
|
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
if (!supabaseUrl || !serviceKey) throw new Error('Supabase admin env not configured');
|
|
return createClient(supabaseUrl, serviceKey, {
|
|
auth: { persistSession: false, autoRefreshToken: false },
|
|
});
|
|
}
|
|
|
|
function isAlreadyExistsError(error) {
|
|
const message = String(error?.message || '').toLowerCase();
|
|
return error?.status === 409 || message.includes('exist') || message.includes('conflict');
|
|
}
|
|
|
|
async function ensureDirectory(config, fullPath) {
|
|
const parts = normalizePath(fullPath).split('/').filter(Boolean);
|
|
let current = '/';
|
|
for (const part of parts) {
|
|
current = joinPath(current, part);
|
|
try {
|
|
await fbFetch(config, 'POST', '/api/resources', {
|
|
params: { path: current, isDir: 'true' },
|
|
});
|
|
} catch (error) {
|
|
if (!isAlreadyExistsError(error)) throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function renameDirectory(config, fromPath, toPath) {
|
|
const sourcePath = normalizePath(fromPath);
|
|
const targetPath = normalizePath(toPath);
|
|
if (sourcePath === targetPath) return { renamed: false, skipped: true, path: targetPath };
|
|
|
|
try {
|
|
await fbFetch(config, 'PATCH', '/api/resources', {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
action: 'rename',
|
|
items: [{ fromSource: FB_SOURCE, fromPath: sourcePath, toSource: FB_SOURCE, toPath: targetPath }],
|
|
overwrite: false,
|
|
}),
|
|
});
|
|
return { renamed: true, path: targetPath };
|
|
} catch (error) {
|
|
if (error?.status === 404) {
|
|
await ensureDirectory(config, targetPath);
|
|
return { renamed: false, ensured: true, path: targetPath };
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export default async function handler(req, res) {
|
|
const action = String(req.query?.action || '').trim();
|
|
if (req.method !== 'POST' || ![
|
|
'ensure-company-folder',
|
|
'ensure-project-folder',
|
|
'ensure-request-folder',
|
|
'ensure-profile-folder',
|
|
'upload-request-file',
|
|
'rename-company-folder',
|
|
'rename-project-folder',
|
|
'rename-profile-folder',
|
|
].includes(action)) {
|
|
return json(res, 405, { error: 'Method not allowed' });
|
|
}
|
|
|
|
const authHeader = req.headers.authorization || '';
|
|
if (!authHeader.startsWith('Bearer ')) {
|
|
return json(res, 401, { error: 'Unauthorized' });
|
|
}
|
|
|
|
const config = getConfig();
|
|
if (!config.configured) {
|
|
return json(res, 200, { ok: false, configured: false, warning: 'FileBrowser is not configured.' });
|
|
}
|
|
|
|
try {
|
|
const rawBody = await readBody(req);
|
|
const body = (() => {
|
|
if (!rawBody.length) return {};
|
|
const contentType = String(req.headers['content-type'] || '').split(';')[0].trim();
|
|
if (contentType === 'application/json') {
|
|
try {
|
|
return JSON.parse(rawBody.toString());
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
return {};
|
|
})();
|
|
const auth = await requirePortalUser(authHeader);
|
|
if (!auth.ok) return json(res, auth.status, { error: auth.message });
|
|
|
|
const admin = createAdminClient();
|
|
if (action === 'ensure-company-folder') {
|
|
if (auth.profile.role !== 'team') {
|
|
return json(res, 403, { error: 'Forbidden' });
|
|
}
|
|
const companyId = String(body.companyId || '').trim();
|
|
if (!companyId) return json(res, 400, { error: 'companyId is required.' });
|
|
|
|
const { data: company, error: companyError } = await admin
|
|
.from('companies')
|
|
.select('id, name')
|
|
.eq('id', companyId)
|
|
.single();
|
|
if (companyError || !company) return json(res, 404, { error: 'Company not found.' });
|
|
|
|
const fullPath = joinPath(config.clientRoot, safeName(company.name, company.id));
|
|
await ensureDirectory(config, fullPath);
|
|
return json(res, 200, { ok: true, configured: true, path: fullPath });
|
|
}
|
|
|
|
if (action === 'rename-company-folder') {
|
|
if (auth.profile.role !== 'team') {
|
|
return json(res, 403, { error: 'Forbidden' });
|
|
}
|
|
const companyId = String(body.companyId || '').trim();
|
|
const oldName = safeName(body.oldName, '');
|
|
const newName = safeName(body.newName, '');
|
|
if (!companyId || !oldName || !newName) return json(res, 400, { error: 'companyId, oldName, and newName are required.' });
|
|
|
|
const { data: company, error: companyError } = await admin
|
|
.from('companies')
|
|
.select('id')
|
|
.eq('id', companyId)
|
|
.single();
|
|
if (companyError || !company) return json(res, 404, { error: 'Company not found.' });
|
|
|
|
const result = await renameDirectory(config, joinPath(config.clientRoot, oldName), joinPath(config.clientRoot, newName));
|
|
return json(res, 200, { ok: true, configured: true, ...result });
|
|
}
|
|
|
|
if (action === 'ensure-profile-folder') {
|
|
if (auth.profile.role !== 'team') {
|
|
return json(res, 403, { error: 'Forbidden' });
|
|
}
|
|
const profileId = String(body.profileId || '').trim();
|
|
if (!profileId) return json(res, 400, { error: 'profileId is required.' });
|
|
|
|
const { data: profile, error: profileError } = await admin
|
|
.from('profiles')
|
|
.select('id, name, role')
|
|
.eq('id', profileId)
|
|
.single();
|
|
if (profileError || !profile) return json(res, 404, { error: 'Profile not found.' });
|
|
if (profile.role !== 'external') return json(res, 400, { error: 'Only subcontractor folders are supported here.' });
|
|
|
|
const fullPath = joinPath(config.subsRoot, safeName(profile.name, profile.id));
|
|
await ensureDirectory(config, fullPath);
|
|
return json(res, 200, { ok: true, configured: true, path: fullPath });
|
|
}
|
|
|
|
if (action === 'rename-profile-folder') {
|
|
if (auth.profile.role !== 'team') {
|
|
return json(res, 403, { error: 'Forbidden' });
|
|
}
|
|
const profileId = String(body.profileId || '').trim();
|
|
const oldName = safeName(body.oldName, '');
|
|
const newName = safeName(body.newName, '');
|
|
if (!profileId || !oldName || !newName) return json(res, 400, { error: 'profileId, oldName, and newName are required.' });
|
|
|
|
const { data: profile, error: profileError } = await admin
|
|
.from('profiles')
|
|
.select('id, role')
|
|
.eq('id', profileId)
|
|
.single();
|
|
if (profileError || !profile) return json(res, 404, { error: 'Profile not found.' });
|
|
if (profile.role !== 'external') return json(res, 400, { error: 'Only subcontractor folders are supported here.' });
|
|
|
|
const result = await renameDirectory(config, joinPath(config.subsRoot, oldName), joinPath(config.subsRoot, newName));
|
|
return json(res, 200, { ok: true, configured: true, ...result });
|
|
}
|
|
|
|
const projectId = String(req.query?.projectId || body.projectId || '').trim();
|
|
if (!projectId) return json(res, 400, { error: 'projectId is required.' });
|
|
|
|
const { data: project, error: projectError } = await admin
|
|
.from('projects')
|
|
.select('id, name, company_id, company:companies(id, name)')
|
|
.eq('id', projectId)
|
|
.single();
|
|
if (projectError || !project) return json(res, 404, { error: 'Project not found.' });
|
|
|
|
if (auth.profile.role === 'client') {
|
|
const allowedCompanyIds = new Set((auth.profile.clientCompanies || []).map(company => company.id));
|
|
if (!allowedCompanyIds.has(project.company_id)) return json(res, 403, { error: 'Forbidden' });
|
|
}
|
|
|
|
const companyName = safeName(project.company?.name, String(project.company_id || 'company'));
|
|
const projectName = safeName(project.name, projectId);
|
|
|
|
if (action === 'rename-project-folder') {
|
|
const oldName = safeName(body.oldName, '');
|
|
const newName = safeName(body.newName, '');
|
|
if (!oldName || !newName) return json(res, 400, { error: 'oldName and newName are required.' });
|
|
const result = await renameDirectory(
|
|
config,
|
|
joinPath(config.clientRoot, companyName, 'Projects', oldName),
|
|
joinPath(config.clientRoot, companyName, 'Projects', newName)
|
|
);
|
|
return json(res, 200, { ok: true, configured: true, ...result });
|
|
}
|
|
|
|
if (action === 'ensure-project-folder') {
|
|
const fullPath = joinPath(config.clientRoot, companyName, 'Projects', projectName);
|
|
await ensureDirectory(config, fullPath);
|
|
for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) {
|
|
await ensureDirectory(config, joinPath(fullPath, folderName));
|
|
}
|
|
return json(res, 200, { ok: true, configured: true, path: fullPath });
|
|
}
|
|
|
|
const taskTitle = safeName(req.query?.taskTitle || body.taskTitle, '');
|
|
if (!taskTitle) return json(res, 400, { error: 'taskTitle is required.' });
|
|
const fullPath = joinPath(config.clientRoot, companyName, 'Projects', projectName, taskTitle);
|
|
|
|
if (action === 'upload-request-file') {
|
|
const bucket = String(body.bucket || 'submissions').trim();
|
|
const storagePath = String(body.storagePath || '').trim();
|
|
const fileName = safeName(body.fileName || req.query?.fileName || req.headers['x-file-name'], '');
|
|
if (!storagePath) return json(res, 400, { error: 'storagePath is required.' });
|
|
if (!fileName) return json(res, 400, { error: 'fileName is required.' });
|
|
const surveyPath = joinPath(fullPath, 'Survey');
|
|
await ensureDirectory(config, surveyPath);
|
|
const { data: downloadedFile, error: downloadError } = await admin.storage.from(bucket).download(storagePath);
|
|
if (downloadError || !downloadedFile) {
|
|
return json(res, 500, { error: downloadError?.message || 'Failed to download request file from storage.' });
|
|
}
|
|
const fileBuffer = Buffer.from(await downloadedFile.arrayBuffer());
|
|
const uploadUrl = `${config.url}/api/resources?source=${FB_SOURCE}&path=${encodeURIComponent(joinPath(surveyPath, fileName))}&override=true`;
|
|
const contentType = String(downloadedFile.type || '').trim() || 'application/octet-stream';
|
|
const response = await fetch(uploadUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${getToken(config)}`,
|
|
'Content-Type': contentType,
|
|
},
|
|
body: fileBuffer,
|
|
});
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => '');
|
|
return json(res, response.status, { error: text || `Upload failed (${response.status})` });
|
|
}
|
|
return json(res, 200, { ok: true, configured: true, path: joinPath(surveyPath, fileName) });
|
|
}
|
|
|
|
await ensureDirectory(config, fullPath);
|
|
for (const folderName of REQUEST_DEFAULT_SUBFOLDERS) {
|
|
await ensureDirectory(config, joinPath(fullPath, folderName));
|
|
}
|
|
return json(res, 200, { ok: true, configured: true, path: fullPath });
|
|
} catch (error) {
|
|
return json(res, error?.status || 500, { error: error?.message || 'Failed to ensure request folder.' });
|
|
}
|
|
}
|