diff --git a/api/_lib/filebrowser.js b/api/_lib/filebrowser.js new file mode 100644 index 0000000..a2c425b --- /dev/null +++ b/api/_lib/filebrowser.js @@ -0,0 +1,143 @@ +import { createClient } from '@supabase/supabase-js'; + +export const FB_SOURCE = 'srv'; +export const PROJECT_DEFAULT_SUBFOLDERS = ['00 Project Files']; +export const REQUEST_DEFAULT_SUBFOLDERS = ['Old Books', 'Working Files', 'Survey']; + +export 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('/')}`; +} + +export function joinPath(...parts) { + return normalizePath(parts.join('/')); +} + +export function safeName(value, fallback = '') { + const cleaned = String(value || '') + .trim() + .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') + .replace(/\s+/g, ' ') + .replace(/^-+|-+$/g, ''); + return cleaned || fallback; +} + +export function getConfig() { + const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, ''); + return { + url, + token: process.env.FILEBROWSER_TOKEN || '', + clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'), + subsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/team'), + configured: Boolean(url), + }; +} + +export function getToken(config) { + const token = String(config.token || '').trim(); + if (!token) throw new Error('FILEBROWSER_TOKEN not configured'); + return token; +} + +export async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { + const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); + const url = `${config.url}${endpoint}?${qs}`; + const token = getToken(config); + const response = await fetch(url, { + method, + headers: { Authorization: `Bearer ${token}`, ...headers }, + body, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + const error = new Error(text || `FileBrowser ${response.status}`); + error.status = response.status; + throw error; + } + + const text = await response.text(); + try { + return text ? JSON.parse(text) : null; + } catch { + return text; + } +} + +function isAlreadyExistsError(error) { + const message = String(error?.message || '').toLowerCase(); + return error?.status === 409 || message.includes('exist') || message.includes('conflict'); +} + +// Creates a single directory whose parent is already known to exist. +// Much cheaper than ensureDirectory, which re-walks the path from the root. +export async function createDirectory(config, fullPath) { + try { + await fbFetch(config, 'POST', '/api/resources', { + params: { path: normalizePath(fullPath), isDir: 'true' }, + }); + } catch (error) { + if (!isAlreadyExistsError(error)) throw error; + } +} + +// Runs tasks with bounded concurrency, preserving input order in the result. +export async function pooled(items, limit, worker) { + const results = new Array(items.length); + let cursor = 0; + const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (cursor < items.length) { + const index = cursor; + cursor += 1; + results[index] = await worker(items[index], index); + } + }); + await Promise.all(runners); + return results; +} + +export async function ensureDirectory(config, fullPath) { + const parts = normalizePath(fullPath).split('/').filter(Boolean); + let current = '/'; + for (const part of parts) { + current = joinPath(current, part); + try { + await fbFetch(config, 'POST', '/api/resources', { + params: { path: current, isDir: 'true' }, + }); + } catch (error) { + if (!isAlreadyExistsError(error)) throw error; + } + } +} + +// Returns directory names at `path`, or null when the path itself does not exist. +export async function listDirectories(config, path) { + let payload; + try { + payload = await fbFetch(config, 'GET', '/api/resources', { params: { path: normalizePath(path) } }); + } catch (error) { + if (error?.status === 404) return null; + throw error; + } + // This FileBrowser returns child directories under `folders`; older builds use a mixed `items` array. + if (Array.isArray(payload?.folders)) return payload.folders.map(folder => folder.name); + const items = payload?.items || []; + return items.filter(item => item.type === 'directory' || item.isDir).map(item => item.name); +} + +export function createAdminClient() { + const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!supabaseUrl || !serviceKey) throw new Error('Supabase admin env not configured'); + return createClient(supabaseUrl, serviceKey, { + auth: { persistSession: false, autoRefreshToken: false }, + }); +} diff --git a/api/folder-reconcile.js b/api/folder-reconcile.js new file mode 100644 index 0000000..1d89057 --- /dev/null +++ b/api/folder-reconcile.js @@ -0,0 +1,225 @@ +import { + PROJECT_DEFAULT_SUBFOLDERS, + REQUEST_DEFAULT_SUBFOLDERS, + createAdminClient, + createDirectory, + ensureDirectory, + getConfig, + joinPath, + listDirectories, + pooled, + safeName, +} from './_lib/filebrowser.js'; + +export const config = { maxDuration: 300 }; + +// Leave headroom under maxDuration so a partial run still returns its report. +const TIME_BUDGET_MS = 235000; +const CONCURRENCY = 8; + +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 isAuthorized(req) { + const expected = String(process.env.FOLDER_RECONCILE_SECRET || '').trim(); + if (!expected) return false; + const header = req.headers['x-reconcile-secret']; + const provided = String(Array.isArray(header) ? header[0] : header || '').trim(); + if (provided.length !== expected.length) return false; + let diff = 0; + for (let i = 0; i < expected.length; i += 1) diff |= expected.charCodeAt(i) ^ provided.charCodeAt(i); + return diff === 0; +} + +export default async function handler(req, res) { + if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' }); + if (!isAuthorized(req)) return json(res, 401, { error: 'Unauthorized' }); + + const fbConfig = getConfig(); + if (!fbConfig.configured) { + return json(res, 200, { ok: false, configured: false, warning: 'FileBrowser is not configured.' }); + } + + const startedAt = Date.now(); + const outOfTime = () => Date.now() - startedAt > TIME_BUDGET_MS; + + const created = []; + const orphans = []; + const errors = []; + const record = (type, path) => created.push({ type, path }); + const fail = (scope, error) => errors.push({ scope, message: String(error?.message || error).slice(0, 300) }); + + try { + const admin = createAdminClient(); + const [companiesResult, projectsResult, tasksResult, subsResult] = await Promise.all([ + admin.from('companies').select('id, name'), + admin.from('projects').select('id, name, company_id'), + admin.from('tasks').select('id, title, project_id'), + admin.from('profiles').select('id, name').eq('role', 'external'), + ]); + for (const result of [companiesResult, projectsResult, tasksResult, subsResult]) { + if (result.error) throw result.error; + } + + const companies = companiesResult.data || []; + const projects = projectsResult.data || []; + const tasks = tasksResult.data || []; + const subs = subsResult.data || []; + + const rootDirs = await listDirectories(fbConfig, fbConfig.clientRoot); + if (rootDirs === null) { + await ensureDirectory(fbConfig, fbConfig.clientRoot); + record('client-root', fbConfig.clientRoot); + } + const rootSet = new Set(rootDirs || []); + + let complete = true; + + for (const company of companies) { + if (outOfTime()) { complete = false; break; } + + const companyName = safeName(company.name, company.id); + const companyPath = joinPath(fbConfig.clientRoot, companyName); + const projectsPath = joinPath(companyPath, 'Projects'); + const companyProjects = projects.filter(project => project.company_id === company.id); + + try { + if (!rootSet.has(companyName)) { + await ensureDirectory(fbConfig, companyPath); + record('company', companyPath); + } + + let projectDirs = await listDirectories(fbConfig, projectsPath); + if (projectDirs === null) { + if (companyProjects.length === 0) continue; + await ensureDirectory(fbConfig, projectsPath); + record('projects-root', projectsPath); + projectDirs = []; + } + const projectDirSet = new Set(projectDirs); + + // Phase 1: create project folders that do not exist yet, with their defaults. + const missingProjects = companyProjects.filter(p => !projectDirSet.has(safeName(p.name, p.id))); + await pooled(missingProjects, CONCURRENCY, async (project) => { + const projectPath = joinPath(projectsPath, safeName(project.name, project.id)); + try { + await createDirectory(fbConfig, projectPath); + record('project', projectPath); + for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) { + await createDirectory(fbConfig, joinPath(projectPath, folderName)); + } + } catch (error) { + fail(projectPath, error); + } + }); + + // Phase 2: list every project folder once to learn which task folders exist. + const existingProjects = companyProjects.filter(p => projectDirSet.has(safeName(p.name, p.id))); + const listings = await pooled(existingProjects, CONCURRENCY, async (project) => { + const projectPath = joinPath(projectsPath, safeName(project.name, project.id)); + try { + return await listDirectories(fbConfig, projectPath); + } catch (error) { + fail(projectPath, error); + return null; + } + }); + + // Phase 3: create the missing leaves, flattened so the pool stays saturated. + const work = []; + existingProjects.forEach((project, index) => { + const childDirs = listings[index]; + if (childDirs === null) return; + const childSet = new Set(childDirs); + const projectPath = joinPath(projectsPath, safeName(project.name, project.id)); + for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) { + if (!childSet.has(folderName)) work.push({ type: 'project-subfolder', path: joinPath(projectPath, folderName), children: [] }); + } + for (const task of tasks.filter(t => t.project_id === project.id)) { + const taskName = safeName(task.title, task.id); + if (childSet.has(taskName)) continue; + work.push({ type: 'task', path: joinPath(projectPath, taskName), children: REQUEST_DEFAULT_SUBFOLDERS }); + } + }); + + missingProjects.forEach((project) => { + const projectPath = joinPath(projectsPath, safeName(project.name, project.id)); + for (const task of tasks.filter(t => t.project_id === project.id)) { + work.push({ type: 'task', path: joinPath(projectPath, safeName(task.title, task.id)), children: REQUEST_DEFAULT_SUBFOLDERS }); + } + }); + + await pooled(work, CONCURRENCY, async (item) => { + if (outOfTime()) { complete = false; return; } + try { + await createDirectory(fbConfig, item.path); + record(item.type, item.path); + for (const folderName of item.children) { + await createDirectory(fbConfig, joinPath(item.path, folderName)); + } + } catch (error) { + fail(item.path, error); + } + }); + + // Reported only — never deleted, since these may hold real work. + const expected = new Set(companyProjects.map(p => safeName(p.name, p.id))); + for (const dirName of projectDirSet) { + if (!expected.has(dirName)) orphans.push(joinPath(projectsPath, dirName)); + } + } catch (error) { + fail(companyPath, error); + } + } + + if (subs.length > 0 && !outOfTime()) { + try { + const subDirs = await listDirectories(fbConfig, fbConfig.subsRoot); + if (subDirs === null) { + await ensureDirectory(fbConfig, fbConfig.subsRoot); + record('subs-root', fbConfig.subsRoot); + } + const subDirSet = new Set(subDirs || []); + const missingSubs = subs.filter(profile => !subDirSet.has(safeName(profile.name, profile.id))); + await pooled(missingSubs, CONCURRENCY, async (profile) => { + const subPath = joinPath(fbConfig.subsRoot, safeName(profile.name, profile.id)); + try { + await createDirectory(fbConfig, subPath); + record('subcontractor', subPath); + } catch (error) { + fail(subPath, error); + } + }); + } catch (error) { + fail(fbConfig.subsRoot, error); + } + } + + return json(res, 200, { + ok: errors.length === 0, + complete, + elapsedMs: Date.now() - startedAt, + configured: true, + createdCount: created.length, + created, + orphanCount: orphans.length, + orphans, + errorCount: errors.length, + errors: errors.slice(0, 50), + }); + } catch (error) { + return json(res, error?.status || 500, { + ok: false, + complete: false, + elapsedMs: Date.now() - startedAt, + error: String(error?.message || 'Folder reconcile failed.'), + createdCount: created.length, + created, + errorCount: errors.length, + errors: errors.slice(0, 50), + }); + } +} diff --git a/supabase/migrations/20260721120000_schedule_folder_reconcile.sql b/supabase/migrations/20260721120000_schedule_folder_reconcile.sql new file mode 100644 index 0000000..2a122b0 --- /dev/null +++ b/supabase/migrations/20260721120000_schedule_folder_reconcile.sql @@ -0,0 +1,27 @@ +-- Hourly reconcile of FileBrowser folders against companies/projects/tasks/subcontractors. +-- Vercel Hobby crons only run once per day, so the schedule lives in Postgres instead. +-- +-- One-time manual step before this works (run once, do NOT commit the secret): +-- alter database postgres set app.folder_reconcile_secret = ''; + +create extension if not exists pg_cron with schema extensions; +create extension if not exists pg_net with schema extensions; + +select cron.unschedule('folder-reconcile-hourly') +where exists (select 1 from cron.job where jobname = 'folder-reconcile-hourly'); + +select cron.schedule( + 'folder-reconcile-hourly', + '7 * * * *', + $$ + select net.http_post( + url := 'https://portal.fourgebranding.com/api/folder-reconcile', + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'x-reconcile-secret', current_setting('app.folder_reconcile_secret', true) + ), + body := '{}'::jsonb, + timeout_milliseconds := 120000 + ); + $$ +);