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:
+21
-3
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user