ab7a6b5a57
- 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>
140 lines
5.1 KiB
JavaScript
140 lines
5.1 KiB
JavaScript
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 });
|
|
}
|