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
@@ -0,0 +1,19 @@
alter table public.profiles
add column if not exists avatar_url text;
insert into storage.buckets (id, name, public)
select 'avatars', 'avatars', true
where not exists (select 1 from storage.buckets where id = 'avatars');
do $$
begin
if not exists (select 1 from pg_policies where policyname = 'Avatar images are publicly accessible' and tablename = 'objects') then
execute 'create policy "Avatar images are publicly accessible" on storage.objects for select using (bucket_id = ''avatars'')';
end if;
if not exists (select 1 from pg_policies where policyname = 'Users can upload their own avatar' and tablename = 'objects') then
execute 'create policy "Users can upload their own avatar" on storage.objects for insert with check (bucket_id = ''avatars'' and auth.uid()::text = (storage.foldername(name))[1])';
end if;
if not exists (select 1 from pg_policies where policyname = 'Users can update their own avatar' and tablename = 'objects') then
execute 'create policy "Users can update their own avatar" on storage.objects for update using (bucket_id = ''avatars'' and auth.uid()::text = (storage.foldername(name))[1])';
end if;
end $$;
@@ -0,0 +1,5 @@
alter table public.profiles
add column if not exists website text,
add column if not exists linkedin text,
add column if not exists instagram text,
add column if not exists twitter text;
@@ -0,0 +1,3 @@
alter table public.profiles
drop column if exists instagram,
drop column if exists twitter;
@@ -0,0 +1,2 @@
alter table public.profiles
drop column if exists title;
Executable → Regular
View File