4c78b624f8
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>
226 lines
8.5 KiB
JavaScript
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),
|
|
});
|
|
}
|
|
}
|