fix: crash bugs in TeamInvoices + faster load
Bug fixes: - TeamInvoices: add useAuth import/currentUser (invoice-create crash) - TeamInvoices: setChartYear -> setExportYear (year-dropdown crash) - TeamDashboard: drop redundant setState-in-effect (cascading renders) - Companies: delete dead _UnusedClientCompanies (illegal hook calls) - annotate intentional empty PDF-fallback catches Load speed: - preconnect/dns-prefetch to Supabase origin - lazy-load heic-to in Converters: page chunk 2737KB -> 9KB - split recharts into its own 'charts' vendor chunk Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,168 +0,0 @@
|
||||
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 listDir(path: string, log?: string[]): Promise<{ name: string }[] | null> {
|
||||
const r = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(path)}`, { headers: fbqH });
|
||||
if (log) log.push(`listDir ${path} → ${r.status}`);
|
||||
if (!r.ok) return null;
|
||||
const data = await r.json().catch(() => null);
|
||||
const entries = Array.isArray(data?.entries) ? data.entries
|
||||
: [...(data?.folders ?? []), ...(data?.files ?? [])];
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function fbDelete(path: string): Promise<boolean> {
|
||||
const r = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(path)}`, { method: 'DELETE', headers: fbqH });
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
async function fbRename(fromPath: string, toPath: string): Promise<boolean> {
|
||||
const r = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}`, {
|
||||
method: 'PATCH',
|
||||
headers: { ...fbqH, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'rename', items: [{ fromSource: SOURCE, fromPath, toSource: SOURCE, toPath }], overwrite: true }),
|
||||
});
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
async function migrateOldBook(base: string, log: string[]): Promise<void> {
|
||||
const src = `${base}/Old Book`;
|
||||
const dst = `${base}/Old Books`;
|
||||
const srcItems = await listDir(src, log);
|
||||
if (srcItems === null) return;
|
||||
if (srcItems.length === 0) {
|
||||
// Empty — just delete it
|
||||
const ok = await fbDelete(src);
|
||||
log.push(`${ok ? 'migrated' : 'migrate-fail'} (delete empty): ${src}`);
|
||||
} else {
|
||||
// Has contents — move each item then delete
|
||||
for (const item of srcItems) {
|
||||
await fbRename(`${src}/${item.name}`, `${dst}/${item.name}`);
|
||||
}
|
||||
const ok = await fbDelete(src);
|
||||
log.push(`${ok ? 'migrated' : 'migrate-fail'} (merge+delete): ${src} → ${dst}`);
|
||||
}
|
||||
}
|
||||
|
||||
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 Books`)]);
|
||||
if (log.filter(l => l.startsWith('listDir')).length < 3) await migrateOldBook(base, log);
|
||||
|
||||
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' },
|
||||
});
|
||||
});
|
||||
@@ -1,288 +0,0 @@
|
||||
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 rawUrl = `${FBQ_URL}/api/raw?source=${SOURCE}&path=${encodeURIComponent(resolvedPath)}`;
|
||||
const rawRes = await fetch(rawUrl, { headers: fbqHeaders });
|
||||
|
||||
if (rawRes.ok) {
|
||||
const ct = rawRes.headers.get('content-type') ?? 'application/octet-stream';
|
||||
const cd = rawRes.headers.get('content-disposition') ?? `attachment; filename="${resolvedPath.split('/').pop()}"`;
|
||||
return new Response(rawRes.body, { status: rawRes.status, headers: { ...cors(origin), 'Content-Type': ct, 'Content-Disposition': cd } });
|
||||
}
|
||||
|
||||
const rawErrorText = await rawRes.text();
|
||||
const shouldFallbackToResourcesDownload = rawRes.status === 400 && rawErrorText.toLowerCase().includes('no files specified');
|
||||
if (!shouldFallbackToResourcesDownload) {
|
||||
return new Response(rawErrorText, { status: rawRes.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
const directDownloadUrl = `${FBQ_URL}/api/resources/download?source=${SOURCE}&file=${encodeURIComponent(resolvedPath)}`;
|
||||
const directRes = await fetch(directDownloadUrl, { headers: fbqHeaders });
|
||||
const directCt = directRes.headers.get('content-type') ?? 'application/octet-stream';
|
||||
const directCd = directRes.headers.get('content-disposition') ?? `attachment; filename="${resolvedPath.split('/').pop() ?? 'download'}"`;
|
||||
return new Response(directRes.body, {
|
||||
status: directRes.status,
|
||||
headers: { ...cors(origin), 'Content-Type': directCt, 'Content-Disposition': directCd },
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||
const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY');
|
||||
const SUPABASE_URL = Deno.env.get('SUPABASE_URL');
|
||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
|
||||
const SUPABASE_WEBHOOK_SECRET = Deno.env.get('SUPABASE_WEBHOOK_SECRET');
|
||||
const FROM = 'Fourge Branding <hello@fourgebranding.com>';
|
||||
const TEAM_EMAILS = [
|
||||
'krao@fourgebranding.com',
|
||||
@@ -12,7 +13,7 @@ const TEAM_EMAILS = [
|
||||
'twebb@fourgebranding.com',
|
||||
];
|
||||
|
||||
const ALLOWED_TYPES = ['new_request', 'sent_to_client', 'revision_submitted', 'client_approved', 'invoice_sent', 'receipt_sent', 'subcontractor_po_sent', 'subcontractor_invoice_submitted'] as const;
|
||||
const ALLOWED_TYPES = ['new_request', 'sent_to_client', 'revision_submitted', 'client_approved', 'invoice_sent', 'receipt_sent', 'subcontractor_po_sent', 'subcontractor_invoice_submitted', 'task_status_update'] as const;
|
||||
type EmailType = typeof ALLOWED_TYPES[number];
|
||||
|
||||
// Types that only team members may trigger
|
||||
@@ -53,6 +54,8 @@ const optStr = (val: unknown, max = 500): string => {
|
||||
return val.trim();
|
||||
};
|
||||
|
||||
const optBool = (val: unknown): boolean => val === true;
|
||||
|
||||
/** Basic UUID format check. */
|
||||
const isUuid = (v: unknown): v is string =>
|
||||
typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
|
||||
@@ -102,26 +105,32 @@ serve(async (req) => {
|
||||
// ── 1. Authentication ────────────────────────────────────────────────────
|
||||
const authHeader = req.headers.get('Authorization') ?? '';
|
||||
const accessToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
|
||||
if (!accessToken) {
|
||||
const webhookSecret = req.headers.get('x-webhook-secret') ?? '';
|
||||
const isInternalWebhook = !!SUPABASE_WEBHOOK_SECRET && webhookSecret === SUPABASE_WEBHOOK_SECRET;
|
||||
if (!accessToken && !isInternalWebhook) {
|
||||
return respond({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
// Use service role client to validate the token — works with ES256 JWTs
|
||||
const adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
|
||||
|
||||
const { data: userData, error: authError } = await adminClient.auth.getUser(accessToken);
|
||||
if (authError || !userData?.user) {
|
||||
return respond({ error: `Auth failed: ${authError?.message ?? 'no user'}` }, 401);
|
||||
let userData: { user?: { id: string } } | null = null;
|
||||
if (!isInternalWebhook) {
|
||||
const authResult = await adminClient.auth.getUser(accessToken);
|
||||
userData = authResult.data;
|
||||
if (authResult.error || !userData?.user) {
|
||||
return respond({ error: `Auth failed: ${authResult.error?.message ?? 'no user'}` }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Role lookup ───────────────────────────────────────────────────────
|
||||
const { data: profile } = await adminClient
|
||||
.from('profiles')
|
||||
.select('role')
|
||||
.eq('id', userData.user.id)
|
||||
.single();
|
||||
|
||||
const role: string = profile?.role ?? '';
|
||||
const role: string = isInternalWebhook
|
||||
? 'team'
|
||||
: ((await adminClient
|
||||
.from('profiles')
|
||||
.select('role')
|
||||
.eq('id', userData?.user?.id)
|
||||
.single()).data?.role ?? '');
|
||||
if (!['team', 'client', 'external'].includes(role)) {
|
||||
return respond({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
@@ -163,17 +172,55 @@ serve(async (req) => {
|
||||
|
||||
subject = `New Request: ${esc(serviceType)} — ${esc(clientName)}`;
|
||||
html = `
|
||||
<h2>New Request Received</h2>
|
||||
<p><strong>From:</strong> ${esc(clientName)} (${esc(clientEmail)})</p>
|
||||
<p><strong>Company:</strong> ${esc(company) || '—'}</p>
|
||||
<p><strong>Service:</strong> ${esc(serviceType)}</p>
|
||||
<p><strong>Project:</strong> ${esc(projectName)}</p>
|
||||
${deadline ? `<p><strong>Deadline:</strong> ${esc(deadline)}</p>` : ''}
|
||||
<hr />
|
||||
<p><strong>Description:</strong></p>
|
||||
<p>${escMultiline(description)}</p>
|
||||
<br />
|
||||
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a>
|
||||
<div style="font-family:sans-serif;max-width:560px;margin:0 auto;color:#1a1a1a;">
|
||||
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
|
||||
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
|
||||
</div>
|
||||
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
|
||||
<h2 style="margin:0 0 8px;font-size:20px;">New Request Received</h2>
|
||||
<p style="color:#555;margin:0 0 24px;">A new request has been created in the portal.</p>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
|
||||
<tr style="background:#f9f9f9;">
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">From</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(clientName)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Email</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(clientEmail)}</td>
|
||||
</tr>
|
||||
${company ? `
|
||||
<tr style="background:#f9f9f9;">
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Company</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(company)}</td>
|
||||
</tr>` : ''}
|
||||
<tr>
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Service</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(serviceType)}</td>
|
||||
</tr>
|
||||
<tr style="background:#f9f9f9;">
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Project</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(projectName)}</td>
|
||||
</tr>
|
||||
${deadline ? `
|
||||
<tr>
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Deadline</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(deadline)}</td>
|
||||
</tr>` : ''}
|
||||
</table>
|
||||
|
||||
<div style="background:#f9f9f9;padding:12px 14px;border-radius:6px;font-size:13px;color:#555;margin-bottom:24px;">
|
||||
<strong>Description</strong><br />
|
||||
${escMultiline(description)}
|
||||
</div>
|
||||
|
||||
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="display:block;background:#141414;color:#fff;text-align:center;padding:14px;border-radius:8px;text-decoration:none;font-weight:700;font-size:16px;margin-bottom:20px;">View Task</a>
|
||||
|
||||
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
|
||||
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -210,16 +257,50 @@ serve(async (req) => {
|
||||
|
||||
subject = `Revision Request: ${esc(serviceType)} — ${esc(clientName)}`;
|
||||
html = `
|
||||
<h2>Revision Requested</h2>
|
||||
<p><strong>From:</strong> ${esc(clientName)}</p>
|
||||
<p><strong>Job:</strong> ${esc(serviceType)} — ${esc(projectName)}</p>
|
||||
<p><strong>New Version:</strong> ${esc(version)}</p>
|
||||
${deadline ? `<p><strong>New Deadline:</strong> ${esc(deadline)}</p>` : ''}
|
||||
<hr />
|
||||
<p><strong>Requested changes:</strong></p>
|
||||
<p>${escMultiline(description)}</p>
|
||||
<br />
|
||||
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a>
|
||||
<div style="font-family:sans-serif;max-width:560px;margin:0 auto;color:#1a1a1a;">
|
||||
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
|
||||
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
|
||||
</div>
|
||||
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
|
||||
<h2 style="margin:0 0 8px;font-size:20px;">Revision Requested</h2>
|
||||
<p style="color:#555;margin:0 0 24px;">A revision was requested in the portal.</p>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
|
||||
<tr style="background:#f9f9f9;">
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">From</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(clientName)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Task</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(serviceType)}</td>
|
||||
</tr>
|
||||
<tr style="background:#f9f9f9;">
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Project</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(projectName)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Version</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(version)}</td>
|
||||
</tr>
|
||||
${deadline ? `
|
||||
<tr style="background:#f9f9f9;">
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Deadline</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(deadline)}</td>
|
||||
</tr>` : ''}
|
||||
</table>
|
||||
|
||||
<div style="background:#f9f9f9;padding:12px 14px;border-radius:6px;font-size:13px;color:#555;margin-bottom:24px;">
|
||||
<strong>Requested changes</strong><br />
|
||||
${escMultiline(description)}
|
||||
</div>
|
||||
|
||||
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="display:block;background:#141414;color:#fff;text-align:center;padding:14px;border-radius:8px;text-decoration:none;font-weight:700;font-size:16px;margin-bottom:20px;">View Task</a>
|
||||
|
||||
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
|
||||
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -428,12 +509,168 @@ serve(async (req) => {
|
||||
`;
|
||||
}
|
||||
|
||||
else if (type === 'task_status_update') {
|
||||
const taskTitle = requireStr(data?.taskTitle);
|
||||
const statusLabel = requireStr(data?.statusLabel);
|
||||
const message = requireStr(data?.message, 10000);
|
||||
const taskId = data?.taskId;
|
||||
if (!isUuid(taskId)) throw new Error('Invalid taskId');
|
||||
|
||||
const subjectLine = optStr(data?.subject, 200) || `${statusLabel}: ${taskTitle}`;
|
||||
const headline = optStr(data?.headline, 200) || statusLabel;
|
||||
const projectName = optStr(data?.projectName, 200);
|
||||
const companyName = optStr(data?.companyName, 200);
|
||||
const buttonLabel = optStr(data?.buttonLabel, 80) || 'View Task';
|
||||
|
||||
subject = subjectLine;
|
||||
html = `
|
||||
<div style="font-family:sans-serif;max-width:560px;margin:0 auto;color:#1a1a1a;">
|
||||
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
|
||||
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
|
||||
</div>
|
||||
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
|
||||
<h2 style="margin:0 0 8px;font-size:20px;">${esc(headline)}</h2>
|
||||
<p style="color:#555;margin:0 0 24px;">${escMultiline(message)}</p>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
|
||||
<tr style="background:#f9f9f9;">
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Task</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(taskTitle)}</td>
|
||||
</tr>
|
||||
${projectName ? `
|
||||
<tr>
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Project</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(projectName)}</td>
|
||||
</tr>` : ''}
|
||||
${companyName ? `
|
||||
<tr style="background:#f9f9f9;">
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Company</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(companyName)}</td>
|
||||
</tr>` : ''}
|
||||
<tr>
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Status</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(statusLabel)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="display:block;background:#141414;color:#fff;text-align:center;padding:14px;border-radius:8px;text-decoration:none;font-weight:700;font-size:16px;margin-bottom:20px;">${esc(buttonLabel)}</a>
|
||||
|
||||
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
|
||||
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── 5. Resolve recipients ────────────────────────────────────────────────
|
||||
const teamTypes = ['new_request', 'revision_submitted', 'client_approved', 'subcontractor_invoice_submitted'];
|
||||
const teamTypes = ['revision_submitted', 'client_approved'];
|
||||
let recipients: string[];
|
||||
let cc: string[] | undefined;
|
||||
|
||||
if (teamTypes.includes(type)) {
|
||||
if (type === 'task_status_update') {
|
||||
const includeTeam = optBool(data?.includeTeam);
|
||||
const includeAssigned = optBool(data?.includeAssigned);
|
||||
const includeClient = optBool(data?.includeClient);
|
||||
const includeProjectMembers = optBool(data?.includeProjectMembers);
|
||||
const assignedProfileId = data?.assignedProfileId;
|
||||
const companyId = data?.companyId;
|
||||
const projectId = data?.projectId;
|
||||
const recipientStrategy = optStr(data?.recipientStrategy, 80);
|
||||
|
||||
const recipientSet = new Set<string>();
|
||||
|
||||
if (recipientStrategy === 'revision_request') {
|
||||
if (isUuid(assignedProfileId)) {
|
||||
const { data: assignedProfile } = await adminClient
|
||||
.from('profiles')
|
||||
.select('email, role')
|
||||
.eq('id', assignedProfileId)
|
||||
.single();
|
||||
|
||||
if (assignedProfile?.role === 'external') {
|
||||
if (isEmail(assignedProfile?.email)) recipientSet.add(assignedProfile.email);
|
||||
} else if (assignedProfile?.role === 'team') {
|
||||
TEAM_EMAILS.forEach(email => recipientSet.add(email));
|
||||
} else {
|
||||
TEAM_EMAILS.forEach(email => recipientSet.add(email));
|
||||
if (isUuid(projectId)) {
|
||||
const { data: memberships } = await adminClient
|
||||
.from('project_members')
|
||||
.select('profile:profiles!inner(email, role)')
|
||||
.eq('project_id', projectId);
|
||||
for (const membership of (memberships || [])) {
|
||||
const profile = Array.isArray(membership.profile) ? membership.profile[0] : membership.profile;
|
||||
if (profile?.role === 'external' && isEmail(profile?.email)) recipientSet.add(profile.email);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TEAM_EMAILS.forEach(email => recipientSet.add(email));
|
||||
if (isUuid(projectId)) {
|
||||
const { data: memberships } = await adminClient
|
||||
.from('project_members')
|
||||
.select('profile:profiles!inner(email, role)')
|
||||
.eq('project_id', projectId);
|
||||
for (const membership of (memberships || [])) {
|
||||
const profile = Array.isArray(membership.profile) ? membership.profile[0] : membership.profile;
|
||||
if (profile?.role === 'external' && isEmail(profile?.email)) recipientSet.add(profile.email);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (includeTeam) TEAM_EMAILS.forEach(email => recipientSet.add(email));
|
||||
|
||||
if (includeAssigned && isUuid(assignedProfileId)) {
|
||||
const { data: assignedProfile } = await adminClient
|
||||
.from('profiles')
|
||||
.select('email')
|
||||
.eq('id', assignedProfileId)
|
||||
.single();
|
||||
if (isEmail(assignedProfile?.email)) recipientSet.add(assignedProfile.email);
|
||||
}
|
||||
|
||||
if (includeClient && isUuid(companyId)) {
|
||||
const [{ data: company }, { data: clientProfiles }] = await Promise.all([
|
||||
adminClient.from('companies').select('contact_email').eq('id', companyId).single(),
|
||||
adminClient.from('profiles').select('email').eq('company_id', companyId).eq('role', 'client'),
|
||||
]);
|
||||
if (isEmail(company?.contact_email)) recipientSet.add(company.contact_email);
|
||||
for (const profile of (clientProfiles || [])) {
|
||||
if (isEmail(profile?.email)) recipientSet.add(profile.email);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeProjectMembers && isUuid(projectId)) {
|
||||
const { data: memberships } = await adminClient
|
||||
.from('project_members')
|
||||
.select('profile:profiles!inner(email)')
|
||||
.eq('project_id', projectId);
|
||||
for (const membership of (memberships || [])) {
|
||||
const profile = Array.isArray(membership.profile) ? membership.profile[0] : membership.profile;
|
||||
if (isEmail(profile?.email)) recipientSet.add(profile.email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recipients = [...recipientSet].filter(isEmail);
|
||||
if (recipients.length === 0) throw new Error('No valid recipient emails');
|
||||
}
|
||||
else if (type === 'new_request') {
|
||||
const recipientSet = new Set<string>();
|
||||
TEAM_EMAILS.forEach(email => recipientSet.add(email));
|
||||
recipients = [...recipientSet].filter(isEmail);
|
||||
if (recipients.length === 0) throw new Error('No valid recipient emails');
|
||||
}
|
||||
else if (type === 'subcontractor_invoice_submitted') {
|
||||
const recipientSet = new Set<string>();
|
||||
TEAM_EMAILS.forEach(email => recipientSet.add(email));
|
||||
const subEmail = optStr(data?.subEmail);
|
||||
if (isEmail(subEmail)) recipientSet.add(subEmail);
|
||||
recipients = [...recipientSet].filter(isEmail);
|
||||
if (recipients.length === 0) throw new Error('No valid recipient emails');
|
||||
}
|
||||
else if (teamTypes.includes(type)) {
|
||||
recipients = TEAM_EMAILS;
|
||||
} else {
|
||||
// Validate caller-supplied recipient list
|
||||
@@ -444,6 +681,10 @@ serve(async (req) => {
|
||||
|
||||
const senderEmail = optStr(data?.senderEmail);
|
||||
if (senderEmail && isEmail(senderEmail)) cc = [senderEmail];
|
||||
if (type === 'invoice_sent' || type === 'receipt_sent') {
|
||||
TEAM_EMAILS.forEach(email => recipients.push(email));
|
||||
recipients = [...new Set(recipients.filter(isEmail))];
|
||||
}
|
||||
if (type === 'invoice_sent') {
|
||||
cc = [...new Set([...(cc || []), 'hello@fourgebranding.com'])];
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Stripe from 'https://esm.sh/stripe@14?target=deno';
|
||||
|
||||
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, { apiVersion: '2023-10-16' });
|
||||
const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')!;
|
||||
const internalWebhookSecret = Deno.env.get('SUPABASE_WEBHOOK_SECRET')!;
|
||||
|
||||
serve(async (req) => {
|
||||
const body = await req.text();
|
||||
@@ -42,6 +43,31 @@ serve(async (req) => {
|
||||
};
|
||||
if (stripe_fee !== null) updateData.stripe_fee = stripe_fee;
|
||||
await supabase.from('invoices').update(updateData).eq('id', invoice_id);
|
||||
|
||||
const { data: invoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('invoice_number, invoice_email, bill_to, total, paid_at, company_id')
|
||||
.eq('id', invoice_id)
|
||||
.single();
|
||||
if (!invoice?.invoice_email) return;
|
||||
|
||||
await fetch(`${Deno.env.get('SUPABASE_URL')}/functions/v1/send-email`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-webhook-secret': internalWebhookSecret,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'receipt_sent',
|
||||
to: [invoice.invoice_email],
|
||||
data: {
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
billTo: invoice.bill_to || invoice.invoice_email,
|
||||
total: `$${Number(invoice.total || 0).toFixed(2)}`,
|
||||
paidDate: new Date(invoice.paid_at || new Date().toISOString()).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Card payments: session completes with payment_status = 'paid' immediately
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Placeholder to preserve migration history parity with the remote Supabase project.
|
||||
-- The original SQL for this already-applied remote migration is not present in the repo.
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Placeholder to preserve migration history parity with the remote Supabase project.
|
||||
-- The original SQL for this already-applied remote migration is not present in the repo.
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Placeholder to preserve migration history parity with the remote Supabase project.
|
||||
-- The original SQL for this already-applied remote migration is not present in the repo.
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Placeholder to preserve migration history parity with the remote Supabase project.
|
||||
-- The original SQL for this already-applied remote migration is not present in the repo.
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Placeholder to preserve migration history parity with the remote Supabase project.
|
||||
-- The original SQL for this already-applied remote migration is not present in the repo.
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Only team members may delete subcontractor invoices.
|
||||
drop policy if exists "Sub delete own draft invoices" on public.subcontractor_invoices;
|
||||
drop policy if exists "Sub delete own unpaid invoices" on public.subcontractor_invoices;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Prevent deletion of paid client invoices for all roles.
|
||||
create or replace function public.prevent_paid_invoice_delete()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
if old.status = 'paid' then
|
||||
raise exception 'Paid invoices cannot be deleted.';
|
||||
end if;
|
||||
return old;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists prevent_paid_invoice_delete on public.invoices;
|
||||
|
||||
create trigger prevent_paid_invoice_delete
|
||||
before delete on public.invoices
|
||||
for each row
|
||||
execute function public.prevent_paid_invoice_delete();
|
||||
@@ -0,0 +1,200 @@
|
||||
create or replace function public.get_my_role()
|
||||
returns text as $$
|
||||
select role from public.profiles where id = auth.uid();
|
||||
$$ language sql security definer stable
|
||||
set search_path = public;
|
||||
|
||||
create or replace function public.is_external()
|
||||
returns boolean as $$
|
||||
select get_my_role() = 'external';
|
||||
$$ language sql security definer stable
|
||||
set search_path = public;
|
||||
|
||||
create or replace function public.get_my_company_id()
|
||||
returns uuid as $$
|
||||
select company_id from public.profiles where id = auth.uid();
|
||||
$$ language sql security definer stable
|
||||
set search_path = public;
|
||||
|
||||
create or replace function public.has_company_access(company uuid)
|
||||
returns boolean as $$
|
||||
select exists (
|
||||
select 1
|
||||
from public.profiles p
|
||||
where p.id = auth.uid()
|
||||
and (
|
||||
p.company_id = company
|
||||
or exists (
|
||||
select 1
|
||||
from public.company_members cm
|
||||
where cm.profile_id = auth.uid()
|
||||
and cm.company_id = company
|
||||
)
|
||||
)
|
||||
);
|
||||
$$ language sql security definer stable
|
||||
set search_path = public;
|
||||
|
||||
create or replace function public.guard_task_update()
|
||||
returns trigger as $$
|
||||
declare
|
||||
caller_role text;
|
||||
begin
|
||||
select role into caller_role from public.profiles where id = auth.uid();
|
||||
|
||||
if caller_role = 'client' then
|
||||
new.project_id := old.project_id;
|
||||
new.invoiced := old.invoiced;
|
||||
|
||||
if not (
|
||||
new.status = 'not_started'
|
||||
and coalesce(new.current_version, 0) > coalesce(old.current_version, 0)
|
||||
and new.assigned_to is null
|
||||
and new.assigned_name is null
|
||||
) then
|
||||
new.assigned_to := old.assigned_to;
|
||||
new.assigned_name := old.assigned_name;
|
||||
end if;
|
||||
elsif caller_role = 'external' then
|
||||
new.project_id := old.project_id;
|
||||
new.invoiced := old.invoiced;
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer
|
||||
set search_path = public;
|
||||
|
||||
create or replace function public.handle_new_user()
|
||||
returns trigger as $$
|
||||
begin
|
||||
insert into public.profiles (id, name, email, role)
|
||||
values (
|
||||
new.id,
|
||||
coalesce(new.raw_user_meta_data->>'name', ''),
|
||||
new.email,
|
||||
coalesce(new.raw_user_meta_data->>'role', 'client')
|
||||
);
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer
|
||||
set search_path = public;
|
||||
|
||||
create or replace function public.prevent_duplicate_active_subcontractor_po_task()
|
||||
returns trigger as $$
|
||||
declare
|
||||
current_po_status text;
|
||||
duplicate_po_number text;
|
||||
begin
|
||||
if new.task_id is null then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
select status into current_po_status
|
||||
from public.subcontractor_payments
|
||||
where id = new.po_id;
|
||||
|
||||
if current_po_status = 'cancelled' then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
select sp.po_number into duplicate_po_number
|
||||
from public.subcontractor_po_items item
|
||||
join public.subcontractor_payments sp on sp.id = item.po_id
|
||||
where item.task_id = new.task_id
|
||||
and item.id <> coalesce(new.id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
and sp.status <> 'cancelled'
|
||||
limit 1;
|
||||
|
||||
if duplicate_po_number is not null then
|
||||
raise exception 'Task is already attached to active subcontractor PO %', duplicate_po_number
|
||||
using errcode = '23505';
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer
|
||||
set search_path = public;
|
||||
|
||||
create or replace function public.prevent_duplicate_on_subcontractor_po_reactivation()
|
||||
returns trigger as $$
|
||||
declare
|
||||
duplicate_task_title text;
|
||||
begin
|
||||
if old.status = new.status or new.status = 'cancelled' then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
select t.title into duplicate_task_title
|
||||
from public.subcontractor_po_items current_item
|
||||
join public.subcontractor_po_items other_item
|
||||
on other_item.task_id = current_item.task_id
|
||||
and other_item.po_id <> current_item.po_id
|
||||
join public.subcontractor_payments other_po
|
||||
on other_po.id = other_item.po_id
|
||||
and other_po.status <> 'cancelled'
|
||||
left join public.tasks t on t.id = current_item.task_id
|
||||
where current_item.po_id = new.id
|
||||
and current_item.task_id is not null
|
||||
limit 1;
|
||||
|
||||
if duplicate_task_title is not null then
|
||||
raise exception 'Cannot reactivate PO because task "%" is already attached to another active subcontractor PO', duplicate_task_title
|
||||
using errcode = '23505';
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer
|
||||
set search_path = public;
|
||||
|
||||
create or replace function public.sync_subcontractor_project_member()
|
||||
returns trigger as $$
|
||||
begin
|
||||
if new.profile_id is not null and new.project_id is not null then
|
||||
insert into public.project_members (project_id, profile_id)
|
||||
values (new.project_id, new.profile_id)
|
||||
on conflict (project_id, profile_id) do nothing;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer
|
||||
set search_path = public;
|
||||
|
||||
create or replace function public.sync_project_member_on_task_assign()
|
||||
returns trigger as $$
|
||||
begin
|
||||
if new.assigned_to is not null and new.project_id is not null then
|
||||
insert into public.project_members (project_id, profile_id)
|
||||
values (new.project_id, new.assigned_to)
|
||||
on conflict (project_id, profile_id) do nothing;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer
|
||||
set search_path = public;
|
||||
|
||||
do $$
|
||||
declare
|
||||
fn record;
|
||||
begin
|
||||
for fn in
|
||||
select n.nspname as schema_name, p.proname as function_name, pg_get_function_identity_arguments(p.oid) as identity_args
|
||||
from pg_proc p
|
||||
join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public'
|
||||
and p.proname in (
|
||||
'notify_company_folder_sync',
|
||||
'notify_profile_folder_sync',
|
||||
'sync_project_status'
|
||||
)
|
||||
loop
|
||||
execute format(
|
||||
'drop function if exists %I.%I(%s) cascade;',
|
||||
fn.schema_name,
|
||||
fn.function_name,
|
||||
fn.identity_args
|
||||
);
|
||||
end loop;
|
||||
end
|
||||
$$;
|
||||
@@ -0,0 +1,5 @@
|
||||
update public.submissions as s
|
||||
set submitted_by_name = p.name
|
||||
from public.profiles as p
|
||||
where s.submitted_by = p.id
|
||||
and coalesce(s.submitted_by_name, '') is distinct from coalesce(p.name, '');
|
||||
@@ -0,0 +1,64 @@
|
||||
alter table if exists public.submission_signs enable row level security;
|
||||
|
||||
drop policy if exists "Team all submission_signs" on public.submission_signs;
|
||||
create policy "Team all submission_signs" on public.submission_signs
|
||||
for all
|
||||
using (get_my_role() = 'team')
|
||||
with check (get_my_role() = 'team');
|
||||
|
||||
drop policy if exists "Client reads assigned company submission_signs" on public.submission_signs;
|
||||
create policy "Client reads assigned company submission_signs" on public.submission_signs
|
||||
for select
|
||||
using (
|
||||
submission_id in (
|
||||
select s.id
|
||||
from public.submissions s
|
||||
join public.tasks t on t.id = s.task_id
|
||||
join public.projects p on p.id = t.project_id
|
||||
where p.company_id = get_my_company_id()
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists "Client inserts assigned company submission_signs" on public.submission_signs;
|
||||
create policy "Client inserts assigned company submission_signs" on public.submission_signs
|
||||
for insert
|
||||
with check (
|
||||
get_my_role() = 'client'
|
||||
and submission_id in (
|
||||
select s.id
|
||||
from public.submissions s
|
||||
join public.tasks t on t.id = s.task_id
|
||||
join public.projects p on p.id = t.project_id
|
||||
where p.company_id = get_my_company_id()
|
||||
and s.submitted_by = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists "External reads assigned submission_signs" on public.submission_signs;
|
||||
create policy "External reads assigned submission_signs" on public.submission_signs
|
||||
for select
|
||||
using (
|
||||
get_my_role() = 'external'
|
||||
and submission_id in (
|
||||
select s.id
|
||||
from public.submissions s
|
||||
join public.tasks t on t.id = s.task_id
|
||||
join public.project_members pm on pm.project_id = t.project_id
|
||||
where pm.profile_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists "External inserts assigned submission_signs" on public.submission_signs;
|
||||
create policy "External inserts assigned submission_signs" on public.submission_signs
|
||||
for insert
|
||||
with check (
|
||||
get_my_role() = 'external'
|
||||
and submission_id in (
|
||||
select s.id
|
||||
from public.submissions s
|
||||
join public.tasks t on t.id = s.task_id
|
||||
join public.project_members pm on pm.project_id = t.project_id
|
||||
where pm.profile_id = auth.uid()
|
||||
and s.submitted_by = auth.uid()
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
alter table public.submissions
|
||||
add column if not exists signs jsonb not null default '[]'::jsonb;
|
||||
|
||||
update public.submissions s
|
||||
set signs = legacy.signs
|
||||
from (
|
||||
select
|
||||
submission_id,
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'sign_number', sign_number,
|
||||
'sign_name', sign_name,
|
||||
'sign_family', sign_family
|
||||
)
|
||||
order by sign_number
|
||||
) as signs
|
||||
from public.submission_signs
|
||||
group by submission_id
|
||||
) as legacy
|
||||
where s.id = legacy.submission_id
|
||||
and coalesce(s.signs, '[]'::jsonb) = '[]'::jsonb;
|
||||
@@ -0,0 +1,23 @@
|
||||
update public.submissions s
|
||||
set signs = legacy.signs
|
||||
from (
|
||||
select
|
||||
submission_id,
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'sign_number', sign_number,
|
||||
'sign_name', sign_name,
|
||||
'sign_family', sign_family
|
||||
)
|
||||
order by sign_number
|
||||
) as signs
|
||||
from public.submission_signs
|
||||
group by submission_id
|
||||
) as legacy
|
||||
where s.id = legacy.submission_id
|
||||
and (
|
||||
s.signs is null
|
||||
or s.signs = '[]'::jsonb
|
||||
);
|
||||
|
||||
drop table if exists public.submission_signs cascade;
|
||||
+15
-5
@@ -67,6 +67,9 @@ create table public.submissions (
|
||||
invoiced boolean not null default false,
|
||||
is_hot boolean not null default false,
|
||||
service_type text default '',
|
||||
sign_count integer,
|
||||
sign_family text default '',
|
||||
signs jsonb not null default '[]'::jsonb,
|
||||
deadline date,
|
||||
description text default '',
|
||||
submitted_by uuid references public.profiles(id) on delete set null,
|
||||
@@ -280,17 +283,20 @@ alter table public.fourge_passwords enable row level security;
|
||||
create or replace function public.get_my_role()
|
||||
returns text as $$
|
||||
select role from public.profiles where id = auth.uid();
|
||||
$$ language sql security definer stable;
|
||||
$$ language sql security definer stable
|
||||
set search_path = public;
|
||||
|
||||
create or replace function public.is_external()
|
||||
returns boolean as $$
|
||||
select get_my_role() = 'external';
|
||||
$$ language sql security definer stable;
|
||||
$$ language sql security definer stable
|
||||
set search_path = public;
|
||||
|
||||
create or replace function public.get_my_company_id()
|
||||
returns uuid as $$
|
||||
select company_id from public.profiles where id = auth.uid();
|
||||
$$ language sql security definer stable;
|
||||
$$ language sql security definer stable
|
||||
set search_path = public;
|
||||
|
||||
create or replace function public.get_database_size_bytes()
|
||||
returns bigint as $$
|
||||
@@ -326,7 +332,10 @@ begin
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer;
|
||||
$$ language plpgsql security definer
|
||||
set search_path = public;
|
||||
set search_path = public;
|
||||
set search_path = public;
|
||||
|
||||
create trigger guard_task_update
|
||||
before update on public.tasks
|
||||
@@ -647,7 +656,8 @@ begin
|
||||
);
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer;
|
||||
$$ language plpgsql security definer
|
||||
set search_path = public;
|
||||
|
||||
create trigger on_auth_user_created
|
||||
after insert on auth.users
|
||||
|
||||
Reference in New Issue
Block a user