import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; const FBQ_URL = Deno.env.get('FBQ_URL') ?? ''; const FBQ_TOKEN = Deno.env.get('FBQ_TOKEN') ?? ''; const SUPABASE_URL = Deno.env.get('SUPABASE_URL') ?? ''; const SUPABASE_SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''; const SOURCE = 'srv'; const fbqH = { Authorization: `Bearer ${FBQ_TOKEN}` }; function safe(v: string) { return String(v||'').trim().replace(/[\\/:*?"<>|#%{}^~[\]`]+/g,'-').replace(/\s+/g,' ').replace(/^-+|-+$/g,''); } // 200 = created new, 409 = already existed async function mkdir(path: string): Promise { const p = path.endsWith('/') ? path : path + '/'; const r = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(p)}&isDir=true`, { method: 'POST', headers: fbqH, }); return r.status === 200; } async function upload(path: string, data: ArrayBuffer, ct: string) { await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(path)}`, { method: 'POST', headers: { ...fbqH, 'Content-Type': ct }, body: data, }); } function mime(name: string) { const e = name.split('.').pop()?.toLowerCase() ?? ''; const m: Record = { pdf:'application/pdf', jpg:'image/jpeg', jpeg:'image/jpeg', png:'image/png', gif:'image/gif', webp:'image/webp', svg:'image/svg+xml', ai:'application/postscript', eps:'application/postscript', zip:'application/zip', mp4:'video/mp4', mov:'video/quicktime' }; return m[e] ?? 'application/octet-stream'; } async function listDir(path: string): Promise<{ name: string; type: string }[] | null> { const r = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(path)}`, { headers: fbqH }); if (!r.ok) return null; const data = await r.json().catch(() => null); return [...(data?.folders ?? []), ...(data?.files ?? [])]; } async function renameDir(fromPath: string, toPath: string) { 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 }), }); } 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) { 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); } else { // Both exist — move each item from Old Book into Old Books, then delete Old Book for (const item of srcItems) { await renameDir(`${src}/${item.name}`, `${dst}/${item.name}`); } await deleteDir(src); } } async function chunkAll(items: T[], fn: (item: T) => Promise, size = 8) { for (let i = 0; i < items.length; i += size) { await Promise.all(items.slice(i, i + size).map(fn)); } } Deno.serve(async () => { const sb = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY); const log: string[] = []; let created = 0, skipped = 0, fileCount = 0, errors = 0; // fetch all data upfront in parallel const [usersRes, companiesRes, tasksRes] = await Promise.all([ sb.from('profiles').select('name,role').in('role',['team','external']), sb.from('companies').select('name'), sb.from('tasks').select('id,title,projects!project_id(name,companies!company_id(name))'), ]); log.push(`db: ${usersRes.data?.length} users, ${companiesRes.data?.length} companies, ${tasksRes.data?.length ?? 0} tasks`); if (tasksRes.error) log.push(`task err: ${tasksRes.error.message}`); // 1. user folders in parallel await mkdir('/Team'); await chunkAll(usersRes.data ?? [], async (u) => { if (!u.name) return; const isNew = await mkdir(`/Team/${safe(u.name)}`); if (isNew) { log.push(`+ /Team/${safe(u.name)}`); created++; } else skipped++; }); // 2. company folders in parallel await mkdir('/Clients'); await chunkAll(companiesRes.data ?? [], async (c) => { if (!c.name) return; const isNew = await mkdir(`/Clients/${safe(c.name)}`); if (isNew) { log.push(`+ /Clients/${safe(c.name)}`); created++; } else skipped++; }); // 3. tasks — project structure + task folder in parallel, then files sequentially for new ones const newTasks: { task: any; base: string; co: string; pr: string }[] = []; await chunkAll(tasksRes.data ?? [], async (task: any) => { const coName = task?.projects?.companies?.name; const prjName = task?.projects?.name; if (!coName || !prjName || !task.title) return; const co = safe(coName); const pr = safe(prjName); const tk = safe(task.title); const base = `/Clients/${co}/Projects/${pr}/${tk}`; // ensure project structure (idempotent) await Promise.all([ mkdir(`/Clients/${co}/Projects`), mkdir(`/Clients/${co}/Projects/${pr}`), mkdir(`/Clients/${co}/Projects/${pr}/00 Project Files`), ]); const taskIsNew = await mkdir(base); await Promise.all([mkdir(`${base}/Request Info`), mkdir(`${base}/Old Books`)]); await migrateOldBook(base); if (taskIsNew) { newTasks.push({ task, base, co, pr }); created++; } else skipped++; }, 6); log.push(`new task folders: ${newTasks.length}`); newTasks.forEach(({ base }) => log.push(`+ ${base}`)); // 4. upload files for newly created task folders (sequential to avoid memory spikes) for (const { task, base } of newTasks) { const { data: subs } = await sb.from('submissions').select('id,version_number').eq('task_id', task.id); for (const sub of subs ?? []) { const { data: subFiles } = await sb.from('submission_files').select('name,storage_path').eq('submission_id', sub.id); if (!subFiles?.length) continue; const rev = `R${String(sub.version_number).padStart(2,'0')}`; await mkdir(`${base}/Request Info/${rev}`); for (const f of subFiles) { try { const { data: blob, error } = await sb.storage.from('submissions').download(f.storage_path); if (error || !blob) { errors++; continue; } await upload(`${base}/Request Info/${rev}/${f.name}`, await blob.arrayBuffer(), mime(f.name)); fileCount++; } catch { errors++; } } } } return new Response(JSON.stringify({ created, skipped, files: fileCount, errors, log }, null, 2), { headers: { 'Content-Type': 'application/json' }, }); });