From eee08858113f2df1fc23f9ce5e47940cb191d864 Mon Sep 17 00:00:00 2001 From: Krao Hasanee Date: Wed, 13 May 2026 14:20:38 -0400 Subject: [PATCH] Fix file sharing load speed and move error; misc updates - Remove recursive directory size calculations (single Seafile API call per list) - Remove 'Used in this location' usage display - Fix move using v2 per-type endpoints instead of broken batch endpoint - Send entry type from frontend for correct move routing Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 55 +- .gitignore | 1 + api/fourge-passwords-crypto.js | 98 + api/seafile.js | 496 +++ api/server-status.js | 446 +++ eslint.config.js | 13 +- index.html | 4 + kakeibo-preview/index.html | 1603 ++++++++ package-lock.json | 114 + package.json | 3 + ...le-developer-merchantid-domain-association | 1 + public/apple-touch-icon.png | Bin 0 -> 3993 bytes public/favicon-16.png | Bin 0 -> 458 bytes public/favicon-32.png | Bin 0 -> 688 bytes public/favicon.svg | 5 +- scripts/cleanup-orphaned-storage.mjs | 113 + src/components/Layout.jsx | 117 +- src/components/LoadingButton.jsx | 8 + src/components/PageLoader.jsx | 15 + src/context/AuthContext.jsx | 133 +- src/index.css | 850 ++++- src/lib/archiveHelpers.js | 839 +++++ src/lib/brandBookEditor.js | 713 +++- src/lib/brandbook.js | 546 --- src/lib/dates.js | 49 + src/lib/deleteHelpers.js | 88 +- src/lib/email.js | 98 +- src/lib/invoice.js | 1162 +++++- src/lib/pageCache.js | 24 + src/lib/requestSubmission.js | 118 + src/lib/seafileFolders.js | 19 + src/lib/signsurvey.js | 441 --- src/lib/surveyMakerPdf.js | 239 ++ src/lib/taskDeadlines.js | 28 + src/lib/withTimeout.js | 13 + src/pages/Login.jsx | 6 +- src/pages/PayInvoice.jsx | 153 + src/pages/Settings.jsx | 88 +- src/pages/Signup.jsx | 63 - src/pages/SignupConfirmation.jsx | 19 - src/pages/external/MyPurchaseOrders.jsx | 183 + src/pages/team/BrandBook.jsx | 3270 +++++++++++++++-- src/pages/team/BrandIdentity.jsx | 505 --- src/pages/team/Companies.jsx | 622 +++- src/pages/team/CompanyDetail.jsx | 96 +- src/pages/team/Converters.jsx | 432 +++ src/pages/team/CreateInvoice.jsx | 288 +- src/pages/team/CreateSubcontractorPO.jsx | 393 ++ src/pages/team/Dashboard.jsx | 390 +- src/pages/team/FileSharing.jsx | 102 +- src/pages/team/FourgePasswords.jsx | 370 ++ src/pages/team/Invoices.jsx | 896 ++++- src/pages/team/MeetingNotes.jsx | 193 + src/pages/team/Projects.jsx | 58 - src/pages/team/Requests.jsx | 798 +++- src/pages/team/ServerStatus.jsx | 253 ++ src/pages/team/SignSurvey.jsx | 802 ---- src/pages/team/SubcontractorPODetail.jsx | 247 ++ src/pages/team/SurveyMaker.jsx | 312 ++ src/pages/team/TaskDetail.jsx | 759 +++- supabase/.temp/cli-latest | 2 +- supabase/.temp/gotrue-version | 0 supabase/.temp/pooler-url | 0 supabase/.temp/postgres-version | 0 supabase/.temp/project-ref | 0 supabase/.temp/rest-version | 0 supabase/.temp/storage-migration | 2 +- supabase/.temp/storage-version | 2 +- supabase/config.toml | 5 + .../create-checkout-session/index.ts | 92 + supabase/functions/create-user/index.ts | 66 +- supabase/functions/delete-user/index.ts | 53 + .../fourge-passwords-crypto/index.ts | 99 + .../functions/get-public-invoice/index.ts | 70 + supabase/functions/send-email/index.ts | 413 ++- supabase/functions/stripe-webhook/index.ts | 68 +- .../20260407173000_add_database_size_rpc.sql | 12 + ...0407180500_add_server_status_overrides.sql | 22 + ...08025500_normalize_submission_versions.sql | 24 + ...alize_invalid_submission_service_types.sql | 14 + .../20260408140000_add_bill_to_invoices.sql | 1 + ...20260409110000_add_fourge_files_bucket.sql | 17 + ...l => 20260409120000_add_external_role.sql} | 40 +- .../20260409120100_add_revision_billing.sql | 23 + ...0409120200_fix_submission_files_insert.sql | 28 + ...409120300_restrict_profile_self_update.sql | 10 + ...0260409120400_tighten_storage_policies.sql | 118 + ...60409120500_add_client_update_policies.sql | 11 + ...9124500_add_fourge_files_update_policy.sql | 4 + .../20260409133000_add_fourge_passwords.sql | 18 + .../20260414180000_add_expenses.sql | 14 + ...20260414181000_add_paid_at_to_invoices.sql | 1 + ...0414182000_add_expense_receipt_columns.sql | 2 + .../20260414193000_add_expense_receipts.sql | 22 + ...20260420140000_add_company_memberships.sql | 153 + .../20260420143000_add_invoice_email.sql | 2 + ...60420150000_add_subcontractor_payments.sql | 18 + ...00_remove_external_company_memberships.sql | 9 + ...20154500_subcontractor_purchase_orders.sql | 52 + ...60421103000_add_subcontractor_po_items.sql | 33 + ...1120000_require_nonblank_project_names.sql | 10 + ...uplicate_active_subcontractor_po_tasks.sql | 75 + ...3113000_allow_client_revision_unassign.sql | 30 + .../20260423150000_add_meeting_notes.sql | 15 + ...20260424090000_add_submission_hot_flag.sql | 2 + ...60428103000_prevent_duplicate_requests.sql | 16 + ...uto_sync_subcontractor_project_members.sql | 27 + ...30000_allow_all_mime_types_submissions.sql | 5 + ...260513120000_client_task_delete_policy.sql | 20 + .../add_brand_book_cover_fields.sql | 13 - .../add_brand_book_template_fields.sql | 4 - .../migrations/add_company_brand_fields.sql | 29 - supabase/migrations/add_price_type.sql | 17 - supabase/migrations/add_revision_billing.sql | 15 - supabase/schema.sql | 89 +- vercel.json | 2 +- vite.config.js | 10 + 117 files changed, 17592 insertions(+), 4057 deletions(-) create mode 100644 api/fourge-passwords-crypto.js create mode 100644 api/seafile.js create mode 100644 api/server-status.js create mode 100644 kakeibo-preview/index.html create mode 100644 public/.well-known/apple-developer-merchantid-domain-association create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon-16.png create mode 100644 public/favicon-32.png mode change 100755 => 100644 public/favicon.svg create mode 100644 scripts/cleanup-orphaned-storage.mjs create mode 100644 src/components/LoadingButton.jsx create mode 100644 src/components/PageLoader.jsx create mode 100644 src/lib/archiveHelpers.js delete mode 100644 src/lib/brandbook.js create mode 100644 src/lib/dates.js mode change 100755 => 100644 src/lib/email.js create mode 100644 src/lib/pageCache.js create mode 100644 src/lib/requestSubmission.js create mode 100644 src/lib/seafileFolders.js delete mode 100644 src/lib/signsurvey.js create mode 100644 src/lib/surveyMakerPdf.js create mode 100644 src/lib/taskDeadlines.js create mode 100644 src/lib/withTimeout.js create mode 100644 src/pages/PayInvoice.jsx delete mode 100755 src/pages/Signup.jsx delete mode 100755 src/pages/SignupConfirmation.jsx create mode 100644 src/pages/external/MyPurchaseOrders.jsx delete mode 100644 src/pages/team/BrandIdentity.jsx create mode 100644 src/pages/team/Converters.jsx create mode 100644 src/pages/team/CreateSubcontractorPO.jsx create mode 100644 src/pages/team/FourgePasswords.jsx create mode 100644 src/pages/team/MeetingNotes.jsx delete mode 100755 src/pages/team/Projects.jsx create mode 100644 src/pages/team/ServerStatus.jsx delete mode 100644 src/pages/team/SignSurvey.jsx create mode 100644 src/pages/team/SubcontractorPODetail.jsx create mode 100644 src/pages/team/SurveyMaker.jsx mode change 100755 => 100644 supabase/.temp/cli-latest mode change 100755 => 100644 supabase/.temp/gotrue-version mode change 100755 => 100644 supabase/.temp/pooler-url mode change 100755 => 100644 supabase/.temp/postgres-version mode change 100755 => 100644 supabase/.temp/project-ref mode change 100755 => 100644 supabase/.temp/rest-version mode change 100755 => 100644 supabase/.temp/storage-migration mode change 100755 => 100644 supabase/.temp/storage-version create mode 100644 supabase/config.toml create mode 100644 supabase/functions/create-checkout-session/index.ts create mode 100644 supabase/functions/delete-user/index.ts create mode 100644 supabase/functions/fourge-passwords-crypto/index.ts create mode 100644 supabase/functions/get-public-invoice/index.ts create mode 100644 supabase/migrations/20260407173000_add_database_size_rpc.sql create mode 100644 supabase/migrations/20260407180500_add_server_status_overrides.sql create mode 100644 supabase/migrations/20260408025500_normalize_submission_versions.sql create mode 100644 supabase/migrations/20260408130000_normalize_invalid_submission_service_types.sql create mode 100644 supabase/migrations/20260408140000_add_bill_to_invoices.sql create mode 100644 supabase/migrations/20260409110000_add_fourge_files_bucket.sql rename supabase/migrations/{add_external_role.sql => 20260409120000_add_external_role.sql} (78%) create mode 100644 supabase/migrations/20260409120100_add_revision_billing.sql create mode 100644 supabase/migrations/20260409120200_fix_submission_files_insert.sql create mode 100644 supabase/migrations/20260409120300_restrict_profile_self_update.sql create mode 100644 supabase/migrations/20260409120400_tighten_storage_policies.sql create mode 100644 supabase/migrations/20260409120500_add_client_update_policies.sql create mode 100644 supabase/migrations/20260409124500_add_fourge_files_update_policy.sql create mode 100644 supabase/migrations/20260409133000_add_fourge_passwords.sql create mode 100644 supabase/migrations/20260414180000_add_expenses.sql create mode 100644 supabase/migrations/20260414181000_add_paid_at_to_invoices.sql create mode 100644 supabase/migrations/20260414182000_add_expense_receipt_columns.sql create mode 100644 supabase/migrations/20260414193000_add_expense_receipts.sql create mode 100644 supabase/migrations/20260420140000_add_company_memberships.sql create mode 100644 supabase/migrations/20260420143000_add_invoice_email.sql create mode 100644 supabase/migrations/20260420150000_add_subcontractor_payments.sql create mode 100644 supabase/migrations/20260420153000_remove_external_company_memberships.sql create mode 100644 supabase/migrations/20260420154500_subcontractor_purchase_orders.sql create mode 100644 supabase/migrations/20260421103000_add_subcontractor_po_items.sql create mode 100644 supabase/migrations/20260421120000_require_nonblank_project_names.sql create mode 100644 supabase/migrations/20260421121000_prevent_duplicate_active_subcontractor_po_tasks.sql create mode 100644 supabase/migrations/20260423113000_allow_client_revision_unassign.sql create mode 100644 supabase/migrations/20260423150000_add_meeting_notes.sql create mode 100644 supabase/migrations/20260424090000_add_submission_hot_flag.sql create mode 100644 supabase/migrations/20260428103000_prevent_duplicate_requests.sql create mode 100644 supabase/migrations/20260507120000_auto_sync_subcontractor_project_members.sql create mode 100644 supabase/migrations/20260507130000_allow_all_mime_types_submissions.sql create mode 100644 supabase/migrations/20260513120000_client_task_delete_policy.sql delete mode 100644 supabase/migrations/add_brand_book_cover_fields.sql delete mode 100644 supabase/migrations/add_brand_book_template_fields.sql delete mode 100644 supabase/migrations/add_company_brand_fields.sql delete mode 100644 supabase/migrations/add_price_type.sql delete mode 100644 supabase/migrations/add_revision_billing.sql 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 @@ + + + + fourge-portal diff --git a/kakeibo-preview/index.html b/kakeibo-preview/index.html new file mode 100644 index 0000000..bcc1d57 --- /dev/null +++ b/kakeibo-preview/index.html @@ -0,0 +1,1603 @@ + + + + + +Kakeibo — App Preview + + + + + +
+ Theme +
+
+
+
+
+ +
+
Home
+
Envelopes
+
Log
+
Journal
+
+ + +
+
+
+ + +
+ 9:41 +
+ + WiFi + ■■■ +
+
+ + +
+
+
+
今月の予算 · May 2026
+
Good morning, Krao
+
+ + +
+
+ Logging streak + 7 days 🔥 +
+
+
M
+
T
+
W
+
T
+
F
+
S
+
S
+
+
+ + +
+
+
Remaining Budget
+
฿28,450
+
of ฿65,000 allocated this month
+
+
+ Income + ฿85,000 +
+
+ Spent + ฿36,550 +
+
+ Saved + ฿20,000 +
+
+
+
+ + +
+
+ + + + +
+ 23% + saved +
+
+
+ + + +
+
+
+
🏠
+
+
Survival
+
生存
+
+
+
+ ฿18,200 + / ฿22,000 +
+
+
+ +
+
+
+
+
Optional
+
娯楽
+
+
+
+ ฿8,100 + / ฿15,000 +
+
+
+ +
+
+
📚
+
+
Culture
+
教養
+
+
+
+ ฿4,250 + / ฿8,000 +
+
+
+ +
+
+
+
+
Extra
+
予備
+
+
+
+ ฿6,000 + / ฿10,000 +
+
+
+
+ + + +
+
+
🛒
+
+
Tops Supermarket
+
Groceries · Survival
+
+
-฿1,240
+
+
+
+
+
Café Amazon
+
Coffee · Optional
+
+
-฿85
+
+
+
📖
+
+
Kinokuniya
+
Books · Culture
+
+
-฿520
+
+
+
💰
+
+
Salary
+
Income · May 1
+
+
+฿85,000
+
+
+
+ + +
+
+ + +
+
+
+
Home
+
+
+
+
Envelopes
+
+
+
+
Journal
+
+
+
+
Settings
+
+
+
+ + +
+
+
Envelopes
+
May 2026 · ฿65,000 allocated
+ + +
+
+ 🏠 Survival · 生存 + ฿18,200 / ฿22,000 +
+
+
+
🏠
+
+
Rent
+
+
+ 100% +
+
+
+
฿0
+
of ฿12,000
+
+
+
+
🛒
+
+
Groceries
+
+
+ 62% +
+
+
+
฿3,800
+
of ฿10,000
+
+
+
+ + +
+
+ ✨ Optional · 娯楽 + ฿8,100 / ฿15,000 +
+
+
+
🍜
+
+
Dining Out
+
+
+ 44% +
+
+
+
฿5,600
+
of ฿10,000
+
+
+
+
🛍
+
+
Shopping
+
+
+ 76% +
+
+
+
฿1,200
+
of ฿5,000
+
+
+
+ + +
+
+ 📚 Culture · 教養 + ฿4,250 / ฿8,000 +
+
+
+
📖
+
+
Books
+
+
+ 35% +
+
+
+
฿3,250
+
of ฿5,000
+
+
+
+
🎬
+
+
Entertainment
+
+
+ 67% +
+
+
+
฿1,000
+
of ฿3,000
+
+
+
+ + +
+
+ ⚡ Extra · 予備 + ฿6,000 / ฿10,000 +
+
+
+
💊
+
+
Medical
+
+
+ 60% +
+
+
+
฿4,000
+
of ฿10,000
+
+
+
+
+ +
+
+ +
+
+
+
Home
+
+
+
+
Envelopes
+
+
+
+
Journal
+
+
+
+
Settings
+
+
+
+ + +
+
+
+
+
Log Expense
+
Today
+
+ +
+
+ ฿0 +
+
+ + +
+
What did you spend on?
+
e.g. Lunch at Pier 21…
+
+ + +
+
Pillar
+
+
🏠 Survival
+
✨ Optional
+
📚 Culture
+
⚡ Extra
+
+
+ + +
+
Envelope
+
Select envelope →
+
+ + +
+
Note (optional)
+
How did this make you feel?
+
+ + +
+
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
.
+
0
+
+
+ +
+
Save Transaction
+
+
+
+ + +
+
+
Journal
+
月次記録 · Monthly reflection
+ +
+
+
May 2026
+
+
+ + + +
+
+ "Save more by cooking at home. Try to keep Optional spending under ฿12,000 this month." +
+
+ + + +
+
+
01 · いくら稼ぎましたか
+
How much did you earn?
+
฿85,000
+
+ +
+
02 · いくら使いましたか
+
How much did you spend?
+
฿36,550 so far
+
+ +
+
03 · いくら貯金できましたか
+
How much did you save?
+
฿20,000 (23%)
+
+ +
+
04 · どうすれば改善できますか
+
How can you improve?
+
"Shopping envelope is 76% used with 2 weeks left. Plan before buying."
+
+
+ + + +
+
+
📓
+
+
April 2026
+
Saved 18% · ฿15,300
+
+
+
+
+
📓
+
+
March 2026
+
Saved 22% · ฿18,700
+
+
+
+
+
+ +
+
+
+
Home
+
+
+
+
Envelopes
+
+
+
+
Journal
+
+
+
+
Settings
+
+
+
+ +
+
+ + + + diff --git a/package-lock.json b/package-lock.json index eba71c3..cec7c2d 100755 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,11 @@ "version": "0.0.0", "dependencies": { "@supabase/supabase-js": "^2.99.3", + "heic-to": "^1.4.2", + "heic2any": "^0.0.4", "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.7", + "jszip": "^3.10.1", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.13.1" @@ -1328,6 +1331,12 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1787,6 +1796,18 @@ "node": ">=8" } }, + "node_modules/heic-to": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/heic-to/-/heic-to-1.4.2.tgz", + "integrity": "sha512-y69thwxfNcEm2Vk8lbOD/cMabnvMJyOREfJYiCHcXCDqlfcPyJoBhyRc8+iDe1B95LRfpbTOpzxzY1xbRkdwBA==", + "license": "LGPL-3.0" + }, + "node_modules/heic2any": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz", + "integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==", + "license": "MIT" + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -1837,6 +1858,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -1864,6 +1891,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/iobuffer": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", @@ -1893,6 +1926,12 @@ "node": ">=0.10.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1993,6 +2032,24 @@ "jspdf": "^2 || ^3 || ^4" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2017,6 +2074,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2519,6 +2585,12 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2598,6 +2670,21 @@ "react-dom": ">=18" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", @@ -2666,6 +2753,12 @@ "dev": true, "license": "MIT" }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2688,6 +2781,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2731,6 +2830,15 @@ "node": ">=0.1.14" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2860,6 +2968,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utrie": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", diff --git a/package.json b/package.json index ca3f856..6f52232 100755 --- a/package.json +++ b/package.json @@ -11,8 +11,11 @@ }, "dependencies": { "@supabase/supabase-js": "^2.99.3", + "heic-to": "^1.4.2", + "heic2any": "^0.0.4", "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.7", + "jszip": "^3.10.1", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.13.1" diff --git a/public/.well-known/apple-developer-merchantid-domain-association b/public/.well-known/apple-developer-merchantid-domain-association new file mode 100644 index 0000000..579b09f --- /dev/null +++ b/public/.well-known/apple-developer-merchantid-domain-association @@ -0,0 +1 @@ +7B227073704964223A2239373943394538343346343131343044463144313834343232393232313734313034353044314339464446394437384337313531303944334643463542433731222C2276657273696F6E223A312C22637265617465644F6E223A313731353230333737303832312C227369676E6174757265223A223330383030363039326138363438383666373064303130373032613038303330383030323031303133313064333030623036303936303836343830313635303330343032303133303830303630393261383634383836663730643031303730313030303061303830333038323033653333303832303338386130303330323031303230323038313636333463386230653330353731373330306130363038326138363438636533643034303330323330376133313265333032633036303335353034303330633235343137303730366336353230343137303730366336393633363137343639366636653230343936653734363536373732363137343639366636653230343334313230326432303437333333313236333032343036303335353034306230633164343137303730366336353230343336353732373436393636363936333631373436393666366532303431373537343638366637323639373437393331313333303131303630333535303430613063306134313730373036633635323034393665363332653331306233303039303630333535303430363133303235353533333031653137306433323334333033343332333933313337333433373332333735613137306433323339333033343332333833313337333433373332333635613330356633313235333032333036303335353034303330633163363536333633326437333664373032643632373236663662363537323264373336393637366535663535343333343264353035323466343433313134333031323036303335353034306230633062363934663533323035333739373337343635366437333331313333303131303630333535303430613063306134313730373036633635323034393665363332653331306233303039303630333535303430363133303235353533333035393330313330363037326138363438636533643032303130363038326138363438636533643033303130373033343230303034633231353737656465626436633762323231386636386464373039306131323138646337623062643666326332383364383436303935643934616634613534313162383334323065643831316633343037653833333331663163353463336637656233323230643662616435643465666634393238393839336537633066313361333832303231313330383230323064333030633036303335353164313330313031666630343032333030303330316630363033353531643233303431383330313638303134323366323439633434663933653465663237653663346636323836633366613262626664326534623330343530363038326230363031303530353037303130313034333933303337333033353036303832623036303130353035303733303031383632393638373437343730336132663266366636333733373032653631373037303663363532653633366636643266366636333733373033303334326436313730373036633635363136393633363133333330333233303832303131643036303335353164323030343832303131343330383230313130333038323031306330363039326138363438383666373633363430353031333038316665333038316333303630383262303630313035303530373032303233303831623630633831623335323635366336393631366536333635323036663665323037343638363937333230363336353732373436393636363936333631373436353230363237393230363136653739323037303631373237343739323036313733373337353664363537333230363136333633363537303734363136653633363532303666363632303734363836353230373436383635366532303631373037303663363936333631363236633635323037333734363136653634363137323634323037343635373236643733323036313665363432303633366636653634363937343639366636653733323036663636323037353733363532633230363336353732373436393636363936333631373436353230373036663663363936333739323036313665363432303633363537323734363936363639363336313734363936663665323037303732363136333734363936333635323037333734363137343635366436353665373437333265333033363036303832623036303130353035303730323031313632613638373437343730336132663266373737373737326536313730373036633635326536333666366432663633363537323734363936363639363336313734363536313735373436383666373236393734373932663330333430363033353531643166303432643330326233303239613032376130323538363233363837343734373033613266326636333732366332653631373037303663363532653633366636643266363137303730366336353631363936333631333332653633373236633330316430363033353531643065303431363034313439343537646236666435373438313836383938393736326637653537383530376537396235383234333030653036303335353164306630313031666630343034303330323037383033303066303630393261383634383836663736333634303631643034303230353030333030613036303832613836343863653364303430333032303334393030333034363032323130306336663032336362323631346262333033383838613136323938336531613933663130353666353066613738636462396261346361323431636331346532356530323231303062653363643064666431363234376636343934343735333830653964343463323238613130383930613361316463373234623862346362383838393831386263333038323032656533303832303237356130303330323031303230323038343936643266626633613938646139373330306130363038326138363438636533643034303330323330363733313162333031393036303335353034303330633132343137303730366336353230353236663666373432303433343132303264323034373333333132363330323430363033353530343062306331643431373037303663363532303433363537323734363936363639363336313734363936663665323034313735373436383666373236393734373933313133333031313036303335353034306130633061343137303730366336353230343936653633326533313062333030393036303335353034303631333032353535333330316531373064333133343330333533303336333233333334333633333330356131373064333233393330333533303336333233333334333633333330356133303761333132653330326330363033353530343033306332353431373037303663363532303431373037303663363936333631373436393666366532303439366537343635363737323631373436393666366532303433343132303264323034373333333132363330323430363033353530343062306331643431373037303663363532303433363537323734363936363639363336313734363936663665323034313735373436383666373236393734373933313133333031313036303335353034306130633061343137303730366336353230343936653633326533313062333030393036303335353034303631333032353535333330353933303133303630373261383634386365336430323031303630383261383634386365336430333031303730333432303030346630313731313834313964373634383564353161356532353831303737366538383061326566646537626165346465303864666334623933653133333536643536363562333561653232643039373736306432323465376262613038666437363137636538386362373662623636373062656338653832393834666635343435613338316637333038316634333034363036303832623036303130353035303730313031303433613330333833303336303630383262303630313035303530373330303138363261363837343734373033613266326636663633373337303265363137303730366336353265363336663664326636663633373337303330333432643631373037303663363537323666366637343633363136373333333031643036303335353164306530343136303431343233663234396334346639336534656632376536633466363238366333666132626266643265346233303066303630333535316431333031303166663034303533303033303130316666333031663036303335353164323330343138333031363830313462626230646561313538333338383961613438613939646562656264656261666461636232346162333033373036303335353164316630343330333032653330326361303261613032383836323636383734373437303361326632663633373236633265363137303730366336353265363336663664326636313730373036633635373236663666373436333631363733333265363337323663333030653036303335353164306630313031666630343034303330323031303633303130303630613261383634383836663736333634303630323065303430323035303033303061303630383261383634386365336430343033303230333637303033303634303233303361636637323833353131363939623138366662333563333536636136326266663431376564643930663735346461323865626566313963383135653432623738396638393866373962353939663938643534313064386639646539633266653032333033323264643534343231623061333035373736633564663333383362393036376664313737633263323136643936346663363732363938323132366635346638376137643162393963623962303938393231363130363939306630393932316430303030333138323031383833303832303138343032303130313330383138363330376133313265333032633036303335353034303330633235343137303730366336353230343137303730366336393633363137343639366636653230343936653734363536373732363137343639366636653230343334313230326432303437333333313236333032343036303335353034306230633164343137303730366336353230343336353732373436393636363936333631373436393666366532303431373537343638366637323639373437393331313333303131303630333535303430613063306134313730373036633635323034393665363332653331306233303039303630333535303430363133303235353533303230383136363334633862306533303537313733303062303630393630383634383031363530333034303230316130383139333330313830363039326138363438383666373064303130393033333130623036303932613836343838366637306430313037303133303163303630393261383634383836663730643031303930353331306631373064333233343330333533303338333233313332333933333330356133303238303630393261383634383836663730643031303933343331316233303139333030623036303936303836343830313635303330343032303161313061303630383261383634386365336430343033303233303266303630393261383634383836663730643031303930343331323230343230333232323236336439393239313365333235663163306437643761363331346230343535303337343561363032346633633930313232366166333530626332653330306130363038326138363438636533643034303330323034343733303435303232303537386536353236623062356233306465323562346231343865366632336530626438383631353335613666623865633461396465373338343333633262653530323231303062653834323635333334393162303965376330306437333565323762643865623236373964653462366433613138666434636564386261376565306166383161303030303030303030303030227D \ No newline at end of file diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ac15e90f6cb2651f35e847dfa4d217c4cc466cad GIT binary patch literal 3993 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 literal 0 HcmV?d00001 diff --git a/public/favicon.svg b/public/favicon.svg old mode 100755 new mode 100644 index 6893eb1..271b8b9 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + diff --git a/scripts/cleanup-orphaned-storage.mjs b/scripts/cleanup-orphaned-storage.mjs new file mode 100644 index 0000000..9d8e459 --- /dev/null +++ b/scripts/cleanup-orphaned-storage.mjs @@ -0,0 +1,113 @@ +/** + * cleanup-orphaned-storage.mjs + * + * Removes files in the `submissions` storage bucket that have no matching + * row in `submission_files`. These are orphaned uploads from before the + * silent-failure bug was fixed. + * + * Usage: + * SUPABASE_SERVICE_ROLE_KEY= node scripts/cleanup-orphaned-storage.mjs + * + * The service role key is in Supabase dashboard → Project Settings → API. + * Run once, then you can delete this script if you like. + */ + +import { createClient } from '@supabase/supabase-js'; + +const SUPABASE_URL = 'https://fqflxxqvennhvoeywrdw.supabase.co'; +const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; + +if (!SERVICE_ROLE_KEY) { + console.error('Error: SUPABASE_SERVICE_ROLE_KEY env var is required.'); + console.error(' SUPABASE_SERVICE_ROLE_KEY= node scripts/cleanup-orphaned-storage.mjs'); + process.exit(1); +} + +const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY); + +async function listAllStorageFiles(bucket) { + // Storage structure: submissions/{taskId}/{timestamp}_{filename} + // List the root to get task-ID "folders", then list each folder for files. + const allPaths = []; + + const { data: folders, error: folderError } = await supabase.storage + .from(bucket) + .list('', { limit: 1000, sortBy: { column: 'name', order: 'asc' } }); + + if (folderError) throw new Error(`Failed to list root: ${folderError.message}`); + if (!folders?.length) return allPaths; + + for (const folder of folders) { + // Supabase returns folders as items with `id: null` + if (folder.id !== null) { + // It's a top-level file (rare, skip) + allPaths.push(folder.name); + continue; + } + + const { data: files, error: fileError } = await supabase.storage + .from(bucket) + .list(folder.name, { limit: 1000, sortBy: { column: 'name', order: 'asc' } }); + + if (fileError) { + console.warn(` Warning: failed to list ${folder.name}/: ${fileError.message}`); + continue; + } + + for (const file of files ?? []) { + if (file.id !== null) { + allPaths.push(`${folder.name}/${file.name}`); + } + } + } + + return allPaths; +} + +async function main() { + console.log('Fetching all submission_files records from database...'); + const { data: dbFiles, error: dbError } = await supabase + .from('submission_files') + .select('storage_path') + .not('storage_path', 'is', null); + + if (dbError) throw new Error(`DB query failed: ${dbError.message}`); + + const knownPaths = new Set((dbFiles ?? []).map(r => r.storage_path)); + console.log(` ${knownPaths.size} file records found in database.`); + + console.log('\nListing all files in submissions storage bucket...'); + const storagePaths = await listAllStorageFiles('submissions'); + console.log(` ${storagePaths.length} files found in storage.`); + + const orphans = storagePaths.filter(p => !knownPaths.has(p)); + console.log(`\n${orphans.length} orphaned file(s) found (in storage but not in DB).`); + + if (orphans.length === 0) { + console.log('Nothing to clean up.'); + return; + } + + console.log('\nOrphaned files:'); + orphans.forEach(p => console.log(` ${p}`)); + + // Delete in batches of 100 (Supabase limit) + const BATCH = 100; + let deleted = 0; + for (let i = 0; i < orphans.length; i += BATCH) { + const batch = orphans.slice(i, i + BATCH); + const { error: delError } = await supabase.storage.from('submissions').remove(batch); + if (delError) { + console.error(` Error deleting batch: ${delError.message}`); + } else { + deleted += batch.length; + } + } + + console.log(`\nDone. ${deleted}/${orphans.length} orphaned file(s) deleted.`); +} + +main().catch(err => { + console.error('Fatal error:', err.message); + process.exit(1); +}); diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 517503f..8fad640 100755 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -3,15 +3,35 @@ import { NavLink, useNavigate, useLocation } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; function TeamNav({ onNav }) { + const primaryLinks = [ + { to: '/dashboard', label: 'Dashboard' }, + { to: '/requests', label: 'Requests' }, + { to: '/file-sharing', label: 'File Sharing' }, + { to: '/companies', label: 'Clients & Users' }, + ]; + + const utilityLinks = [ + { to: '/meeting-notes', label: 'Meeting Notes' }, + { to: '/invoices', label: 'Invoices & Expenses' }, + { to: '/survey-maker', label: 'Survey Maker' }, + { to: '/brand-book', label: 'Brand Book Maker' }, + { to: '/converters', label: 'Image Converter' }, + { to: '/fourge-passwords', label: 'Fourge Passwords' }, + { to: '/server-status', label: 'Server Status' }, + ]; + return (
- {[ - { to: '/dashboard', label: 'Dashboard' }, - { to: '/requests', label: 'Requests Inbox' }, - { to: '/brand-book', label: 'Brand Book (beta)' }, - { to: '/invoices', label: 'Invoices' }, - { to: '/companies', label: 'Clients & Users' }, - ].map(({ to, label }) => ( + {primaryLinks.map(({ to, label }) => ( + `sidebar-link${isActive ? ' active' : ''}`}> + {label} + + ))} +
+
+ Team Tools +
+ {utilityLinks.map(({ to, label }) => ( `sidebar-link${isActive ? ' active' : ''}`}> {label} @@ -26,6 +46,8 @@ function ClientNav({ onNav }) { {[ { to: '/my-dashboard', label: 'Dashboard' }, { to: '/my-projects', label: 'Projects' }, + { to: '/my-requests', label: 'Requests' }, + { to: '/file-sharing', label: 'File Sharing' }, { to: '/my-invoices', label: 'Invoices' }, { to: '/my-company', label: 'Company' }, ].map(({ to, label }) => ( @@ -38,11 +60,33 @@ function ClientNav({ onNav }) { } function ExternalNav({ onNav }) { + const links = [ + { to: '/dashboard', label: 'Dashboard' }, + { to: '/assigned-requests', label: 'Requests' }, + { to: '/my-purchase-orders', label: 'Purchase Orders' }, + { to: '/file-sharing', label: 'File Sharing' }, + { to: '/survey-maker', label: 'Survey Maker' }, + { to: '/brand-book', label: 'Brand Book Maker' }, + { to: '/converters', label: 'Image Converter' }, + ]; + return (
- `sidebar-link${isActive ? ' active' : ''}`}> - Dashboard - + {links.map(({ to, label }, index) => ( +
+ {index === 2 && ( + <> +
+
+ Team Tools +
+ + )} + `sidebar-link${isActive ? ' active' : ''}`}> + {label} + +
+ ))}
); } @@ -53,36 +97,58 @@ export default function Layout({ children }) { const location = useLocation(); const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'dark'); const [menuOpen, setMenuOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem('sidebarCollapsed') === 'true'); useEffect(() => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('theme', theme); }, [theme]); - // Close menu on route change - useEffect(() => { setMenuOpen(false); }, [location.pathname]); + useEffect(() => { + localStorage.setItem('sidebarCollapsed', String(sidebarCollapsed)); + }, [sidebarCollapsed]); + + // Close menu on route change (derived-state pattern, no effect needed) + const [lastPathname, setLastPathname] = useState(location.pathname); + if (lastPathname !== location.pathname) { + setLastPathname(location.pathname); + setMenuOpen(false); + } const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark'); - const handleLogout = () => { logout(); navigate('/'); }; + const handleLogout = async () => { + await logout(); + navigate('/'); + }; const initials = currentUser?.name ?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2); return ( -
+
{/* Overlay */} {menuOpen &&
setMenuOpen(false)} />}
@@ -719,7 +1236,7 @@ export default function BrandBook() { { setSiteMapFile(null); setSiteMapPath(''); setSiteMapPreview(null); }} + onClear={clearSiteMap} inputRef={siteMapRef} />
@@ -727,13 +1244,13 @@ export default function BrandBook() { {/* ── INVENTORY MAP ─────────────────────────────────────────────────── */}
-
Sign Inventory Map
+
Site Map
{ setInventoryMapFile(null); setInventoryMapPath(''); setInventoryMapPreview(null); }} + onClear={clearInventoryMap} inputRef={inventoryMapRef} />
@@ -751,16 +1268,17 @@ export default function BrandBook() { key={sign._key} sign={sign} index={i} - expanded={expandedSign === i} - onToggle={() => setExpandedSign(expandedSign === i ? null : i)} onChange={(field, val) => updateSign(sign._key, field, val)} - onPhotoChange={(file) => handleSignPhoto(sign._key, file)} + onPhotoChange={(field, file) => handleSignPhoto(sign._key, field, file)} onRemove={() => removeSign(sign._key)} canRemove={signs.length > 1} + template={bookInfo.template} /> ))}
- +
+ +
{/* ── SITE PHOTOS ───────────────────────────────────────────────────── */} @@ -782,12 +1300,13 @@ export default function BrandBook() { {/* Bottom actions */}
- - + Generate PDF
{notification && ( @@ -800,96 +1319,33 @@ export default function BrandBook() { } // ─── Book List Item ─────────────────────────────────────────────────────────── -function BookListItem({ book, onEdit, onRevision, onDelete }) { - const signCount = Array.isArray(book.signs) ? book.signs.length : 0; - const date = book.book_date - ? new Date(book.book_date + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) - : null; - const updated = book.updated_at - ? new Date(book.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) - : null; - - return ( -
-
-
- {book.client_name} - {book.project_name && ( - — {book.project_name} - )} - R{String(book.revision || '01').padStart(2, '0')} - {book.template && book.template !== 'fourge' && ( - - {TEMPLATE_OPTIONS.find(t => t.value === book.template)?.label || book.template} - - )} -
-
- {book.site_address && 📍 {book.site_address}} - {signCount > 0 && 🪧 {signCount} sign{signCount !== 1 ? 's' : ''}} - {date && 📅 {date}} - {updated && Saved {updated}} -
-
-
- - - -
-
- ); -} - // ─── Sign Card ──────────────────────────────────────────────────────────────── -function SignCard({ sign, index, expanded, onToggle, onChange, onPhotoChange, onRemove, canRemove }) { +function SignCard({ sign, index, onChange, onPhotoChange, onRemove, canRemove, template }) { const photoInputRef = useRef(); + const existingPhotoInputRef = useRef(); + const recommendationPhotoInputRef = useRef(); + const signDetailPhotoInputRef = useRef(); const dragCounter = useRef(0); const [dragging, setDragging] = useState(false); const handleDragEnter = (e) => { e.preventDefault(); dragCounter.current++; setDragging(true); }; const handleDragLeave = (e) => { e.preventDefault(); dragCounter.current--; if (dragCounter.current === 0) setDragging(false); }; const handleDragOver = (e) => e.preventDefault(); - const handleDrop = (e) => { + const handleDrop = (field) => (e) => { e.preventDefault(); dragCounter.current = 0; setDragging(false); const file = e.dataTransfer.files[0]; - if (file && file.type.startsWith('image/')) onPhotoChange(file); + if (isPhotoFile(file)) onPhotoChange(field, file); }; - const summary = [sign.type, sign.location].filter(Boolean).join(' — ') || 'New Sign'; - const hasPhoto = sign._photoPreview; + const summary = [sign.type, sign.recommendation].filter(Boolean).join(' — ') || 'New Sign'; + const hasPhoto = sign._photoPreview || sign._existingPhotoPreview || sign._recommendationPhotoPreview || sign._signDetailPhotoPreview; return (
- +
- {expanded && ( -
-
-
- - onChange('signNumber', e.target.value)} /> -
-
- - onChange('type', e.target.value)} /> -
-
- - onChange('recommendation', e.target.value)} /> -
-
- - onChange('location', e.target.value)} /> -
-
- -
- onChange('width', e.target.value)} style={{ flex: 1 }} /> - × - onChange('height', e.target.value)} style={{ flex: 1 }} /> -
-
-
-
-
- - onChange('material', e.target.value)} /> -
-
- - onChange('illumination', e.target.value)} /> -
-
- - onChange('condition', e.target.value)} /> -
-
-
-
- - onChange('mountType', e.target.value)} /> -
-
- - onChange('notes', e.target.value)} /> -
+
+
+
+ + onChange('signNumber', e.target.value)} />
- -
photoInputRef.current?.click()} - style={{ - border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`, - borderRadius: 8, - background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))', - padding: 12, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 12, - transition: 'border-color 0.15s, background 0.15s', minHeight: 60, - }} - > - {sign._photoPreview ? ( - <> - sign -
-
- {sign.photo ? sign.photo.name : 'Saved photo'} -
-
Click to replace · drag a new photo
-
- - ) : ( -
- {dragging ? '📂 Drop photo here' : '📷 Click to upload or drag & drop a photo'} -
- )} -
- { const f = e.target.files[0]; if (f) onPhotoChange(f); e.target.value = ''; }} /> + + onChange('type', e.target.value)} /> +
+
+ + onChange('recommendation', e.target.value)} />
- )} + +
+
+ +