Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions functions/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.tsbuildinfo
45 changes: 43 additions & 2 deletions functions/src/sendCompletionEmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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.");
}
}
Expand Down
2 changes: 1 addition & 1 deletion web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ function ContractorRoutes() {
/>
<Route
path="work-items/:id/email/:type"
element={<EmailComposer workItems={workItems} clients={clients} />}
element={<EmailComposer workItems={workItems} clients={clients} settings={settings} />}
/>
<Route
path="time-logs"
Expand Down
91 changes: 91 additions & 0 deletions web/src/lib/__tests__/invoiceBranding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';
import { slug, BRAND, BRAND_FROM_ADDRESS } from '../brand';
import { uint8ToBase64 } from '../utils';
import { buildChangeOrderPdf, buildChangeOrderPdfBytes } from '../buildPdf';
import type { WorkItem, Client } from '../types';

describe('slug', () => {
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');
});
});
80 changes: 80 additions & 0 deletions web/src/lib/brand.ts
Original file line number Diff line number Diff line change
@@ -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';
}
Loading
Loading