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 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-05-13 14:20:38 -04:00
parent c9e7816e28
commit eee0885811
117 changed files with 17592 additions and 4057 deletions
@@ -0,0 +1,92 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
import Stripe from 'https://esm.sh/stripe@14?target=deno';
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, { apiVersion: '2023-10-16' });
const STRIPE_CURRENCY = 'usd';
const STRIPE_CURRENCY_LABEL = 'USD';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });
try {
const { invoice_id, invoice_ref } = await req.json();
const invoiceRef = invoice_ref || invoice_id;
if (!invoiceRef) return new Response(JSON.stringify({ error: 'invoice_ref required' }), { status: 400, headers: corsHeaders });
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
let invoice = null;
let error = null;
({ data: invoice, error } = await supabase
.from('invoices')
.select('*')
.eq('id', invoiceRef)
.maybeSingle());
if (!invoice) {
({ data: invoice, error } = await supabase
.from('invoices')
.select('*')
.eq('invoice_number', invoiceRef)
.maybeSingle());
}
if (error || !invoice) return new Response(JSON.stringify({ error: 'Invoice not found' }), { status: 404, headers: corsHeaders });
if (invoice.status === 'paid') return new Response(JSON.stringify({ error: 'Invoice already paid' }), { status: 400, headers: corsHeaders });
const { data: company } = invoice.company_id
? await supabase
.from('companies')
.select('name, email')
.eq('id', invoice.company_id)
.maybeSingle()
: { data: null };
const origin = 'https://portal.fourgebranding.com';
const billToLabel = invoice.bill_to || company?.name;
const session = await stripe.checkout.sessions.create({
// No payment_method_types — Stripe uses whatever is enabled in the dashboard
// (cards, ACH, etc.) without needing to hardcode them here.
mode: 'payment',
customer_email: company?.email || undefined,
line_items: [{
price_data: {
currency: STRIPE_CURRENCY,
product_data: {
name: `Invoice ${invoice.invoice_number} (${STRIPE_CURRENCY_LABEL})`,
description: billToLabel
? `Fourge Branding — ${billToLabel} (${STRIPE_CURRENCY_LABEL})`
: `Fourge Branding invoice charged in ${STRIPE_CURRENCY_LABEL}`,
},
unit_amount: Math.round(Number(invoice.total) * 100),
},
quantity: 1,
}],
// Stamp invoice_id on both the session and the payment intent so the
// webhook can match it whether the payment settles instantly (card) or
// asynchronously (ACH — payment_intent.succeeded fires days later).
metadata: { invoice_id: invoice.id, currency: STRIPE_CURRENCY },
payment_intent_data: {
metadata: { invoice_id: invoice.id },
},
success_url: `${origin}/pay/${encodeURIComponent(invoice.invoice_number)}?success=1`,
cancel_url: `${origin}/pay/${encodeURIComponent(invoice.invoice_number)}?cancelled=1`,
});
return new Response(JSON.stringify({ url: session.url }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
} catch (err) {
console.error(err);
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: corsHeaders });
}
});
+48 -18
View File
@@ -1,5 +1,4 @@
// deno-lint-ignore-file
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const corsHeaders = {
@@ -7,17 +6,18 @@ const corsHeaders = {
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
const ok = (body: Record<string, unknown>) => new Response(JSON.stringify(body), {
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
const json = (body: Record<string, unknown>, status: number) =>
new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
serve(async (req) => {
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
try {
const authHeader = req.headers.get('Authorization') ?? ''
if (!authHeader) return ok({ error: 'No authorization header' })
if (!authHeader) return json({ error: 'No authorization header' }, 401)
const callerClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
@@ -26,16 +26,19 @@ serve(async (req) => {
)
const { data: userData, error: userError } = await callerClient.auth.getUser()
if (userError || !userData?.user) return ok({ error: `Auth failed: ${userError?.message ?? 'no user'}` })
if (userError || !userData?.user) return json({ error: `Auth failed: ${userError?.message ?? 'no user'}` }, 401)
const { data: profile, error: profileError } = await callerClient
.from('profiles').select('role').eq('id', userData.user.id).single()
if (profileError) return ok({ error: `Profile error: ${profileError.message}` })
if (profile?.role !== 'team') return ok({ error: 'Forbidden: team only' })
if (profileError) return json({ error: `Profile error: ${profileError.message}` }, 500)
if (profile?.role !== 'team') return json({ error: 'Forbidden: team only' }, 403)
const body = await req.json()
const { name, email, password, company_id } = body
if (!name || !email || !password) return ok({ error: 'name, email and password are required' })
const { name, email, password, company_id, role: roleParam } = body
if (!name || !email || !password) return json({ error: 'name, email and password are required' }, 400)
const validRoles = ['team', 'client', 'external']
const role = validRoles.includes(roleParam) ? roleParam : 'client'
const admin = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
@@ -46,20 +49,47 @@ serve(async (req) => {
email,
password,
email_confirm: true,
user_metadata: { name, role: 'client' },
user_metadata: { name, role },
})
if (createError) return ok({ error: `Create user failed: ${createError.message}` })
if (createError) return json({ error: `Create user failed: ${createError.message}` }, 500)
if (role === 'client' && company_id && created?.user) {
const { error: profileUpsertError } = await admin
.from('profiles')
.upsert({
id: created.user.id,
name,
email,
role,
company_id,
}, { onConflict: 'id' })
if (profileUpsertError) return json({ error: `User created but profile setup failed: ${profileUpsertError.message}` }, 500)
if (company_id && created?.user) {
const { error: assignError } = await admin
.from('profiles').update({ company_id }).eq('id', created.user.id)
if (assignError) return ok({ error: `User created but company assign failed: ${assignError.message}` })
if (assignError) return json({ error: `User created but company assign failed: ${assignError.message}` }, 500)
const { error: membershipError } = await admin
.from('company_members')
.upsert({ company_id, profile_id: created.user.id }, { onConflict: 'company_id,profile_id' })
if (membershipError) return json({ error: `User created but company membership failed: ${membershipError.message}` }, 500)
} else if (role === 'external' && created?.user) {
const { error: profileUpsertError } = await admin
.from('profiles')
.upsert({
id: created.user.id,
name,
email,
role,
company_id: null,
}, { onConflict: 'id' })
if (profileUpsertError) return json({ error: `User created but profile setup failed: ${profileUpsertError.message}` }, 500)
}
return ok({ success: true })
return json({ success: true }, 200)
} catch (err) {
return ok({ error: `Unexpected: ${String(err)}` })
return json({ error: `Unexpected: ${String(err)}` }, 500)
}
})
+53
View File
@@ -0,0 +1,53 @@
// deno-lint-ignore-file
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
const json = (body: Record<string, unknown>, status: number) =>
new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
try {
const authHeader = req.headers.get('Authorization') ?? ''
if (!authHeader) return json({ error: 'No authorization header' }, 401)
const callerClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
{ global: { headers: { Authorization: authHeader } } }
)
const { data: userData, error: userError } = await callerClient.auth.getUser()
if (userError || !userData?.user) return json({ error: `Auth failed: ${userError?.message ?? 'no user'}` }, 401)
const { data: profile, error: profileError } = await callerClient
.from('profiles').select('role').eq('id', userData.user.id).single()
if (profileError) return json({ error: `Profile error: ${profileError.message}` }, 500)
if (profile?.role !== 'team') return json({ error: 'Forbidden: team only' }, 403)
const body = await req.json()
const { user_id } = body
if (!user_id) return json({ error: 'user_id is required' }, 400)
const admin = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
)
const { error: deleteError } = await admin.auth.admin.deleteUser(user_id)
if (deleteError) return json({ error: `Delete failed: ${deleteError.message}` }, 500)
return json({ success: true }, 200)
} catch (err) {
return json({ error: `Unexpected: ${String(err)}` }, 500)
}
})
@@ -0,0 +1,99 @@
// deno-lint-ignore-file
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
const json = (body: Record<string, unknown>, status: number) =>
new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
function encodeBase64(bytes: Uint8Array) {
return btoa(String.fromCharCode(...bytes))
}
function decodeBase64(value: string) {
return Uint8Array.from(atob(value), char => char.charCodeAt(0))
}
async function requireTeam(authHeader: string) {
const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? ''
const supabaseKey = Deno.env.get('SUPABASE_ANON_KEY') || Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') || ''
const callerClient = createClient(
supabaseUrl,
supabaseKey,
{ global: { headers: { Authorization: authHeader } } }
)
const { data: userData, error: userError } = await callerClient.auth.getUser()
if (userError || !userData?.user) return { ok: false, error: `Auth failed: ${userError?.message ?? 'no user'}`, status: 401 }
const { data: profile, error: profileError } = await callerClient
.from('profiles').select('role').eq('id', userData.user.id).single()
if (profileError) return { ok: false, error: `Profile error: ${profileError.message}`, status: 500 }
if (profile?.role !== 'team') return { ok: false, error: 'Forbidden: team only', status: 403 }
return { ok: true }
}
async function getKey() {
const secret = Deno.env.get('PASSWORD_VAULT_KEY') ?? ''
if (!secret) throw new Error('PASSWORD_VAULT_KEY is not configured.')
const rawKey = decodeBase64(secret)
return crypto.subtle.importKey('raw', rawKey, 'AES-GCM', false, ['encrypt', 'decrypt'])
}
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
try {
const authHeader = req.headers.get('Authorization') ?? ''
if (!authHeader) return json({ error: 'No authorization header' }, 401)
const auth = await requireTeam(authHeader)
if (!auth.ok) return json({ error: auth.error as string }, auth.status as number)
const body = await req.json()
const action = body?.action
const key = await getKey()
if (action === 'encrypt') {
const plaintext = String(body?.plaintext ?? '')
if (!plaintext) return json({ error: 'plaintext required' }, 400)
const iv = crypto.getRandomValues(new Uint8Array(12))
const encoded = new TextEncoder().encode(plaintext)
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded)
return json({
ciphertext: encodeBase64(new Uint8Array(ciphertext)),
iv: encodeBase64(iv),
}, 200)
}
if (action === 'decrypt') {
const ciphertext = String(body?.ciphertext ?? '')
const ivValue = String(body?.iv ?? '')
if (!ciphertext || !ivValue) return json({ error: 'ciphertext and iv required' }, 400)
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: decodeBase64(ivValue) },
key,
decodeBase64(ciphertext),
)
return json({ plaintext: new TextDecoder().decode(decrypted) }, 200)
}
return json({ error: 'Invalid action' }, 400)
} catch (err) {
return json({ error: `Unexpected: ${String(err)}` }, 500)
}
})
@@ -0,0 +1,70 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });
try {
const { invoice_id, invoice_ref } = await req.json();
const invoiceRef = invoice_ref || invoice_id;
if (!invoiceRef) {
return new Response(JSON.stringify({ error: 'invoice_ref required' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
let invoice = null;
let error = null;
({ data: invoice, error } = await supabase
.from('invoices')
.select('id, invoice_number, invoice_date, due_date, total, status, bill_to, company_id')
.eq('id', invoiceRef)
.maybeSingle());
if (!invoice) {
({ data: invoice, error } = await supabase
.from('invoices')
.select('id, invoice_number, invoice_date, due_date, total, status, bill_to, company_id')
.eq('invoice_number', invoiceRef)
.maybeSingle());
}
if (error || !invoice) {
return new Response(JSON.stringify({ error: 'Invoice not found' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const { data: company } = invoice.company_id
? await supabase
.from('companies')
.select('name, email')
.eq('id', invoice.company_id)
.maybeSingle()
: { data: null };
return new Response(JSON.stringify({ invoice: { ...invoice, companies: company || null } }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
} catch (err) {
console.error(err);
return new Response(JSON.stringify({ error: err.message }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
});
+378 -35
View File
@@ -1,102 +1,445 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY');
const SUPABASE_URL = Deno.env.get('SUPABASE_URL');
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
const FROM = 'Fourge Branding <hello@fourgebranding.com>';
const TEAM_EMAILS = [
'krao@fourgebranding.com',
'smarsell@fourgebranding.com',
'swebb@fourgebranding.com',
'twebb@fourgebranding.com',
];
const ALLOWED_TYPES = ['new_request', 'sent_to_client', 'revision_submitted', 'client_approved', 'invoice_sent', 'receipt_sent', 'subcontractor_po_sent'] as const;
type EmailType = typeof ALLOWED_TYPES[number];
// Types that only team members may trigger
const TEAM_ONLY_TYPES: EmailType[] = ['sent_to_client', 'invoice_sent', 'receipt_sent', 'subcontractor_po_sent'];
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
/** Escape a value for safe insertion into HTML. */
const esc = (s: unknown): string => {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
};
/** Escape and convert newlines to <br> for multi-line text fields. */
const escMultiline = (s: unknown): string =>
esc(s).replace(/\n/g, '<br>');
/** Validate a string field: must be a non-empty string within max length. */
const requireStr = (val: unknown, max = 500): string => {
if (typeof val !== 'string' || !val.trim()) throw new Error('Missing required field');
if (val.length > max) throw new Error('Field exceeds maximum length');
return val.trim();
};
/** Validate an optional string field. Returns '' if absent. */
const optStr = (val: unknown, max = 500): string => {
if (val == null || val === '') return '';
if (typeof val !== 'string') throw new Error('Invalid field type');
if (val.length > max) throw new Error('Field exceeds maximum length');
return val.trim();
};
/** Basic UUID format check. */
const isUuid = (v: unknown): v is string =>
typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
/** Basic email format check. */
const isEmail = (v: unknown): v is string =>
typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) && v.length <= 254;
const parseJsonSafe = async (res: Response) => {
const text = await res.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return { raw: text };
}
};
const isBase64 = (value: unknown): value is string =>
typeof value === 'string' && value.length <= 10_000_000 && /^[A-Za-z0-9+/=]+$/.test(value);
const safeFilename = (value: unknown): string => {
const filename = requireStr(value, 180);
if (!/^[a-zA-Z0-9._ -]+\.pdf$/i.test(filename)) throw new Error('Invalid attachment filename');
return filename;
};
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const { type, to, data } = await req.json();
const respond = (body: unknown, status: number) =>
new Response(JSON.stringify(body), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status,
});
try {
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
return respond({ error: 'Missing Supabase environment variables' }, 500);
}
if (!RESEND_API_KEY) {
return respond({ error: 'Missing RESEND_API_KEY' }, 500);
}
// ── 1. Authentication ────────────────────────────────────────────────────
const authHeader = req.headers.get('Authorization') ?? '';
const accessToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
if (!accessToken) {
return respond({ error: 'Unauthorized' }, 401);
}
// Use service role client to validate the token — works with ES256 JWTs
const adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
const { data: userData, error: authError } = await adminClient.auth.getUser(accessToken);
if (authError || !userData?.user) {
return respond({ error: `Auth failed: ${authError?.message ?? 'no user'}` }, 401);
}
// ── 2. Role lookup ───────────────────────────────────────────────────────
const { data: profile } = await adminClient
.from('profiles')
.select('role')
.eq('id', userData.user.id)
.single();
const role: string = profile?.role ?? '';
if (!['team', 'client', 'external'].includes(role)) {
return respond({ error: 'Forbidden' }, 403);
}
// ── 3. Parse and validate body ───────────────────────────────────────────
const body = await req.json();
const { type, to, data, attachments } = body;
if (!ALLOWED_TYPES.includes(type)) {
return respond({ error: 'Invalid email type' }, 400);
}
if (TEAM_ONLY_TYPES.includes(type) && role !== 'team') {
return respond({ error: 'Forbidden' }, 403);
}
const safeAttachments = Array.isArray(attachments)
? attachments.slice(0, 3).map((attachment) => {
const filename = safeFilename(attachment?.filename);
if (!isBase64(attachment?.content)) throw new Error('Invalid attachment content');
return { filename, content: attachment.content };
})
: [];
// ── 4. Build email per type (with validated + escaped fields) ────────────
let subject = '';
let html = '';
if (type === 'new_request') {
subject = `New Request: ${data.serviceType}${data.clientName}`;
const serviceType = requireStr(data?.serviceType);
const clientName = requireStr(data?.clientName);
const clientEmail = requireStr(data?.clientEmail);
const projectName = requireStr(data?.projectName);
const description = requireStr(data?.description, 10000);
const company = optStr(data?.company);
const deadline = optStr(data?.deadline);
const taskId = data?.taskId;
if (!isUuid(taskId)) throw new Error('Invalid taskId');
subject = `New Request: ${esc(serviceType)}${esc(clientName)}`;
html = `
<h2>New Request Received</h2>
<p><strong>From:</strong> ${data.clientName} (${data.clientEmail})</p>
<p><strong>Company:</strong> ${data.company || '—'}</p>
<p><strong>Service:</strong> ${data.serviceType}</p>
<p><strong>Project:</strong> ${data.projectName}</p>
${data.deadline ? `<p><strong>Deadline:</strong> ${data.deadline}</p>` : ''}
<p><strong>From:</strong> ${esc(clientName)} (${esc(clientEmail)})</p>
<p><strong>Company:</strong> ${esc(company) || '—'}</p>
<p><strong>Service:</strong> ${esc(serviceType)}</p>
<p><strong>Project:</strong> ${esc(projectName)}</p>
${deadline ? `<p><strong>Deadline:</strong> ${esc(deadline)}</p>` : ''}
<hr />
<p><strong>Description:</strong></p>
<p>${data.description}</p>
<p>${escMultiline(description)}</p>
<br />
<a href="https://portal.fourgebranding.com/tasks/${data.taskId}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a>
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a>
`;
}
else if (type === 'sent_to_client') {
subject = `Your ${data.serviceType} is ready for review!`;
const serviceType = requireStr(data?.serviceType);
const clientFirstName = requireStr(data?.clientFirstName);
const projectName = requireStr(data?.projectName);
const message = optStr(data?.message, 5000);
const taskId = data?.taskId;
if (!isUuid(taskId)) throw new Error('Invalid taskId');
subject = `Your ${esc(serviceType)} is ready for review!`;
html = `
<h2>Hi ${data.clientFirstName},</h2>
<p>Your <strong>${data.serviceType}</strong> for <strong>${data.projectName}</strong> is ready for review.</p>
${data.message ? `<p>${data.message}</p>` : ''}
<h2>Hi ${esc(clientFirstName)},</h2>
<p>Your <strong>${esc(serviceType)}</strong> for <strong>${esc(projectName)}</strong> is ready for review.</p>
${message ? `<p>${escMultiline(message)}</p>` : ''}
<p>Please log in to the portal to review and approve or request changes.</p>
<br />
<a href="https://portal.fourgebranding.com/my-requests/${data.taskId}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Review Your Work</a>
<a href="https://portal.fourgebranding.com/my-requests/${esc(taskId)}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Review Your Work</a>
<br /><br />
<p style="color:#666;font-size:13px;">— The Fourge Branding Team</p>
`;
}
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 = `
<h2>Revision Requested</h2>
<p><strong>From:</strong> ${data.clientName}</p>
<p><strong>Job:</strong> ${data.serviceType}${data.projectName}</p>
<p><strong>New Version:</strong> ${data.version}</p>
${data.deadline ? `<p><strong>New Deadline:</strong> ${data.deadline}</p>` : ''}
<p><strong>From:</strong> ${esc(clientName)}</p>
<p><strong>Job:</strong> ${esc(serviceType)}${esc(projectName)}</p>
<p><strong>New Version:</strong> ${esc(version)}</p>
${deadline ? `<p><strong>New Deadline:</strong> ${esc(deadline)}</p>` : ''}
<hr />
<p><strong>Requested changes:</strong></p>
<p>${data.description}</p>
<p>${escMultiline(description)}</p>
<br />
<a href="https://portal.fourgebranding.com/tasks/${data.taskId}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a>
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a>
`;
}
else if (type === 'client_approved') {
subject = `Approved! ${data.serviceType}${data.clientName}`;
const serviceType = requireStr(data?.serviceType);
const clientName = requireStr(data?.clientName);
const projectName = requireStr(data?.projectName);
const taskId = data?.taskId;
if (!isUuid(taskId)) throw new Error('Invalid taskId');
subject = `Approved! ${esc(serviceType)}${esc(clientName)}`;
html = `
<h2>Job Approved ✓</h2>
<p><strong>${data.clientName}</strong> has approved <strong>${data.serviceType}</strong> for <strong>${data.projectName}</strong>.</p>
<p><strong>${esc(clientName)}</strong> has approved <strong>${esc(serviceType)}</strong> for <strong>${esc(projectName)}</strong>.</p>
<p>This job is now complete.</p>
<br />
<a href="https://portal.fourgebranding.com/tasks/${data.taskId}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a>
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a>
`;
}
else if (type === 'invoice_sent') {
const invoiceNumber = requireStr(data?.invoiceNumber);
const billTo = requireStr(data?.billTo);
const total = requireStr(data?.total);
const dueDate = requireStr(data?.dueDate);
const payUrl = requireStr(data?.payUrl);
const notes = optStr(data?.notes, 2000);
subject = `Invoice ${esc(invoiceNumber)} from Fourge Branding — ${esc(total)} due ${esc(dueDate)}`;
html = `
<div style="font-family:sans-serif;max-width:520px;margin:0 auto;color:#1a1a1a;">
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
</div>
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
<h2 style="margin:0 0 8px;font-size:20px;">Hi ${esc(billTo)},</h2>
<p style="color:#555;margin:0 0 24px;">Please find your invoice from Fourge Branding below.</p>
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">Invoice #</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(invoiceNumber)}</td>
</tr>
<tr>
<td style="padding:10px 14px;font-size:13px;color:#666;">Amount Due</td>
<td style="padding:10px 14px;font-size:18px;font-weight:700;color:#141414;text-align:right;">${esc(total)}</td>
</tr>
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">Due Date</td>
<td style="padding:10px 14px;font-size:13px;font-weight:600;text-align:right;">${esc(dueDate)}</td>
</tr>
</table>
${notes ? `<p style="background:#f9f9f9;padding:12px 14px;border-radius:6px;font-size:13px;color:#555;margin-bottom:24px;">${escMultiline(notes)}</p>` : ''}
<a href="${esc(payUrl)}" style="display:block;background:#141414;color:#fff;text-align:center;padding:14px;border-radius:8px;text-decoration:none;font-weight:700;font-size:16px;margin-bottom:20px;">Pay Now — ${esc(total)}</a>
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
Secured by Stripe · Charged in USD<br/>
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
</p>
</div>
</div>
`;
}
else if (type === 'receipt_sent') {
const invoiceNumber = requireStr(data?.invoiceNumber);
const billTo = requireStr(data?.billTo);
const total = requireStr(data?.total);
const paidDate = requireStr(data?.paidDate);
subject = `Payment Receipt — ${esc(invoiceNumber)} — Thank you!`;
html = `
<div style="font-family:sans-serif;max-width:520px;margin:0 auto;color:#1a1a1a;">
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
</div>
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
<div style="text-align:center;margin-bottom:20px;">
<div style="display:inline-block;background:#16a34a;color:#fff;font-weight:700;font-size:13px;padding:6px 16px;border-radius:20px;letter-spacing:0.5px;">✓ PAYMENT RECEIVED</div>
</div>
<h2 style="margin:0 0 8px;font-size:20px;">Hi ${esc(billTo)},</h2>
<p style="color:#555;margin:0 0 24px;">Thank you — your payment has been received. Here's your receipt.</p>
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">Invoice #</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(invoiceNumber)}</td>
</tr>
<tr>
<td style="padding:10px 14px;font-size:13px;color:#666;">Amount Paid</td>
<td style="padding:10px 14px;font-size:18px;font-weight:700;color:#16a34a;text-align:right;">${esc(total)}</td>
</tr>
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">Payment Date</td>
<td style="padding:10px 14px;font-size:13px;font-weight:600;text-align:right;">${esc(paidDate)}</td>
</tr>
</table>
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
</p>
</div>
</div>
`;
}
else if (type === 'subcontractor_po_sent') {
const poNumber = requireStr(data?.poNumber);
const subcontractorName = requireStr(data?.subcontractorName);
const projectName = requireStr(data?.projectName);
const companyName = requireStr(data?.companyName);
const amount = requireStr(data?.amount);
const dueDate = requireStr(data?.dueDate);
const terms = requireStr(data?.terms);
const scope = requireStr(data?.scope, 10000);
const portalUrl = requireStr(data?.portalUrl);
subject = `Purchase Order ${esc(poNumber)} from Fourge Branding — ${esc(amount)}`;
html = `
<div style="font-family:sans-serif;max-width:560px;margin:0 auto;color:#1a1a1a;">
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
</div>
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
<h2 style="margin:0 0 8px;font-size:20px;">Hi ${esc(subcontractorName)},</h2>
<p style="color:#555;margin:0 0 24px;">A subcontractor purchase order is ready for your review.</p>
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">PO #</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(poNumber)}</td>
</tr>
<tr>
<td style="padding:10px 14px;font-size:13px;color:#666;">Project</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(projectName)}</td>
</tr>
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">Company</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(companyName)}</td>
</tr>
<tr>
<td style="padding:10px 14px;font-size:13px;color:#666;">Amount</td>
<td style="padding:10px 14px;font-size:18px;font-weight:700;color:#141414;text-align:right;">${esc(amount)}</td>
</tr>
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">Due</td>
<td style="padding:10px 14px;font-size:13px;font-weight:600;text-align:right;">${esc(dueDate)}</td>
</tr>
<tr>
<td style="padding:10px 14px;font-size:13px;color:#666;">Terms</td>
<td style="padding:10px 14px;font-size:13px;font-weight:600;text-align:right;">${esc(terms)}</td>
</tr>
</table>
<div style="background:#f9f9f9;padding:12px 14px;border-radius:6px;font-size:13px;color:#555;margin-bottom:24px;">
<strong>Scope</strong><br />
${escMultiline(scope)}
</div>
<a href="${esc(portalUrl)}" style="display:block;background:#141414;color:#fff;text-align:center;padding:14px;border-radius:8px;text-decoration:none;font-weight:700;font-size:16px;margin-bottom:20px;">Review and Approve PO</a>
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
</p>
</div>
</div>
`;
}
// ── 5. Resolve recipients ────────────────────────────────────────────────
const teamTypes = ['new_request', 'revision_submitted', 'client_approved'];
let recipients: string[];
let cc: string[] | undefined;
if (teamTypes.includes(type)) {
recipients = TEAM_EMAILS;
} else {
// Validate caller-supplied recipient list
const toList = Array.isArray(to) ? to : [to];
recipients = [...new Set(toList.map(val => typeof val === 'string' ? val.trim() : val).filter(isEmail))];
if (recipients.length === 0) throw new Error('No valid recipient emails');
if (recipients.length > 20) throw new Error('Too many recipients');
const senderEmail = optStr(data?.senderEmail);
if (senderEmail && isEmail(senderEmail)) cc = [senderEmail];
if (type === 'invoice_sent') {
cc = [...new Set([...(cc || []), 'hello@fourgebranding.com'])];
}
}
// ── 6. Send via Resend ───────────────────────────────────────────────────
const payload: Record<string, unknown> = { from: FROM, to: recipients, subject, html };
if (cc) payload.cc = cc;
if (safeAttachments.length) payload.attachments = safeAttachments;
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ from: FROM, to, subject, html }),
body: JSON.stringify(payload),
});
const result = await res.json();
const result = await parseJsonSafe(res);
if (!res.ok) {
const message =
(result && typeof result === 'object' && 'message' in result && typeof result.message === 'string' && result.message)
|| (result && typeof result === 'object' && 'error' in result && typeof result.error === 'string' && result.error)
|| `Resend request failed with status ${res.status}`;
console.error('Resend API error:', { status: res.status, result });
return respond({ error: message, resend_status: res.status, resend_body: result }, 400);
}
return new Response(JSON.stringify(result), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: res.ok ? 200 : 400,
});
return respond(result, 200);
} catch (err) {
return new Response(JSON.stringify({ error: err.message }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
});
console.error('send-email failed:', err);
return respond({ error: (err as Error).message }, 500);
}
});
+41 -27
View File
@@ -17,38 +17,52 @@ serve(async (req) => {
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
// Helper: retrieve fee from a payment intent and mark invoice paid
async function markPaid(paymentIntentId: string, invoice_id: string) {
let stripe_fee: number | null = null;
try {
const pi = await stripe.paymentIntents.retrieve(paymentIntentId, {
expand: ['latest_charge.balance_transaction'],
});
const charge = pi.latest_charge as Stripe.Charge | null;
const balanceTx = charge?.balance_transaction as Stripe.BalanceTransaction | null;
if (balanceTx?.fee != null) stripe_fee = balanceTx.fee / 100;
} catch (err) {
console.error('Failed to retrieve Stripe fee:', err.message);
}
const updateData: Record<string, unknown> = {
status: 'paid',
paid_at: new Date().toISOString(),
};
if (stripe_fee !== null) updateData.stripe_fee = stripe_fee;
await supabase.from('invoices').update(updateData).eq('id', invoice_id);
}
// Card payments: session completes with payment_status = 'paid' immediately
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
const invoice_id = session.metadata?.invoice_id;
const paymentIntentId = session.payment_intent as string | null;
// Only mark paid here for instant payment methods (card).
// ACH sessions complete with payment_status 'unpaid' — let payment_intent.succeeded handle those.
if (invoice_id && paymentIntentId && session.payment_status === 'paid') {
await markPaid(paymentIntentId, invoice_id);
}
}
// ACH / async payment methods: payment_intent.succeeded fires when money actually settles
if (event.type === 'payment_intent.succeeded') {
const pi = event.data.object as Stripe.PaymentIntent;
const invoice_id = pi.metadata?.invoice_id;
if (invoice_id) {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
// Retrieve the Stripe processing fee from the balance transaction
let stripe_fee: number | null = null;
try {
const paymentIntentId = session.payment_intent as string;
if (paymentIntentId) {
const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId, {
expand: ['latest_charge.balance_transaction'],
});
const charge = paymentIntent.latest_charge as Stripe.Charge | null;
const balanceTx = charge?.balance_transaction as Stripe.BalanceTransaction | null;
if (balanceTx?.fee != null) {
stripe_fee = balanceTx.fee / 100;
}
}
} catch (err) {
console.error('Failed to retrieve Stripe fee:', err.message);
}
const updateData: Record<string, unknown> = { status: 'paid' };
if (stripe_fee !== null) updateData.stripe_fee = stripe_fee;
await supabase.from('invoices').update(updateData).eq('id', invoice_id);
await markPaid(pi.id, invoice_id);
}
}