From ab7a6b5a5748ffb57922625d5ed1575606dafe82 Mon Sep 17 00:00:00 2001 From: Krao Hasanee Date: Sun, 31 May 2026 11:17:47 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20migrate=20Old=20Book=20=E2=86=92=20Old?= =?UTF-8?q?=20Books=20via=20Vercel=20API=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- api/migrate-old-books.js | 139 +++++++++++++++++++++++ supabase/functions/fbq-backfill/index.ts | 48 ++++---- 2 files changed, 165 insertions(+), 22 deletions(-) create mode 100644 api/migrate-old-books.js diff --git a/api/migrate-old-books.js b/api/migrate-old-books.js new file mode 100644 index 0000000..48d88ea --- /dev/null +++ b/api/migrate-old-books.js @@ -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 }); +} diff --git a/supabase/functions/fbq-backfill/index.ts b/supabase/functions/fbq-backfill/index.ts index 12771a0..e641ace 100644 --- a/supabase/functions/fbq-backfill/index.ts +++ b/supabase/functions/fbq-backfill/index.ts @@ -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 { + 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 { + 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 { 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++;