Files
fourge-portal/api/folder-reconcile.js
Krao Hasanee 4c78b624f8 feat: add folder reconcile endpoint to backfill missing FileBrowser folders
Folder sync failed silently whenever FileBrowser was unreachable: every
call site swallows the error with console.error, so projects and tasks
were created in the portal with no matching folder on the drive.

Add api/folder-reconcile.js, a secret-protected endpoint that walks
companies/projects/tasks/subcontractors and creates only the folders that
are missing. It never deletes or renames. Folders on disk with no portal
record are reported as orphans for manual review, since they predate the
portal and hold real client work.

Extract shared FileBrowser helpers into api/_lib/filebrowser.js. Note that
this build returns directory listings under a `folders` key rather than
`items`; reading the wrong key yields an empty list and makes the whole
tree look missing, so listDirectories handles both shapes.

Reconcile lists before writing and creates leaves directly rather than
re-walking each path from the root, which keeps a full run at a few
seconds instead of timing out at the 300s function limit.

Schedule hourly via pg_cron: Vercel Hobby caps crons at once per day.
Requires app.folder_reconcile_secret to be set on the database separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 14:18:53 -04:00

226 lines
8.5 KiB
JavaScript

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