Session 2026-05-20: UI fixes, invoice filtering, file browser, request approvals, sub invoice task scope

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-05-20 21:32:55 -04:00
parent ff159c5937
commit 565d2ed4bc
34 changed files with 3384 additions and 1161 deletions
+83
View File
@@ -0,0 +1,83 @@
// One-time: creates 00 Project Files folder inside every existing project folder in FileBrowser
// Run: node scripts/backfill-project-files-folder.mjs
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createClient } from '@supabase/supabase-js';
const __dir = dirname(fileURLToPath(import.meta.url));
const envFile = resolve(__dir, '../.env.backfill');
const env = {};
readFileSync(envFile, 'utf8').split('\n').forEach(line => {
const m = line.match(/^([^#=]+)=(.*)$/);
if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
});
const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
const FB_TOKEN = env.FILEBROWSER_TOKEN;
const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
const FB_SOURCE = 'files';
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } });
function safeName(v) {
return String(v || '').trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
function joinPath(...parts) {
const raw = parts.join('/');
const clean = raw.split('/').filter(p => p && p !== '.');
return `/${clean.join('/')}`;
}
async function mkdir(path) {
const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString();
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
method: 'POST',
headers: { Authorization: `Bearer ${FB_TOKEN}` },
});
if (!res.ok) {
const text = await res.text();
if (!text.includes('already') && !text.includes('exist')) {
console.warn(` mkdir ${path}: ${res.status} ${text.slice(0, 80)}`);
}
}
}
async function main() {
const { data: projects, error } = await admin
.from('projects')
.select('id, name, company:companies(name)');
if (error) { console.error('Query failed:', error.message); process.exit(1); }
console.log(`Found ${projects.length} projects`);
for (const p of projects) {
const company = safeName(p.company?.name || '');
const project = safeName(p.name || '');
if (!company || !project) { console.log(` Skipping (missing name): ${p.id}`); continue; }
const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project);
const targetDir = joinPath(projectDir, '00 Project Files');
await mkdir(joinPath(CLIENT_ROOT, company));
await mkdir(joinPath(CLIENT_ROOT, company, 'Projects'));
await mkdir(projectDir);
await mkdir(targetDir);
console.log(`${company} / ${project} / 00 Project Files`);
}
console.log('\nDone.');
}
main().catch(err => { console.error(err); process.exit(1); });
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env node
// Backfill FileBrowser folders for all existing projects and tasks.
// Project: Clients/{company}/Projects/{project}/00 Project Files/ + 00 Project Info/
// Task: Clients/{company}/Projects/{project}/{task}/Working Files/ + Request Info/
// Run: node --env-file=.env.backfill scripts/backfill-project-folders.mjs
const FB_SOURCE = 'files';
const FILEBROWSER_URL = (process.env.FILEBROWSER_URL || 'https://fourgebranding.krao.us').replace(/\/+$/, '');
const FILEBROWSER_TOKEN = process.env.FILEBROWSER_TOKEN;
const CLIENT_ROOT = process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
const SUPABASE_URL = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!FILEBROWSER_TOKEN) { console.error('Missing FILEBROWSER_TOKEN'); process.exit(1); }
if (!SUPABASE_URL || !SUPABASE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
function safeName(value) {
return String(value || '')
.trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
function normalizePath(path) {
const parts = String(path || '/').split('/').filter(p => p && p !== '.' && p !== '..');
return `/${parts.join('/')}`;
}
function joinPath(...parts) {
return normalizePath(parts.join('/'));
}
async function mkdir(path) {
const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString();
const res = await fetch(`${FILEBROWSER_URL}/api/resources?${qs}`, {
method: 'POST',
headers: { Authorization: `Bearer ${FILEBROWSER_TOKEN}` },
});
// 200 = created, 409 = already exists — both fine
if (!res.ok && res.status !== 409) {
const text = await res.text();
throw new Error(`mkdir ${path} failed (${res.status}): ${text}`);
}
return res.status;
}
async function supabaseFetch(path) {
const res = await fetch(`${SUPABASE_URL}/rest/v1/${path}`, {
headers: {
apikey: SUPABASE_KEY,
Authorization: `Bearer ${SUPABASE_KEY}`,
},
});
if (!res.ok) throw new Error(`Supabase ${path}: ${await res.text()}`);
return res.json();
}
async function run() {
console.log('Fetching projects from Supabase...');
const projects = await supabaseFetch('projects?select=id,name,company:companies(name)&order=created_at.asc');
console.log(`Found ${projects.length} projects.`);
console.log('Fetching tasks from Supabase...');
const tasks = await supabaseFetch('tasks?select=id,title,project:projects(name,company:companies(name))&order=submitted_at.asc');
console.log(`Found ${tasks.length} tasks.\n`);
let created = 0;
let existing = 0;
let errors = 0;
// ── Projects ──────────────────────────────────────────────────────────────
console.log('=== PROJECTS ===');
for (const project of projects) {
const companyName = project.company?.name;
if (!companyName) { console.log(` SKIP ${project.name} — no company`); continue; }
const companyDir = joinPath(CLIENT_ROOT, safeName(companyName));
const projectsDir = joinPath(companyDir, 'Projects');
const projectDir = joinPath(projectsDir, safeName(project.name));
try {
await mkdir(companyDir);
await mkdir(projectsDir);
const s = await mkdir(projectDir);
await mkdir(joinPath(projectDir, '00 Project Files'));
await mkdir(joinPath(projectDir, '00 Project Info'));
if (s === 409) { console.log(` EXISTS ${companyName} / ${project.name}`); existing++; }
else { console.log(` CREATED ${companyName} / ${project.name}`); created++; }
} catch (err) {
console.error(` ERROR ${companyName} / ${project.name}: ${err.message}`);
errors++;
}
}
// ── Tasks ─────────────────────────────────────────────────────────────────
console.log('\n=== TASKS ===');
for (const task of tasks) {
const projectName = task.project?.name;
const companyName = task.project?.company?.name;
if (!projectName || !companyName) { console.log(` SKIP ${task.title} — missing project/company`); continue; }
const projectDir = joinPath(CLIENT_ROOT, safeName(companyName), 'Projects', safeName(projectName));
const taskDir = joinPath(projectDir, safeName(task.title));
try {
// Ensure parent exists (idempotent)
await mkdir(joinPath(CLIENT_ROOT, safeName(companyName)));
await mkdir(joinPath(CLIENT_ROOT, safeName(companyName), 'Projects'));
await mkdir(projectDir);
const s = await mkdir(taskDir);
await mkdir(joinPath(taskDir, 'Working Files'));
await mkdir(joinPath(taskDir, 'Request Info'));
if (s === 409) { console.log(` EXISTS ${companyName} / ${projectName} / ${task.title}`); existing++; }
else { console.log(` CREATED ${companyName} / ${projectName} / ${task.title}`); created++; }
} catch (err) {
console.error(` ERROR ${companyName} / ${projectName} / ${task.title}: ${err.message}`);
errors++;
}
}
console.log(`\nDone. Created: ${created} Already existed: ${existing} Errors: ${errors}`);
}
run().catch(err => { console.error(err); process.exit(1); });
+165
View File
@@ -0,0 +1,165 @@
// One-time script: copies existing submission files from Supabase Storage to FileBrowser
// Run: node scripts/backfill-request-files.mjs
import { readFileSync } from 'fs';
import { createClient } from '@supabase/supabase-js';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dir = dirname(fileURLToPath(import.meta.url));
const envFile = resolve(__dir, '../.env.backfill');
// Parse env file
const env = {};
readFileSync(envFile, 'utf8').split('\n').forEach(line => {
const m = line.match(/^([^#=]+)=(.*)$/);
if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
});
const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
const FB_TOKEN = env.FILEBROWSER_TOKEN;
const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
const FB_SOURCE = 'files';
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
auth: { persistSession: false, autoRefreshToken: false },
});
function normalizePath(path) {
const parts = String(path || '/').trim().split('/').filter(Boolean);
const clean = [];
for (const p of parts) {
if (p === '.') continue;
if (p === '..') throw new Error('path traversal');
clean.push(p);
}
return `/${clean.join('/')}`;
}
function joinPath(...parts) { return normalizePath(parts.join('/')); }
function safeName(v) {
return String(v || '').trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
async function fbFetch(method, endpoint, { params = {}, headers = {}, body } = {}) {
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
const res = await fetch(`${FB_URL}${endpoint}?${qs}`, {
method,
headers: { Authorization: `Bearer ${FB_TOKEN}`, ...headers },
body,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`FB ${res.status}: ${text.slice(0, 200)}`);
}
return res;
}
async function mkdir(path) {
await fbFetch('POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {});
}
async function main() {
const { data: rows, error } = await admin
.from('submission_files')
.select(`
id, name, storage_path,
submission:submissions!inner(
id, version_number,
task:tasks!inner(
id, title,
project:projects!inner(
id, name,
company:companies!inner(name)
)
)
)
`);
if (error) { console.error('Query failed:', error.message); process.exit(1); }
// Group by task + version
const groups = new Map();
for (const row of rows || []) {
const sub = row.submission;
const task = sub?.task;
const project = task?.project;
const company = project?.company;
if (!task || !project || !company) continue;
const key = `${task.id}::${sub.version_number}`;
if (!groups.has(key)) {
groups.set(key, {
companyName: company.name,
projectName: project.name,
taskTitle: task.title,
versionNumber: sub.version_number,
files: [],
});
}
groups.get(key).files.push({ name: row.name, storage_path: row.storage_path });
}
console.log(`Found ${groups.size} task/revision groups, ${rows.length} total files`);
let processed = 0, skipped = 0, errors = 0;
for (const [key, group] of groups) {
if (group.files.length === 0) { skipped++; continue; }
const revFolder = `R${String(group.versionNumber).padStart(2, '0')}`;
const companyDir = joinPath(CLIENT_ROOT, safeName(group.companyName));
const projectDir = joinPath(companyDir, 'Projects', safeName(group.projectName));
const taskDir = joinPath(projectDir, safeName(group.taskTitle));
const requestInfoDir = joinPath(taskDir, 'Request Info');
const revDir = joinPath(requestInfoDir, revFolder);
console.log(`\n[${group.companyName}] ${group.projectName} / ${group.taskTitle} / ${revFolder} (${group.files.length} files)`);
await mkdir(companyDir);
await mkdir(joinPath(companyDir, 'Projects'));
await mkdir(projectDir);
await mkdir(taskDir);
await mkdir(requestInfoDir);
await mkdir(revDir);
for (const file of group.files) {
try {
const { data: signed, error: signErr } = await admin.storage
.from('submissions')
.createSignedUrl(file.storage_path, 120);
if (signErr || !signed?.signedUrl) throw new Error(`signed url failed: ${signErr?.message}`);
const fileRes = await fetch(signed.signedUrl);
if (!fileRes.ok) throw new Error(`download failed: ${fileRes.status}`);
const fileBuffer = await fileRes.arrayBuffer();
const fbFilePath = joinPath(revDir, file.name);
await fbFetch('POST', '/api/resources', {
params: { path: fbFilePath, override: 'true' },
headers: { 'Content-Type': 'application/octet-stream' },
body: fileBuffer,
});
console.log(`${file.name}`);
processed++;
} catch (err) {
console.error(`${file.name}: ${err.message}`);
errors++;
}
}
}
console.log(`\nDone. Processed: ${processed}, Skipped: ${skipped}, Errors: ${errors}`);
}
main().catch(err => { console.error(err); process.exit(1); });
@@ -0,0 +1,103 @@
// One-time: removes leftover '.Project Files' and 'Project Files' folders
// that may still exist alongside the renamed '00 Project Files'
// Run: node scripts/cleanup-old-project-files-folders.mjs
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createClient } from '@supabase/supabase-js';
const __dir = dirname(fileURLToPath(import.meta.url));
const envFile = resolve(__dir, '../.env.backfill');
const env = {};
readFileSync(envFile, 'utf8').split('\n').forEach(line => {
const m = line.match(/^([^#=]+)=(.*)$/);
if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
});
const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
const FB_TOKEN = env.FILEBROWSER_TOKEN;
const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
const FB_SOURCE = 'files';
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } });
function safeName(v) {
return String(v || '').trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
function joinPath(...parts) {
const clean = parts.join('/').split('/').filter(p => p && p !== '.');
return `/${clean.join('/')}`;
}
async function fbList(path) {
const qs = new URLSearchParams({ source: FB_SOURCE, path }).toString();
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
headers: { Authorization: `Bearer ${FB_TOKEN}` },
});
if (!res.ok) return null;
const data = await res.json().catch(() => null);
return data?.folders || [];
}
async function fbDelete(path) {
const qs = new URLSearchParams({ source: FB_SOURCE, path }).toString();
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${FB_TOKEN}` },
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status}: ${text.slice(0, 100)}`);
}
}
const OLD_NAMES = ['.Project Files', 'Project Files'];
async function main() {
const { data: projects, error } = await admin
.from('projects')
.select('id, name, company:companies(name)');
if (error) { console.error('Query failed:', error.message); process.exit(1); }
console.log(`Checking ${projects.length} projects...\n`);
let cleaned = 0;
for (const p of projects) {
const company = safeName(p.company?.name || '');
const project = safeName(p.name || '');
if (!company || !project) continue;
const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project);
const entries = await fbList(projectDir);
if (!entries) { console.log(` ? could not list: ${company} / ${project}`); continue; }
const names = entries.map(e => e.name);
for (const oldName of OLD_NAMES) {
if (names.includes(oldName)) {
const oldPath = joinPath(projectDir, oldName);
try {
await fbDelete(oldPath);
console.log(` ✓ deleted "${oldName}": ${company} / ${project}`);
cleaned++;
} catch (err) {
console.error(` ✗ failed to delete "${oldName}" in ${company} / ${project}: ${err.message}`);
}
}
}
}
console.log(`\nDone. Cleaned ${cleaned} old folder(s).`);
}
main().catch(err => { console.error(err); process.exit(1); });
+104
View File
@@ -0,0 +1,104 @@
// One-time: renames .00 Project Files → 00 Project Files for all existing project folders
// Run: node scripts/rename-project-files-folder.mjs
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createClient } from '@supabase/supabase-js';
const __dir = dirname(fileURLToPath(import.meta.url));
const envFile = resolve(__dir, '../.env.backfill');
const env = {};
readFileSync(envFile, 'utf8').split('\n').forEach(line => {
const m = line.match(/^([^#=]+)=(.*)$/);
if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
});
const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
const FB_TOKEN = env.FILEBROWSER_TOKEN;
const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
const FB_SOURCE = 'files';
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } });
function safeName(v) {
return String(v || '').trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
function joinPath(...parts) {
const clean = parts.join('/').split('/').filter(p => p && p !== '.');
return `/${clean.join('/')}`;
}
async function fbRename(fromPath, toPath) {
const qs = new URLSearchParams({ source: FB_SOURCE }).toString();
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${FB_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'rename',
items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }],
overwrite: false,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status}: ${text.slice(0, 120)}`);
}
}
async function fbMkdir(path) {
const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString();
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
method: 'POST',
headers: { Authorization: `Bearer ${FB_TOKEN}` },
});
if (!res.ok) {
const text = await res.text();
if (!text.includes('already') && !text.includes('exist')) throw new Error(`mkdir ${path}: ${res.status}`);
}
}
async function main() {
const { data: projects, error } = await admin
.from('projects')
.select('id, name, company:companies(name)');
if (error) { console.error('Query failed:', error.message); process.exit(1); }
console.log(`Found ${projects.length} projects`);
for (const p of projects) {
const company = safeName(p.company?.name || '');
const project = safeName(p.name || '');
if (!company || !project) continue;
const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project);
const oldPath = joinPath(projectDir, 'Project Files');
const newPath = joinPath(projectDir, '00 Project Files');
try {
await fbRename(oldPath, newPath);
console.log(` ✓ renamed: ${company} / ${project}`);
} catch (err) {
// If rename fails (source doesn't exist), ensure new folder exists
try {
await fbMkdir(newPath);
console.log(` + created: ${company} / ${project} (no dot folder found)`);
} catch (e2) {
console.log(` ~ exists: ${company} / ${project}`);
}
}
}
console.log('\nDone.');
}
main().catch(err => { console.error(err); process.exit(1); });