Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26ed7790b1 | |||
| 4c78b624f8 |
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = '<same value as 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
|
||||||
|
);
|
||||||
|
$$
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user