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:
@@ -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 });
|
||||
}
|
||||
@@ -32,42 +32,46 @@ function mime(name: string) {
|
||||
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 });
|
||||
if (log) log.push(`listDir ${path} → ${r.status}`);
|
||||
if (!r.ok) return 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) {
|
||||
await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}`, {
|
||||
async function fbDelete(path: string): Promise<boolean> {
|
||||
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',
|
||||
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) {
|
||||
await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(path.endsWith('/') ? path : path + '/')}`, {
|
||||
method: 'DELETE', headers: fbqH,
|
||||
});
|
||||
}
|
||||
|
||||
async function migrateOldBook(base: string) {
|
||||
async function migrateOldBook(base: string, log: string[]): Promise<void> {
|
||||
const src = `${base}/Old Book`;
|
||||
const dst = `${base}/Old Books`;
|
||||
const srcItems = await listDir(src);
|
||||
if (srcItems === null) return; // Old Book doesn't exist
|
||||
const dstItems = await listDir(dst);
|
||||
if (dstItems === null) {
|
||||
// Old Books doesn't exist — simple rename
|
||||
await renameDir(src, dst);
|
||||
const srcItems = await listDir(src, log);
|
||||
if (srcItems === null) return;
|
||||
if (srcItems.length === 0) {
|
||||
// Empty — just delete it
|
||||
const ok = await fbDelete(src);
|
||||
log.push(`${ok ? 'migrated' : 'migrate-fail'} (delete empty): ${src}`);
|
||||
} 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) {
|
||||
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);
|
||||
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++; }
|
||||
else skipped++;
|
||||
|
||||
Reference in New Issue
Block a user