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
+21 -3
View File
@@ -1,13 +1,31 @@
import { supabase } from './supabase';
export async function logActivity({ actorId, actorName, action, taskId, taskTitle, projectId, projectName }) {
const duplicateWindowMs = 8000;
const duplicateCutoff = new Date(Date.now() - duplicateWindowMs).toISOString();
const normalizedActorId = actorId || null;
const normalizedTaskId = taskId || null;
const normalizedProjectId = projectId || null;
// Guard against fast duplicate writes from double-clicks/retries.
const { data: existing, error: existingError } = await supabase
.from('activity_log')
.select('id')
.eq('action', action)
.eq('actor_id', normalizedActorId)
.eq('task_id', normalizedTaskId)
.eq('project_id', normalizedProjectId)
.gte('created_at', duplicateCutoff)
.limit(1);
if (!existingError && (existing || []).length > 0) return;
const { error } = await supabase.from('activity_log').insert({
actor_id: actorId || null,
actor_id: normalizedActorId,
actor_name: actorName || null,
action,
task_id: taskId || null,
task_id: normalizedTaskId,
task_title: taskTitle || null,
project_id: projectId || null,
project_id: normalizedProjectId,
project_name: projectName || null,
});
if (error) console.error('logActivity failed:', action, error);
+85 -91
View File
@@ -1,33 +1,19 @@
import { supabase } from './supabase';
async function fbCall(method, action, body = null) {
const FBQ_FN = `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/fbq-proxy`;
async function fbq(op, path, extra = {}) {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) return;
await fetch(`/api/filebrowser?action=${action}`, {
method,
headers: {
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
}).catch(() => {});
const headers = {
Authorization: `Bearer ${session.access_token}`,
'X-Operation': op,
'X-Path': path,
...extra.headers,
};
await fetch(FBQ_FN, { method: 'POST', headers, body: extra.body ?? undefined }).catch(() => {});
}
// Create /Clients/{name}/ folder. Silently fails if already exists.
export async function createClientFolder(companyName) {
if (!companyName) return;
await fbCall('POST', 'mkdir', { path: '/', name: 'Clients' });
await fbCall('POST', 'mkdir', { path: '/Clients', name: companyName });
}
// Rename /Clients/{oldName} → /Clients/{newName}
export async function renameClientFolder(oldName, newName) {
if (!oldName || !newName || oldName === newName) return;
await fbCall('POST', 'rename', { path: `/Clients/${oldName}`, name: newName });
}
// Same safeName logic as the server (api/filebrowser.js)
function safeName(v) {
return String(v || '').trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
@@ -35,86 +21,94 @@ function safeName(v) {
.replace(/^-+|-+$/g, '');
}
// Upload files to the correct Request Info/{rev} folder in FileBrowser.
// companyName is required. versionNumber defaults to 0 (R00).
// Best-effort — call with .catch(() => {}) so failures don't block submission.
export async function createProjectFolder(companyName, projectName) {
if (!companyName || !projectName) return;
const co = safeName(companyName);
const proj = safeName(projectName);
await fbq('mkdir', `/Clients/${co}/`);
await fbq('mkdir', `/Clients/${co}/Projects/`);
await fbq('mkdir', `/Clients/${co}/Projects/${proj}/`);
await fbq('mkdir', `/Clients/${co}/Projects/${proj}/00 Project Files/`);
}
export async function createTeamMemberFolder(name) {
if (!name) return;
await fbq('mkdir', '/Team/');
await fbq('mkdir', `/Team/${safeName(name)}/`);
}
export async function createTaskFolder(companyName, projectName, taskTitle) {
if (!companyName || !projectName || !taskTitle) return;
const co = safeName(companyName);
const proj = safeName(projectName);
const task = safeName(taskTitle);
const base = `/Clients/${co}/Projects/${proj}/${task}`;
const mkdirs = [
`/Clients/${co}/`,
`/Clients/${co}/Projects/`,
`/Clients/${co}/Projects/${proj}/`,
`/Clients/${co}/Projects/${proj}/00 Project Files/`,
`${base}/`,
`${base}/Request Info/`,
`${base}/Old Book/`,
];
for (const dir of mkdirs) {
await fbq('mkdir', dir);
}
}
export async function createClientFolder(companyName) {
if (!companyName) return;
await fbq('mkdir', '/Clients/');
await fbq('mkdir', `/Clients/${companyName}/`);
}
export async function backfillClientFolders() {
const { data } = await supabase.from('companies').select('name');
if (!data?.length) return;
await fbq('mkdir', '/Clients/');
for (const company of data) {
if (company.name) await fbq('mkdir', `/Clients/${company.name}/`);
}
}
export async function uploadFilesToRequestInfo(files, companyName, projectName, taskTitle, versionNumber = 0) {
if (!files?.length || !companyName || !projectName || !taskTitle) return;
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) return;
const authHeader = `Bearer ${session.access_token}`;
// Determine role
const { data: profile } = await supabase.from('profiles').select('role').eq('id', session.user.id).single();
const role = profile?.role;
const co = safeName(companyName);
const proj = safeName(projectName);
const task = safeName(taskTitle);
const rev = `R${String(versionNumber).padStart(2, '0')}`;
const base = `/Clients/${co}/Projects/${proj}/${task}/Request Info/${rev}`;
// Build virtual path segments for mkdir.
// Clients: virtual root is per-company; company folder already exists — start one level in.
// Team/external: full path under /Clients/{co}/...
let mkdirs;
let revPath;
if (role === 'client') {
revPath = `/${co}/Projects/${proj}/${task}/Request Info/${rev}`;
mkdirs = [
{ path: `/${co}`, name: 'Projects' },
{ path: `/${co}/Projects`, name: proj },
{ path: `/${co}/Projects/${proj}`, name: task },
{ path: `/${co}/Projects/${proj}/${task}`, name: 'Request Info' },
{ path: `/${co}/Projects/${proj}/${task}/Request Info`, name: rev },
];
} else {
revPath = `/Clients/${co}/Projects/${proj}/${task}/Request Info/${rev}`;
mkdirs = [
{ path: '/Clients', name: co },
{ path: `/Clients/${co}`, name: 'Projects' },
{ path: `/Clients/${co}/Projects`, name: proj },
{ path: `/Clients/${co}/Projects/${proj}`, name: task },
{ path: `/Clients/${co}/Projects/${proj}/${task}`, name: 'Request Info' },
{ path: `/Clients/${co}/Projects/${proj}/${task}/Request Info`, name: rev },
];
const mkdirs = [
`/Clients/${co}/`,
`/Clients/${co}/Projects/`,
`/Clients/${co}/Projects/${proj}/`,
`/Clients/${co}/Projects/${proj}/${task}/`,
`/Clients/${co}/Projects/${proj}/${task}/Request Info/`,
`/Clients/${co}/Projects/${proj}/${task}/Working Files/`,
`/Clients/${co}/Projects/${proj}/${task}/Old Books/`,
`${base}/`,
];
for (const dir of mkdirs) {
await fbq('mkdir', dir);
}
for (const seg of mkdirs) {
await fetch('/api/filebrowser?action=mkdir', {
method: 'POST',
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify(seg),
}).catch(() => {});
}
// Get upload token for the revision folder
const tokenRes = await fetch('/api/filebrowser?action=upload-token', {
method: 'POST',
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify({ path: revPath }),
}).catch(() => null);
if (!tokenRes?.ok) return;
const { token, url, fbPath } = await tokenRes.json();
if (!token || !url || !fbPath) return;
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) return;
for (const file of files) {
await fetch(`${url}/api/resources?source=files&path=${encodeURIComponent(`${fbPath}/${file.name}`)}&override=true`, {
const filePath = `${base}/${file.name}`;
const fd = new FormData(); fd.append('file', file);
await fetch(FBQ_FN, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': file.type || 'application/octet-stream' },
body: file,
headers: {
Authorization: `Bearer ${session.access_token}`,
'X-Operation': 'upload',
'X-Path': filePath,
},
body: fd,
}).catch(() => {});
}
}
// Create missing /Clients/{name}/ folders for all companies.
export async function backfillClientFolders() {
const { data } = await supabase.from('companies').select('name');
if (!data?.length) return;
await fbCall('POST', 'mkdir', { path: '/', name: 'Clients' });
for (const company of data) {
if (company.name) await fbCall('POST', 'mkdir', { path: '/Clients', name: company.name });
}
}
Executable → Regular
View File