From 7737278ddebe3467a88806de6895a553d69afaa1 Mon Sep 17 00:00:00 2001 From: Devin Wilson Date: Mon, 18 May 2026 15:28:36 -0700 Subject: [PATCH 1/2] feat(invoice): DW Tailored Systems brand + PDF attachment via SendGrid Rework invoice sending: send from verified devin@dwtailored.com as DW Tailored Systems, re-skin the invoice email + PDF to the teal-on-cream brand, and attach the invoice PDF copy to the email. - new web/src/lib/brand.ts: single source of truth for identity + palette - buildPdf.ts: split out buildChangeOrderPdfBytes() (backward-compatible blob-URL wrapper kept); brand palette, serif wordmark, DW footer - EmailComposer.tsx: brand From/signature/footer, solid teal header (removed navy gradient), attach PDF + cc for invoices - sendCompletionEmail.ts: contractor-only gate, cc + PDF attachment, PII-safe error logging - utils.ts: uint8ToBase64 helper; tabular-nums on currency cells - tests: slug, uint8ToBase64, PDF bytes/wrapper (59 pass) --- functions/.gitignore | 1 + functions/src/sendCompletionEmail.ts | 45 ++++++- web/src/App.tsx | 2 +- web/src/lib/__tests__/invoiceBranding.test.ts | 91 +++++++++++++ web/src/lib/brand.ts | 80 ++++++++++++ web/src/lib/buildPdf.ts | 90 ++++++++----- web/src/lib/utils.ts | 13 ++ web/src/routes/contractor/EmailComposer.tsx | 121 ++++++++++-------- 8 files changed, 355 insertions(+), 88 deletions(-) create mode 100644 functions/.gitignore create mode 100644 web/src/lib/__tests__/invoiceBranding.test.ts create mode 100644 web/src/lib/brand.ts diff --git a/functions/.gitignore b/functions/.gitignore new file mode 100644 index 0000000..46688ba --- /dev/null +++ b/functions/.gitignore @@ -0,0 +1 @@ +tsbuildinfo diff --git a/functions/src/sendCompletionEmail.ts b/functions/src/sendCompletionEmail.ts index f31ae66..e8ea393 100644 --- a/functions/src/sendCompletionEmail.ts +++ b/functions/src/sendCompletionEmail.ts @@ -9,10 +9,14 @@ const sendgridApiKey = defineSecret("SENDGRID_API_KEY"); interface SendEmailRequest { to: string; toName?: string; + cc?: string[]; subject: string; fromEmail: string; fromName: string; html: string; + /** Base64-encoded PDF (no data: prefix) to attach, e.g. the invoice copy. */ + pdfBase64?: string; + pdfFilename?: string; } export const sendCompletionEmail = onCall( @@ -22,32 +26,69 @@ export const sendCompletionEmail = onCall( throw new HttpsError("unauthenticated", "Must be signed in."); } + // Only contractors (Google sign-in) may send mail from the verified + // sender. Portal clients authenticate via custom token and must never + // be able to call this. + if (request.auth.token.firebase?.sign_in_provider !== "google.com") { + throw new HttpsError("permission-denied", "Contractor access only."); + } + const data = request.data as SendEmailRequest; if (!data.to || !data.subject || !data.html) { throw new HttpsError("invalid-argument", "Missing required fields: to, subject, html."); } + if (data.pdfBase64 !== undefined && typeof data.pdfBase64 !== "string") { + throw new HttpsError("invalid-argument", "pdfBase64 must be a string."); + } sgMail.setApiKey(sendgridApiKey.value()); + const ccList = Array.isArray(data.cc) ? data.cc : []; + const cc = ccList.filter((addr) => typeof addr === "string" && addr && addr !== data.to); + try { await sgMail.send({ to: { email: data.to, name: data.toName || undefined, }, + cc: cc.length > 0 ? cc : undefined, from: { email: data.fromEmail || "noreply@example.com", name: data.fromName || "Open TEN99", }, subject: data.subject, html: data.html, + attachments: data.pdfBase64 + ? [ + { + content: data.pdfBase64, + filename: data.pdfFilename || "invoice.pdf", + type: "application/pdf", + disposition: "attachment", + }, + ] + : undefined, }); - logger.info("Email sent", { to: data.to, subject: data.subject }); + logger.info("Email sent", { + to: data.to, + cc: cc.length, + subject: data.subject, + hasAttachment: Boolean(data.pdfBase64), + }); return { success: true }; } catch (err) { - logger.error("SendGrid error", err); + // SendGrid puts the actionable detail (e.g. unverified sender) in + // response.body.errors — log only the structured error array to avoid + // persisting recipient PII; client gets a generic message. + const sgErrors = (err as { response?: { body?: { errors?: unknown } } }) + ?.response?.body?.errors; + logger.error("SendGrid error", { + message: (err as Error)?.message, + errors: sgErrors, + }); throw new HttpsError("internal", "Failed to send email."); } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 1c812b1..3fd701f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -381,7 +381,7 @@ function ContractorRoutes() { /> } + element={} /> { + it('lowercases and hyphenates', () => { + expect(slug('Acme Corp Website Redesign')).toBe('acme-corp-website-redesign'); + }); + + it('strips leading/trailing/duplicate separators', () => { + expect(slug(' **Hello, World!!** ')).toBe('hello-world'); + }); + + it('caps length and never ends with a hyphen', () => { + const out = slug('a'.repeat(80), 10); + expect(out.length).toBeLessThanOrEqual(10); + expect(out.endsWith('-')).toBe(false); + }); + + it('falls back to "invoice" when empty', () => { + expect(slug('!!!')).toBe('invoice'); + }); +}); + +describe('uint8ToBase64', () => { + it('round-trips through atob', () => { + const bytes = new Uint8Array([0, 1, 2, 255, 128, 64, 65, 66]); + const b64 = uint8ToBase64(bytes); + const decoded = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + expect(Array.from(decoded)).toEqual(Array.from(bytes)); + }); + + it('handles large buffers without throwing', () => { + const big = new Uint8Array(100_000).fill(7); + expect(() => uint8ToBase64(big)).not.toThrow(); + }); +}); + +describe('brand defaults', () => { + it('uses the DW Tailored Systems verified sender', () => { + expect(BRAND.fromEmail).toBe('devin@dwtailored.com'); + expect(BRAND.company).toBe('DW Tailored Systems'); + }); + + it('builds a multi-line from-address block', () => { + expect(BRAND_FROM_ADDRESS.split('\n')).toEqual([ + 'Devin Wilson', + 'DW Tailored Systems', + 'devin@dwtailored.com', + '+1 (530) 753-5503', + ]); + }); +}); + +describe('buildChangeOrderPdf', () => { + const client: Client = { + name: 'Acme Co', + email: 'ap@acme.test', + createdAt: new Date('2026-01-01'), + }; + + const workItem: WorkItem = { + type: 'featureRequest', + status: 'completed', + clientId: 'c1', + sourceEmail: 'in@dwtailored.com', + subject: 'API integration', + lineItems: [{ id: 'li1', description: 'Build endpoint', hours: 4, cost: 600 }], + totalHours: 4, + totalCost: 600, + isBillable: true, + createdAt: new Date('2026-05-18'), + updatedAt: new Date('2026-05-18'), + }; + + const settings = { companyName: 'Your Company', hourlyRate: 150 }; + + it('returns a non-empty PDF byte stream', async () => { + const bytes = await buildChangeOrderPdfBytes(workItem, client, settings); + expect(bytes.byteLength).toBeGreaterThan(0); + // PDF magic header "%PDF" + expect(Array.from(bytes.subarray(0, 4))).toEqual([0x25, 0x50, 0x44, 0x46]); + }); + + it('blob-URL wrapper still resolves to a string', async () => { + const url = await buildChangeOrderPdf(workItem, client, settings); + expect(typeof url).toBe('string'); + }); +}); diff --git a/web/src/lib/brand.ts b/web/src/lib/brand.ts new file mode 100644 index 0000000..a194f3d --- /dev/null +++ b/web/src/lib/brand.ts @@ -0,0 +1,80 @@ +/** + * DW Tailored Systems brand identity — single source of truth for the + * invoice PDF (`buildPdf.ts`) and the invoice email (`EmailComposer.tsx`). + * + * Scope: invoice documents only. This intentionally does NOT restyle the app. + */ + +/** Business identity shown on invoices and outbound invoice email. */ +export const BRAND = { + name: 'Devin Wilson', + title: 'Independent Software Contractor', + company: 'DW Tailored Systems', + email: 'devin@dwtailored.com', + phone: '+1 (530) 753-5503', + website: 'dwtailored.com', + websiteUrl: 'https://dwtailored.com', + /** SendGrid From — must stay a verified sender. */ + fromEmail: 'devin@dwtailored.com', + fromName: 'DW Tailored Systems', +} as const; + +/** + * Default multi-line "from" address block used on the invoice PDF when the + * contractor has not set a custom one in Settings. + */ +export const BRAND_FROM_ADDRESS = [ + BRAND.name, + BRAND.company, + BRAND.email, + BRAND.phone, +].join('\n'); + +/** + * Brand palette (hex) for HTML/CSS contexts (the invoice email). + * Teal-on-cream, charcoal text — matches dwtailored.com. + * + * Contrast notes (WCAG AA): + * - White on DARK_TEAL header bar: large/bold wordmark only. + * - Body text uses CHARCOAL on white/cream (>= 12:1). + * - Secondary text uses MUTED on white (>= 5:1). + * - Never use TEAL for small text on cream (fails 4.5:1). + */ +export const BRAND_HEX = { + teal: '#1C8A8A', + darkTeal: '#14706E', + charcoal: '#2D2D2D', + muted: '#5C574F', + cream: '#F3EFE4', + subtle: '#F8F6F3', + border: '#E4DFDA', + white: '#FFFFFF', +} as const; + +/** + * Same palette as 0–1 RGB tuples for pdf-lib (`rgb(...BRAND_RGB.teal)`). + */ +export const BRAND_RGB = { + teal: [0.11, 0.541, 0.541] as const, + darkTeal: [0.078, 0.439, 0.431] as const, + charcoal: [0.176, 0.176, 0.176] as const, + muted: [0.361, 0.341, 0.31] as const, + cream: [0.953, 0.937, 0.894] as const, + subtle: [0.973, 0.965, 0.953] as const, + border: [0.894, 0.875, 0.855] as const, + white: [1, 1, 1] as const, +} as const; + +/** + * Slugify a string for use in a downloadable filename. + * Lowercase, alphanumerics + single hyphens, trimmed, capped length. + */ +export function slug(input: string, maxLength = 40): string { + const cleaned = input + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, maxLength) + .replace(/-+$/g, ''); + return cleaned || 'invoice'; +} diff --git a/web/src/lib/buildPdf.ts b/web/src/lib/buildPdf.ts index 7e127cf..8901eb0 100644 --- a/web/src/lib/buildPdf.ts +++ b/web/src/lib/buildPdf.ts @@ -1,6 +1,7 @@ import { PDFDocument, StandardFonts, rgb, type PDFFont, type PDFPage } from 'pdf-lib'; import type { WorkItem, Client } from './types'; import { WORK_ITEM_STATUS_LABELS } from './types'; +import { BRAND, BRAND_FROM_ADDRESS, BRAND_RGB } from './brand'; interface PdfSettings { companyName: string; @@ -15,15 +16,15 @@ interface PdfSettings { /* ────────────────────────────────────────────────────────── * Brand palette * ────────────────────────────────────────────────────────── */ -const TEAL = rgb(0.29, 0.66, 0.66); // #4BA8A8 -const DARK_TEAL = rgb(0.18, 0.48, 0.48); // #2D7A7A -const CHARCOAL = rgb(0.176, 0.176, 0.176); // #2D2D2D -const GRAY = rgb(0.45, 0.45, 0.45); -const LIGHT_GRAY = rgb(0.6, 0.6, 0.6); -const TABLE_STRIPE = rgb(0.96, 0.96, 0.96); -const TABLE_BORDER = rgb(0.82, 0.82, 0.82); +// DW Tailored Systems palette (see web/src/lib/brand.ts) +const TEAL = rgb(...BRAND_RGB.teal); // #1C8A8A +const DARK_TEAL = rgb(...BRAND_RGB.darkTeal); // #14706E +const CHARCOAL = rgb(...BRAND_RGB.charcoal); // #2D2D2D +const GRAY = rgb(...BRAND_RGB.muted); // #5C574F +const TABLE_STRIPE = rgb(...BRAND_RGB.subtle); // #F8F6F3 +const TABLE_BORDER = rgb(...BRAND_RGB.border); // #E4DFDA const WHITE = rgb(1, 1, 1); -const RETAINER_ORANGE = rgb(0.9, 0.49, 0.13); +const RETAINER_ORANGE = rgb(0.541, 0.329, 0.075); // #8A5413 (AA on white) /* ────────────────────────────────────────────────────────── * Page dimensions & layout constants @@ -42,16 +43,18 @@ const LINE_H = 16; // standard text line height const SECTION_GAP = 24; // gap between major sections /** - * Generates a branded, professional invoice PDF and returns a blob URL. + * Generates a branded, professional invoice PDF and returns the raw bytes. + * Used directly when the PDF needs to be attached to an email. */ -export async function buildChangeOrderPdf( +export async function buildChangeOrderPdfBytes( workItem: WorkItem, client: Client, settings: PdfSettings, -): Promise { +): Promise { const pdfDoc = await PDFDocument.create(); - const font = await pdfDoc.embedFont(StandardFonts.Helvetica); - const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + const fontSerif = await pdfDoc.embedFont(StandardFonts.TimesRomanBold); // Track which pages already have footers drawn (for page-break pages) const footeredPages = new Set(); @@ -92,7 +95,16 @@ export async function buildChangeOrderPdf( const coBoxH = coFontSize + coPadY * 2; const coBoxX = rightX - coBoxW; - // Company logo or name — top left + // Brand wordmark (serif) — top left. A custom logo URL still wins if set. + const wordmark = brandCompanyName(settings); + const maxCompanyW = coBoxX - MARGIN - 10; + const drawWordmark = (): number => { + page.drawText(truncateText(wordmark, fontSerif, 22, maxCompanyW), { + x: MARGIN, y, size: 22, font: fontSerif, color: DARK_TEAL, + }); + return 22; + }; + let headerH = 22; // default text height if (settings.pdfLogoUrl) { try { @@ -116,19 +128,11 @@ export async function buildChangeOrderPdf( }); headerH = logoH; } catch { - // Logo fetch/embed failed — fall back to text - const companyDisplayName = settings.companyName.toUpperCase(); - page.drawText(companyDisplayName, { - x: MARGIN, y, size: 22, font: fontBold, color: DARK_TEAL, - }); + // Logo fetch/embed failed — fall back to the brand wordmark + headerH = drawWordmark(); } } else { - const companyDisplayName = settings.companyName.toUpperCase(); - const maxCompanyW = coBoxX - MARGIN - 10; - const displayText = truncateText(companyDisplayName, fontBold, 22, maxCompanyW); - page.drawText(displayText, { - x: MARGIN, y, size: 22, font: fontBold, color: DARK_TEAL, - }); + headerH = drawWordmark(); } // Draw CHANGE ORDER box aligned with company name baseline @@ -161,14 +165,14 @@ export async function buildChangeOrderPdf( // Max width for left column text: stop before right column starts (with gutter) const leftColMaxW = colRightStart - MARGIN - 20; - const companyLine = settings.companyName || 'Your Company'; + const companyLine = brandCompanyName(settings); page.drawText(truncateText(companyLine, fontBold, 11, leftColMaxW), { x: MARGIN, y, size: 11, font: fontBold, color: CHARCOAL, }); y -= LINE_H; - // Render from-address lines (user-customizable) - const fromLines = (settings.invoiceFromAddress || 'Your Name\nYour Business\nyou@example.com') + // Render from-address lines (user-customizable; defaults to DW brand) + const fromLines = (settings.invoiceFromAddress || BRAND_FROM_ADDRESS) .split('\n') .filter(Boolean); for (const line of fromLines) { @@ -472,11 +476,33 @@ export async function buildChangeOrderPdf( } } - const pdfBytes = await pdfDoc.save(); + return pdfDoc.save(); +} + +/** + * Generates a branded, professional invoice PDF and returns a blob URL. + * Thin wrapper over {@link buildChangeOrderPdfBytes} for preview/download. + */ +export async function buildChangeOrderPdf( + workItem: WorkItem, + client: Client, + settings: PdfSettings, +): Promise { + const pdfBytes = await buildChangeOrderPdfBytes(workItem, client, settings); const blob = new Blob([pdfBytes as unknown as BlobPart], { type: 'application/pdf' }); return URL.createObjectURL(blob); } +/** + * Company name shown on the invoice. Falls back to the DW Tailored Systems + * brand when Settings still holds the placeholder/empty value. + */ +function brandCompanyName(settings: PdfSettings): string { + const name = settings.companyName?.trim(); + if (!name || name === 'Your Company') return BRAND.company; + return name; +} + /* ────────────────────────────────────────────────────────── * Footer bar + attribution on every page * ────────────────────────────────────────────────────────── */ @@ -493,7 +519,7 @@ function drawFooter( }); // Attribution text centered below the bar - const attrib = `Generated by ${settings.companyName || 'Your Company'} via Open TEN99`; + const attrib = `${brandCompanyName(settings)} · ${BRAND.website}`; const attribW = font.widthOfTextAtSize(attrib, 7); const attribX = MARGIN + (CONTENT_W - attribW) / 2; // Ensure attribution doesn't go outside margins @@ -501,9 +527,9 @@ function drawFooter( page.drawText(attrib, { x: clampedAttribX, y: barY - 12, - size: 7, + size: 8, font, - color: LIGHT_GRAY, + color: GRAY, }); } diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 1ac22e6..524a576 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -122,3 +122,16 @@ export function addBusinessDays(start: Date, days: number): Date { } return result; } + +/** + * Base64-encode a byte array. Chunked to stay within the argument limit of + * String.fromCharCode for large buffers (used to attach a PDF to an email). + */ +export function uint8ToBase64(bytes: Uint8Array): string { + const CHUNK = 0x8000; + let binary = ''; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); +} diff --git a/web/src/routes/contractor/EmailComposer.tsx b/web/src/routes/contractor/EmailComposer.tsx index aad04b5..55984f0 100644 --- a/web/src/routes/contractor/EmailComposer.tsx +++ b/web/src/routes/contractor/EmailComposer.tsx @@ -2,9 +2,11 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { httpsCallable } from 'firebase/functions'; import { functions } from '../../lib/firebase'; -import type { WorkItem, Client, EmailTemplate } from '../../lib/types'; +import type { WorkItem, Client, EmailTemplate, AppSettings } from '../../lib/types'; import { WORK_ITEM_TYPE_LABELS } from '../../lib/types'; -import { formatCurrency } from '../../lib/utils'; +import { formatCurrency, uint8ToBase64 } from '../../lib/utils'; +import { BRAND, BRAND_HEX, slug } from '../../lib/brand'; +import { buildChangeOrderPdfBytes } from '../../lib/buildPdf'; import { subscribeEmailTemplates, saveEmailTemplate, @@ -17,22 +19,22 @@ export type EmailEditorType = 'completion' | 'invoice'; interface EmailComposerProps { workItems: WorkItem[]; clients: Client[]; + settings: AppSettings; } type SendState = 'idle' | 'sending' | 'sent' | 'error'; -/* ── Brand colors for the email template ─────────────────── */ -const TEAL = '#1A8F8F'; -const HEADER_GRADIENT_START = '#1E2A3A'; -const HEADER_GRADIENT_END = '#243545'; -const TEAL_BORDER = '#1A9E9E'; -const TEXT_DARK = '#2C2C2C'; -const TEXT_MED = '#5A5550'; -const TEXT_LIGHT = '#7A756E'; -const BORDER_COLOR = '#E4DFDA'; -const BG_SUBTLE = '#F8F6F3'; -const BG_PAGE = '#F0EDEA'; -const DUE_DATE_COLOR = '#B5711F'; +/* ── DW Tailored Systems email palette (see lib/brand.ts) ── */ +const TEAL = BRAND_HEX.teal; // accent fills +const ACCENT = BRAND_HEX.darkTeal; // links + emphasis (AA on light) +const HEADER_BG = BRAND_HEX.darkTeal; // solid header bar (white text AA) +const TEXT_DARK = BRAND_HEX.charcoal; +const TEXT_MED = BRAND_HEX.muted; +const TEXT_LIGHT = BRAND_HEX.muted; +const BORDER_COLOR = BRAND_HEX.border; +const BG_SUBTLE = BRAND_HEX.subtle; +const BG_PAGE = BRAND_HEX.cream; +const DUE_DATE_COLOR = '#8A5413'; // amber, AA on white /* ── HTML escape to prevent XSS in email content ─────────── */ function escapeHtml(str: string): string { @@ -44,11 +46,6 @@ function escapeHtml(str: string): string { .replace(/'/g, '''); } -/* ── Logo placeholder (hosted URL for emails) ────────────── */ -// Set your hosted logo URLs here, or leave empty for text fallback -const LOGO_WIDE_URL = ''; -const LOGO_ICON_URL = ''; - /* ── Signature fields ────────────────────────────────────── */ interface SignatureData { name: string; @@ -58,10 +55,10 @@ interface SignatureData { } const DEFAULT_SIGNATURE: SignatureData = { - name: 'Your Name', - title: 'Your Title', - website: '', - websiteLabel: '', + name: BRAND.name, + title: `${BRAND.title} · ${BRAND.company}`, + website: BRAND.websiteUrl, + websiteLabel: BRAND.website, }; /* ── Build email HTML ────────────────────────────────────── */ @@ -92,7 +89,7 @@ function buildEmailHtml(opts: { ${i + 1}. ${esc(li.description) || '(no description)'} - + ${li.hours.toFixed(1)} hrs — ${formatCurrency(li.cost)} ` @@ -105,40 +102,33 @@ function buildEmailHtml(opts: { ` : ''; - const logoWideTag = LOGO_WIDE_URL - ? `Open TEN99` - : `Open TEN99`; - - const logoIconTag = LOGO_ICON_URL - ? `Open TEN99` - : ''; - - const signatureIconCell = logoIconTag - ? `${logoIconTag}` - : ''; + const serif = "Georgia,'Times New Roman',serif"; return ` -
+
- +
- + @@ -188,7 +178,7 @@ function buildEmailHtml(opts: { - @@ -205,16 +195,15 @@ function buildEmailHtml(opts: {
+ - - +
${logoWideTag} + + ${esc(BRAND.company)} + ${opts.headerLabel}
 
Total + ${opts.totalHours.toFixed(1)} hrs — ${formatCurrency(opts.totalCost)}
- ${signatureIconCell} @@ -228,10 +217,17 @@ function buildEmailHtml(opts: { - @@ -245,7 +241,7 @@ function buildEmailHtml(opts: { /* ── Component ───────────────────────────────────────────── */ -export default function EmailComposer({ workItems, clients }: EmailComposerProps) { +export default function EmailComposer({ workItems, clients, settings }: EmailComposerProps) { const { id, type } = useParams<{ id: string; type: string }>(); const navigate = useNavigate(); @@ -288,8 +284,8 @@ export default function EmailComposer({ workItems, clients }: EmailComposerProps ? `Invoice — ${item.subject}` : `Work Order Completed! — ${item.subject}`; }); - const fromEmail = 'noreply@example.com'; - const fromName = 'Open TEN99'; + const fromEmail = BRAND.fromEmail; + const fromName = BRAND.fromName; const [greeting, setGreeting] = useState(`Hello ${client?.name ?? ''},`); const [message, setMessage] = useState(() => { @@ -429,6 +425,23 @@ export default function EmailComposer({ workItems, clients }: EmailComposerProps setSendState('sending'); setError(null); try { + // Attach a branded PDF copy of the invoice (invoices only). + let pdfBase64: string | undefined; + let pdfFilename: string | undefined; + if (isInvoice && item && client) { + const pdfBytes = await buildChangeOrderPdfBytes(item, client, { + companyName: settings.companyName, + hourlyRate: settings.hourlyRate, + taxRate: settings.invoiceTaxRate, + pdfLogoUrl: settings.pdfLogoUrl, + invoiceFromAddress: settings.invoiceFromAddress, + invoiceNotes: settings.invoiceNotes, + invoiceTerms: settings.invoiceTerms, + }); + pdfBase64 = uint8ToBase64(pdfBytes); + pdfFilename = `Invoice-${slug(item.subject)}-${(item.id ?? '').slice(0, 6)}.pdf`; + } + const fn = httpsCallable(functions, 'sendCompletionEmail'); await fn({ to: toEmail, @@ -438,6 +451,8 @@ export default function EmailComposer({ workItems, clients }: EmailComposerProps fromEmail, fromName, html: currentHtml, + pdfBase64, + pdfFilename, }); if (emailType === 'invoice' && item?.id) { From 12a26b394c5edd23c048c41f9d52e120ee3d2362 Mon Sep 17 00:00:00 2001 From: Devin Wilson Date: Mon, 18 May 2026 15:29:41 -0700 Subject: [PATCH 2/2] chore: ignore *.tsbuildinfo build cache --- functions/.gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/.gitignore b/functions/.gitignore index 46688ba..43370fa 100644 --- a/functions/.gitignore +++ b/functions/.gitignore @@ -1 +1 @@ -tsbuildinfo +*.tsbuildinfo
-

+

${esc(opts.signature.name)}

-

+

${esc(opts.signature.title)}

-

- ${esc(opts.signature.websiteLabel)} +

+ ${esc(opts.signature.websiteLabel)}

- - Open TEN99 - + +

+ ${esc(BRAND.company)} +

+

+ ${esc(BRAND.email)} +  ·  + ${esc(BRAND.website)} +  ·  + ${esc(BRAND.phone)} +