Session 2026-05-29: profile layout 2-col, file browser copy/paste, fbq-proxy/backfill fns, migrations, UI updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-05-29 13:49:21 -04:00
parent 283511bf3a
commit c9998d4a8d
54 changed files with 2646 additions and 396 deletions
+124
View File
@@ -0,0 +1,124 @@
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<boolean> {
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<string,string> = { 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 chunkAll<T>(items: T[], fn: (item: T) => Promise<void>, 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 Book`)]);
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' },
});
});
+269
View File
@@ -0,0 +1,269 @@
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';
function cors(origin: string) {
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-Operation, X-Path, X-Files',
};
}
function json(body: unknown, status = 200, origin = '*') {
return new Response(JSON.stringify(body), {
status,
headers: { ...cors(origin), 'Content-Type': 'application/json' },
});
}
function normalizePath(path: string) {
const raw = String(path || '/').trim();
const parts = raw.split('/').filter(Boolean);
return '/' + parts.join('/');
}
function safeSegment(value: string, fallback = '') {
const cleaned = String(value || '')
.trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
return cleaned || fallback;
}
type ExternalProject = {
id: string;
name: string;
companyName: string;
virtualName: string;
virtualRoot: string;
realRoot: string;
};
type Access =
| { role: 'team'; roots: string[] }
| { role: 'client'; roots: string[] }
| { role: 'external'; roots: string[]; projects: ExternalProject[] };
function pathAllowed(path: string, access: Access): boolean {
if (access.roots.includes('*')) return true;
const p = normalizePath(path).replace(/\/+$/, '') || '/';
if (access.role === 'client' && p === '/Clients' && access.roots.some(r => r.startsWith('/Clients/'))) return true;
if (access.role === 'external' && p === '/') return true;
return access.roots.some(r => p === r || p.startsWith(r.replace(/\/$/, '') + '/'));
}
async function getExternalProjects(sb: ReturnType<typeof createClient>, userId: string): Promise<ExternalProject[]> {
const { data, error } = await sb
.from('project_members')
.select('project:projects(id, name, company:companies(name))')
.eq('profile_id', userId);
if (error) throw error;
const seen = new Map<string, number>();
return (data ?? [])
.map((row: any) => {
const projectId = row?.project?.id;
const projectName = row?.project?.name;
const companyName = row?.project?.company?.name;
if (!projectId || !projectName || !companyName) return null;
const baseName = safeSegment(projectName, projectId);
const count = seen.get(baseName) ?? 0;
seen.set(baseName, count + 1);
const virtualName = count === 0 ? baseName : `${baseName} (${safeSegment(companyName, projectId)})`;
const companyFolder = safeSegment(companyName, companyName);
const projectFolder = safeSegment(projectName, projectId);
return {
id: projectId,
name: projectName,
companyName,
virtualName,
virtualRoot: `/${virtualName}`,
realRoot: `/Clients/${companyFolder}/Projects/${projectFolder}`,
};
})
.filter(Boolean)
.sort((a, b) => a!.virtualName.localeCompare(b!.virtualName)) as ExternalProject[];
}
async function getUserAccess(userId: string): Promise<Access | null> {
const sb = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
const { data: profile } = await sb.from('profiles').select('role, company_id').eq('id', userId).single();
if (!profile) return null;
if (profile.role === 'team') return { role: 'team', roots: ['*'] };
if (profile.role === 'external') {
const projects = await getExternalProjects(sb, userId);
return {
role: 'external',
roots: ['/Team', ...projects.map(project => project.virtualRoot)],
projects,
};
}
if (profile.role === 'client') {
const roots: string[] = [];
if (profile.company_id) {
const { data: co } = await sb.from('companies').select('name').eq('id', profile.company_id).single();
if (co?.name) roots.push(`/Clients/${co.name}`);
}
const { data: memberships } = await sb.from('company_members').select('company:companies(name)').eq('profile_id', userId);
(memberships ?? []).forEach((m: any) => {
const n = m?.company?.name;
if (n && !roots.includes(`/Clients/${n}`)) roots.push(`/Clients/${n}`);
});
return roots.length ? { role: 'client', roots } : null;
}
return null;
}
async function listVirtualClientsRoot(roots: string[]) {
const folders = roots
.filter((root) => root.startsWith('/Clients/'))
.map((root) => root.replace(/\/+$/, ''))
.map((root) => {
const name = root.split('/').filter(Boolean).pop() ?? '';
return {
name,
filename: name,
path: root,
type: 'directory',
isDir: true,
size: 0,
};
})
.sort((a, b) => a.name.localeCompare(b.name));
return { folders, files: [] };
}
function listVirtualExternalRoot(projects: ExternalProject[]) {
return {
folders: [
{ name: 'Team', filename: 'Team', path: '/Team', type: 'directory', isDir: true, size: 0 },
...projects.map((project) => ({
name: project.virtualName,
filename: project.virtualName,
path: project.virtualRoot,
type: 'directory',
isDir: true,
size: 0,
})),
],
files: [],
};
}
function resolvePath(path: string, access: Access) {
const normalized = normalizePath(path);
if (access.role !== 'external') return normalized;
if (normalized === '/') return normalized;
if (normalized === '/Team' || normalized.startsWith('/Team/')) {
return normalized.replace(/^\/Team(?=\/|$)/, '/Team');
}
const project = access.projects.find((entry) => normalized === entry.virtualRoot || normalized.startsWith(entry.virtualRoot + '/'));
if (!project) return normalized;
const suffix = normalized.slice(project.virtualRoot.length);
return `${project.realRoot}${suffix}` || project.realRoot;
}
const fbqHeaders = { Authorization: `Bearer ${FBQ_TOKEN}` };
Deno.serve(async (req) => {
const origin = req.headers.get('origin') ?? '*';
if (req.method === 'OPTIONS') return new Response(null, { headers: cors(origin) });
const authHeader = req.headers.get('authorization') ?? '';
if (!authHeader.startsWith('Bearer ')) return json({ error: 'Unauthorized' }, 401, origin);
const sb = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
const { data: { user }, error } = await sb.auth.getUser(authHeader.slice(7));
if (error || !user) return json({ error: 'Unauthorized' }, 401, origin);
const access = await getUserAccess(user.id);
if (!access) return json({ error: 'Forbidden' }, 403, origin);
const op = req.headers.get('x-operation') ?? '';
const path = req.headers.get('x-path') ?? '/';
if (!pathAllowed(path, access)) return json({ error: 'Forbidden' }, 403, origin);
const resolvedPath = resolvePath(path, access);
if (op === 'list') {
if (access.role === 'client' && path.replace(/\/+$/, '') === '/Clients' && !access.roots.includes('*')) {
return json(await listVirtualClientsRoot(access.roots), 200, origin);
}
if (access.role === 'external' && normalizePath(path) === '/') {
return json(listVirtualExternalRoot(access.projects), 200, origin);
}
const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(resolvedPath)}`, { headers: fbqHeaders });
const body = await res.text();
return new Response(body, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
}
if (op === 'archive') {
const files = JSON.parse(req.headers.get('x-files') ?? '[]') as string[];
const params = new URLSearchParams({ source: SOURCE });
files.map(f => resolvePath(f, access)).forEach(f => params.append('files[]', f));
const res = await fetch(`${FBQ_URL}/api/archive?${params.toString()}`, { headers: fbqHeaders });
const cd = `attachment; filename="download.zip"`;
return new Response(res.body, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/zip', 'Content-Disposition': cd } });
}
if (op === 'download') {
const res = await fetch(`${FBQ_URL}/api/raw?source=${SOURCE}&path=${encodeURIComponent(resolvedPath)}`, { headers: fbqHeaders });
const ct = res.headers.get('content-type') ?? 'application/octet-stream';
const cd = res.headers.get('content-disposition') ?? `attachment; filename="${resolvedPath.split('/').pop()}"`;
return new Response(res.body, { status: res.status, headers: { ...cors(origin), 'Content-Type': ct, 'Content-Disposition': cd } });
}
if (op === 'upload') {
const body = await req.arrayBuffer();
const ct = req.headers.get('content-type') ?? '';
const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(resolvedPath)}`, {
method: 'POST', headers: { ...fbqHeaders, 'Content-Type': ct }, body,
});
const text = await res.text();
return new Response(text, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
}
if (op === 'delete') {
const files = JSON.parse(req.headers.get('x-files') ?? '[]') as string[];
await Promise.all(files.map(f =>
fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(resolvePath(f, access))}`, { method: 'DELETE', headers: fbqHeaders })
));
return json({ ok: true }, 200, origin);
}
if (op === 'copy' || op === 'move') {
const srcPaths = JSON.parse(req.headers.get('x-files') ?? '[]') as string[];
const destDir = resolvedPath.replace(/\/+$/, '');
const items = srcPaths.map(src => {
const resolved = resolvePath(src, access);
const name = resolved.split('/').filter(Boolean).pop() ?? '';
return { fromSource: SOURCE, fromPath: resolved, toSource: SOURCE, toPath: `${destDir}/${name}` };
});
const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}`, {
method: 'PATCH',
headers: { ...fbqHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: op, items, overwrite: false }),
});
const text = await res.text();
return new Response(text, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
}
if (op === 'mkdir') {
const dirPath = resolvedPath.endsWith('/') ? resolvedPath : resolvedPath + '/';
const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(dirPath)}&isDir=true`, {
method: 'POST', headers: fbqHeaders,
});
const text = await res.text();
return new Response(text, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
}
return json({ error: 'Unknown operation' }, 400, origin);
});
View File