diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6fd1d97..0127d29 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,60 @@ "permissions": { "allow": [ "Bash(\"/Users/kraohasanee/Documents/40-49 Fourge:*)", - "Bash(vercel --version)" + "Bash(vercel --version)", + "Bash(vercel --prod)", + "Bash(supabase --version)", + "Bash(brew install:*)", + "Read(//usr/local/bin/**)", + "Read(//Users/kraohasanee/.local/bin/**)", + "Read(//usr/**)", + "Bash(command -v supabase)", + "Read(//Users/kraohasanee/Library/**)", + "Bash(npx supabase:*)", + "Bash(echo $PATH)", + "Bash(supabase functions:*)", + "Bash(npm install:*)", + "Bash(export PATH=\"/opt/homebrew/bin:$PATH\")", + "Read(//opt/homebrew/bin/**)", + "Read(//Users/kraohasanee/.npm/bin/**)", + "Bash(export PATH=\"/usr/local/bin:/opt/homebrew/bin:$PATH\")", + "Read(//Users/kraohasanee/.supabase/**)", + "Bash(supabase status:*)", + "Bash(supabase orgs:*)", + "Bash(supabase projects:*)", + "Bash(supabase db:*)", + "Bash(npm run:*)", + "Bash(npx vercel:*)", + "Bash(curl -vI https://portal.fourgebranding.com)", + "Bash(dig portal.fourgebranding.com A +short)", + "Bash(dig portal.fourgebranding.com CNAME +short)", + "Bash(curl:*)", + "Bash(supabase migration:*)", + "Bash(ls \"/Users/kraohasanee/Documents/40-49 Fourge Branding/41 Website/fourge-portal\"/.env*)", + "Bash(supabase secrets:*)", + "Bash(stripe version:*)", + "Bash(stripe config:*)", + "Bash(stripe checkout:*)", + "Bash(stripe payment_intents list --limit 10)", + "Bash(stripe charges:*)", + "Bash(vercel ls:*)", + "Bash(vercel promote:*)", + "Bash(vercel inspect:*)", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\('gitSource', d.get\\('meta', ''\\)\\)\\)\")", + "Bash(npx vite:*)", + "Bash(wait)", + "Bash(stripe webhook_endpoints list)", + "Bash(grep VITE_SUPABASE_ANON_KEY .env.local)", + "Bash(grep VITE_SUPABASE_ANON_KEY .env.production)", + "Bash(vercel whoami *)", + "Bash(vercel deploy *)", + "Bash(vercel build *)", + "Bash(vercel pull *)", + "Bash(sudo npm *)", + "Bash(npx vercel@latest --prod)", + "Bash(git add *)", + "Bash(git commit -m ' *)", + "Bash(git push *)" ] } } diff --git a/.gitignore b/.gitignore index fc5ae9f..3b62bc5 100755 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ dist-ssr *.sln *.sw? .vercel +supabase/.temp diff --git a/api/fourge-passwords-crypto.js b/api/fourge-passwords-crypto.js new file mode 100644 index 0000000..c6cc61e --- /dev/null +++ b/api/fourge-passwords-crypto.js @@ -0,0 +1,98 @@ +import crypto from 'crypto'; +import { createClient } from '@supabase/supabase-js'; + +function json(res, status, body) { + res.status(status).setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.send(JSON.stringify(body)); +} + +async function requireTeam(authHeader) { + const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL; + const supabaseAnonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY; + + if (!supabaseUrl || !supabaseAnonKey) { + throw new Error('Supabase auth env is not configured on Vercel.'); + } + + const callerClient = createClient(supabaseUrl, supabaseAnonKey, { + auth: { persistSession: false, autoRefreshToken: false }, + global: { headers: { Authorization: authHeader } }, + }); + + const { data: userData, error: userError } = await callerClient.auth.getUser(); + if (userError || !userData?.user) { + return { ok: false, status: 401, message: 'Unauthorized' }; + } + + const { data: profile, error: profileError } = await callerClient + .from('profiles') + .select('role') + .eq('id', userData.user.id) + .single(); + + if (profileError) { + return { ok: false, status: 500, message: profileError.message }; + } + + if (profile?.role !== 'team') { + return { ok: false, status: 403, message: 'Forbidden' }; + } + + return { ok: true }; +} + +function getKey() { + const secret = process.env.PASSWORD_VAULT_KEY || ''; + if (!secret) throw new Error('PASSWORD_VAULT_KEY is not configured on Vercel.'); + return Buffer.from(secret, 'base64'); +} + +export default async function handler(req, res) { + if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' }); + + try { + const authHeader = req.headers.authorization || ''; + if (!authHeader) return json(res, 401, { error: 'No authorization header' }); + + const auth = await requireTeam(authHeader); + if (!auth.ok) return json(res, auth.status, { error: auth.message }); + + const { action, plaintext, ciphertext, iv } = req.body || {}; + const key = getKey(); + + if (action === 'encrypt') { + if (!plaintext) return json(res, 400, { error: 'plaintext required' }); + + const ivBuffer = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key, ivBuffer); + const encrypted = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + const packed = Buffer.concat([encrypted, tag]); + + return json(res, 200, { + ciphertext: packed.toString('base64'), + iv: ivBuffer.toString('base64'), + }); + } + + if (action === 'decrypt') { + if (!ciphertext || !iv) return json(res, 400, { error: 'ciphertext and iv required' }); + + const raw = Buffer.from(String(ciphertext), 'base64'); + const ivBuffer = Buffer.from(String(iv), 'base64'); + const encrypted = raw.subarray(0, raw.length - 16); + const tag = raw.subarray(raw.length - 16); + + const decipher = crypto.createDecipheriv('aes-256-gcm', key, ivBuffer); + decipher.setAuthTag(tag); + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + + return json(res, 200, { plaintext: decrypted.toString('utf8') }); + } + + return json(res, 400, { error: 'Invalid action' }); + } catch (error) { + return json(res, 500, { error: error.message || 'Unexpected server error' }); + } +} diff --git a/api/seafile.js b/api/seafile.js new file mode 100644 index 0000000..77f59f8 --- /dev/null +++ b/api/seafile.js @@ -0,0 +1,496 @@ +import { createClient } from '@supabase/supabase-js'; + +const DIRECTORY_USAGE_CACHE_TTL_MS = 2 * 60 * 1000; +const directoryUsageCache = new Map(); + +function json(res, status, body) { + res.status(status).setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.send(JSON.stringify(body)); +} + +function normalizeBaseUrl(url) { + return String(url || '').trim().replace(/\/+$/, ''); +} + +function normalizePath(path) { + const raw = String(path || '/').trim(); + const parts = raw.split('/').filter(Boolean); + const clean = []; + + for (const part of parts) { + if (part === '.' || part === '') continue; + if (part === '..') throw new Error('Invalid path'); + clean.push(part); + } + + return `/${clean.join('/')}`; +} + +function joinPath(...parts) { + return normalizePath(parts.join('/')); +} + +function safeName(value, fallback) { + const cleaned = String(value || '') + .trim() + .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') + .replace(/\s+/g, ' ') + .replace(/^-+|-+$/g, ''); + + return cleaned || fallback; +} + +function fillTemplate(template, profile) { + const name = safeName(profile.name, profile.id); + const companyName = safeName(profile.company?.name, name); + const email = safeName(profile.email, profile.id); + const emailName = safeName(String(profile.email || '').split('@')[0], profile.id); + + return String(template || '') + .replaceAll('{id}', profile.id) + .replaceAll('{name}', name) + .replaceAll('{companyName}', companyName) + .replaceAll('{email}', email) + .replaceAll('{emailName}', emailName); +} + +function getConfig() { + const serverUrl = normalizeBaseUrl(process.env.SEAFILE_SERVER_URL); + const apiToken = process.env.SEAFILE_API_TOKEN; + const repoId = process.env.SEAFILE_REPO_ID; + + return { + serverUrl, + webUrl: normalizeBaseUrl(process.env.SEAFILE_WEB_URL || serverUrl), + apiToken, + repoId, + teamRoot: normalizePath(process.env.SEAFILE_TEAM_ROOT_PATH || '/'), + externalRoot: normalizePath(process.env.SEAFILE_EXTERNAL_ROOT_PATH || '/Subcontractors'), + externalTemplate: process.env.SEAFILE_EXTERNAL_FOLDER_TEMPLATE || '{name}', + clientRoot: normalizePath(process.env.SEAFILE_CLIENT_ROOT_PATH || '/Clients'), + clientTemplate: process.env.SEAFILE_CLIENT_FOLDER_TEMPLATE || '{companyName}', + configured: Boolean(serverUrl && apiToken && repoId), + }; +} + +async function createCallerClient(authHeader) { + const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL; + const supabaseAnonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY; + + if (!supabaseUrl || !supabaseAnonKey) { + throw new Error('Supabase auth env is not configured on Vercel.'); + } + + return createClient(supabaseUrl, supabaseAnonKey, { + auth: { persistSession: false, autoRefreshToken: false }, + global: { headers: { Authorization: authHeader } }, + }); +} + +async function requirePortalUser(authHeader) { + const callerClient = await createCallerClient(authHeader); + + const { data: userData, error: userError } = await callerClient.auth.getUser(); + if (userError || !userData?.user) { + return { ok: false, status: 401, message: 'Unauthorized' }; + } + + const { data: profile, error: profileError } = await callerClient + .from('profiles') + .select('id, name, role, company:companies(id, name)') + .eq('id', userData.user.id) + .single(); + + if (profileError) { + return { ok: false, status: 500, message: profileError.message }; + } + + if (!['team', 'external', 'client'].includes(profile?.role)) { + return { ok: false, status: 403, message: 'Forbidden' }; + } + + return { + ok: true, + callerClient, + profile: { + ...profile, + email: userData.user.email, + }, + }; +} + +function getUserRoot(config, profile) { + if (profile.role === 'team') return config.teamRoot; + + if (profile.role === 'client') { + const templated = fillTemplate(config.clientTemplate, profile); + if (templated.startsWith('/')) return normalizePath(templated); + + return joinPath(config.clientRoot, templated); + } + + const templated = fillTemplate(config.externalTemplate, profile); + if (templated.startsWith('/')) return normalizePath(templated); + + return joinPath(config.externalRoot, templated); +} + +function resolveSeafilePath(config, profile, requestedPath = '/') { + const root = getUserRoot(config, profile); + const virtualPath = normalizePath(requestedPath); + return { + root, + virtualPath, + seafilePath: joinPath(root, virtualPath), + }; +} + +async function seafileRequest(config, endpoint, options = {}) { + const response = await fetch(`${config.serverUrl}${endpoint}`, { + ...options, + headers: { + Authorization: `Token ${config.apiToken}`, + Accept: 'application/json', + ...(options.headers || {}), + }, + }); + + const text = await response.text(); + let body = text; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = text; + } + + if (!response.ok) { + const message = typeof body === 'object' && body?.error_msg ? body.error_msg : text || `Seafile returned ${response.status}`; + const error = new Error(message); + error.status = response.status; + throw error; + } + + return body; +} + +async function createSeafileFolder(config, path) { + const body = new URLSearchParams({ operation: 'mkdir', create_parents: 'true' }); + await seafileRequest(config, `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(path)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); +} + +async function createSeafileFolderIfMissing(config, path) { + try { + await createSeafileFolder(config, path); + return true; + } catch (error) { + const message = String(error.message || '').toLowerCase(); + if (error.status === 400 || message.includes('already') || message.includes('exist')) return false; + throw error; + } +} + +function getCachedDirectoryUsage(cacheKey) { + const cached = directoryUsageCache.get(cacheKey); + if (!cached) return null; + if ((Date.now() - cached.timestamp) > DIRECTORY_USAGE_CACHE_TTL_MS) { + directoryUsageCache.delete(cacheKey); + return null; + } + + return cached.bytes; +} + +function setCachedDirectoryUsage(cacheKey, bytes) { + directoryUsageCache.set(cacheKey, { + bytes: Number(bytes) || 0, + timestamp: Date.now(), + }); +} + +async function listDirectoryEntries(config, path) { + const entries = await seafileRequest( + config, + `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(path)}` + ); + + return Array.isArray(entries) ? entries : []; +} + +async function getDirectoryUsageBytes(config, path, prefetchedEntries = null) { + const normalizedPath = normalizePath(path); + const cacheKey = `${config.repoId}:${normalizedPath}`; + const cached = getCachedDirectoryUsage(cacheKey); + if (cached != null) return cached; + + const entries = prefetchedEntries || await listDirectoryEntries(config, normalizedPath); + let total = 0; + + for (const entry of entries) { + if (entry.type === 'file') { + total += Number(entry.size || 0); + continue; + } + + if (entry.type === 'dir') { + total += await getDirectoryUsageBytes(config, joinPath(normalizedPath, entry.name)); + } + } + + setCachedDirectoryUsage(cacheKey, total); + return total; +} + +function clearDirectoryUsageCache(config, path) { + const normalizedPath = normalizePath(path); + const prefixes = []; + let cursor = normalizedPath; + + while (true) { + prefixes.push(`${config.repoId}:${cursor}`); + if (cursor === '/') break; + cursor = parentDir(cursor); + } + + for (const key of prefixes) { + directoryUsageCache.delete(key); + } +} + +function entryPath(parent, name) { + return joinPath(parent, name); +} + +function parentDir(path) { + const normalized = normalizePath(path); + const parts = normalized.split('/').filter(Boolean); + parts.pop(); + return `/${parts.join('/')}`; +} + +function basename(path) { + const parts = normalizePath(path).split('/').filter(Boolean); + return parts[parts.length - 1] || ''; +} + +async function syncManagedFolders(config, auth) { + if (auth.profile.role !== 'team') { + const error = new Error('Team only'); + error.status = 403; + throw error; + } + + const [{ data: companies, error: companiesError }, { data: externals, error: externalsError }] = await Promise.all([ + auth.callerClient.from('companies').select('id, name').order('name'), + auth.callerClient.from('profiles').select('id, name, email, role').eq('role', 'external').order('name'), + ]); + + if (companiesError) throw new Error(companiesError.message); + if (externalsError) throw new Error(externalsError.message); + + const folderPaths = [ + config.clientRoot, + config.externalRoot, + ...(companies || []).map(company => { + const profile = { id: company.id, name: company.name, email: '', company }; + const templated = fillTemplate(config.clientTemplate, profile); + return templated.startsWith('/') ? normalizePath(templated) : joinPath(config.clientRoot, templated); + }), + ...(externals || []).map(profile => { + const templated = fillTemplate(config.externalTemplate, profile); + return templated.startsWith('/') ? normalizePath(templated) : joinPath(config.externalRoot, templated); + }), + ]; + + const uniquePaths = [...new Set(folderPaths)].filter(path => path !== '/'); + let created = 0; + + for (const path of uniquePaths) { + if (await createSeafileFolderIfMissing(config, path)) created += 1; + } + + return { + created, + checked: uniquePaths.length, + clients: companies?.length || 0, + subcontractors: externals?.length || 0, + }; +} + +export default async function handler(req, res) { + try { + const authHeader = req.headers.authorization || ''; + if (!authHeader) return json(res, 401, { error: 'No authorization header' }); + + const auth = await requirePortalUser(authHeader); + if (!auth.ok) return json(res, auth.status, { error: auth.message }); + + const config = getConfig(); + if (!config.configured) { + return json(res, 200, { + configured: false, + error: 'Seafile is not configured yet.', + requiredEnv: ['SEAFILE_SERVER_URL', 'SEAFILE_API_TOKEN', 'SEAFILE_REPO_ID'], + }); + } + + const action = req.query.action || (req.method === 'GET' ? 'list' : ''); + const requestedPath = req.query.path || req.body?.path; + const resolved = resolveSeafilePath(config, auth.profile, requestedPath || '/'); + const invalidateUsage = req.query.invalidateUsage === '1'; + + if (req.method === 'POST' && action === 'sync-folders') { + const result = await syncManagedFolders(config, auth); + return json(res, 200, { success: true, ...result }); + } + + if (req.method === 'GET' && action === 'config') { + return json(res, 200, { + configured: true, + role: auth.profile.role, + root: resolved.root, + webUrl: config.webUrl, + }); + } + + if (req.method === 'GET' && action === 'list') { + if (invalidateUsage) clearDirectoryUsageCache(config, resolved.seafilePath); + let entries; + try { + entries = await listDirectoryEntries(config, resolved.seafilePath); + } catch (error) { + if (!['external', 'client'].includes(auth.profile.role) || resolved.virtualPath !== '/') throw error; + await createSeafileFolder(config, resolved.root); + entries = await listDirectoryEntries(config, resolved.seafilePath); + } + + const normalizedEntries = (Array.isArray(entries) ? entries : []).map((item) => { + const itemPath = entryPath(resolved.virtualPath, item.name); + + return { + id: item.id, + name: item.name, + type: item.type, + size: item.type === 'file' ? Number(item.size || 0) : 0, + aggregateSize: item.type === 'file' ? Number(item.size || 0) : null, + mtime: item.mtime || null, + permission: item.permission || null, + path: itemPath, + }; + }).sort((a, b) => { + if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + return json(res, 200, { + configured: true, + path: resolved.virtualPath, + canGoUp: resolved.virtualPath !== '/', + parentPath: parentDir(resolved.virtualPath), + entries: normalizedEntries, + webUrl: config.webUrl, + }); + } + + if (req.method === 'GET' && action === 'download') { + const url = await seafileRequest( + config, + `/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolved.seafilePath)}&reuse=1` + ); + + return json(res, 200, { url }); + } + + if (req.method === 'POST' && action === 'mkdir') { + const folderName = safeName(req.body?.name, ''); + if (!folderName) return json(res, 400, { error: 'Folder name is required.' }); + + const folderPath = joinPath(resolved.seafilePath, folderName); + await createSeafileFolder(config, folderPath); + clearDirectoryUsageCache(config, resolved.seafilePath); + + return json(res, 200, { success: true }); + } + + if (req.method === 'POST' && action === 'upload-link') { + const uploadLink = await seafileRequest( + config, + `/api2/repos/${encodeURIComponent(config.repoId)}/upload-link/?p=${encodeURIComponent(resolved.seafilePath)}` + ); + + return json(res, 200, { + uploadLink: typeof uploadLink === 'string' ? uploadLink : String(uploadLink || ''), + parentDir: resolved.seafilePath, + }); + } + + if (req.method === 'POST' && action === 'move') { + const srcPath = req.body?.srcPath; + const dstDir = req.body?.dstDir; + if (!srcPath || !dstDir) return json(res, 400, { error: 'srcPath and dstDir are required.' }); + + const resolvedSrc = resolveSeafilePath(config, auth.profile, srcPath); + const resolvedDst = resolveSeafilePath(config, auth.profile, dstDir); + const itemName = basename(resolvedSrc.seafilePath); + const srcDir = parentDir(resolvedSrc.seafilePath); + if (!itemName) return json(res, 400, { error: 'Cannot move root.' }); + + const type = req.body?.type || 'file'; + const endpoint = type === 'dir' + ? `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(resolvedSrc.seafilePath)}` + : `/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolvedSrc.seafilePath)}`; + + const body = new URLSearchParams({ + operation: 'move', + dst_repo: config.repoId, + dst_dir: resolvedDst.seafilePath, + }); + + await seafileRequest(config, endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + clearDirectoryUsageCache(config, srcDir); + clearDirectoryUsageCache(config, resolvedDst.seafilePath); + + return json(res, 200, { success: true }); + } + + if (req.method === 'DELETE' && action === 'delete') { + const type = req.query.type || req.body?.type; + if (!['file', 'dir'].includes(type)) return json(res, 400, { error: 'Valid item type is required.' }); + + const itemName = basename(resolved.seafilePath); + const itemParent = parentDir(resolved.seafilePath); + if (!itemName) return json(res, 400, { error: 'Cannot delete the root folder.' }); + + const body = new URLSearchParams({ + file_names: itemName, + }); + + await seafileRequest( + config, + `/api2/repos/${encodeURIComponent(config.repoId)}/fileops/delete/?p=${encodeURIComponent(itemParent)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + } + ); + + clearDirectoryUsageCache(config, itemParent); + + return json(res, 200, { success: true }); + } + + return json(res, 405, { error: 'Method not allowed' }); + } catch (error) { + return json(res, error.status || 500, { error: error.message || 'Unexpected Seafile error' }); + } +} diff --git a/api/server-status.js b/api/server-status.js new file mode 100644 index 0000000..c7e2259 --- /dev/null +++ b/api/server-status.js @@ -0,0 +1,446 @@ +import { createClient } from '@supabase/supabase-js'; + +const STATUS_ENDPOINTS = { + supabase: 'https://supabase.statuspage.io/api/v2/summary.json', + vercel: 'https://www.vercel-status.com/api/v2/summary.json', +}; + +const SUPABASE_LIMITS = { + storageBytes: 1024 * 1024 * 1024, + databaseBytes: 500 * 1024 * 1024, + egressBytes: 5 * 1024 * 1024 * 1024, +}; + +const VERCEL_LIMITS = { + fastDataTransferBytes: 100 * 1024 * 1024 * 1024, + edgeRequests: 1_000_000, + functionInvocations: 1_000_000, + activeCpuHours: 4, +}; + +function json(res, status, body) { + res.status(status).setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.send(JSON.stringify(body)); +} + +function toNumber(value) { + if (value === undefined || value === null || value === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function makeQuota(id, label, used, limit, unit, note, source = 'live') { + return { + id, + label, + used, + limit, + unit, + note, + source, + }; +} + +async function fetchJson(url) { + const response = await fetch(url, { + headers: { 'User-Agent': 'FourgePortal/1.0' }, + }); + + if (!response.ok) { + throw new Error(`Request failed (${response.status})`); + } + + return response.json(); +} + +async function fetchStatusSummary(url) { + try { + const summary = await fetchJson(url); + return { + ok: true, + status: summary.status || null, + updatedAt: summary.page?.updated_at || null, + components: Array.isArray(summary.components) + ? summary.components.map(component => ({ + id: component.id, + name: component.name, + status: component.status, + })) + : [], + }; + } catch (error) { + return { + ok: false, + status: { indicator: 'major', description: error.message || 'Status unavailable' }, + updatedAt: null, + components: [], + }; + } +} + +async function requireTeam(authHeader) { + const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL; + const supabaseAnonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY; + + if (!supabaseUrl || !supabaseAnonKey) { + throw new Error('Supabase auth env is not configured on Vercel.'); + } + + const callerClient = createClient(supabaseUrl, supabaseAnonKey, { + auth: { persistSession: false, autoRefreshToken: false }, + global: { headers: { Authorization: authHeader } }, + }); + + const { data: userData, error: userError } = await callerClient.auth.getUser(); + if (userError || !userData?.user) { + return { ok: false, status: 401, message: 'Unauthorized' }; + } + + const { data: profile, error: profileError } = await callerClient + .from('profiles') + .select('role') + .eq('id', userData.user.id) + .single(); + + if (profileError) { + return { ok: false, status: 500, message: profileError.message }; + } + + if (profile?.role !== 'team') { + return { ok: false, status: 403, message: 'Forbidden' }; + } + + return { ok: true, supabaseUrl }; +} + +async function countRows(client, table) { + const { count, error } = await client.from(table).select('id', { count: 'exact', head: true }); + if (error) throw error; + return count || 0; +} + +async function listStorageFolder(storageClient, bucketId, folder = '') { + const pageSize = 100; + let offset = 0; + const entries = []; + + while (true) { + const { data, error } = await storageClient.from(bucketId).list(folder, { + limit: pageSize, + offset, + sortBy: { column: 'name', order: 'asc' }, + }); + + if (error) throw error; + if (!data?.length) break; + + entries.push(...data); + if (data.length < pageSize) break; + offset += pageSize; + } + + return entries; +} + +function isStorageFile(entry) { + return Boolean(entry?.metadata && typeof entry.metadata === 'object' && 'size' in entry.metadata); +} + +async function getBucketUsage(storageClient, bucketId, folder = '') { + let totalBytes = 0; + let totalFiles = 0; + + const entries = await listStorageFolder(storageClient, bucketId, folder); + for (const entry of entries) { + if (isStorageFile(entry)) { + totalFiles += 1; + totalBytes += Number(entry.metadata.size || 0); + continue; + } + + const childFolder = folder ? `${folder}/${entry.name}` : entry.name; + const childUsage = await getBucketUsage(storageClient, bucketId, childFolder); + totalFiles += childUsage.totalFiles; + totalBytes += childUsage.totalBytes; + } + + return { totalBytes, totalFiles }; +} + +async function getStorageUsage(admin) { + const { data: buckets, error } = await admin.storage.listBuckets(); + if (error) throw error; + + const bucketEntries = []; + let totalBytes = 0; + let totalFiles = 0; + + for (const bucket of buckets || []) { + const bucketUsage = await getBucketUsage(admin.storage, bucket.id); + totalBytes += bucketUsage.totalBytes; + totalFiles += bucketUsage.totalFiles; + bucketEntries.push({ bucketId: bucket.id, bytes: bucketUsage.totalBytes }); + } + + bucketEntries.sort((a, b) => b.bytes - a.bytes); + return { totalBytes, totalFiles, buckets: bucketEntries }; +} + +async function getSupabaseUsageSnapshot(supabaseUrl, overridesResult) { + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + const databaseBytesFallback = toNumber(process.env.SUPABASE_DB_SIZE_BYTES); + const egressBytesFallback = toNumber(process.env.SUPABASE_EGRESS_BYTES); + const overrides = overridesResult?.data || null; + const egressBytes = toNumber(overrides?.supabase_egress_bytes) ?? egressBytesFallback; + + if (!serviceRoleKey) { + return { + configured: false, + message: 'Set SUPABASE_SERVICE_ROLE_KEY on Vercel to show live Supabase usage.', + quotas: [ + makeQuota('storage', 'Storage', null, SUPABASE_LIMITS.storageBytes, 'bytes', 'Live storage usage is not configured.', 'unavailable'), + makeQuota('database', 'Database size', databaseBytesFallback, SUPABASE_LIMITS.databaseBytes, 'bytes', databaseBytesFallback === null ? 'Run the latest migration or set SUPABASE_DB_SIZE_BYTES manually.' : 'Manual value from env.', databaseBytesFallback === null ? 'unavailable' : 'manual'), + makeQuota('egress', 'Egress', egressBytes, SUPABASE_LIMITS.egressBytes, 'bytes', egressBytes === null ? 'Set this on the Server Status page when you want to track current egress manually.' : 'Manual value saved from Server Status.', egressBytes === null ? 'unavailable' : 'manual'), + ], + stats: [], + }; + } + + const admin = createClient(supabaseUrl, serviceRoleKey, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const [ + storageUsage, + companyCount, + projectCount, + taskCount, + brandBookCount, + profileCount, + databaseSizeResult, + ] = await Promise.all([ + getStorageUsage(admin), + countRows(admin, 'companies'), + countRows(admin, 'projects'), + countRows(admin, 'tasks'), + countRows(admin, 'brand_books'), + countRows(admin, 'profiles'), + admin.rpc('get_database_size_bytes'), + ]); + + const databaseBytes = databaseSizeResult.error + ? null + : toNumber(databaseSizeResult.data); + + return { + configured: true, + message: null, + quotas: [ + makeQuota('storage', 'Storage', storageUsage.totalBytes, SUPABASE_LIMITS.storageBytes, 'bytes', `${storageUsage.totalFiles} file${storageUsage.totalFiles === 1 ? '' : 's'} across ${storageUsage.buckets.length} bucket${storageUsage.buckets.length === 1 ? '' : 's'}.`), + makeQuota( + 'database', + 'Database size', + databaseBytes, + SUPABASE_LIMITS.databaseBytes, + 'bytes', + databaseBytes === null + ? 'Run the latest Supabase migration to enable live database-size reporting.' + : 'Live reading from Supabase.', + databaseBytes === null ? 'unavailable' : 'live' + ), + makeQuota('egress', 'Egress', egressBytes, SUPABASE_LIMITS.egressBytes, 'bytes', egressBytes === null ? 'Set this on the Server Status page when you want to track current egress manually.' : 'Manual value saved from Server Status.', egressBytes === null ? 'unavailable' : 'manual'), + ], + stats: [ + { label: 'Storage Files', value: storageUsage.totalFiles }, + { label: 'Buckets', value: storageUsage.buckets.length }, + { label: 'Companies', value: companyCount }, + { label: 'Projects', value: projectCount }, + { label: 'Jobs', value: taskCount }, + { label: 'Users', value: profileCount }, + { label: 'Brand Books', value: brandBookCount }, + ], + buckets: storageUsage.buckets.slice(0, 5), + }; +} + +async function getVercelUsageSnapshot() { + const vercelToken = process.env.VERCEL_TOKEN; + const teamId = process.env.VERCEL_TEAM_ID || process.env.VERCEL_ORG_ID || ''; + const projectId = process.env.VERCEL_PROJECT_ID || ''; + + const fastDataTransferBytes = toNumber(process.env.VERCEL_FAST_DATA_TRANSFER_BYTES); + const edgeRequests = toNumber(process.env.VERCEL_EDGE_REQUESTS); + const functionInvocations = toNumber(process.env.VERCEL_FUNCTION_INVOCATIONS); + const activeCpuHours = toNumber(process.env.VERCEL_ACTIVE_CPU_HOURS); + + const quotas = [ + makeQuota('fast-data-transfer', 'Fast Data Transfer', fastDataTransferBytes, VERCEL_LIMITS.fastDataTransferBytes, 'bytes', fastDataTransferBytes === null ? 'Optional: set VERCEL_FAST_DATA_TRANSFER_BYTES to visualize current usage.' : 'Manual value from env.', fastDataTransferBytes === null ? 'unavailable' : 'manual'), + makeQuota('edge-requests', 'Edge Requests', edgeRequests, VERCEL_LIMITS.edgeRequests, 'count', edgeRequests === null ? 'Optional: set VERCEL_EDGE_REQUESTS to visualize current usage.' : 'Manual value from env.', edgeRequests === null ? 'unavailable' : 'manual'), + makeQuota('function-invocations', 'Function Invocations', functionInvocations, VERCEL_LIMITS.functionInvocations, 'count', functionInvocations === null ? 'Optional: set VERCEL_FUNCTION_INVOCATIONS to visualize current usage.' : 'Manual value from env.', functionInvocations === null ? 'unavailable' : 'manual'), + makeQuota('active-cpu', 'Functions Active CPU', activeCpuHours, VERCEL_LIMITS.activeCpuHours, 'hours', activeCpuHours === null ? 'Optional: set VERCEL_ACTIVE_CPU_HOURS to visualize current usage.' : 'Manual value from env.', activeCpuHours === null ? 'unavailable' : 'manual'), + ]; + + if (!vercelToken) { + return { + configured: false, + message: 'Set VERCEL_TOKEN on Vercel to show project and deployment counts.', + quotas, + stats: [], + }; + } + + const authHeaders = { Authorization: `Bearer ${vercelToken}` }; + const teamQuery = teamId ? `?teamId=${encodeURIComponent(teamId)}` : ''; + const deploymentParams = new URLSearchParams({ + limit: '20', + since: String(Date.now() - 30 * 24 * 60 * 60 * 1000), + }); + + if (projectId) deploymentParams.set('projectId', projectId); + if (teamId) deploymentParams.set('teamId', teamId); + + const [projectsResponse, deploymentsResponse] = await Promise.all([ + fetch(`https://api.vercel.com/v9/projects${teamQuery}`, { headers: authHeaders }), + fetch(`https://api.vercel.com/v6/deployments?${deploymentParams.toString()}`, { headers: authHeaders }), + ]); + + let projectCount = null; + let deployments30d = null; + let deploymentsToday = null; + + if (projectsResponse.ok) { + const projectsJson = await projectsResponse.json(); + projectCount = Array.isArray(projectsJson.projects) ? projectsJson.projects.length : null; + } + + if (deploymentsResponse.ok) { + const deploymentsJson = await deploymentsResponse.json(); + const deployments = Array.isArray(deploymentsJson.deployments) ? deploymentsJson.deployments : []; + deployments30d = deployments.length; + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + deploymentsToday = deployments.filter(deployment => { + const createdAt = Number(deployment.createdAt || 0); + return createdAt >= todayStart.getTime(); + }).length; + } + + return { + configured: true, + message: null, + quotas, + stats: [ + ...(projectCount === null ? [] : [{ label: 'Projects', value: projectCount }]), + ...(deployments30d === null ? [] : [{ label: 'Deployments (30d)', value: deployments30d }]), + ...(deploymentsToday === null ? [] : [{ label: 'Deployments Today', value: deploymentsToday }]), + ], + }; +} + +function applyManualOverrides(services, overrides) { + if (!overrides) return services; + + const updateQuota = (serviceKey, quotaId, value, note) => { + const service = services[serviceKey]; + if (!service) return; + service.usage.quotas = (service.usage.quotas || []).map(quota => + quota.id === quotaId + ? { + ...quota, + used: toNumber(value), + note: toNumber(value) === null ? quota.note : note, + source: toNumber(value) === null ? quota.source : 'manual', + } + : quota + ); + }; + + updateQuota('supabase', 'egress', overrides.supabase_egress_bytes, 'Manual value saved from Server Status.'); + updateQuota('vercel', 'fast-data-transfer', overrides.vercel_fast_data_transfer_bytes, 'Manual value saved from Server Status.'); + updateQuota('vercel', 'edge-requests', overrides.vercel_edge_requests, 'Manual value saved from Server Status.'); + updateQuota('vercel', 'function-invocations', overrides.vercel_function_invocations, 'Manual value saved from Server Status.'); + updateQuota('vercel', 'active-cpu', overrides.vercel_active_cpu_hours, 'Manual value saved from Server Status.'); + + return services; +} + +export default async function handler(req, res) { + if (!['GET', 'POST'].includes(req.method)) { + return json(res, 405, { error: 'Method not allowed' }); + } + + const authHeader = req.headers.authorization || ''; + if (!authHeader) { + return json(res, 401, { error: 'Missing authorization header' }); + } + + try { + const authResult = await requireTeam(authHeader); + if (!authResult.ok) { + return json(res, authResult.status, { error: authResult.message }); + } + + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + const admin = serviceRoleKey + ? createClient(authResult.supabaseUrl, serviceRoleKey, { + auth: { persistSession: false, autoRefreshToken: false }, + }) + : null; + + if (req.method === 'POST') { + if (!admin) return json(res, 500, { error: 'SUPABASE_SERVICE_ROLE_KEY is required to save overrides.' }); + + const body = typeof req.body === 'string' ? JSON.parse(req.body || '{}') : (req.body || {}); + const payload = { + id: true, + supabase_egress_bytes: toNumber(body.supabase_egress_bytes), + vercel_fast_data_transfer_bytes: toNumber(body.vercel_fast_data_transfer_bytes), + vercel_edge_requests: toNumber(body.vercel_edge_requests), + vercel_function_invocations: toNumber(body.vercel_function_invocations), + vercel_active_cpu_hours: toNumber(body.vercel_active_cpu_hours), + updated_at: new Date().toISOString(), + }; + + const { error } = await admin.from('server_status_overrides').upsert(payload); + if (error) return json(res, 500, { error: error.message }); + return json(res, 200, { success: true }); + } + + const overridesResult = admin + ? await admin.from('server_status_overrides').select('*').eq('id', true).maybeSingle() + : { data: null, error: null }; + + const [supabaseStatus, vercelStatus, supabaseUsage, vercelUsage] = await Promise.all([ + fetchStatusSummary(STATUS_ENDPOINTS.supabase), + fetchStatusSummary(STATUS_ENDPOINTS.vercel), + getSupabaseUsageSnapshot(authResult.supabaseUrl, overridesResult), + getVercelUsageSnapshot(), + ]); + + const services = applyManualOverrides({ + supabase: { + name: 'Supabase', + provider: 'supabase', + status: supabaseStatus, + usage: supabaseUsage, + limits: SUPABASE_LIMITS, + }, + vercel: { + name: 'Vercel', + provider: 'vercel', + status: vercelStatus, + usage: vercelUsage, + limits: VERCEL_LIMITS, + }, + }, overridesResult.data); + + return json(res, 200, { + checkedAt: new Date().toISOString(), + overrides: overridesResult.data || null, + services, + }); + } catch (error) { + return json(res, 500, { error: error.message || 'Server status failed' }); + } +} diff --git a/eslint.config.js b/eslint.config.js index 4fa125d..e0901fc 100755 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,18 @@ import reactRefresh from 'eslint-plugin-react-refresh' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', '.vercel']), + { + files: ['api/**/*.js'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.node, + sourceType: 'module', + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, { files: ['**/*.{js,jsx}'], extends: [ diff --git a/index.html b/index.html index 29e0177..3171ca4 100755 --- a/index.html +++ b/index.html @@ -3,6 +3,10 @@
+ + + +Loading...
- These users have signed up but haven't been assigned to a company yet. -
-Create a company to get started.
+| Company | +Clients | +Phone | +Address | +Actions | +
|---|---|---|---|---|
|
+ {company.name}
+ {companyProfiles.length > 0 && (
+
+ {companyProfiles.map(profile => (
+
+ )}
+
+ •
+
+ ))}
+
+
+ {profile.name || '—'}
+ |
+ {companyProfiles.length} | +{company.phone || '—'} | +{company.address || '—'} | + e.stopPropagation()}>
+ |
+
Create a company to get started.
-+ These client users are not linked to any company yet. +
+Create a client user to link them to a company.
+| Name | +Company | +Role | +Actions | +|
|---|---|---|---|---|
|
+ {editingUserId === user.id ? (
+
+ setEditUserVal(e.target.value)}
+ autoFocus
+ style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
+ onKeyDown={e => {
+ if (e.key === 'Enter') handleEditUserSave(user.id);
+ if (e.key === 'Escape') setEditingUserId(null);
+ }}
+ />
+
+ ) : (
+ user.name || '—'
+ )}
+ |
+ {user.email || '—'} | +{companyNames.length ? companyNames.join(', ') : '—'} | +{getRoleLabel(user.role)} | +
+ {editingUserId !== user.id && (
+
+
+ )}
+ |
+
Create a subcontractor user to manage external access and POs.
+| Name | +Role | +Actions | +|
|---|---|---|---|
|
+ {editingUserId === user.id ? (
+
+ setEditUserVal(e.target.value)}
+ autoFocus
+ style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
+ onKeyDown={e => {
+ if (e.key === 'Enter') handleEditUserSave(user.id);
+ if (e.key === 'Escape') setEditingUserId(null);
+ }}
+ />
+
+ ) : (
+ user.name || '—'
+ )}
+ |
+ {user.email || '—'} | +{getRoleLabel(user.role)} | +
+ {editingUserId !== user.id && (
+
+
+ )}
+ |
+
- These users have signed up but aren't assigned to any company yet. + Add an existing client user to this company. External subcontractors are assigned to projects instead.
No tasks found for this project.
+ ) : ( +Loading...
Upload files or create a folder to start this workspace.
Add encrypted password entries for internal services and team access.
+Loading...
- ) : filtered.length === 0 ? ( -Create your first invoice to get started.
-| Invoice # | -Company | -Date | -Due | -Status | -Total | -
|---|---|---|---|---|---|
| {inv.invoice_number} | -
- {inv.company?.name}
- |
- {new Date(inv.invoice_date).toLocaleDateString()} | -- - {new Date(inv.due_date).toLocaleDateString()} - - | -{inv.status} | -${Number(inv.total).toFixed(2)} | -
| Invoice # | +Bill To | +Date | +Due | +Status | +Total | +
|---|---|---|---|---|---|
| {inv.invoice_number} | +{inv.bill_to || inv.company?.name} | +{new Date(inv.invoice_date).toLocaleDateString()} | ++ + {new Date(inv.due_date).toLocaleDateString()} + + | +{inv.status} | +${Number(inv.total).toFixed(2)} | +
Loading...
+ ) : expensesError ? ( +{expensesError}
+ ) : filteredExpenses.length === 0 ? ( +No expenses yet.
+ ) : ( +| Date | +Description | +Category | +Notes | +Amount | +Action | +
|---|---|---|---|---|---|
| {new Date(exp.date).toLocaleDateString()} | +{exp.description} | +{exp.category} | +
+ {exp.notes || exp.receipt_path ? (
+
+ {exp.notes || '—'}
+ {exp.receipt_path && (
+
+ ) : '—'}
+ |
+ ${Number(exp.amount).toFixed(2)} | +
+
+
+ |
+
Loading...
+ ) : subcontractorError ? ( +{subcontractorError}
+ ) : subcontractorPOs.length === 0 ? ( +No subcontractor POs yet.
+ ) : ( + <> + {selectedSubcontractorPO && ( +{poStatusLabel[selectedSubcontractorPO.status] || selectedSubcontractorPO.status}
${Number(selectedSubcontractorPO.amount).toFixed(2)}
{new Date(selectedSubcontractorPO.date).toLocaleDateString()}
{selectedSubcontractorPO.due_date ? new Date(selectedSubcontractorPO.due_date).toLocaleDateString() : '—'}
{selectedSubcontractorPO.notes}
+ )} +| PO # | +Subcontractor | +Date | +Status | +Amount | +
|---|---|---|---|---|
|
+ {po.po_number || 'PO'}
+ |
+
+ {po.profile?.name || 'External'}
+ {po.profile?.email || '—'}
+ |
+
+ {new Date(po.paid_at || po.date).toLocaleDateString()}
+ {po.due_date && Due {new Date(po.due_date).toLocaleDateString()} }
+ |
+ + + {poStatusLabel[po.status] || po.status} + + | +${Number(po.amount).toFixed(2)} | +
Add the first entry to start the internal timeline.
+| Project | -Client | -Company | -Jobs | -Status | -Created | -
|---|---|---|---|---|---|
| - {project.name} - | -- {project.clientName} - {!project.clientId && ( - Guest - )} - | -{project.company || '—'} | -- {activeTasks.length} - / {tasks.length} active jobs - | -{project.createdAt} | -
Loading...
Client requests will appear here.
0 ? 12 : 0 }}>{primary.description}
- - {amendments.map(amendment => ( -{amendment.description}
-Try clearing the current company or requester filters.
+| Project | +Name | +Revision | +Request Type | +Client | +Deadline | +Status | +
|---|
| Project | +Name | +Revision | +Request Type | +Client | +Deadline | +Status | +
|---|
| + | Project | +Name | +Revision | +Request Type | +Client | +Deadline | +Status | +
|---|
Requests move here once they are completed, invoiced, and paid.
+| Project | +Name | +Revision | +Request Type | +Client | +Deadline | +Status | +
|---|
Loading...
- ) : savedBooks.length === 0 ? ( -Create your first brand book to get started.
-Loading...
Purchase order not found.
{new Date(po.date).toLocaleDateString()}
{po.due_date ? new Date(po.due_date).toLocaleDateString() : '—'}
{po.terms || 'Net 15'}
{po.project?.name || 'No project'}
{po.project?.company?.name || '—'}
${Number(po.amount).toFixed(2)}
{new Date(po.paid_at).toLocaleDateString()}
| Project | +Task | +Description | +Amount | +
|---|---|---|---|
| {po.project?.name || 'No project'} | +{item.task?.title || '—'} | +{item.description} | +${Number(item.amount).toFixed(2)} | +
{po.description}
} + {po.notes?.trim() &&{po.notes}
} +Loading...
Job not found.
- Upload the completed file and add an optional message for the client. + {currentDelivery + ? 'Add files or resend the email with the current delivery package.' + : 'Upload the completed file and add an optional message for the client.'}
+ This will be saved as the company contact email. +
+{vLabel(task.current_version)}
{new Date(task.submitted_at).toLocaleDateString()}
{task.completed_at ? new Date(task.completed_at).toLocaleDateString() : '—'}
+ {rLabel(revisionBaseline)}
+ {!isExternal && (
+
{formatDateEST(task.submitted_at)}
{task.completed_at ? formatDateEST(task.completed_at) : '—'}
{revisionBaseline}
No request notes yet.
) : (() => { - const currentVersion = task.current_version + 1; - const currentGroup = submissions.filter(s => s.version_number === currentVersion); - const primary = currentGroup.find(s => s.type !== 'amendment') || currentGroup[0]; + const primary = getCurrentPrimarySubmission(submissions, revisionBaseline); + const currentGroup = primary ? submissions.filter(s => s.version_number === primary.version_number) : []; const amendments = currentGroup.filter(s => s.type === 'amendment'); if (!primary) return null; + const startEditRequest = () => { + setRequestForm({ + serviceType: primary.service_type || '', + deadline: primary.deadline || '', + description: primary.description || '', + requestedBy: primary.submitted_by || currentUser?.id || '', + isHot: Boolean(primary.is_hot), + }); + setEditingRequest(true); + }; return ( <>{primary.service_type}
{primary.deadline || '—'}
- {primary.description} -
-{primary.service_type}
{primary.deadline || '—'}
{primary.is_hot ? 'Yes' : 'No'}
{primary.submitted_by_name || '—'}
++ {primary.description} +
+{amendment.description}
{amendment.files?.length > 0 && ( @@ -557,7 +1060,7 @@ export default function TaskDetail() { 📎 {file.name}{amendment.description}
{amendment.files?.length > 0 && ( @@ -741,7 +1240,7 @@ export default function TaskDetail() { 📎 {file.name}From: ${data.clientName} (${data.clientEmail})
-Company: ${data.company || '—'}
-Service: ${data.serviceType}
-Project: ${data.projectName}
- ${data.deadline ? `Deadline: ${data.deadline}
` : ''} +From: ${esc(clientName)} (${esc(clientEmail)})
+Company: ${esc(company) || '—'}
+Service: ${esc(serviceType)}
+Project: ${esc(projectName)}
+ ${deadline ? `Deadline: ${esc(deadline)}
` : ''}Description:
-${data.description}
+${escMultiline(description)}
Your ${data.serviceType} for ${data.projectName} is ready for review.
- ${data.message ? `${data.message}
` : ''} +Your ${esc(serviceType)} for ${esc(projectName)} is ready for review.
+ ${message ? `${escMultiline(message)}
` : ''}Please log in to the portal to review and approve or request changes.
— The Fourge Branding Team
`; } else if (type === 'revision_submitted') { - subject = `Revision Request: ${data.serviceType} — ${data.clientName}`; + const serviceType = requireStr(data?.serviceType); + const clientName = requireStr(data?.clientName); + const projectName = requireStr(data?.projectName); + const version = requireStr(data?.version); + const description = requireStr(data?.description, 10000); + const deadline = optStr(data?.deadline); + const taskId = data?.taskId; + if (!isUuid(taskId)) throw new Error('Invalid taskId'); + + subject = `Revision Request: ${esc(serviceType)} — ${esc(clientName)}`; html = `From: ${data.clientName}
-Job: ${data.serviceType} — ${data.projectName}
-New Version: ${data.version}
- ${data.deadline ? `New Deadline: ${data.deadline}
` : ''} +From: ${esc(clientName)}
+Job: ${esc(serviceType)} — ${esc(projectName)}
+New Version: ${esc(version)}
+ ${deadline ? `New Deadline: ${esc(deadline)}
` : ''}Requested changes:
-${data.description}
+${escMultiline(description)}
${data.clientName} has approved ${data.serviceType} for ${data.projectName}.
+${esc(clientName)} has approved ${esc(serviceType)} for ${esc(projectName)}.
This job is now complete.
+ Please find your invoice from Fourge Branding below.
+ +| Invoice # | +${esc(invoiceNumber)} | +
| Amount Due | +${esc(total)} | +
| Due Date | +${esc(dueDate)} | +
${escMultiline(notes)}
` : ''} + + Pay Now — ${esc(total)} + +
+ Secured by Stripe · Charged in USD
+ Questions? hello@fourgebranding.com
+
+ Thank you — your payment has been received. Here's your receipt.
+ +| Invoice # | +${esc(invoiceNumber)} | +
| Amount Paid | +${esc(total)} | +
| Payment Date | +${esc(paidDate)} | +
+ Questions? hello@fourgebranding.com +
+
+ A subcontractor purchase order is ready for your review.
+ +| PO # | +${esc(poNumber)} | +
| Project | +${esc(projectName)} | +
| Company | +${esc(companyName)} | +
| Amount | +${esc(amount)} | +
| Due | +${esc(dueDate)} | +
| Terms | +${esc(terms)} | +
+ Questions? hello@fourgebranding.com +
+