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), }); } }