Compare commits
2 Commits
2cfbbec9dc
...
main
| 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