diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 13ea0fd..296d24a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,6 +1,7 @@ { "permissions": { "allow": [ + "Bash(*)", "Bash(\"/Users/kraohasanee/Documents/40-49 Fourge:*)", "Bash(vercel --version)", "Bash(vercel --prod)", @@ -63,7 +64,10 @@ "Bash(xargs -0 '-I{}' bash -c 'name=$\\(basename \"{}\" .jsx\\); grep -q \"$name\" \"/Users/kraohasanee/Documents/40-49 Fourge Branding/41 Website/fourge-portal/src/App.jsx\" || echo \"UNLINKED: {}\"')", "mcp__plugin_supabase_supabase__execute_sql", "mcp__plugin_supabase_supabase__apply_migration", - "Bash(git commit *)" + "Bash(git commit *)", + "mcp__plugin_supabase_supabase__list_projects", + "WebFetch(domain:github.com)", + "Skill(supabase:supabase)" ] } } diff --git a/api/backfill-request-files.js b/api/backfill-request-files.js new file mode 100644 index 0000000..a5469b9 --- /dev/null +++ b/api/backfill-request-files.js @@ -0,0 +1,179 @@ +import { createClient } from '@supabase/supabase-js'; + +const FB_SOURCE = 'files'; + +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'); + clean.push(part); + } + return `/${clean.join('/')}`; +} + +function joinPath(...parts) { + return normalizePath(parts.join('/')); +} + +function safeName(value) { + return String(value || '') + .trim() + .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') + .replace(/\s+/g, ' ') + .replace(/^-+|-+$/g, ''); +} + +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'), + configured: Boolean(url), + }; +} + +async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { + const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); + const res = await fetch(`${config.url}${endpoint}?${qs}`, { + method, + headers: { Authorization: `Bearer ${config.token}`, ...headers }, + body, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `FileBrowser ${res.status}`); + } + return res; +} + +async function mkdir(config, path) { + await fbFetch(config, 'POST', '/api/resources', { + params: { path, isDir: 'true' }, + }).catch(() => {}); +} + +function json(res, status, body) { + return res.status(status).json(body); +} + +export default async function handler(req, res) { + if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' }); + + const secret = process.env.SUPABASE_WEBHOOK_SECRET; + if (secret) { + const incoming = req.headers['x-webhook-secret'] || ''; + if (incoming.trim() !== secret.trim()) return json(res, 401, { error: 'Unauthorized' }); + } + + const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL; + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!supabaseUrl || !serviceRoleKey) return json(res, 500, { error: 'Supabase env not configured' }); + + const admin = createClient(supabaseUrl, serviceRoleKey, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const config = getConfig(); + if (!config.configured || !config.token) return json(res, 200, { ok: true, skipped: 'FileBrowser not configured' }); + + // Fetch all submission files with task/project/company context + const { data: rows, error } = await admin + .from('submission_files') + .select(` + id, name, storage_path, size, + submission:submissions!inner( + id, version_number, + task:tasks!inner( + id, title, + project:projects!inner( + id, name, + company:companies!inner(name) + ) + ) + ) + `); + + if (error) return json(res, 500, { error: error.message }); + + // Group by task + version_number, skip groups with no files + const groups = new Map(); + for (const row of rows || []) { + const sub = row.submission; + const task = sub?.task; + const project = task?.project; + const company = project?.company; + if (!task || !project || !company) continue; + + const key = `${task.id}::${sub.version_number}`; + if (!groups.has(key)) { + groups.set(key, { + companyName: company.name, + projectName: project.name, + taskTitle: task.title, + versionNumber: sub.version_number, + files: [], + }); + } + groups.get(key).files.push({ name: row.name, storage_path: row.storage_path }); + } + + const results = { processed: 0, skipped: 0, errors: [] }; + + for (const group of groups.values()) { + if (group.files.length === 0) { results.skipped++; continue; } + + const revFolder = `R${String(group.versionNumber).padStart(2, '0')}`; + const companyDir = joinPath(config.clientRoot, safeName(group.companyName)); + const projectDir = joinPath(companyDir, 'Projects', safeName(group.projectName)); + const taskDir = joinPath(projectDir, safeName(group.taskTitle)); + const requestInfoDir = joinPath(taskDir, 'Request Info'); + const revDir = joinPath(requestInfoDir, revFolder); + + // Ensure all parent dirs exist + await mkdir(config, companyDir); + await mkdir(config, joinPath(companyDir, 'Projects')); + await mkdir(config, projectDir); + await mkdir(config, taskDir); + await mkdir(config, requestInfoDir); + await mkdir(config, revDir); + + for (const file of group.files) { + try { + // Get signed URL from Supabase Storage + const { data: signed, error: signedError } = await admin.storage + .from('submissions') + .createSignedUrl(file.storage_path, 60); + if (signedError || !signed?.signedUrl) { + results.errors.push(`signed url failed: ${file.storage_path}`); + continue; + } + + // Download file from Supabase Storage + const fileRes = await fetch(signed.signedUrl); + if (!fileRes.ok) { + results.errors.push(`download failed: ${file.name}`); + continue; + } + const fileBuffer = await fileRes.arrayBuffer(); + + // Upload to FileBrowser + const fbFilePath = joinPath(revDir, file.name); + await fbFetch(config, 'POST', '/api/resources', { + params: { path: fbFilePath, override: 'true' }, + headers: { 'Content-Type': 'application/octet-stream' }, + body: fileBuffer, + }); + + results.processed++; + } catch (err) { + results.errors.push(`${file.name}: ${err.message}`); + } + } + } + + return json(res, 200, { ok: true, ...results }); +} diff --git a/api/delete-project.js b/api/delete-project.js new file mode 100644 index 0000000..a4fd39d --- /dev/null +++ b/api/delete-project.js @@ -0,0 +1,206 @@ +import { createClient } from '@supabase/supabase-js'; + +const FB_SOURCE = 'files'; + +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'); + clean.push(part); + } + return `/${clean.join('/')}`; +} + +function joinPath(...parts) { + return normalizePath(parts.join('/')); +} + +function safeName(value) { + return String(value || '') + .trim() + .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') + .replace(/\s+/g, ' ') + .replace(/^-+|-+$/g, ''); +} + +function basename(path) { + const parts = normalizePath(path).split('/').filter(Boolean); + return parts[parts.length - 1] || ''; +} + +function parentDir(path) { + const parts = normalizePath(path).split('/').filter(Boolean); + parts.pop(); + return `/${parts.join('/')}`; +} + +function getFbConfig() { + 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'), + archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/fourgebranding/Archive'), + configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN), + }; +} + +async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { + const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); + const res = await fetch(`${config.url}${endpoint}?${qs}`, { + method, + headers: { Authorization: `Bearer ${config.token}`, ...headers }, + body, + }); + if (!res.ok) { + const text = await res.text(); + const err = new Error(text || `FileBrowser ${res.status}`); + err.status = res.status; + throw err; + } + const text = await res.text(); + try { return text ? JSON.parse(text) : null; } catch { return text; } +} + +async function fbExists(config, path) { + try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; } + catch (e) { if (e.status === 404) return false; throw e; } +} + +async function fbMkdir(config, path) { + await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {}); +} + +async function mergeMove(config, fbSrc, fbDstParent) { + const name = basename(fbSrc); + const fbDst = joinPath(fbDstParent, name); + const destExists = await fbExists(config, fbDst); + if (!destExists) { + await fbFetch(config, 'PATCH', '/api/resources', { + params: {}, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'move', + items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }], + overwrite: false, + }), + }); + } else { + const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } }); + const dirs = (data?.folders || []).map(f => f.name); + const files = (data?.files || []).map(f => f.name); + await fbMkdir(config, fbDst); + for (const dir of dirs) await mergeMove(config, joinPath(fbSrc, dir), fbDst); + for (const file of files) { + await fbFetch(config, 'PATCH', '/api/resources', { + params: {}, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'move', + items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }], + overwrite: true, + }), + }).catch(() => {}); + } + await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {}); + } +} + +async function archiveProject(companyName, projectName) { + const config = getFbConfig(); + if (!config.configured || !companyName || !projectName) return; + const co = safeName(companyName); + const proj = safeName(projectName); + // Ensure archive dirs exist + await fbMkdir(config, config.archiveRoot); + await fbMkdir(config, joinPath(config.archiveRoot, 'Clients')); + await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co)); + await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co, 'Projects')); + // Merge-move project folder into archive + const srcPath = joinPath(config.clientRoot, co, 'Projects', proj); + const dstParentPath = joinPath(config.archiveRoot, 'Clients', co, 'Projects'); + await mergeMove(config, srcPath, dstParentPath); +} + +export default async function handler(req, res) { + if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' }); + + const authHeader = req.headers.authorization || ''; + if (!authHeader.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' }); + + const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL; + const anonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + const callerClient = createClient(supabaseUrl, anonKey, { + auth: { persistSession: false, autoRefreshToken: false }, + global: { headers: { Authorization: authHeader } }, + }); + const { data: userData } = await callerClient.auth.getUser(); + if (!userData?.user) return res.status(401).json({ error: 'Unauthorized' }); + + const { data: profile } = await callerClient.from('profiles').select('id, role').eq('id', userData.user.id).single(); + if (!profile || !['team', 'client'].includes(profile.role)) return res.status(403).json({ error: 'Forbidden' }); + + const projectId = req.query.id; + if (!projectId) return res.status(400).json({ error: 'Project ID required' }); + + const admin = createClient(supabaseUrl, serviceKey, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + // Fetch project + company name before deletion (needed for archive path) + const { data: projRecord } = await admin + .from('projects') + .select('id, name, company:companies(name)') + .eq('id', projectId) + .single(); + + if (!projRecord) return res.status(404).json({ error: 'Project not found' }); + + // For clients, confirm they can see this project (RLS gate) + if (profile.role === 'client') { + const { data: proj, error: projErr } = await callerClient.from('projects').select('id').eq('id', projectId).single(); + if (projErr || !proj) return res.status(404).json({ error: 'Project not found or access denied' }); + } + + // Cleanup storage files + const { data: tasks } = await admin.from('tasks').select('id').eq('project_id', projectId); + const taskIds = (tasks || []).map(t => t.id); + + if (taskIds.length) { + const { data: subs } = await admin.from('submissions').select('id').in('task_id', taskIds); + const subIds = (subs || []).map(s => s.id); + + if (subIds.length) { + const { data: subFiles } = await admin.from('submission_files').select('storage_path').in('submission_id', subIds); + const subPaths = (subFiles || []).map(f => f.storage_path).filter(Boolean); + if (subPaths.length) await admin.storage.from('submissions').remove(subPaths); + + const { data: deliveries } = await admin.from('deliveries').select('id').in('submission_id', subIds); + const delIds = (deliveries || []).map(d => d.id); + if (delIds.length) { + const { data: delFiles } = await admin.from('delivery_files').select('storage_path').in('delivery_id', delIds); + const delPaths = (delFiles || []).map(f => f.storage_path).filter(Boolean); + if (delPaths.length) await admin.storage.from('deliveries').remove(delPaths); + } + } + } + + // Delete project (DB cascade handles tasks/submissions/etc.) + const { error } = await admin.from('projects').delete().eq('id', projectId); + if (error) return res.status(500).json({ error: error.message }); + + // Archive FileBrowser project folder (server-side, so errors are logged) + try { + await archiveProject(projRecord.company?.name, projRecord.name); + } catch (e) { + console.error('[delete-project] archive failed:', e.message); + // Don't fail — DB delete succeeded + } + + return res.status(200).json({ ok: true }); +} diff --git a/api/delete-task.js b/api/delete-task.js new file mode 100644 index 0000000..385906e --- /dev/null +++ b/api/delete-task.js @@ -0,0 +1,192 @@ +import { createClient } from '@supabase/supabase-js'; + +const FB_SOURCE = 'files'; + +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'); + clean.push(part); + } + return `/${clean.join('/')}`; +} + +function joinPath(...parts) { + return normalizePath(parts.join('/')); +} + +function safeName(value) { + return String(value || '') + .trim() + .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') + .replace(/\s+/g, ' ') + .replace(/^-+|-+$/g, ''); +} + +function basename(path) { + const parts = normalizePath(path).split('/').filter(Boolean); + return parts[parts.length - 1] || ''; +} + +function getFbConfig() { + 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'), + archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/fourgebranding/Archive'), + configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN), + }; +} + +async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { + const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); + const res = await fetch(`${config.url}${endpoint}?${qs}`, { + method, + headers: { Authorization: `Bearer ${config.token}`, ...headers }, + body, + }); + if (!res.ok) { + const text = await res.text(); + const err = new Error(text || `FileBrowser ${res.status}`); + err.status = res.status; + throw err; + } + const text = await res.text(); + try { return text ? JSON.parse(text) : null; } catch { return text; } +} + +async function fbExists(config, path) { + try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; } + catch (e) { if (e.status === 404) return false; throw e; } +} + +async function fbMkdir(config, path) { + await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {}); +} + +async function mergeMove(config, fbSrc, fbDstParent) { + const name = basename(fbSrc); + const fbDst = joinPath(fbDstParent, name); + const destExists = await fbExists(config, fbDst); + if (!destExists) { + await fbFetch(config, 'PATCH', '/api/resources', { + params: {}, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'move', + items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }], + overwrite: false, + }), + }); + } else { + const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } }); + const dirs = (data?.folders || []).map(f => f.name); + const files = (data?.files || []).map(f => f.name); + await fbMkdir(config, fbDst); + for (const dir of dirs) await mergeMove(config, joinPath(fbSrc, dir), fbDst); + for (const file of files) { + await fbFetch(config, 'PATCH', '/api/resources', { + params: {}, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'move', + items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }], + overwrite: true, + }), + }).catch(() => {}); + } + await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {}); + } +} + +async function archiveTask(companyName, projectName, taskTitle) { + const config = getFbConfig(); + if (!config.configured || !companyName || !projectName || !taskTitle) return; + const co = safeName(companyName); + const proj = safeName(projectName); + const task = safeName(taskTitle); + // Ensure archive dirs exist + await fbMkdir(config, config.archiveRoot); + await fbMkdir(config, joinPath(config.archiveRoot, 'Clients')); + await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co)); + await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co, 'Projects')); + await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co, 'Projects', proj)); + // Merge-move task folder into archive + const srcPath = joinPath(config.clientRoot, co, 'Projects', proj, task); + const dstParentPath = joinPath(config.archiveRoot, 'Clients', co, 'Projects', proj); + await mergeMove(config, srcPath, dstParentPath); +} + +export default async function handler(req, res) { + if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' }); + + const authHeader = req.headers.authorization || ''; + if (!authHeader.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' }); + + const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL; + const anonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + const callerClient = createClient(supabaseUrl, anonKey, { + auth: { persistSession: false, autoRefreshToken: false }, + global: { headers: { Authorization: authHeader } }, + }); + const { data: userData } = await callerClient.auth.getUser(); + if (!userData?.user) return res.status(401).json({ error: 'Unauthorized' }); + + const { data: profile } = await callerClient.from('profiles').select('id, role').eq('id', userData.user.id).single(); + if (!profile || !['team', 'client'].includes(profile.role)) return res.status(403).json({ error: 'Forbidden' }); + + const taskId = req.query.id; + if (!taskId) return res.status(400).json({ error: 'Task ID required' }); + + const admin = createClient(supabaseUrl, serviceKey, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + // Fetch task + project + company before deletion using explicit joins + const { data: taskRecord } = await admin.from('tasks').select('id, title, project_id').eq('id', taskId).single(); + if (!taskRecord) return res.status(404).json({ error: 'Task not found' }); + + const { data: projectRecord } = await admin.from('projects').select('id, name, company_id').eq('id', taskRecord.project_id).single(); + const { data: companyRecord } = projectRecord?.company_id + ? await admin.from('companies').select('id, name').eq('id', projectRecord.company_id).single() + : { data: null }; + + // Cleanup storage files + const { data: subs } = await admin.from('submissions').select('id').eq('task_id', taskId); + const subIds = (subs || []).map(s => s.id); + + if (subIds.length) { + const { data: subFiles } = await admin.from('submission_files').select('storage_path').in('submission_id', subIds); + const subPaths = (subFiles || []).map(f => f.storage_path).filter(Boolean); + if (subPaths.length) await admin.storage.from('submissions').remove(subPaths); + + const { data: deliveries } = await admin.from('deliveries').select('id').in('submission_id', subIds); + const delIds = (deliveries || []).map(d => d.id); + if (delIds.length) { + const { data: delFiles } = await admin.from('delivery_files').select('storage_path').in('delivery_id', delIds); + const delPaths = (delFiles || []).map(f => f.storage_path).filter(Boolean); + if (delPaths.length) await admin.storage.from('deliveries').remove(delPaths); + } + } + + // Delete task (DB cascade handles submissions/etc.) + const { error } = await admin.from('tasks').delete().eq('id', taskId); + if (error) return res.status(500).json({ error: error.message }); + + // Archive FileBrowser task folder + let archiveError = null; + try { + await archiveTask(companyRecord?.name, projectRecord?.name, taskRecord.title); + } catch (e) { + archiveError = e.message; + console.error('[delete-task] archive failed:', e.message); + } + + return res.status(200).json({ ok: true, archiveError }); +} diff --git a/api/filebrowser.js b/api/filebrowser.js new file mode 100644 index 0000000..a929073 --- /dev/null +++ b/api/filebrowser.js @@ -0,0 +1,461 @@ +import { createClient } from '@supabase/supabase-js'; + +const FB_SOURCE = 'files'; + +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 parentDir(path) { + const parts = normalizePath(path).split('/').filter(Boolean); + parts.pop(); + return `/${parts.join('/')}`; +} + +function basename(path) { + const parts = normalizePath(path).split('/').filter(Boolean); + return parts[parts.length - 1] || ''; +} + +function safeName(value, fallback = '') { + const cleaned = String(value || '') + .trim() + .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') + .replace(/\s+/g, ' ') + .replace(/^-+|-+$/g, ''); + return cleaned || fallback; +} + +function getConfig() { + const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, ''); + return { + url, + token: process.env.FILEBROWSER_TOKEN || '', + teamRoot: normalizePath(process.env.FILEBROWSER_TEAM_ROOT || '/'), + clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'), + externalSubsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/Subcontractors'), + externalClientsRoot: normalizePath(process.env.FILEBROWSER_CLIENTS_ROOT || '/fourgebranding/Clients'), + configured: Boolean(url), + }; +} + +function getToken(config) { + if (!config.token) throw new Error('FILEBROWSER_TOKEN not configured'); + return config.token; +} + +async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { + const token = getToken(config); + const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); + const url = `${config.url}${endpoint}?${qs}`; + + const res = await fetch(url, { + method, + headers: { Authorization: `Bearer ${token}`, ...headers }, + body, + }); + + if (!res.ok) { + const text = await res.text(); + const err = new Error(text || `FileBrowser ${res.status}`); + err.status = res.status; + throw err; + } + + const text = await res.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, name, role, company:companies(id, name)') + .eq('id', userData.user.id) + .single(); + + if (profileError) return { ok: false, status: 500, message: profileError.message }; + if (!['team', 'external', 'client'].includes(profile?.role)) { + return { ok: false, status: 403, message: 'Forbidden' }; + } + + // Mirror AuthContext: load all companies (FK + company_members) + let 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 m of memberships || []) { + if (m.company?.id && !seen.has(m.company.id)) { + clientCompanies.push(m.company); + seen.add(m.company.id); + } + } + } + + return { ok: true, callerClient, profile: { ...profile, clientCompanies, email: userData.user.email } }; +} + +async function getExternalProjects(callerClient, userId) { + const { data, error } = await callerClient + .from('project_members') + .select('project:projects(id, name, company:companies(name))') + .eq('profile_id', userId); + + if (error) throw new Error(error.message); + return (data || []).map(r => r.project).filter(Boolean); +} + +function resolveUserRoot(config, profile) { + if (profile.role === 'team') return config.teamRoot; + if (profile.role === 'client') { + const companyFolder = safeName(profile.company?.name, profile.id); + return joinPath(config.clientRoot, companyFolder); + } + if (profile.role === 'external') return config.externalSubsRoot; + return '/'; +} + +function resolveClientPath(config, vPath, companies) { + const parts = normalizePath(vPath).split('/').filter(Boolean); + if (parts.length === 0) return { virtual: true }; + + const companyFolder = parts[0]; + const match = companies.find(c => safeName(c.name, '') === companyFolder); + if (!match) { + const err = new Error('Access denied'); + err.status = 403; + throw err; + } + + const rest = parts.slice(1); + const base = joinPath(config.clientRoot, companyFolder); + const fbPath = rest.length > 0 ? joinPath(base, ...rest) : base; + return { virtual: false, fbPath }; +} + +function buildClientVirtualEntries(companies) { + return companies.map(c => { + const name = safeName(c.name, c.id); + return { name, type: 'dir', size: 0, mtime: null, path: `/${name}` }; + }); +} + +function resolveExternalPath(config, vPath, profile, projects) { + const myFolder = safeName(profile.name, profile.id); + const parts = normalizePath(vPath).split('/').filter(Boolean); + + if (parts.length === 0) return { virtual: true }; + + // Their personal Team folder + if (parts[0] === myFolder) { + const fbPath = joinPath(config.externalSubsRoot, ...parts); + return { virtual: false, fbPath }; + } + + // Assigned client projects — flattened: Projects/{project}/... + if (parts[0] === 'Projects') { + if (parts.length < 2) return { virtual: true }; + const [, projectFolder, ...rest] = parts; + const match = projects.find(p => safeName(p.name, '') === projectFolder); + if (!match) { + const err = new Error('Access denied to this project'); + err.status = 403; + throw err; + } + const company = safeName(match.company?.name, ''); + const base = joinPath(config.externalClientsRoot, company, 'Projects', projectFolder); + const fbPath = rest.length > 0 ? joinPath(base, ...rest) : base; + return { virtual: false, fbPath }; + } + + const err = new Error('Access denied'); + err.status = 403; + throw err; +} + +function buildExternalVirtualEntries(vPath, profile, projects) { + const myFolder = safeName(profile.name, profile.id); + const parts = normalizePath(vPath).split('/').filter(Boolean); + + if (parts.length === 0) { + const entries = [{ name: myFolder, type: 'dir', size: 0, mtime: null, path: `/${myFolder}` }]; + if (projects.length > 0) entries.push({ name: 'Projects', type: 'dir', size: 0, mtime: null, path: '/Projects' }); + return entries; + } + + if (parts[0] === 'Projects' && parts.length === 1) { + const seen = new Set(); + return projects + .map(p => safeName(p.name, '')) + .filter(name => name && !seen.has(name) && seen.add(name)) + .map(name => ({ name, type: 'dir', size: 0, mtime: null, path: `/Projects/${name}` })); + } + + return []; +} + +function normalizeQuantumItems(data, virtualPath) { + const dirs = (data?.folders || []).map(item => ({ ...item, _type: 'directory' })); + const files = (data?.files || []).map(item => ({ ...item, _type: item.type || 'file' })); + const items = [...dirs, ...files].map(item => ({ + name: item.name, + type: (item._type === 'directory' || item.type === 'directory') ? 'dir' : 'file', + size: (item._type === 'directory' || item.type === 'directory') ? 0 : (item.size || 0), + mtime: item.modified || null, + path: joinPath(virtualPath, item.name), + })); + return items.sort((a, b) => { + if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; + if (a.name === '00 Project Files') return -1; + if (b.name === '00 Project Files') return 1; + return a.name.localeCompare(b.name); + }); +} + +function toListResponse(vPath, entries, { readOnly = false } = {}) { + return { + configured: true, + path: vPath, + canGoUp: vPath !== '/', + parentPath: parentDir(vPath), + entries, + readOnly, + }; +} + +export default async function handler(req, res) { + try { + const authHeader = req.headers.authorization || ''; + if (!authHeader) return json(res, 401, { error: 'No authorization header' }); + + const auth = await requirePortalUser(authHeader); + if (!auth.ok) return json(res, auth.status, { error: auth.message }); + + const config = getConfig(); + if (!config.configured || !config.token) { + return json(res, 200, { + configured: false, + error: 'FileBrowser not configured.', + requiredEnv: ['FILEBROWSER_URL', 'FILEBROWSER_TOKEN'], + }); + } + + const action = req.query.action || (req.method === 'GET' ? 'list' : ''); + const requestedPath = req.query.path || req.body?.path || '/'; + + let externalProjects = []; + if (auth.profile.role === 'external') { + externalProjects = await getExternalProjects(auth.callerClient, auth.profile.id); + } + + function toFbPath(vPath = requestedPath) { + if (auth.profile.role === 'external') { + return resolveExternalPath(config, normalizePath(vPath), auth.profile, externalProjects); + } + if (auth.profile.role === 'client') { + return resolveClientPath(config, normalizePath(vPath), auth.profile.clientCompanies); + } + const root = resolveUserRoot(config, auth.profile); + return { virtual: false, fbPath: joinPath(root, normalizePath(vPath)) }; + } + + if (req.method === 'GET' && action === 'config') { + return json(res, 200, { configured: true, role: auth.profile.role, url: config.url }); + } + + if (req.method === 'GET' && action === 'list') { + const vPath = normalizePath(requestedPath); + if (auth.profile.role === 'external') { + const resolved = resolveExternalPath(config, vPath, auth.profile, externalProjects); + if (resolved.virtual) { + return json(res, 200, toListResponse(vPath, buildExternalVirtualEntries(vPath, auth.profile, externalProjects), { readOnly: true })); + } + const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: resolved.fbPath } }); + return json(res, 200, toListResponse(vPath, normalizeQuantumItems(data, vPath))); + } + if (auth.profile.role === 'client') { + const resolved = resolveClientPath(config, vPath, auth.profile.clientCompanies); + if (resolved.virtual) { + return json(res, 200, toListResponse(vPath, buildClientVirtualEntries(auth.profile.clientCompanies))); + } + const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: resolved.fbPath } }); + return json(res, 200, toListResponse(vPath, normalizeQuantumItems(data, vPath))); + } + const root = resolveUserRoot(config, auth.profile); + const fbPath = joinPath(root, vPath); + const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbPath } }); + return json(res, 200, toListResponse(vPath, normalizeQuantumItems(data, vPath))); + } + + if (req.method === 'GET' && action === 'download') { + const resolved = toFbPath(); + if (resolved.virtual) return json(res, 400, { error: 'Cannot download virtual directory' }); + const token = getToken(config); + const downloadUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}`; + return json(res, 200, { url: downloadUrl, token }); + } + + if (req.method === 'POST' && action === 'upload-token') { + const resolved = toFbPath(); + if (resolved.virtual) return json(res, 400, { error: 'Cannot upload to virtual directory' }); + const token = getToken(config); + return json(res, 200, { token, url: config.url, fbPath: resolved.fbPath }); + } + + if (req.method === 'POST' && action === 'mkdir') { + const folderName = safeName(req.body?.name, ''); + if (!folderName) return json(res, 400, { error: 'Folder name required' }); + + const resolved = toFbPath(); + if (resolved.virtual) return json(res, 400, { error: 'Cannot create folder in virtual directory' }); + + await fbFetch(config, 'POST', '/api/resources', { + params: { path: joinPath(resolved.fbPath, folderName), isDir: 'true' }, + }); + return json(res, 200, { success: true }); + } + + if (req.method === 'DELETE' && action === 'delete') { + const resolved = toFbPath(); + if (resolved.virtual) return json(res, 400, { error: 'Cannot delete virtual directory' }); + if (!basename(resolved.fbPath)) return json(res, 400, { error: 'Cannot delete root' }); + + await fbFetch(config, 'DELETE', '/api/resources', { params: { path: resolved.fbPath } }); + return json(res, 200, { success: true }); + } + + if (req.method === 'POST' && action === 'rename') { + const newName = safeName(req.body?.name, ''); + if (!newName) return json(res, 400, { error: 'New name required' }); + + const resolved = toFbPath(); + if (resolved.virtual) return json(res, 400, { error: 'Cannot rename virtual directory' }); + + const newFbPath = joinPath(parentDir(resolved.fbPath), newName); + await fbFetch(config, 'PATCH', '/api/resources', { + params: {}, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'rename', + items: [{ fromSource: FB_SOURCE, fromPath: resolved.fbPath, toSource: FB_SOURCE, toPath: newFbPath }], + overwrite: false, + }), + }); + return json(res, 200, { success: true }); + } + + if (req.method === 'POST' && action === 'archive-move') { + // Moves srcPath into dstParentPath. If destination already exists, merges contents recursively. + const srcVPath = req.body?.srcPath; + const dstParentVPath = req.body?.dstParentPath; + if (!srcVPath || !dstParentVPath) return json(res, 400, { error: 'srcPath and dstParentPath required' }); + + const resolvedSrc = toFbPath(srcVPath); + const resolvedDstParent = toFbPath(dstParentVPath); + if (resolvedSrc.virtual || resolvedDstParent.virtual) return json(res, 400, { error: 'Cannot operate on virtual directories' }); + + async function fbExists(path) { + try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; } + catch (e) { if (e.status === 404) return false; throw e; } + } + + async function fbMkdir(path) { + await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {}); + } + + async function mergeMove(fbSrc, fbDstParent) { + const name = basename(fbSrc); + const fbDst = joinPath(fbDstParent, name); + const destExists = await fbExists(fbDst); + if (!destExists) { + await fbFetch(config, 'PATCH', '/api/resources', { + params: {}, headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'move', items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }], overwrite: false }), + }); + } else { + const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } }); + const dirs = (data?.folders || []).map(f => f.name); + const files = (data?.files || []).map(f => f.name); + await fbMkdir(fbDst); + for (const dir of dirs) await mergeMove(joinPath(fbSrc, dir), fbDst); + for (const file of files) { + await fbFetch(config, 'PATCH', '/api/resources', { + params: {}, headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'move', items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }], overwrite: true }), + }).catch(() => {}); + } + await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {}); + } + } + + await mergeMove(resolvedSrc.fbPath, resolvedDstParent.fbPath); + return json(res, 200, { success: true }); + } + + if (req.method === 'POST' && action === 'move') { + const srcPath = req.body?.srcPath; + const dstPath = req.body?.dstPath; + if (!srcPath || !dstPath) return json(res, 400, { error: 'srcPath and dstPath required' }); + + const resolvedSrc = toFbPath(srcPath); + const resolvedDst = toFbPath(dstPath); + if (resolvedSrc.virtual || resolvedDst.virtual) return json(res, 400, { error: 'Cannot move virtual directories' }); + + const itemName = basename(resolvedSrc.fbPath); + const newFbPath = joinPath(resolvedDst.fbPath, itemName); + + await fbFetch(config, 'PATCH', '/api/resources', { + params: {}, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'move', + items: [{ fromSource: FB_SOURCE, fromPath: resolvedSrc.fbPath, toSource: FB_SOURCE, toPath: newFbPath }], + overwrite: false, + }), + }); + return json(res, 200, { success: true }); + } + + return json(res, 405, { error: 'Method not allowed' }); + } catch (error) { + return json(res, error.status || 500, { error: error.message || 'Unexpected error' }); + } +} diff --git a/api/sync-company-folder.js b/api/sync-company-folder.js new file mode 100644 index 0000000..d2bd8aa --- /dev/null +++ b/api/sync-company-folder.js @@ -0,0 +1,111 @@ +const FB_SOURCE = 'files'; + +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'); + clean.push(part); + } + return `/${clean.join('/')}`; +} + +function joinPath(...parts) { + return normalizePath(parts.join('/')); +} + +function parentDir(path) { + const parts = normalizePath(path).split('/').filter(Boolean); + parts.pop(); + return `/${parts.join('/')}`; +} + +function safeName(value) { + return String(value || '') + .trim() + .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') + .replace(/\s+/g, ' ') + .replace(/^-+|-+$/g, ''); +} + +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'), + configured: Boolean(url), + }; +} + +async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { + const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); + const res = await fetch(`${config.url}${endpoint}?${qs}`, { + method, + headers: { Authorization: `Bearer ${config.token}`, ...headers }, + body, + }); + if (!res.ok) { + const text = await res.text(); + const err = new Error(text || `FileBrowser ${res.status}`); + err.status = res.status; + throw err; + } +} + +async function mkdir(config, parentPath, name) { + const folderPath = joinPath(parentPath, safeName(name)); + await fbFetch(config, 'POST', '/api/resources', { + params: { path: folderPath, isDir: 'true' }, + }).catch(() => {}); +} + +async function renameFolder(config, oldName, newName) { + const oldSafe = safeName(oldName); + const newSafe = safeName(newName); + if (!oldSafe || !newSafe || oldSafe === newSafe) return; + const fromPath = joinPath(config.clientRoot, oldSafe); + const toPath = joinPath(config.clientRoot, newSafe); + await fbFetch(config, 'PATCH', '/api/resources', { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'rename', + items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }], + overwrite: false, + }), + }).catch(() => {}); +} + +export default async function handler(req, res) { + if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }); + + const secret = process.env.SUPABASE_WEBHOOK_SECRET; + if (secret) { + const incoming = req.headers['x-webhook-secret'] || req.headers['x-supabase-webhook-secret'] || ''; + if (incoming.trim() !== secret.trim()) return res.status(401).json({ error: 'Unauthorized' }); + } + + const { type, record, old_record } = req.body || {}; + if (!record?.name) return res.status(200).json({ ok: true, skipped: true }); + + const config = getConfig(); + if (!config.configured || !config.token) return res.status(200).json({ ok: true, skipped: 'not configured' }); + + const clientRoot = config.clientRoot; + const clientsParent = parentDir(clientRoot); + const clientsDirName = clientRoot.split('/').filter(Boolean).pop(); + + // Ensure parent Clients dir exists + await mkdir(config, clientsParent, clientsDirName); + + if (type === 'UPDATE' && old_record?.name && old_record.name !== record.name) { + await renameFolder(config, old_record.name, record.name); + } + + // Always ensure folder for current name exists (idempotent) + await mkdir(config, clientRoot, record.name); + + res.status(200).json({ ok: true, type, name: record.name }); +} diff --git a/api/sync-profile-folder.js b/api/sync-profile-folder.js new file mode 100644 index 0000000..4becb8a --- /dev/null +++ b/api/sync-profile-folder.js @@ -0,0 +1,117 @@ +const FB_SOURCE = 'files'; +const MEMBER_ROLES = new Set(['team', 'external']); + +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'); + clean.push(part); + } + return `/${clean.join('/')}`; +} + +function joinPath(...parts) { + return normalizePath(parts.join('/')); +} + +function parentDir(path) { + const parts = normalizePath(path).split('/').filter(Boolean); + parts.pop(); + return `/${parts.join('/')}`; +} + +function safeName(value) { + return String(value || '') + .trim() + .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') + .replace(/\s+/g, ' ') + .replace(/^-+|-+$/g, ''); +} + +function getConfig() { + const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, ''); + return { + url, + token: process.env.FILEBROWSER_TOKEN || '', + membersRoot: normalizePath(process.env.FILEBROWSER_MEMBERS_ROOT || '/fourgebranding/Team'), + configured: Boolean(url), + }; +} + +async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { + const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); + const res = await fetch(`${config.url}${endpoint}?${qs}`, { + method, + headers: { Authorization: `Bearer ${config.token}`, ...headers }, + body, + }); + if (!res.ok) { + const text = await res.text(); + const err = new Error(text || `FileBrowser ${res.status}`); + err.status = res.status; + throw err; + } +} + +async function mkdir(config, parentPath, name) { + const safe = safeName(name); + if (!safe) return; + await fbFetch(config, 'POST', '/api/resources', { + params: { path: joinPath(parentPath, safe), isDir: 'true' }, + }).catch(() => {}); +} + +async function renameFolder(config, root, oldName, newName) { + const oldSafe = safeName(oldName); + const newSafe = safeName(newName); + if (!oldSafe || !newSafe || oldSafe === newSafe) return; + await fbFetch(config, 'PATCH', '/api/resources', { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'rename', + items: [{ + fromSource: FB_SOURCE, fromPath: joinPath(root, oldSafe), + toSource: FB_SOURCE, toPath: joinPath(root, newSafe), + }], + overwrite: false, + }), + }).catch(() => {}); +} + +export default async function handler(req, res) { + if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }); + + const secret = process.env.SUPABASE_WEBHOOK_SECRET; + if (secret) { + const incoming = req.headers['x-webhook-secret'] || req.headers['x-supabase-webhook-secret'] || ''; + if (incoming.trim() !== secret.trim()) return res.status(401).json({ error: 'Unauthorized' }); + } + + const { type, record, old_record } = req.body || {}; + + // Only process team and external roles + if (!MEMBER_ROLES.has(record?.role)) return res.status(200).json({ ok: true, skipped: true }); + if (!record?.name) return res.status(200).json({ ok: true, skipped: 'no name' }); + + const config = getConfig(); + if (!config.configured || !config.token) return res.status(200).json({ ok: true, skipped: 'not configured' }); + + const membersRoot = config.membersRoot; + const membersParent = parentDir(membersRoot); + const membersDirName = membersRoot.split('/').filter(Boolean).pop(); + + // Ensure /fourgebranding/team dir exists + await mkdir(config, membersParent, membersDirName); + + if (type === 'UPDATE' && old_record?.name && old_record.name !== record.name) { + await renameFolder(config, membersRoot, old_record.name, record.name); + } + + // Ensure folder for current name exists + await mkdir(config, membersRoot, record.name); + + res.status(200).json({ ok: true, type, name: record.name, role: record.role }); +} diff --git a/api/sync-project-folder.js b/api/sync-project-folder.js new file mode 100644 index 0000000..c5ea011 --- /dev/null +++ b/api/sync-project-folder.js @@ -0,0 +1,124 @@ +import { createClient } from '@supabase/supabase-js'; + +const FB_SOURCE = 'files'; + +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'); + clean.push(part); + } + return `/${clean.join('/')}`; +} + +function joinPath(...parts) { + return normalizePath(parts.join('/')); +} + +function safeName(value) { + return String(value || '') + .trim() + .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') + .replace(/\s+/g, ' ') + .replace(/^-+|-+$/g, ''); +} + +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'), + configured: Boolean(url), + }; +} + +async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { + const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); + const res = await fetch(`${config.url}${endpoint}?${qs}`, { + method, + headers: { Authorization: `Bearer ${config.token}`, ...headers }, + body, + }); + if (!res.ok) { + const text = await res.text(); + const err = new Error(text || `FileBrowser ${res.status}`); + err.status = res.status; + throw err; + } +} + +async function mkdir(config, path) { + await fbFetch(config, 'POST', '/api/resources', { + params: { path, isDir: 'true' }, + }).catch(() => {}); +} + +async function renameFolder(config, fromPath, toPath) { + await fbFetch(config, 'PATCH', '/api/resources', { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'rename', + items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }], + overwrite: false, + }), + }).catch(() => {}); +} + +async function requireTeamUser(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) return false; + const client = createClient(supabaseUrl, supabaseAnonKey, { + auth: { persistSession: false, autoRefreshToken: false }, + global: { headers: { Authorization: authHeader } }, + }); + const { data: userData } = await client.auth.getUser(); + if (!userData?.user) return false; + const { data: profile } = await client.from('profiles').select('role').eq('id', userData.user.id).single(); + return profile?.role === 'team'; +} + +export default async function handler(req, res) { + if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }); + + const authHeader = req.headers.authorization || ''; + const secret = process.env.SUPABASE_WEBHOOK_SECRET; + + if (authHeader.startsWith('Bearer ')) { + const isTeam = await requireTeamUser(authHeader); + if (!isTeam) return res.status(403).json({ error: 'Team members only' }); + } else if (secret) { + const incoming = req.headers['x-webhook-secret'] || req.headers['x-supabase-webhook-secret'] || ''; + if (incoming.trim() !== secret.trim()) return res.status(401).json({ error: 'Unauthorized' }); + } + + const { type, record, old_record } = req.body || {}; + if (!record?.name || !record?.company_name) return res.status(200).json({ ok: true, skipped: 'missing name or company_name' }); + + const config = getConfig(); + if (!config.configured || !config.token) return res.status(200).json({ ok: true, skipped: 'not configured' }); + + const companyDir = joinPath(config.clientRoot, safeName(record.company_name)); + const projectsDir = joinPath(companyDir, 'Projects'); + + // Ensure Clients/{company}/Projects/ exists + await mkdir(config, companyDir); + await mkdir(config, projectsDir); + + if (type === 'UPDATE' && old_record?.name && old_record.name !== record.name) { + const oldPath = joinPath(projectsDir, safeName(old_record.name)); + const newPath = joinPath(projectsDir, safeName(record.name)); + await renameFolder(config, oldPath, newPath); + } + + // Ensure folder for current project name exists + const projectDir = joinPath(projectsDir, safeName(record.name)); + await mkdir(config, projectDir); + await mkdir(config, joinPath(projectDir, '00 Project Files')); + + res.status(200).json({ ok: true, type, company: record.company_name, project: record.name }); +} diff --git a/api/sync-task-folder.js b/api/sync-task-folder.js new file mode 100644 index 0000000..1423fe2 --- /dev/null +++ b/api/sync-task-folder.js @@ -0,0 +1,105 @@ +const FB_SOURCE = 'files'; + +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'); + clean.push(part); + } + return `/${clean.join('/')}`; +} + +function joinPath(...parts) { + return normalizePath(parts.join('/')); +} + +function safeName(value) { + return String(value || '') + .trim() + .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') + .replace(/\s+/g, ' ') + .replace(/^-+|-+$/g, ''); +} + +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'), + configured: Boolean(url), + }; +} + +async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { + const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); + const res = await fetch(`${config.url}${endpoint}?${qs}`, { + method, + headers: { Authorization: `Bearer ${config.token}`, ...headers }, + body, + }); + if (!res.ok) { + const text = await res.text(); + const err = new Error(text || `FileBrowser ${res.status}`); + err.status = res.status; + throw err; + } +} + +async function mkdir(config, path) { + await fbFetch(config, 'POST', '/api/resources', { + params: { path, isDir: 'true' }, + }).catch(() => {}); +} + +async function renameFolder(config, fromPath, toPath) { + await fbFetch(config, 'PATCH', '/api/resources', { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'rename', + items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }], + overwrite: false, + }), + }).catch(() => {}); +} + +export default async function handler(req, res) { + if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }); + + const secret = process.env.SUPABASE_WEBHOOK_SECRET; + if (secret) { + const incoming = req.headers['x-webhook-secret'] || req.headers['x-supabase-webhook-secret'] || ''; + if (incoming.trim() !== secret.trim()) return res.status(401).json({ error: 'Unauthorized' }); + } + + const { type, record, old_record } = req.body || {}; + if (!record?.title || !record?.project_name || !record?.company_name) { + return res.status(200).json({ ok: true, skipped: 'missing title, project_name, or company_name' }); + } + + const config = getConfig(); + if (!config.configured || !config.token) return res.status(200).json({ ok: true, skipped: 'not configured' }); + + const projectDir = joinPath(config.clientRoot, safeName(record.company_name), 'Projects', safeName(record.project_name)); + + // Ensure parent dirs exist + await mkdir(config, joinPath(config.clientRoot, safeName(record.company_name))); + await mkdir(config, joinPath(config.clientRoot, safeName(record.company_name), 'Projects')); + await mkdir(config, projectDir); + + if (type === 'UPDATE' && old_record?.title && old_record.title !== record.title) { + const oldPath = joinPath(projectDir, safeName(old_record.title)); + const newPath = joinPath(projectDir, safeName(record.title)); + await renameFolder(config, oldPath, newPath); + } + + const taskDir = joinPath(projectDir, safeName(record.title)); + await mkdir(config, taskDir); + await mkdir(config, joinPath(taskDir, 'Working Files')); + await mkdir(config, joinPath(taskDir, 'Request Info')); + + res.status(200).json({ ok: true, type, company: record.company_name, project: record.project_name, task: record.title }); +} diff --git a/scripts/backfill-project-files-folder.mjs b/scripts/backfill-project-files-folder.mjs new file mode 100644 index 0000000..0e8afa9 --- /dev/null +++ b/scripts/backfill-project-files-folder.mjs @@ -0,0 +1,83 @@ +// One-time: creates 00 Project Files folder inside every existing project folder in FileBrowser +// Run: node scripts/backfill-project-files-folder.mjs +import { readFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { createClient } from '@supabase/supabase-js'; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const envFile = resolve(__dir, '../.env.backfill'); + +const env = {}; +readFileSync(envFile, 'utf8').split('\n').forEach(line => { + const m = line.match(/^([^#=]+)=(.*)$/); + if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, ''); +}); + +const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL; +const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY; +const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, ''); +const FB_TOKEN = env.FILEBROWSER_TOKEN; +const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'; +const FB_SOURCE = 'files'; + +if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); } +if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); } + +const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } }); + +function safeName(v) { + return String(v || '').trim() + .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') + .replace(/\s+/g, ' ') + .replace(/^-+|-+$/g, ''); +} + +function joinPath(...parts) { + const raw = parts.join('/'); + const clean = raw.split('/').filter(p => p && p !== '.'); + return `/${clean.join('/')}`; +} + +async function mkdir(path) { + const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString(); + const res = await fetch(`${FB_URL}/api/resources?${qs}`, { + method: 'POST', + headers: { Authorization: `Bearer ${FB_TOKEN}` }, + }); + if (!res.ok) { + const text = await res.text(); + if (!text.includes('already') && !text.includes('exist')) { + console.warn(` mkdir ${path}: ${res.status} ${text.slice(0, 80)}`); + } + } +} + +async function main() { + const { data: projects, error } = await admin + .from('projects') + .select('id, name, company:companies(name)'); + + if (error) { console.error('Query failed:', error.message); process.exit(1); } + + console.log(`Found ${projects.length} projects`); + + for (const p of projects) { + const company = safeName(p.company?.name || ''); + const project = safeName(p.name || ''); + if (!company || !project) { console.log(` Skipping (missing name): ${p.id}`); continue; } + + const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project); + const targetDir = joinPath(projectDir, '00 Project Files'); + + await mkdir(joinPath(CLIENT_ROOT, company)); + await mkdir(joinPath(CLIENT_ROOT, company, 'Projects')); + await mkdir(projectDir); + await mkdir(targetDir); + console.log(` ✓ ${company} / ${project} / 00 Project Files`); + } + + console.log('\nDone.'); +} + +main().catch(err => { console.error(err); process.exit(1); }); diff --git a/scripts/backfill-project-folders.mjs b/scripts/backfill-project-folders.mjs new file mode 100644 index 0000000..daac131 --- /dev/null +++ b/scripts/backfill-project-folders.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +// Backfill FileBrowser folders for all existing projects and tasks. +// Project: Clients/{company}/Projects/{project}/00 Project Files/ + 00 Project Info/ +// Task: Clients/{company}/Projects/{project}/{task}/Working Files/ + Request Info/ +// Run: node --env-file=.env.backfill scripts/backfill-project-folders.mjs + +const FB_SOURCE = 'files'; +const FILEBROWSER_URL = (process.env.FILEBROWSER_URL || 'https://fourgebranding.krao.us').replace(/\/+$/, ''); +const FILEBROWSER_TOKEN = process.env.FILEBROWSER_TOKEN; +const CLIENT_ROOT = process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'; +const SUPABASE_URL = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL; +const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; + +if (!FILEBROWSER_TOKEN) { console.error('Missing FILEBROWSER_TOKEN'); process.exit(1); } +if (!SUPABASE_URL || !SUPABASE_KEY) { console.error('Missing Supabase env'); process.exit(1); } + +function safeName(value) { + return String(value || '') + .trim() + .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') + .replace(/\s+/g, ' ') + .replace(/^-+|-+$/g, ''); +} + +function normalizePath(path) { + const parts = String(path || '/').split('/').filter(p => p && p !== '.' && p !== '..'); + return `/${parts.join('/')}`; +} + +function joinPath(...parts) { + return normalizePath(parts.join('/')); +} + +async function mkdir(path) { + const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString(); + const res = await fetch(`${FILEBROWSER_URL}/api/resources?${qs}`, { + method: 'POST', + headers: { Authorization: `Bearer ${FILEBROWSER_TOKEN}` }, + }); + // 200 = created, 409 = already exists — both fine + if (!res.ok && res.status !== 409) { + const text = await res.text(); + throw new Error(`mkdir ${path} failed (${res.status}): ${text}`); + } + return res.status; +} + +async function supabaseFetch(path) { + const res = await fetch(`${SUPABASE_URL}/rest/v1/${path}`, { + headers: { + apikey: SUPABASE_KEY, + Authorization: `Bearer ${SUPABASE_KEY}`, + }, + }); + if (!res.ok) throw new Error(`Supabase ${path}: ${await res.text()}`); + return res.json(); +} + +async function run() { + console.log('Fetching projects from Supabase...'); + const projects = await supabaseFetch('projects?select=id,name,company:companies(name)&order=created_at.asc'); + console.log(`Found ${projects.length} projects.`); + + console.log('Fetching tasks from Supabase...'); + const tasks = await supabaseFetch('tasks?select=id,title,project:projects(name,company:companies(name))&order=submitted_at.asc'); + console.log(`Found ${tasks.length} tasks.\n`); + + let created = 0; + let existing = 0; + let errors = 0; + + // ── Projects ────────────────────────────────────────────────────────────── + console.log('=== PROJECTS ==='); + for (const project of projects) { + const companyName = project.company?.name; + if (!companyName) { console.log(` SKIP ${project.name} — no company`); continue; } + + const companyDir = joinPath(CLIENT_ROOT, safeName(companyName)); + const projectsDir = joinPath(companyDir, 'Projects'); + const projectDir = joinPath(projectsDir, safeName(project.name)); + + try { + await mkdir(companyDir); + await mkdir(projectsDir); + const s = await mkdir(projectDir); + await mkdir(joinPath(projectDir, '00 Project Files')); + await mkdir(joinPath(projectDir, '00 Project Info')); + + if (s === 409) { console.log(` EXISTS ${companyName} / ${project.name}`); existing++; } + else { console.log(` CREATED ${companyName} / ${project.name}`); created++; } + } catch (err) { + console.error(` ERROR ${companyName} / ${project.name}: ${err.message}`); + errors++; + } + } + + // ── Tasks ───────────────────────────────────────────────────────────────── + console.log('\n=== TASKS ==='); + for (const task of tasks) { + const projectName = task.project?.name; + const companyName = task.project?.company?.name; + if (!projectName || !companyName) { console.log(` SKIP ${task.title} — missing project/company`); continue; } + + const projectDir = joinPath(CLIENT_ROOT, safeName(companyName), 'Projects', safeName(projectName)); + const taskDir = joinPath(projectDir, safeName(task.title)); + + try { + // Ensure parent exists (idempotent) + await mkdir(joinPath(CLIENT_ROOT, safeName(companyName))); + await mkdir(joinPath(CLIENT_ROOT, safeName(companyName), 'Projects')); + await mkdir(projectDir); + const s = await mkdir(taskDir); + await mkdir(joinPath(taskDir, 'Working Files')); + await mkdir(joinPath(taskDir, 'Request Info')); + + if (s === 409) { console.log(` EXISTS ${companyName} / ${projectName} / ${task.title}`); existing++; } + else { console.log(` CREATED ${companyName} / ${projectName} / ${task.title}`); created++; } + } catch (err) { + console.error(` ERROR ${companyName} / ${projectName} / ${task.title}: ${err.message}`); + errors++; + } + } + + console.log(`\nDone. Created: ${created} Already existed: ${existing} Errors: ${errors}`); +} + +run().catch(err => { console.error(err); process.exit(1); }); diff --git a/scripts/backfill-request-files.mjs b/scripts/backfill-request-files.mjs new file mode 100644 index 0000000..f9e3dce --- /dev/null +++ b/scripts/backfill-request-files.mjs @@ -0,0 +1,165 @@ +// One-time script: copies existing submission files from Supabase Storage to FileBrowser +// Run: node scripts/backfill-request-files.mjs +import { readFileSync } from 'fs'; +import { createClient } from '@supabase/supabase-js'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const envFile = resolve(__dir, '../.env.backfill'); + +// Parse env file +const env = {}; +readFileSync(envFile, 'utf8').split('\n').forEach(line => { + const m = line.match(/^([^#=]+)=(.*)$/); + if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, ''); +}); + +const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL; +const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY; +const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, ''); +const FB_TOKEN = env.FILEBROWSER_TOKEN; +const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'; +const FB_SOURCE = 'files'; + +if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); } +if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); } + +const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, +}); + +function normalizePath(path) { + const parts = String(path || '/').trim().split('/').filter(Boolean); + const clean = []; + for (const p of parts) { + if (p === '.') continue; + if (p === '..') throw new Error('path traversal'); + clean.push(p); + } + return `/${clean.join('/')}`; +} + +function joinPath(...parts) { return normalizePath(parts.join('/')); } + +function safeName(v) { + return String(v || '').trim() + .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') + .replace(/\s+/g, ' ') + .replace(/^-+|-+$/g, ''); +} + +async function fbFetch(method, endpoint, { params = {}, headers = {}, body } = {}) { + const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); + const res = await fetch(`${FB_URL}${endpoint}?${qs}`, { + method, + headers: { Authorization: `Bearer ${FB_TOKEN}`, ...headers }, + body, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`FB ${res.status}: ${text.slice(0, 200)}`); + } + return res; +} + +async function mkdir(path) { + await fbFetch('POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {}); +} + +async function main() { + const { data: rows, error } = await admin + .from('submission_files') + .select(` + id, name, storage_path, + submission:submissions!inner( + id, version_number, + task:tasks!inner( + id, title, + project:projects!inner( + id, name, + company:companies!inner(name) + ) + ) + ) + `); + + if (error) { console.error('Query failed:', error.message); process.exit(1); } + + // Group by task + version + const groups = new Map(); + for (const row of rows || []) { + const sub = row.submission; + const task = sub?.task; + const project = task?.project; + const company = project?.company; + if (!task || !project || !company) continue; + + const key = `${task.id}::${sub.version_number}`; + if (!groups.has(key)) { + groups.set(key, { + companyName: company.name, + projectName: project.name, + taskTitle: task.title, + versionNumber: sub.version_number, + files: [], + }); + } + groups.get(key).files.push({ name: row.name, storage_path: row.storage_path }); + } + + console.log(`Found ${groups.size} task/revision groups, ${rows.length} total files`); + + let processed = 0, skipped = 0, errors = 0; + + for (const [key, group] of groups) { + if (group.files.length === 0) { skipped++; continue; } + + const revFolder = `R${String(group.versionNumber).padStart(2, '0')}`; + const companyDir = joinPath(CLIENT_ROOT, safeName(group.companyName)); + const projectDir = joinPath(companyDir, 'Projects', safeName(group.projectName)); + const taskDir = joinPath(projectDir, safeName(group.taskTitle)); + const requestInfoDir = joinPath(taskDir, 'Request Info'); + const revDir = joinPath(requestInfoDir, revFolder); + + console.log(`\n[${group.companyName}] ${group.projectName} / ${group.taskTitle} / ${revFolder} (${group.files.length} files)`); + + await mkdir(companyDir); + await mkdir(joinPath(companyDir, 'Projects')); + await mkdir(projectDir); + await mkdir(taskDir); + await mkdir(requestInfoDir); + await mkdir(revDir); + + for (const file of group.files) { + try { + const { data: signed, error: signErr } = await admin.storage + .from('submissions') + .createSignedUrl(file.storage_path, 120); + + if (signErr || !signed?.signedUrl) throw new Error(`signed url failed: ${signErr?.message}`); + + const fileRes = await fetch(signed.signedUrl); + if (!fileRes.ok) throw new Error(`download failed: ${fileRes.status}`); + const fileBuffer = await fileRes.arrayBuffer(); + + const fbFilePath = joinPath(revDir, file.name); + await fbFetch('POST', '/api/resources', { + params: { path: fbFilePath, override: 'true' }, + headers: { 'Content-Type': 'application/octet-stream' }, + body: fileBuffer, + }); + + console.log(` ✓ ${file.name}`); + processed++; + } catch (err) { + console.error(` ✗ ${file.name}: ${err.message}`); + errors++; + } + } + } + + console.log(`\nDone. Processed: ${processed}, Skipped: ${skipped}, Errors: ${errors}`); +} + +main().catch(err => { console.error(err); process.exit(1); }); diff --git a/scripts/cleanup-old-project-files-folders.mjs b/scripts/cleanup-old-project-files-folders.mjs new file mode 100644 index 0000000..67053a4 --- /dev/null +++ b/scripts/cleanup-old-project-files-folders.mjs @@ -0,0 +1,103 @@ +// One-time: removes leftover '.Project Files' and 'Project Files' folders +// that may still exist alongside the renamed '00 Project Files' +// Run: node scripts/cleanup-old-project-files-folders.mjs +import { readFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { createClient } from '@supabase/supabase-js'; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const envFile = resolve(__dir, '../.env.backfill'); + +const env = {}; +readFileSync(envFile, 'utf8').split('\n').forEach(line => { + const m = line.match(/^([^#=]+)=(.*)$/); + if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, ''); +}); + +const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL; +const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY; +const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, ''); +const FB_TOKEN = env.FILEBROWSER_TOKEN; +const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'; +const FB_SOURCE = 'files'; + +if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); } +if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); } + +const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } }); + +function safeName(v) { + return String(v || '').trim() + .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') + .replace(/\s+/g, ' ') + .replace(/^-+|-+$/g, ''); +} + +function joinPath(...parts) { + const clean = parts.join('/').split('/').filter(p => p && p !== '.'); + return `/${clean.join('/')}`; +} + +async function fbList(path) { + const qs = new URLSearchParams({ source: FB_SOURCE, path }).toString(); + const res = await fetch(`${FB_URL}/api/resources?${qs}`, { + headers: { Authorization: `Bearer ${FB_TOKEN}` }, + }); + if (!res.ok) return null; + const data = await res.json().catch(() => null); + return data?.folders || []; +} + +async function fbDelete(path) { + const qs = new URLSearchParams({ source: FB_SOURCE, path }).toString(); + const res = await fetch(`${FB_URL}/api/resources?${qs}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${FB_TOKEN}` }, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`${res.status}: ${text.slice(0, 100)}`); + } +} + +const OLD_NAMES = ['.Project Files', 'Project Files']; + +async function main() { + const { data: projects, error } = await admin + .from('projects') + .select('id, name, company:companies(name)'); + + if (error) { console.error('Query failed:', error.message); process.exit(1); } + console.log(`Checking ${projects.length} projects...\n`); + + let cleaned = 0; + + for (const p of projects) { + const company = safeName(p.company?.name || ''); + const project = safeName(p.name || ''); + if (!company || !project) continue; + + const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project); + const entries = await fbList(projectDir); + if (!entries) { console.log(` ? could not list: ${company} / ${project}`); continue; } + + const names = entries.map(e => e.name); + for (const oldName of OLD_NAMES) { + if (names.includes(oldName)) { + const oldPath = joinPath(projectDir, oldName); + try { + await fbDelete(oldPath); + console.log(` ✓ deleted "${oldName}": ${company} / ${project}`); + cleaned++; + } catch (err) { + console.error(` ✗ failed to delete "${oldName}" in ${company} / ${project}: ${err.message}`); + } + } + } + } + + console.log(`\nDone. Cleaned ${cleaned} old folder(s).`); +} + +main().catch(err => { console.error(err); process.exit(1); }); diff --git a/scripts/rename-project-files-folder.mjs b/scripts/rename-project-files-folder.mjs new file mode 100644 index 0000000..655cad8 --- /dev/null +++ b/scripts/rename-project-files-folder.mjs @@ -0,0 +1,104 @@ +// One-time: renames .00 Project Files → 00 Project Files for all existing project folders +// Run: node scripts/rename-project-files-folder.mjs +import { readFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { createClient } from '@supabase/supabase-js'; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const envFile = resolve(__dir, '../.env.backfill'); + +const env = {}; +readFileSync(envFile, 'utf8').split('\n').forEach(line => { + const m = line.match(/^([^#=]+)=(.*)$/); + if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, ''); +}); + +const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL; +const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY; +const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, ''); +const FB_TOKEN = env.FILEBROWSER_TOKEN; +const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'; +const FB_SOURCE = 'files'; + +if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); } +if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); } + +const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } }); + +function safeName(v) { + return String(v || '').trim() + .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') + .replace(/\s+/g, ' ') + .replace(/^-+|-+$/g, ''); +} + +function joinPath(...parts) { + const clean = parts.join('/').split('/').filter(p => p && p !== '.'); + return `/${clean.join('/')}`; +} + +async function fbRename(fromPath, toPath) { + const qs = new URLSearchParams({ source: FB_SOURCE }).toString(); + const res = await fetch(`${FB_URL}/api/resources?${qs}`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${FB_TOKEN}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'rename', + items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }], + overwrite: false, + }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`${res.status}: ${text.slice(0, 120)}`); + } +} + +async function fbMkdir(path) { + const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString(); + const res = await fetch(`${FB_URL}/api/resources?${qs}`, { + method: 'POST', + headers: { Authorization: `Bearer ${FB_TOKEN}` }, + }); + if (!res.ok) { + const text = await res.text(); + if (!text.includes('already') && !text.includes('exist')) throw new Error(`mkdir ${path}: ${res.status}`); + } +} + +async function main() { + const { data: projects, error } = await admin + .from('projects') + .select('id, name, company:companies(name)'); + + if (error) { console.error('Query failed:', error.message); process.exit(1); } + console.log(`Found ${projects.length} projects`); + + for (const p of projects) { + const company = safeName(p.company?.name || ''); + const project = safeName(p.name || ''); + if (!company || !project) continue; + + const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project); + const oldPath = joinPath(projectDir, 'Project Files'); + const newPath = joinPath(projectDir, '00 Project Files'); + + try { + await fbRename(oldPath, newPath); + console.log(` ✓ renamed: ${company} / ${project}`); + } catch (err) { + // If rename fails (source doesn't exist), ensure new folder exists + try { + await fbMkdir(newPath); + console.log(` + created: ${company} / ${project} (no dot folder found)`); + } catch (e2) { + console.log(` ~ exists: ${company} / ${project}`); + } + } + } + + console.log('\nDone.'); +} + +main().catch(err => { console.error(err); process.exit(1); }); diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index ea24724..b12981f 100755 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -7,8 +7,8 @@ function TeamNav({ onNav }) { const primaryLinks = [ { to: '/dashboard', label: 'Dashboard' }, - { to: '/requests', label: 'Requests' }, { to: '/projects', label: 'Projects' }, + { to: '/requests', label: 'Requests' }, { to: '/file-sharing', label: 'File Sharing' }, ]; diff --git a/src/components/RequestForm.jsx b/src/components/RequestForm.jsx new file mode 100644 index 0000000..6a79e34 --- /dev/null +++ b/src/components/RequestForm.jsx @@ -0,0 +1,218 @@ +import { useState, useEffect } from 'react'; +import { supabase } from '../lib/supabase'; +import { serviceTypes } from '../data/mockData'; +import FileAttachment from './FileAttachment'; +import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates'; + +const defaultDeadline = () => addDaysToDateOnly(getTodayDateOnlyEST(), 3); +const emptyForm = (companyId = '') => ({ + companyId, + project: '', + serviceType: '', + title: '', + deadline: defaultDeadline(), + description: '', + isHot: false, + requestedBy: '', +}); + +// Props: +// companies [{id, name}] — all selectable companies +// currentUser {id, name} — logged-in team member (added to requester list as "You") +// showRequester bool — true for team (submitting on behalf of client) +// onSubmit async (formData, files, existingProjects) => void — throw to show error +// onCancel () => void — optional; shows Cancel button when provided +// saving bool +// error string +// submitLabel string +// initialCompanyId string — pre-selected company (client with 1 company) +export default function RequestForm({ + companies = [], + currentUser = null, + showRequester = false, + onSubmit, + onCancel = null, + saving = false, + error = '', + submitLabel = 'Submit Request', + initialCompanyId = '', +}) { + const [form, setForm] = useState(() => emptyForm(initialCompanyId)); + const [files, setFiles] = useState([]); + const [existingProjects, setExistingProjects] = useState([]); + const [customProjects, setCustomProjects] = useState([]); + const [isTypingProject, setIsTypingProject] = useState(false); + const [newProjectName, setNewProjectName] = useState(''); + const [companyUsers, setCompanyUsers] = useState([]); + + const companyId = form.companyId || initialCompanyId; + + useEffect(() => { + if (!companyId) { setExistingProjects([]); setCompanyUsers([]); return; } + Promise.all([ + supabase.from('projects').select('id, name').eq('company_id', companyId).order('name'), + showRequester + ? Promise.all([ + supabase.from('profiles').select('id, name, email').eq('company_id', companyId).eq('role', 'client'), + supabase.from('company_members').select('profile:profiles(id, name, email, role)').eq('company_id', companyId), + ]) + : Promise.resolve(null), + ]).then(([projectsRes, usersData]) => { + setExistingProjects(projectsRes.data || []); + if (usersData) { + const [directRes, membersRes] = usersData; + const direct = (directRes.data || []); + const fromMembers = (membersRes.data || []).map(m => m.profile).filter(p => p?.role === 'client'); + const seen = new Set(); + const merged = [...direct, ...fromMembers].filter(u => { + if (!u?.id || seen.has(u.id)) return false; + seen.add(u.id); + return true; + }).sort((a, b) => (a.name || '').localeCompare(b.name || '')); + setCompanyUsers(merged); + } + }); + setForm(f => ({ ...f, project: '', requestedBy: '' })); + setCustomProjects([]); + setIsTypingProject(false); + setNewProjectName(''); + }, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps + + const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value })); + + const allProjectNames = [ + ...existingProjects.map(p => p.name), + ...customProjects.filter(name => !existingProjects.some(p => p.name === name)), + ]; + + const handleProjectSelect = (e) => { + if (e.target.value === '__new__') { + setIsTypingProject(true); + setForm(f => ({ ...f, project: '' })); + } else { + setForm(f => ({ ...f, project: e.target.value })); + } + }; + + const handleAddProject = () => { + const name = newProjectName.trim(); + if (!name) return; + if (!customProjects.includes(name) && !existingProjects.some(p => p.name === name)) { + setCustomProjects(prev => [...prev, name]); + } + setForm(f => ({ ...f, project: name })); + setIsTypingProject(false); + setNewProjectName(''); + }; + + const requesterOptions = showRequester ? [ + ...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []), + ...companyUsers.filter(u => u.id !== currentUser?.id), + ] : []; + + const showCompanySelect = companies.length > 1 || showRequester; + + const handleSubmit = (e) => { + e.preventDefault(); + const requester = requesterOptions.find(u => u.id === form.requestedBy); + const requestedByName = requester ? requester.name.replace(' (You)', '') : ''; + onSubmit({ ...form, companyId, requestedByName }, files, existingProjects); + }; + + return ( +
+ ); +} diff --git a/src/components/StatusBadge.jsx b/src/components/StatusBadge.jsx index ec85e0f..3484410 100755 --- a/src/components/StatusBadge.jsx +++ b/src/components/StatusBadge.jsx @@ -4,8 +4,10 @@ const labels = { not_started: 'Not Started', in_progress: 'In Progress', on_hold: 'On Hold', - client_review: 'Client Review', - client_approved: 'Client Approved', + client_review: 'In Review', + client_approved: 'Approved', + invoiced: 'Invoiced', + paid: 'Paid', active: 'Active', completed: 'Completed', superseded: 'Superseded', diff --git a/src/index.css b/src/index.css index 1fc6813..1e7de1f 100755 --- a/src/index.css +++ b/src/index.css @@ -64,6 +64,8 @@ [data-theme="light"] .badge-on_hold { background: #fffbeb; color: #d97706; border-color: #fde68a; } [data-theme="light"] .badge-client_review { background: #f5f3ff; color: #7c3aed; border-color: #ddd6fe; } [data-theme="light"] .badge-client_approved { background: #f0fdf4; color: #16a34a; border-color: #bbf7d0; } +[data-theme="light"] .badge-invoiced { background: #f5f3ff; color: #7c3aed; border-color: #ddd6fe; } +[data-theme="light"] .badge-paid { background: #ecfdf5; color: #059669; border-color: #a7f3d0; } [data-theme="light"] .badge-sent_to_client { background: #f5f3ff; color: #7c3aed; border-color: #ddd6fe; } [data-theme="light"] .badge-revision_requested { background: #fff7ed; color: #c2410c; border-color: #fed7aa; } [data-theme="light"] .badge-approved { background: #f0fdf4; color: #16a34a; border-color: #bbf7d0; } @@ -84,6 +86,8 @@ [data-theme="light"] select option { background: #fff; color: #1a1a1a; } [data-theme="light"] .assign-select option { background: #fff; color: #1a1a1a; } +html, body { height: 100%; overflow: hidden; } + body { font-family: 'Fourge', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); @@ -92,10 +96,10 @@ body { line-height: 1.5; } -#root { all: unset; display: block; } +#root { all: unset; display: block; height: 100%; } /* Layout */ -.app-layout { display: flex; min-height: 100vh; } +.app-layout { display: flex; height: 100vh; overflow: hidden; } .sidebar { width: 240px; @@ -209,9 +213,9 @@ body { .sidebar-user-name { font-size: 13px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sidebar-user-role { font-size: 11px; color: var(--sidebar-text); text-transform: capitalize; } -.main-wrapper { margin-left: 240px; flex: 1; display: flex; flex-direction: column; min-height: 100vh; } +.main-wrapper { margin-left: 240px; flex: 1; display: flex; flex-direction: column; height: 100vh; overflow: hidden; } .main-wrapper { transition: margin-left 0.2s ease; } -.main-content { flex: 1; padding: 32px; } +.main-content { flex: 1; padding: 32px; overflow-y: auto; display: flex; flex-direction: column; min-height: 0; } .app-layout.sidebar-collapsed .sidebar { width: 76px; @@ -254,8 +258,9 @@ body { /* Page header */ .page-header { - margin-bottom: 28px; display: flex; + margin-bottom: 24px; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; + background: var(--card-bg); border-radius: 8px; border: 1px solid var(--border); padding: 20px; } .page-title { font-size: 22px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.3px; } .page-subtitle { font-size: 13px; color: var(--text-secondary); margin-top: 3px; } @@ -517,6 +522,10 @@ body { border-radius: 8px; overflow: hidden; position: relative; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; } .file-browser-progress { @@ -607,10 +616,11 @@ body { } .file-list { + flex: 1; min-height: 0; position: relative; overflow-x: auto; - overflow-y: visible; + overflow-y: auto; } .file-row { @@ -858,6 +868,8 @@ body { .badge-on_hold { background: rgba(217,119,6,0.15); color: #fbbf24; border: 1px solid rgba(217,119,6,0.3); } .badge-client_review { background: rgba(124,58,237,0.15); color: #c4b5fd; border: 1px solid rgba(124,58,237,0.3); } .badge-client_approved { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); } +.badge-invoiced { background: rgba(139,92,246,0.15); color: #a78bfa; border: 1px solid rgba(139,92,246,0.3); } +.badge-paid { background: rgba(16,185,129,0.15); color: #34d399; border: 1px solid rgba(16,185,129,0.3); } .badge-sent_to_client { background: rgba(124,58,237,0.15); color: #c4b5fd; border: 1px solid rgba(124,58,237,0.3); } .badge-revision_requested { background: rgba(239,68,68,0.15); color: #f87171; border: 1px solid rgba(239,68,68,0.3); } .badge-approved { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); } @@ -869,13 +881,15 @@ body { .badge-client { background: rgba(245,165,35,0.15); color: var(--accent); border: 1px solid rgba(245,165,35,0.3); } .badge-client_revision { background: rgba(239,68,68,0.15); color: #f87171; border: 1px solid rgba(239,68,68,0.3); } .badge-fourge_error { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); } +.badge-needs_revision { background: #dc2626; color: #fff; border: 1px solid #b91c1c; padding: 3px 4px; min-width: 28px; justify-content: center; border-radius: 4px; } +[data-theme="light"] .badge-needs_revision { background: #dc2626; color: #fff; border-color: #b91c1c; } /* Table */ .table-wrapper { overflow-x: auto; border-radius: 8px; border: 1px solid var(--border); background: var(--card-bg); } table { width: 100%; border-collapse: collapse; } th { text-align: left; padding: 12px 16px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text-muted); background: var(--card-bg); border-bottom: 1px solid var(--border); } td { padding: 14px 16px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text-primary); } -tr:last-child td { border-bottom: none; } + tr:hover td { background: rgba(255,255,255,0.02); } .table-link { color: var(--accent); text-decoration: none; font-weight: 600; } .table-link:hover { text-decoration: underline; } diff --git a/src/lib/filebrowserFolders.js b/src/lib/filebrowserFolders.js index 1bf78a8..e361e4a 100644 --- a/src/lib/filebrowserFolders.js +++ b/src/lib/filebrowserFolders.js @@ -17,7 +17,6 @@ async function fbCall(method, action, body = null) { // Create /Clients/{name}/ folder. Silently fails if already exists. export async function createClientFolder(companyName) { if (!companyName) return; - // Ensure /Clients dir exists first await fbCall('POST', 'mkdir', { path: '/', name: 'Clients' }); await fbCall('POST', 'mkdir', { path: '/Clients', name: companyName }); } @@ -28,38 +27,73 @@ export async function renameClientFolder(oldName, newName) { await fbCall('POST', 'rename', { path: `/Clients/${oldName}`, name: newName }); } -// Upload files to Clients/{company}/Projects/{project}/{task}/Request Info/ in FileBrowser. +// Same safeName logic as the server (api/filebrowser.js) +function safeName(v) { + return String(v || '').trim() + .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') + .replace(/\s+/g, ' ') + .replace(/^-+|-+$/g, ''); +} + +// Upload files to the correct Request Info/{rev} folder in FileBrowser. +// companyName is required. versionNumber defaults to 0 (R00). // Best-effort — call with .catch(() => {}) so failures don't block submission. -export async function uploadFilesToRequestInfo(files, projectName, taskTitle, versionNumber = 0) { - if (!files?.length || !projectName || !taskTitle) return; +export async function uploadFilesToRequestInfo(files, companyName, projectName, taskTitle, versionNumber = 0) { + if (!files?.length || !companyName || !projectName || !taskTitle) return; + const { data: { session } } = await supabase.auth.getSession(); if (!session?.access_token) return; const authHeader = `Bearer ${session.access_token}`; - const revFolder = `R${String(versionNumber).padStart(2, '0')}`; + // Determine role + const { data: profile } = await supabase.from('profiles').select('role').eq('id', session.user.id).single(); + const role = profile?.role; - // Ensure folder hierarchy exists (mkdir is idempotent) - const segments = [ - { path: '/', name: 'Projects' }, - { path: '/Projects', name: projectName }, - { path: `/Projects/${projectName}`, name: taskTitle }, - { path: `/Projects/${projectName}/${taskTitle}`, name: 'Request Info' }, - { path: `/Projects/${projectName}/${taskTitle}/Request Info`, name: revFolder }, - ]; - for (const seg of segments) { + const co = safeName(companyName); + const proj = safeName(projectName); + const task = safeName(taskTitle); + const rev = `R${String(versionNumber).padStart(2, '0')}`; + + // Build virtual path segments for mkdir. + // Clients: virtual root is per-company; company folder already exists — start one level in. + // Team/external: full path under /Clients/{co}/... + let mkdirs; + let revPath; + + if (role === 'client') { + revPath = `/${co}/Projects/${proj}/${task}/Request Info/${rev}`; + mkdirs = [ + { path: `/${co}`, name: 'Projects' }, + { path: `/${co}/Projects`, name: proj }, + { path: `/${co}/Projects/${proj}`, name: task }, + { path: `/${co}/Projects/${proj}/${task}`, name: 'Request Info' }, + { path: `/${co}/Projects/${proj}/${task}/Request Info`, name: rev }, + ]; + } else { + revPath = `/Clients/${co}/Projects/${proj}/${task}/Request Info/${rev}`; + mkdirs = [ + { path: '/Clients', name: co }, + { path: `/Clients/${co}`, name: 'Projects' }, + { path: `/Clients/${co}/Projects`, name: proj }, + { path: `/Clients/${co}/Projects/${proj}`, name: task }, + { path: `/Clients/${co}/Projects/${proj}/${task}`, name: 'Request Info' }, + { path: `/Clients/${co}/Projects/${proj}/${task}/Request Info`, name: rev }, + ]; + } + + for (const seg of mkdirs) { await fetch('/api/filebrowser?action=mkdir', { method: 'POST', headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: seg.path, name: seg.name }), + body: JSON.stringify(seg), }).catch(() => {}); } - // Get upload token for R## folder - const virtualPath = `/Projects/${projectName}/${taskTitle}/Request Info/${revFolder}`; + // Get upload token for the revision folder const tokenRes = await fetch('/api/filebrowser?action=upload-token', { method: 'POST', headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: virtualPath }), + body: JSON.stringify({ path: revPath }), }).catch(() => null); if (!tokenRes?.ok) return; @@ -75,7 +109,7 @@ export async function uploadFilesToRequestInfo(files, projectName, taskTitle, ve } } -// Create missing /Clients/{name}/ folders for all companies. Run on create/rename only. +// Create missing /Clients/{name}/ folders for all companies. export async function backfillClientFolders() { const { data } = await supabase.from('companies').select('name'); if (!data?.length) return; diff --git a/src/pages/CompaniesPage.jsx b/src/pages/CompaniesPage.jsx index e7c9fd9..8d37302 100644 --- a/src/pages/CompaniesPage.jsx +++ b/src/pages/CompaniesPage.jsx @@ -30,6 +30,7 @@ function TeamCompanies() { const [editUserVal, setEditUserVal] = useState(''); const [deletingUserId, setDeletingUserId] = useState(null); const [filterCompany, setFilterCompany] = useState(''); + const [userSubTab, setUserSubTab] = useState('client'); const { sortKey: coSortKey, sortDir: coSortDir, toggle: coToggle, sort: coSort } = useSortable('name'); const { sortKey: clSortKey, sortDir: clSortDir, toggle: clToggle, sort: clSort } = useSortable('name'); const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('name'); @@ -134,7 +135,7 @@ function TeamCompanies() { return (