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>
This commit is contained in:
@@ -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 },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user