fix: migrate Old Book → Old Books via Vercel API endpoint

- api/migrate-old-books.js: one-time migration endpoint with proper FBQ
  auth (token + admin login fallback); merges contents if both exist
- fbq-backfill: diagnostic logging on listDir (401 confirms FBQ_TOKEN
  has no read permission in edge fn context)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-05-31 11:17:47 -04:00
parent 94d9c3904b
commit ab7a6b5a57
2 changed files with 165 additions and 22 deletions
+139
View File
@@ -0,0 +1,139 @@
import { createClient } from '@supabase/supabase-js';
const FB_SOURCE = 'srv';
function normalizePath(path) {
const parts = String(path || '/').trim().split('/').filter(p => p && p !== '.' && p !== '..');
return `/${parts.join('/')}`;
}
function joinPath(...parts) { return normalizePath(parts.join('/')); }
function safeName(v) {
return String(v || '').trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
function getConfig() {
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
return {
url,
token: process.env.FILEBROWSER_TOKEN || '',
adminUser: process.env.FILEBROWSER_ADMIN_USER || '',
adminPass: process.env.FILEBROWSER_ADMIN_PASS || '',
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/Clients'),
configured: Boolean(url),
};
}
let _cachedToken = '';
let _cachedTokenTs = 0;
async function getToken(config) {
if (config.token) return config.token;
const now = Date.now();
if (_cachedToken && now - _cachedTokenTs < 25 * 60 * 1000) return _cachedToken;
const res = await fetch(`${config.url}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: config.adminUser, password: config.adminPass }),
});
const data = await res.json().catch(() => ({}));
const tok = data?.token || data?.access_token || '';
if (tok) { _cachedToken = tok; _cachedTokenTs = now; }
return tok;
}
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
const token = await getToken(config);
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
method, headers: { Authorization: `Bearer ${token}`, ...headers }, body,
});
if (res.status === 401) {
_cachedToken = '';
const fresh = await getToken(config);
return fetch(`${config.url}${endpoint}?${qs}`, {
method, headers: { Authorization: `Bearer ${fresh}`, ...headers }, body,
});
}
return res;
}
async function listDir(config, path) {
const res = await fbFetch(config, 'GET', '/api/resources', { params: { path } });
if (!res.ok) return null;
const data = await res.json().catch(() => null);
const entries = Array.isArray(data?.entries) ? data.entries
: [...(data?.folders ?? []), ...(data?.files ?? [])];
return entries;
}
async function renameDir(config, fromPath, toPath) {
const res = await fbFetch(config, 'PATCH', '/api/resources', {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'rename', items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }], overwrite: true }),
});
return res.ok;
}
async function deleteDir(config, path) {
const res = await fbFetch(config, 'DELETE', '/api/resources', { params: { path } });
return res.ok;
}
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).json({ error: 'POST only' });
const authHeader = req.headers.authorization || '';
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
const supabaseKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
const sb = createClient(supabaseUrl, supabaseKey, {
auth: { persistSession: false },
global: { headers: { Authorization: authHeader } },
});
const { data: { user } } = await sb.auth.getUser();
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const { data: profile } = await sb.from('profiles').select('role').eq('id', user.id).single();
if (profile?.role !== 'team') return res.status(403).json({ error: 'Team only' });
const config = getConfig();
if (!config.configured) return res.status(500).json({ error: 'FileBrowser not configured' });
const { data: tasks, error } = await sb
.from('tasks')
.select('title, projects!project_id(name, companies!company_id(name))');
if (error) return res.status(500).json({ error: error.message });
const log = [];
let migrated = 0, skipped = 0, failed = 0;
for (const task of tasks || []) {
const co = safeName(task.projects?.companies?.name || '');
const pr = safeName(task.projects?.name || '');
const tk = safeName(task.title || '');
if (!co || !pr || !tk) { skipped++; continue; }
const base = joinPath(config.clientRoot, co, 'Projects', pr, tk);
const src = `${base}/Old Book`;
const dst = `${base}/Old Books`;
const srcItems = await listDir(config, src);
if (srcItems === null) { skipped++; continue; }
if (srcItems.length === 0) {
const ok = await deleteDir(config, src);
ok ? (migrated++, log.push(`deleted empty: ${src}`)) : (failed++, log.push(`delete-fail: ${src}`));
} else {
for (const item of srcItems) {
await renameDir(config, `${src}/${item.name}`, `${dst}/${item.name}`);
}
const ok = await deleteDir(config, src);
ok ? (migrated++, log.push(`merged+deleted: ${src}`)) : (failed++, log.push(`merge-fail: ${src}`));
}
}
return res.status(200).json({ migrated, skipped, failed, log });
}
+26 -22
View File
@@ -32,42 +32,46 @@ function mime(name: string) {
return m[e] ?? 'application/octet-stream'; return m[e] ?? 'application/octet-stream';
} }
async function listDir(path: string): Promise<{ name: string; type: string }[] | null> { async function listDir(path: string, log?: string[]): Promise<{ name: string }[] | null> {
const r = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(path)}`, { headers: fbqH }); const r = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(path)}`, { headers: fbqH });
if (log) log.push(`listDir ${path}${r.status}`);
if (!r.ok) return null; if (!r.ok) return null;
const data = await r.json().catch(() => null); const data = await r.json().catch(() => null);
return [...(data?.folders ?? []), ...(data?.files ?? [])]; const entries = Array.isArray(data?.entries) ? data.entries
: [...(data?.folders ?? []), ...(data?.files ?? [])];
return entries;
} }
async function renameDir(fromPath: string, toPath: string) { async function fbDelete(path: string): Promise<boolean> {
await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}`, { const r = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(path)}`, { method: 'DELETE', headers: fbqH });
return r.ok;
}
async function fbRename(fromPath: string, toPath: string): Promise<boolean> {
const r = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}`, {
method: 'PATCH', method: 'PATCH',
headers: { ...fbqH, 'Content-Type': 'application/json' }, headers: { ...fbqH, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'rename', items: [{ fromSource: SOURCE, fromPath, toSource: SOURCE, toPath }], overwrite: false }), body: JSON.stringify({ action: 'rename', items: [{ fromSource: SOURCE, fromPath, toSource: SOURCE, toPath }], overwrite: true }),
}); });
return r.ok;
} }
async function deleteDir(path: string) { async function migrateOldBook(base: string, log: string[]): Promise<void> {
await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(path.endsWith('/') ? path : path + '/')}`, {
method: 'DELETE', headers: fbqH,
});
}
async function migrateOldBook(base: string) {
const src = `${base}/Old Book`; const src = `${base}/Old Book`;
const dst = `${base}/Old Books`; const dst = `${base}/Old Books`;
const srcItems = await listDir(src); const srcItems = await listDir(src, log);
if (srcItems === null) return; // Old Book doesn't exist if (srcItems === null) return;
const dstItems = await listDir(dst); if (srcItems.length === 0) {
if (dstItems === null) { // Empty — just delete it
// Old Books doesn't exist — simple rename const ok = await fbDelete(src);
await renameDir(src, dst); log.push(`${ok ? 'migrated' : 'migrate-fail'} (delete empty): ${src}`);
} else { } else {
// Both exist — move each item from Old Book into Old Books, then delete Old Book // Has contents — move each item then delete
for (const item of srcItems) { for (const item of srcItems) {
await renameDir(`${src}/${item.name}`, `${dst}/${item.name}`); await fbRename(`${src}/${item.name}`, `${dst}/${item.name}`);
} }
await deleteDir(src); const ok = await fbDelete(src);
log.push(`${ok ? 'migrated' : 'migrate-fail'} (merge+delete): ${src}${dst}`);
} }
} }
@@ -130,7 +134,7 @@ Deno.serve(async () => {
const taskIsNew = await mkdir(base); const taskIsNew = await mkdir(base);
await Promise.all([mkdir(`${base}/Request Info`), mkdir(`${base}/Old Books`)]); await Promise.all([mkdir(`${base}/Request Info`), mkdir(`${base}/Old Books`)]);
await migrateOldBook(base); if (log.filter(l => l.startsWith('listDir')).length < 3) await migrateOldBook(base, log);
if (taskIsNew) { newTasks.push({ task, base, co, pr }); created++; } if (taskIsNew) { newTasks.push({ task, base, co, pr }); created++; }
else skipped++; else skipped++;