diff --git a/apps/mobile/app/(app)/invoices/[id].tsx b/apps/mobile/app/(app)/invoices/[id].tsx index c753c2a..ff2f5cd 100644 --- a/apps/mobile/app/(app)/invoices/[id].tsx +++ b/apps/mobile/app/(app)/invoices/[id].tsx @@ -1,9 +1,19 @@ import { router, Stack, useLocalSearchParams } from "expo-router"; import { useMemo, useState } from "react"; -import { Alert, Platform, ScrollView, StyleSheet, Text, View } from "react-native"; +import { + Alert, + Platform, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; import { AppBackground } from "@/components/AppBackground"; -import { InvoiceViewChips, type InvoiceViewSection } from "@/components/invoices/InvoiceViewChips"; +import { + InvoiceViewChips, + type InvoiceViewSection, +} from "@/components/invoices/InvoiceViewChips"; import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview"; import { InvoiceTotals } from "@/components/invoices/InvoiceTotals"; import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions"; @@ -21,6 +31,7 @@ import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status"; import { buildPreviewPdfInputFromInvoice } from "@/lib/invoice-pdf-input"; import { useTabBarScrollPadding } from "@/lib/tab-bar-insets"; import { api } from "@/lib/trpc"; +import { formatZonedDateTime } from "@beenvoice/domain/time-zone"; export default function InvoiceDetailScreen() { const styles = useThemedStyles(createInvoiceDetailStyles); @@ -53,7 +64,10 @@ export default function InvoiceDetailScreen() { }); const previewInput = useMemo( - () => (invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null), + () => + invoiceQuery.data + ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) + : null, [invoiceQuery.data], ); @@ -73,7 +87,11 @@ export default function InvoiceDetailScreen() { {invoiceQuery.error?.message ?? "Invoice not found"} - + + + ) : null} + {storedStatus === "draft" && ( - + )} @@ -579,7 +674,11 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { ) : null} {invoice.items && invoice.client && ( - + )} {effectiveStatus === "draft" && ( @@ -604,12 +703,16 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { }) } onClear={() => - updateInvoice.mutate({ id: invoiceId, sendReminderAt: null }) + updateInvoice.mutate({ + id: invoiceId, + sendReminderAt: null, + }) } /> )} - {(effectiveStatus === "sent" || effectiveStatus === "overdue") && ( + {(effectiveStatus === "sent" || + effectiveStatus === "overdue") && ( Last sent {daysSince(invoice.lastReminderSentAt)} day - {daysSince(invoice.lastReminderSentAt) === 1 ? "" : "s"} ago + {daysSince(invoice.lastReminderSentAt) === 1 + ? "" + : "s"}{" "} + ago

)} @@ -655,7 +761,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { - @@ -823,9 +942,13 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { disabled={sendReminder.isPending} > {sendReminder.isPending ? ( - <> Sending… + <> + Sending… + ) : ( - <> Send Reminder + <> + Send Reminder + )} @@ -838,8 +961,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { Delete Invoice - Are you sure you want to delete invoice {invoice.invoiceNumber}? - This action cannot be undone. + Are you sure you want to delete invoice{" "} + {invoice.invoiceNumber}? This action cannot be + undone. diff --git a/apps/web/src/app/dashboard/invoices/[id]/send/page.tsx b/apps/web/src/app/dashboard/invoices/[id]/send/page.tsx index 6cc08df..063f48d 100644 --- a/apps/web/src/app/dashboard/invoices/[id]/send/page.tsx +++ b/apps/web/src/app/dashboard/invoices/[id]/send/page.tsx @@ -8,6 +8,13 @@ import { Badge } from "~/components/ui/badge"; import { Separator } from "~/components/ui/separator"; import { Alert, AlertDescription } from "~/components/ui/alert"; import { Label } from "~/components/ui/label"; +import { Input } from "~/components/ui/input"; +import { + formatZonedDateTime, + getDefaultScheduledSendAt, + getLocalTimeZone, + toLocalDateTimeInputValue, +} from "@beenvoice/domain/time-zone"; import { Dialog, DialogContent, @@ -44,6 +51,7 @@ import { ArrowLeft, Loader2, FileText, + CalendarClock, } from "lucide-react"; function SendEmailPageSkeleton() { @@ -54,7 +62,9 @@ function SendEmailPageSkeleton() { description="Loading invoice email" />
-
+
@@ -101,6 +111,9 @@ export default function SendEmailPage() { const [isSending, setIsSending] = useState(false); const [isInitialized, setIsInitialized] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [showScheduleDialog, setShowScheduleDialog] = useState(false); + const [scheduledAt, setScheduledAt] = useState(""); + const [minimumScheduledAt, setMinimumScheduledAt] = useState(""); const [retryCount, setRetryCount] = useState(0); // Email content state @@ -118,6 +131,7 @@ export default function SendEmailPage() { // Get utils for cache invalidation const utils = api.useUtils(); + const timeZone = useMemo(() => getLocalTimeZone(), []); // Email sending mutation const sendEmailMutation = api.email.sendInvoice.useMutation({ @@ -183,6 +197,31 @@ export default function SendEmailPage() { }, }); + const scheduleEmailMutation = api.email.scheduleInvoice.useMutation({ + onSuccess: async (data) => { + await utils.invoices.getById.invalidate({ id: invoiceId }); + toast.success("Invoice scheduled", { + description: `It will send ${formatZonedDateTime(data.scheduledAt, data.timeZone)}.`, + }); + router.push(`/dashboard/invoices/${invoiceId}`); + }, + onError: (error) => { + toast.error("Could not schedule invoice", { description: error.message }); + }, + }); + + const cancelScheduleMutation = api.email.cancelScheduledInvoice.useMutation({ + onSuccess: async () => { + await utils.invoices.getById.invalidate({ id: invoiceId }); + toast.success("Scheduled send cancelled"); + }, + onError: (error) => { + toast.error("Could not cancel scheduled send", { + description: error.message, + }); + }, + }); + // Transform invoice data for components const invoice = useMemo(() => { return invoiceData @@ -196,6 +235,9 @@ export default function SendEmailPage() { taxRate: invoiceData.taxRate, currency: invoiceData.currency, emailMessage: invoiceData.emailMessage, + scheduledSendAt: invoiceData.scheduledSendAt, + scheduledSendTimeZone: invoiceData.scheduledSendTimeZone, + scheduledSendStatus: invoiceData.scheduledSendStatus, client: invoiceData.client ? { name: invoiceData.client.name, @@ -287,6 +329,42 @@ export default function SendEmailPage() { } }; + const confirmScheduleEmail = async () => { + const sendAt = new Date(scheduledAt); + if ( + Number.isNaN(sendAt.getTime()) || + sendAt.getTime() < Date.now() + 60_000 + ) { + toast.error("Choose a future send time", { + description: "The scheduled time must be at least one minute from now.", + }); + return; + } + if (toLocalDateTimeInputValue(sendAt) !== scheduledAt) { + toast.error("That local time does not exist", { + description: + "Choose another time. The selected value falls inside a daylight-saving clock change.", + }); + return; + } + try { + await scheduleEmailMutation.mutateAsync({ + invoiceId, + scheduledAt: sendAt, + timeZone, + customSubject: subject, + customContent: emailContent, + customMessage: normalizedCustomMessage, + useHtml: true, + ccEmails: ccEmail.trim() || undefined, + bccEmails: bccEmail.trim() || undefined, + }); + setShowScheduleDialog(false); + } catch { + // The mutation displays the server error. + } + }; + const handleRetry = () => { if (retryCount < 2) { setRetryCount((prev) => prev + 1); @@ -348,6 +426,31 @@ export default function SendEmailPage() { )} + {invoice.scheduledSendStatus === "pending" && invoice.scheduledSendAt ? ( + + + + + Scheduled for{" "} + {formatZonedDateTime( + invoice.scheduledSendAt, + invoice.scheduledSendTimeZone ?? timeZone, + )}{" "} + ({invoice.scheduledSendTimeZone ?? timeZone}) + + + + + ) : null} + {/* Main Content */}
@@ -365,66 +468,66 @@ export default function SendEmailPage() { - - - - Compose Email - - - - {isInitialized ? ( - - ) : ( -
-
-
-

- Initializing email content... -

-
+ + + + Compose Email + + + + {isInitialized ? ( + + ) : ( +
+
+
+

+ Initializing email content... +

- )} - - +
+ )} +
+ - - - - Email Preview - - - -
- -
-
-
+ + + + Email Preview + + + +
+ +
+
+
@@ -579,6 +682,24 @@ export default function SendEmailPage() { Cancel + + + + + + ); } diff --git a/apps/web/src/components/time-clock/time-clock-panel.tsx b/apps/web/src/components/time-clock/time-clock-panel.tsx index 96e9ed0..eea0ce3 100644 --- a/apps/web/src/components/time-clock/time-clock-panel.tsx +++ b/apps/web/src/components/time-clock/time-clock-panel.tsx @@ -46,15 +46,10 @@ import { import { invoiceLabel } from "~/lib/time-entry-display"; import { TimeEntryList } from "~/components/time-clock/time-entry-list"; import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog"; +import { toLocalDateTimeInputValue } from "@beenvoice/domain/time-zone"; type StartMode = "now" | "pick" | "ago"; -function toDatetimeLocalValue(value: Date | string) { - const start = new Date(value); - start.setMinutes(start.getMinutes() - start.getTimezoneOffset()); - return start.toISOString().slice(0, 16); -} - function RunningTextFields({ running, updateRunningPending, @@ -68,7 +63,7 @@ function RunningTextFields({ }) { const [title, setTitle] = useState(running.description ?? ""); const [runningStartedAt, setRunningStartedAt] = useState(() => - toDatetimeLocalValue(running.startedAt), + toLocalDateTimeInputValue(new Date(running.startedAt)), ); return ( @@ -121,10 +116,8 @@ export function TimeClockPanel({ compact = false, }: TimeClockPanelProps) { const utils = api.useUtils(); - const { data: running, isLoading: runningLoading } = api.timeEntries.getRunning.useQuery( - undefined, - { refetchInterval: 30_000 }, - ); + const { data: running, isLoading: runningLoading } = + api.timeEntries.getRunning.useQuery(undefined, { refetchInterval: 30_000 }); const { data: clients } = api.clients.getAll.useQuery(); const todayStart = useMemo(() => { @@ -169,7 +162,9 @@ export function TimeClockPanel({ if (!running) return; const tick = () => - setElapsed(Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000)); + setElapsed( + Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000), + ); tick(); intervalRef.current = setInterval(tick, 1000); return () => { @@ -223,7 +218,10 @@ export function TimeClockPanel({ window.location.assign(`/dashboard/invoices/${data.invoice!.id}`), }, }); - } else if (data.outcome === "saved_no_invoice" || data.outcome === "saved_no_client") { + } else if ( + data.outcome === "saved_no_invoice" || + data.outcome === "saved_no_client" + ) { toast.warning("Time saved", { description: message }); } else { toast.success(message); @@ -289,7 +287,7 @@ export function TimeClockPanel({ if (mode === "pick" && !pickedStart) { const now = new Date(); now.setMinutes(now.getMinutes() - now.getTimezoneOffset()); - setPickedStart(now.toISOString().slice(0, 16)); + setPickedStart(toLocalDateTimeInputValue(now)); } } @@ -314,7 +312,9 @@ export function TimeClockPanel({ if (runningLoading) { return ( - Loading timer… + + Loading timer… + ); } @@ -330,7 +330,12 @@ export function TimeClockPanel({ ); return ( -
+
@@ -338,7 +343,7 @@ export function TimeClockPanel({

{running ? "In progress" : "Ready to start"}

- + {running ? runningTitle : "What are you working on?"} @@ -403,10 +408,16 @@ export function TimeClockPanel({ - + - Entry only — no invoice + + Entry only — no invoice + {billableInvoices?.map((invoice) => ( {invoiceLabel(invoice)} @@ -507,7 +530,9 @@ export function TimeClockPanel({ step={0.01} placeholder="0.00" /> - {clientId && rate === 0 && selectedClient?.defaultHourlyRate ? ( + {clientId && + rate === 0 && + selectedClient?.defaultHourlyRate ? (

{`Uses ${selectedClient.defaultHourlyRate}/hr from ${selectedClient.name}.`}

@@ -543,7 +568,9 @@ export function TimeClockPanel({ autoComplete="off" type="datetime-local" value={pickedStart} - onChange={(event) => setPickedStart(event.target.value)} + onChange={(event) => + setPickedStart(event.target.value) + } /> ) : null} {startMode === "ago" ? ( @@ -556,10 +583,14 @@ export function TimeClockPanel({ min={1} max={1440} value={minutesAgo} - onChange={(event) => setMinutesAgo(event.target.value)} + onChange={(event) => + setMinutesAgo(event.target.value) + } className="w-24" /> - minutes ago + + minutes ago +
) : null}
@@ -618,19 +649,25 @@ export function TimeClockPanel({ {completedToday.length > 0 ? ( - setEditEntryId(entry.id)} /> + setEditEntryId(entry.id)} + /> ) : (

No time logged yet

- Start your first timer or open history to add an entry manually. + Start your first timer or open history to add an entry + manually.

)}
diff --git a/apps/web/src/components/time-clock/time-entry-edit-dialog.tsx b/apps/web/src/components/time-clock/time-entry-edit-dialog.tsx index 64292d0..5a8b510 100644 --- a/apps/web/src/components/time-clock/time-entry-edit-dialog.tsx +++ b/apps/web/src/components/time-clock/time-entry-edit-dialog.tsx @@ -23,15 +23,10 @@ import { import { toast } from "sonner"; import { invoiceLabel } from "~/lib/time-entry-display"; import type { RouterOutputs } from "~/trpc/react"; +import { toLocalDateTimeInputValue } from "@beenvoice/domain/time-zone"; type TimeEntry = RouterOutputs["timeEntries"]["getById"]; -function toDatetimeLocalValue(value: Date | string) { - const start = new Date(value); - start.setMinutes(start.getMinutes() - start.getTimezoneOffset()); - return start.toISOString().slice(0, 16); -} - export type TimeEntryEditDialogProps = { entryId: string | null; open: boolean; @@ -56,9 +51,11 @@ function TimeEntryEditForm({ const [clientId, setClientId] = useState(entry.clientId ?? ""); const [invoiceId, setInvoiceId] = useState(entry.invoiceId ?? ""); const [rate, setRate] = useState(entry.rate ?? 0); - const [startedAt, setStartedAt] = useState(() => toDatetimeLocalValue(entry.startedAt)); + const [startedAt, setStartedAt] = useState(() => + toLocalDateTimeInputValue(new Date(entry.startedAt)), + ); const [endedAt, setEndedAt] = useState(() => - entry.endedAt ? toDatetimeLocalValue(entry.endedAt) : "", + entry.endedAt ? toLocalDateTimeInputValue(new Date(entry.endedAt)) : "", ); const { data: billableInvoices } = api.invoices.getBillable.useQuery( @@ -70,7 +67,8 @@ function TimeEntryEditForm({ if (!startedAt || !endedAt) return null; const start = new Date(startedAt); const end = new Date(endedAt); - if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return null; + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) + return null; return Math.max(0, (end.getTime() - start.getTime()) / 3_600_000); }, [endedAt, startedAt]); @@ -228,7 +226,11 @@ function TimeEntryEditForm({ -
@@ -246,7 +248,9 @@ export function TimeEntryEditDialog({ { id: entryId ?? "" }, { enabled: Boolean(entryId) && open }, ); - const { data: clients = [] } = api.clients.getAll.useQuery(undefined, { enabled: open }); + const { data: clients = [] } = api.clients.getAll.useQuery(undefined, { + enabled: open, + }); return ( diff --git a/apps/web/src/server/api/routers/email.ts b/apps/web/src/server/api/routers/email.ts index b8aa989..667199c 100644 --- a/apps/web/src/server/api/routers/email.ts +++ b/apps/web/src/server/api/routers/email.ts @@ -1,346 +1,196 @@ +import { isValidTimeZone } from "@beenvoice/domain/time-zone"; +import { and, eq } from "drizzle-orm"; +import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { Resend } from "resend"; -import { createTRPCRouter, sessionProcedure } from "~/server/api/trpc"; -import { invoices, platformSettings } from "~/server/db/schema"; -import { eq } from "drizzle-orm"; -import { env } from "~/env"; -import { NOREPLY_EMAIL } from "~/lib/app-email"; + import { getRequestOrigin } from "~/lib/app-url"; -import { generateInvoicePDFBlob } from "~/lib/pdf-export"; -import { generateInvoiceEmailTemplate } from "~/lib/email-templates"; +import { createTRPCRouter, sessionProcedure } from "~/server/api/trpc"; +import { backgroundJobs, invoices } from "~/server/db/schema"; +import { enqueueJob, jobTypes } from "~/server/jobs/queue"; +import { deliverInvoiceEmail } from "~/server/services/send-invoice-email"; -function plainTextToHtml(value: string) { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'") - .replace(/\n/g, "
"); -} - -function normalizeEmailNoteHtml(value: string) { - const visibleText = value - .replace(//gi, "\n") - .replace(/<\/p>/gi, "\n") - .replace(/<[^>]*>/g, "") - .replace(/ |\u00a0/g, " ") - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .trim(); - - return visibleText ? value.trim() : ""; -} +const emailOptionsSchema = z.object({ + invoiceId: z.string().min(1), + customSubject: z.string().max(500).optional(), + customContent: z.string().max(50_000).optional(), + customMessage: z.string().max(10_000).optional(), + useHtml: z.boolean().default(false), + ccEmails: z.string().max(2_000).optional(), + bccEmails: z.string().max(2_000).optional(), +}); export const emailRouter = createTRPCRouter({ sendInvoice: sessionProcedure + .input(emailOptionsSchema) + .mutation(async ({ ctx, input }) => + deliverInvoiceEmail({ + ...input, + actorUserId: ctx.session.user.id, + baseUrl: getRequestOrigin(ctx.headers), + }), + ), + + scheduleInvoice: sessionProcedure .input( - z.object({ - invoiceId: z.string(), - customSubject: z.string().optional(), - customContent: z.string().optional(), - customMessage: z.string().optional(), - useHtml: z.boolean().default(false), - ccEmails: z.string().optional(), - bccEmails: z.string().optional(), + emailOptionsSchema.extend({ + scheduledAt: z.coerce.date(), + timeZone: z + .string() + .min(1) + .max(100) + .refine(isValidTimeZone, "Invalid time zone"), }), ) .mutation(async ({ ctx, input }) => { - // Fetch invoice with relations const invoice = await ctx.db.query.invoices.findFirst({ - where: eq(invoices.id, input.invoiceId), - with: { - client: true, - business: true, - items: true, - }, + where: and( + eq(invoices.id, input.invoiceId), + eq(invoices.createdById, ctx.session.user.id), + ), + with: { client: true, items: true }, }); - - if (!invoice) { - throw new Error("Invoice not found"); - } - - // Check if invoice belongs to the current user - if (invoice.createdById !== ctx.session.user.id) { - throw new Error("Unauthorized"); - } - + if (!invoice) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Invoice not found", + }); if (!invoice.client?.email) { - throw new Error("Client has no email address"); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Client has no email address", + }); + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(invoice.client.email)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid client email address format", + }); } - if (!invoice.items.length) { - throw new Error("Add at least one line item before sending this invoice"); - } - - // Validate email format - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(invoice.client.email)) { - throw new Error("Invalid client email address format"); - } - - // Generate PDF for attachment - let pdfBuffer: Buffer; - try { - const settings = await ctx.db.query.platformSettings.findFirst({ - where: eq(platformSettings.id, "global"), + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Add at least one line item before sending this invoice", }); - const pdfBlob = await generateInvoicePDFBlob( - invoice, - { - pdfTemplate: settings?.pdfTemplate as - | "classic" - | "minimal" - | undefined, - pdfAccentColor: settings?.pdfAccentColor, - pdfFontFamily: settings?.pdfFontFamily as - | "sans" - | "serif" - | "mono" - | undefined, - pdfNumericFontFamily: settings?.pdfNumericFontFamily as - | "sans" - | "serif" - | "mono" - | undefined, - pdfFooterText: settings?.pdfFooterText, - pdfShowLogo: settings?.pdfShowLogo, - pdfShowPageNumbers: settings?.pdfShowPageNumbers, - }, - { logoBaseUrl: getRequestOrigin(ctx.headers) }, - ); - pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer()); - - // Validate PDF was generated successfully - if (pdfBuffer.length === 0) { - throw new Error("Generated PDF is empty"); - } - } catch (pdfError) { - console.error("PDF generation error:", pdfError); - // Re-throw the original error with more context - if (pdfError instanceof Error) { - throw new Error( - `Failed to generate invoice PDF for attachment: ${pdfError.message}`, - ); - } - throw new Error("Failed to generate invoice PDF for attachment"); } - - // Create email content - const subject = - input.customSubject ?? - `Invoice ${invoice.invoiceNumber} from ${invoice.business ? `${invoice.business.name}${invoice.business.nickname ? ` (${invoice.business.nickname})` : ""}` : "Your Business"}`; - - const userName = - invoice.business?.emailFromName ?? - invoice.business?.name ?? - ctx.session.user?.name ?? - "Your Name"; - const userEmail = - invoice.business?.email ?? ctx.session.user?.email ?? ""; - const customMessage = - input.customMessage !== undefined - ? normalizeEmailNoteHtml(input.customMessage) - : invoice.emailMessage - ? plainTextToHtml(invoice.emailMessage) - : undefined; - - // Generate branded email template - const emailTemplate = generateInvoiceEmailTemplate({ - invoice: { - invoiceNumber: invoice.invoiceNumber, - issueDate: invoice.issueDate, - dueDate: invoice.dueDate, - status: invoice.status, - totalAmount: invoice.totalAmount, - taxRate: invoice.taxRate, - currency: invoice.currency, - client: { - name: invoice.client.name, - email: invoice.client.email, - }, - business: invoice.business, - items: invoice.items, + if (input.scheduledAt.getTime() < Date.now() + 60_000) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Choose a send time at least one minute in the future", + }); + } + if ( + invoice.scheduledSendJobId && + invoice.scheduledSendStatus === "processing" + ) { + throw new TRPCError({ + code: "CONFLICT", + message: "This invoice is already being sent", + }); + } + const idempotencyKey = `${jobTypes.sendInvoice}:${invoice.id}:${input.scheduledAt.toISOString()}:${crypto.randomUUID()}`; + const job = await enqueueJob({ + type: jobTypes.sendInvoice, + idempotencyKey, + runAt: input.scheduledAt, + payload: { + invoiceId: invoice.id, + actorUserId: ctx.session.user.id, + customSubject: input.customSubject, + customContent: input.customContent, + customMessage: input.customMessage, + useHtml: input.useHtml, + ccEmails: input.ccEmails, + bccEmails: input.bccEmails, + timeZone: input.timeZone, }, - customContent: input.customContent, - customMessage, - userName, - userEmail, - baseUrl: getRequestOrigin(ctx.headers), }); - - // Determine Resend instance and email configuration to use - let resendInstance: Resend; - let fromEmail: string; - - // Check if business has custom Resend configuration - if (invoice.business?.resendApiKey && invoice.business?.resendDomain) { - // Use business's custom Resend setup - resendInstance = new Resend(invoice.business.resendApiKey); - const fromName = - invoice.business.emailFromName ?? - (invoice.business.nickname - ? `${invoice.business.name} (${invoice.business.nickname})` - : invoice.business.name) ?? - userName; - fromEmail = `${fromName} `; - } else if (env.RESEND_API_KEY && env.RESEND_DOMAIN) { - // Use system Resend configuration - resendInstance = new Resend(env.RESEND_API_KEY); - fromEmail = `noreply@${env.RESEND_DOMAIN}`; - } else if (env.RESEND_API_KEY) { - resendInstance = new Resend(env.RESEND_API_KEY); - fromEmail = invoice.business?.email ?? NOREPLY_EMAIL; - } else { - throw new Error( - "Email delivery is not configured. Add a Resend API key globally or on this business.", - ); - } - - // Prepare CC and BCC lists - const ccEmails: string[] = []; - const bccEmails: string[] = []; - - // Parse CC emails from input - if (input.ccEmails) { - const ccList = input.ccEmails - .split(",") - .map((email) => email.trim()) - .filter((email) => email); - for (const email of ccList) { - if (emailRegex.test(email)) { - ccEmails.push(email); - } - } - } - - // Parse BCC emails from input - if (input.bccEmails) { - const bccList = input.bccEmails - .split(",") - .map((email) => email.trim()) - .filter((email) => email); - for (const email of bccList) { - if (emailRegex.test(email)) { - bccEmails.push(email); - } - } - } - - // Include business email in CC if it exists and is different from sender - if (invoice.business?.email && invoice.business.email !== fromEmail) { - // Validate business email format before adding to CC - if (emailRegex.test(invoice.business.email)) { - ccEmails.push(invoice.business.email); - } - } - - // Send email with Resend - let emailResult; - try { - // Send HTML email with plain text fallback - emailResult = await resendInstance.emails.send({ - from: fromEmail, - to: [invoice.client?.email ?? ""], - cc: ccEmails.length > 0 ? ccEmails : undefined, - bcc: bccEmails.length > 0 ? bccEmails : undefined, - subject: subject, - html: emailTemplate.html, - text: emailTemplate.text, - headers: { - "X-Priority": "3", - "X-MSMail-Priority": "Normal", - "X-Mailer": "beenvoice", - "MIME-Version": "1.0", - }, - attachments: [ - { - filename: `invoice-${invoice.invoiceNumber}.pdf`, - content: pdfBuffer, - }, - ], + if (!job) { + throw new TRPCError({ + code: "CONFLICT", + message: "Unable to schedule invoice", }); - } catch { - throw new Error( - "Email service is currently unavailable. Please try again later.", - ); } - // Enhanced error checking - if (emailResult.error) { - const errorMsg = emailResult.error.message?.toLowerCase() ?? ""; - - // Provide more specific error messages based on error type + await ctx.db.transaction(async (tx) => { if ( - errorMsg.includes("invalid email") || - errorMsg.includes("invalid recipient") + invoice.scheduledSendJobId && + invoice.scheduledSendStatus === "pending" ) { - throw new Error("Invalid recipient email address"); - } else if ( - errorMsg.includes("domain") || - errorMsg.includes("not verified") - ) { - throw new Error( - "Email domain not verified. Please configure your Resend domain in business settings.", - ); - } else if ( - errorMsg.includes("rate limit") || - errorMsg.includes("too many") - ) { - throw new Error("Rate limit exceeded. Please try again later."); - } else if ( - errorMsg.includes("api key") || - errorMsg.includes("unauthorized") - ) { - throw new Error( - "Email service configuration error. Please check your Resend API key.", - ); - } else if ( - errorMsg.includes("attachment") || - errorMsg.includes("file size") - ) { - throw new Error("Invoice PDF is too large to send via email."); - } else { - throw new Error( - `Email delivery failed: ${emailResult.error.message ?? "Unknown error"}`, - ); + await tx + .update(backgroundJobs) + .set({ status: "cancelled", updatedAt: new Date() }) + .where( + and( + eq(backgroundJobs.id, invoice.scheduledSendJobId), + eq(backgroundJobs.status, "pending"), + ), + ); } - } - - if (!emailResult.data?.id) { - throw new Error( - "Email was not sent successfully - no delivery ID received", - ); - } - - // Update invoice status to "sent" if it was draft - if (invoice.status === "draft") { - try { - await ctx.db - .update(invoices) - .set({ - status: "sent", - updatedAt: new Date(), - }) - .where(eq(invoices.id, input.invoiceId)); - } catch { - // Don't throw here - email was sent successfully, status update is secondary - } - } + await tx + .update(invoices) + .set({ + scheduledSendAt: input.scheduledAt, + scheduledSendTimeZone: input.timeZone, + scheduledSendJobId: job.id, + scheduledSendStatus: "pending", + updatedAt: new Date(), + }) + .where(eq(invoices.id, invoice.id)); + }); return { success: true, - emailId: emailResult.data.id, - message: `Invoice sent successfully to ${invoice.client?.email ?? "client"}${ccEmails.length > 0 ? ` (CC: ${ccEmails.join(", ")})` : ""}${bccEmails.length > 0 ? ` (BCC: ${bccEmails.join(", ")})` : ""}`, - deliveryDetails: { - to: invoice.client?.email ?? "", - cc: ccEmails, - bcc: bccEmails, - sentAt: new Date().toISOString(), - }, + jobId: job.id, + scheduledAt: input.scheduledAt.toISOString(), + timeZone: input.timeZone, }; }), + + cancelScheduledInvoice: sessionProcedure + .input(z.object({ invoiceId: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const invoice = await ctx.db.query.invoices.findFirst({ + where: and( + eq(invoices.id, input.invoiceId), + eq(invoices.createdById, ctx.session.user.id), + ), + }); + if (!invoice) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Invoice not found", + }); + if ( + !invoice.scheduledSendJobId || + invoice.scheduledSendStatus !== "pending" + ) { + throw new TRPCError({ + code: "CONFLICT", + message: "This scheduled send can no longer be cancelled", + }); + } + + const cancelled = await ctx.db + .update(backgroundJobs) + .set({ status: "cancelled", updatedAt: new Date() }) + .where( + and( + eq(backgroundJobs.id, invoice.scheduledSendJobId), + eq(backgroundJobs.status, "pending"), + ), + ) + .returning({ id: backgroundJobs.id }); + if (!cancelled.length) { + throw new TRPCError({ + code: "CONFLICT", + message: "The worker has already started sending this invoice", + }); + } + + await ctx.db + .update(invoices) + .set({ scheduledSendStatus: "cancelled", updatedAt: new Date() }) + .where(eq(invoices.id, invoice.id)); + return { success: true }; + }), }); diff --git a/apps/web/src/server/db/schema.ts b/apps/web/src/server/db/schema.ts index 3eefcb7..d048fc7 100644 --- a/apps/web/src/server/db/schema.ts +++ b/apps/web/src/server/db/schema.ts @@ -384,6 +384,11 @@ export const invoices = createTable( publicTokenExpiresAt: d.timestamp(), lastReminderSentAt: d.timestamp(), sendReminderAt: d.timestamp(), + sentAt: d.timestamp({ withTimezone: true }), + scheduledSendAt: d.timestamp({ withTimezone: true }), + scheduledSendTimeZone: d.varchar({ length: 100 }), + scheduledSendJobId: d.varchar({ length: 255 }), + scheduledSendStatus: d.varchar({ length: 20 }), // pending | processing | completed | failed | cancelled createdAt: d .timestamp() .default(sql`CURRENT_TIMESTAMP`) @@ -397,6 +402,8 @@ export const invoices = createTable( index("invoice_number_idx").on(t.invoiceNumber), index("invoice_status_idx").on(t.status), index("invoice_public_token_idx").on(t.publicToken), + index("invoice_scheduled_send_at_idx").on(t.scheduledSendAt), + index("invoice_scheduled_send_job_idx").on(t.scheduledSendJobId), ], ); @@ -755,15 +762,15 @@ export const backgroundJobs = createTable( payload: d.jsonb().$type>().notNull().default({}), status: d.varchar({ length: 20 }).notNull().default("pending"), idempotencyKey: d.varchar({ length: 500 }).notNull().unique(), - runAt: d.timestamp().notNull().defaultNow(), + runAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(), attempts: d.integer().notNull().default(0), maxAttempts: d.integer().notNull().default(5), - lockedAt: d.timestamp(), + lockedAt: d.timestamp({ withTimezone: true }), lockedBy: d.varchar({ length: 255 }), lastError: d.text(), - completedAt: d.timestamp(), - createdAt: d.timestamp().notNull().defaultNow(), - updatedAt: d.timestamp().notNull().defaultNow(), + completedAt: d.timestamp({ withTimezone: true }), + createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(), + updatedAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(), }), (t) => [ index("background_job_status_run_at_idx").on(t.status, t.runAt), diff --git a/apps/web/src/server/jobs/queue.ts b/apps/web/src/server/jobs/queue.ts index aa760a4..4ae1fae 100644 --- a/apps/web/src/server/jobs/queue.ts +++ b/apps/web/src/server/jobs/queue.ts @@ -1,8 +1,12 @@ import { randomUUID } from "node:crypto"; -import { and, asc, eq, lte, or } from "drizzle-orm"; +import { and, asc, eq, inArray, lte, or } from "drizzle-orm"; import { db } from "~/server/db"; -import { backgroundJobs, recurringInvoices } from "~/server/db/schema"; +import { + backgroundJobs, + invoices, + recurringInvoices, +} from "~/server/db/schema"; export const jobTypes = { generateRecurringInvoice: "recurring_invoice.generate", @@ -111,7 +115,10 @@ export async function completeJob(id: string) { export async function failJob(job: BackgroundJob, error: unknown) { const terminal = job.attempts >= job.maxAttempts; - const retryDelayMs = Math.min(60 * 60_000, 2 ** Math.max(0, job.attempts - 1) * 15_000); + const retryDelayMs = Math.min( + 60 * 60_000, + 2 ** Math.max(0, job.attempts - 1) * 15_000, + ); await db .update(backgroundJobs) .set({ @@ -125,3 +132,15 @@ export async function failJob(job: BackgroundJob, error: unknown) { .where(eq(backgroundJobs.id, job.id)); return terminal; } + +export async function markScheduledInvoiceJobFailed(job: BackgroundJob) { + await db + .update(invoices) + .set({ scheduledSendStatus: "failed", updatedAt: new Date() }) + .where( + and( + eq(invoices.scheduledSendJobId, job.id), + inArray(invoices.scheduledSendStatus, ["pending", "processing"]), + ), + ); +} diff --git a/apps/web/src/server/services/send-invoice-email.ts b/apps/web/src/server/services/send-invoice-email.ts new file mode 100644 index 0000000..8362be0 --- /dev/null +++ b/apps/web/src/server/services/send-invoice-email.ts @@ -0,0 +1,336 @@ +import { and, eq } from "drizzle-orm"; +import { Resend } from "resend"; + +import { NOREPLY_EMAIL } from "~/lib/app-email"; +import { generateInvoiceEmailTemplate } from "~/lib/email-templates"; +import { generateInvoicePDFBlob } from "~/lib/pdf-export"; +import { env } from "~/env"; +import { db } from "~/server/db"; +import { backgroundJobs, invoices, platformSettings } from "~/server/db/schema"; + +export interface InvoiceEmailOptions { + customSubject?: string; + customContent?: string; + customMessage?: string; + useHtml?: boolean; + ccEmails?: string; + bccEmails?: string; +} + +export interface DeliverInvoiceEmailInput extends InvoiceEmailOptions { + invoiceId: string; + actorUserId: string; + baseUrl: string; + idempotencyKey?: string; + scheduledJobId?: string; +} + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function plainTextToHtml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/\n/g, "
"); +} + +function normalizeEmailNoteHtml(value: string) { + const visibleText = value + .replace(//gi, "\n") + .replace(/<\/p>/gi, "\n") + .replace(/<[^>]*>/g, "") + .replace(/ |\u00a0/g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .trim(); + + return visibleText ? value.trim() : ""; +} + +function parseEmailList(value?: string): string[] { + if (!value) return []; + return value + .split(",") + .map((email) => email.trim()) + .filter((email) => EMAIL_PATTERN.test(email)); +} + +function deliveryError(message: string | undefined): Error { + const errorMessage = message?.toLowerCase() ?? ""; + if ( + errorMessage.includes("invalid email") || + errorMessage.includes("invalid recipient") + ) { + return new Error("Invalid recipient email address"); + } + if ( + errorMessage.includes("domain") || + errorMessage.includes("not verified") + ) { + return new Error( + "Email domain not verified. Please configure your Resend domain in business settings.", + ); + } + if ( + errorMessage.includes("rate limit") || + errorMessage.includes("too many") + ) { + return new Error("Rate limit exceeded. Please try again later."); + } + if ( + errorMessage.includes("api key") || + errorMessage.includes("unauthorized") + ) { + return new Error( + "Email service configuration error. Please check your Resend API key.", + ); + } + if ( + errorMessage.includes("attachment") || + errorMessage.includes("file size") + ) { + return new Error("Invoice PDF is too large to send via email."); + } + return new Error(`Email delivery failed: ${message ?? "Unknown error"}`); +} + +export async function deliverInvoiceEmail(input: DeliverInvoiceEmailInput) { + const invoice = await db.query.invoices.findFirst({ + where: eq(invoices.id, input.invoiceId), + with: { + client: true, + business: true, + createdBy: true, + items: true, + }, + }); + + if (!invoice) throw new Error("Invoice not found"); + if (invoice.createdById !== input.actorUserId) + throw new Error("Unauthorized"); + if (!invoice.client?.email) throw new Error("Client has no email address"); + if (!invoice.items.length) { + throw new Error("Add at least one line item before sending this invoice"); + } + if (!EMAIL_PATTERN.test(invoice.client.email)) { + throw new Error("Invalid client email address format"); + } + if ( + input.scheduledJobId && + (invoice.scheduledSendJobId !== input.scheduledJobId || + !["pending", "processing"].includes(invoice.scheduledSendStatus ?? "")) + ) { + return { + skipped: true as const, + message: "Scheduled send is no longer active", + }; + } + if ( + !input.scheduledJobId && + invoice.scheduledSendJobId && + invoice.scheduledSendStatus === "pending" + ) { + const cancelled = await db + .update(backgroundJobs) + .set({ status: "cancelled", updatedAt: new Date() }) + .where( + and( + eq(backgroundJobs.id, invoice.scheduledSendJobId), + eq(backgroundJobs.status, "pending"), + ), + ) + .returning({ id: backgroundJobs.id }); + if (!cancelled.length) { + throw new Error("The worker has already started sending this invoice"); + } + await db + .update(invoices) + .set({ scheduledSendStatus: "cancelled", updatedAt: new Date() }) + .where(eq(invoices.id, invoice.id)); + } else if ( + !input.scheduledJobId && + invoice.scheduledSendStatus === "processing" + ) { + throw new Error("The worker is already sending this invoice"); + } + + const settings = await db.query.platformSettings.findFirst({ + where: eq(platformSettings.id, "global"), + }); + let pdfBuffer: Buffer; + try { + const pdfBlob = await generateInvoicePDFBlob( + invoice, + { + pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined, + pdfAccentColor: settings?.pdfAccentColor, + pdfFontFamily: settings?.pdfFontFamily as + | "sans" + | "serif" + | "mono" + | undefined, + pdfNumericFontFamily: settings?.pdfNumericFontFamily as + | "sans" + | "serif" + | "mono" + | undefined, + pdfFooterText: settings?.pdfFooterText, + pdfShowLogo: settings?.pdfShowLogo, + pdfShowPageNumbers: settings?.pdfShowPageNumbers, + }, + { logoBaseUrl: input.baseUrl }, + ); + pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer()); + if (pdfBuffer.length === 0) throw new Error("Generated PDF is empty"); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + throw new Error( + `Failed to generate invoice PDF for attachment: ${message}`, + ); + } + + const subject = + input.customSubject ?? + `Invoice ${invoice.invoiceNumber} from ${ + invoice.business + ? `${invoice.business.name}${invoice.business.nickname ? ` (${invoice.business.nickname})` : ""}` + : "Your Business" + }`; + const userName = + invoice.business?.emailFromName ?? + invoice.business?.name ?? + invoice.createdBy.name ?? + "Your Name"; + const userEmail = invoice.business?.email ?? invoice.createdBy.email ?? ""; + const customMessage = + input.customMessage !== undefined + ? normalizeEmailNoteHtml(input.customMessage) + : invoice.emailMessage + ? plainTextToHtml(invoice.emailMessage) + : undefined; + const emailTemplate = generateInvoiceEmailTemplate({ + invoice: { + invoiceNumber: invoice.invoiceNumber, + issueDate: invoice.issueDate, + dueDate: invoice.dueDate, + status: invoice.status, + totalAmount: invoice.totalAmount, + taxRate: invoice.taxRate, + currency: invoice.currency, + client: { name: invoice.client.name, email: invoice.client.email }, + business: invoice.business, + items: invoice.items, + }, + customContent: input.customContent, + customMessage, + userName, + userEmail, + baseUrl: input.baseUrl, + }); + + let resend: Resend; + let fromEmail: string; + if (invoice.business?.resendApiKey && invoice.business?.resendDomain) { + resend = new Resend(invoice.business.resendApiKey); + const fromName = + invoice.business.emailFromName ?? + (invoice.business.nickname + ? `${invoice.business.name} (${invoice.business.nickname})` + : invoice.business.name) ?? + userName; + fromEmail = `${fromName} `; + } else if (env.RESEND_API_KEY && env.RESEND_DOMAIN) { + resend = new Resend(env.RESEND_API_KEY); + fromEmail = `noreply@${env.RESEND_DOMAIN}`; + } else if (env.RESEND_API_KEY) { + resend = new Resend(env.RESEND_API_KEY); + fromEmail = invoice.business?.email ?? NOREPLY_EMAIL; + } else { + throw new Error( + "Email delivery is not configured. Add a Resend API key globally or on this business.", + ); + } + + const ccEmails = parseEmailList(input.ccEmails); + const bccEmails = parseEmailList(input.bccEmails); + if ( + invoice.business?.email && + invoice.business.email !== fromEmail && + EMAIL_PATTERN.test(invoice.business.email) + ) { + ccEmails.push(invoice.business.email); + } + + let emailResult; + try { + emailResult = await resend.emails.send( + { + from: fromEmail, + to: [invoice.client.email], + cc: ccEmails.length ? ccEmails : undefined, + bcc: bccEmails.length ? bccEmails : undefined, + subject, + html: emailTemplate.html, + text: emailTemplate.text, + headers: { + "X-Priority": "3", + "X-MSMail-Priority": "Normal", + "X-Mailer": "beenvoice", + "MIME-Version": "1.0", + }, + attachments: [ + { + filename: `invoice-${invoice.invoiceNumber}.pdf`, + content: pdfBuffer, + }, + ], + }, + input.idempotencyKey + ? { idempotencyKey: input.idempotencyKey } + : undefined, + ); + } catch { + throw new Error( + "Email service is currently unavailable. Please try again later.", + ); + } + + if (emailResult.error) throw deliveryError(emailResult.error.message); + if (!emailResult.data?.id) { + throw new Error( + "Email was not sent successfully - no delivery ID received", + ); + } + + const sentAt = new Date(); + await db + .update(invoices) + .set({ + ...(invoice.status === "draft" ? { status: "sent" } : {}), + sentAt, + ...(input.scheduledJobId ? { scheduledSendStatus: "completed" } : {}), + updatedAt: sentAt, + }) + .where(eq(invoices.id, input.invoiceId)); + + return { + skipped: false as const, + success: true, + emailId: emailResult.data.id, + message: `Invoice sent successfully to ${invoice.client.email}${ + ccEmails.length ? ` (CC: ${ccEmails.join(", ")})` : "" + }${bccEmails.length ? ` (BCC: ${bccEmails.join(", ")})` : ""}`, + deliveryDetails: { + to: invoice.client.email, + cc: ccEmails, + bcc: bccEmails, + sentAt: sentAt.toISOString(), + }, + }; +} diff --git a/apps/worker/README.md b/apps/worker/README.md index 41e9518..12fb7e4 100644 --- a/apps/worker/README.md +++ b/apps/worker/README.md @@ -2,10 +2,12 @@ The worker is a separate Bun process backed by the same PostgreSQL database as the web app. It follows the Racetix worker model: a durable database outbox, a lightweight scheduler, and horizontally safe polling with `FOR UPDATE SKIP LOCKED`. -Currently it schedules and generates due recurring invoices. New asynchronous workflows should enqueue a typed job through `apps/web/src/server/jobs/queue.ts` and add a handler in `src/index.ts`. +It generates due recurring invoices and delivers scheduled invoice emails. New asynchronous workflows should enqueue a typed job through `apps/web/src/server/jobs/queue.ts` and add a handler in `src/index.ts`. Jobs have an idempotency key, scheduled run time, bounded exponential retries, and stale-lock recovery. Multiple worker replicas can run safely. Timer elapsed time is still derived from `startedAt`; the worker should only send time-clock reminders, never increment a counter every second. +Scheduled sends store an absolute UTC instant plus the IANA timezone selected by the client. The worker calls the app's secret-protected internal delivery endpoint through `APP_INTERNAL_URL`; that endpoint passes the job idempotency key to Resend, so a retry cannot send the same invoice twice. + ```bash # Uses the web app's .env/.env.local files bun run dev @@ -15,3 +17,4 @@ bun run start ``` `WORKER_POLL_MS` defaults to 2000 and `WORKER_SCHEDULE_MS` defaults to 60000. +`APP_INTERNAL_URL` defaults to `http://app:3000` in Compose, and `CRON_SECRET` authenticates worker requests to the app. diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index b689f78..9ab99bb 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -6,9 +6,11 @@ import { completeJob, failJob, jobTypes, + markScheduledInvoiceJobFailed, scheduleDueRecurringInvoiceJobs, type BackgroundJob, } from "../../web/src/server/jobs/queue"; +import { sendScheduledInvoice } from "./send-invoice"; const workerId = `beenvoice-worker:${randomUUID()}`; const pollMs = Number(process.env.WORKER_POLL_MS ?? 2_000); @@ -17,7 +19,11 @@ let stopping = false; let working = false; let scheduling = false; -function log(level: "info" | "error", event: string, fields: Record = {}) { +function log( + level: "info" | "error", + event: string, + fields: Record = {}, +) { const record = JSON.stringify({ timestamp: new Date().toISOString(), level, @@ -59,6 +65,10 @@ async function handleJob(job: BackgroundJob) { await generateRecurringInvoice(job); return; } + if (job.type === jobTypes.sendInvoice) { + await sendScheduledInvoice(job); + return; + } throw new Error(`No handler registered for ${job.type}`); } @@ -72,9 +82,16 @@ async function drainJobs() { try { await handleJob(job); await completeJob(job.id); - log("info", "job.completed", { jobId: job.id, jobType: job.type, attempts: job.attempts }); + log("info", "job.completed", { + jobId: job.id, + jobType: job.type, + attempts: job.attempts, + }); } catch (error) { const terminal = await failJob(job, error); + if (terminal && job.type === jobTypes.sendInvoice) { + await markScheduledInvoiceJobFailed(job); + } log("error", "job.failed", { jobId: job.id, jobType: job.type, diff --git a/apps/worker/src/send-invoice.ts b/apps/worker/src/send-invoice.ts new file mode 100644 index 0000000..ec44dc3 --- /dev/null +++ b/apps/worker/src/send-invoice.ts @@ -0,0 +1,41 @@ +import type { BackgroundJob } from "../../web/src/server/jobs/queue"; + +function requiredPayloadString(job: BackgroundJob, key: string): string { + const value = job.payload[key]; + if (typeof value !== "string" || !value) { + throw new Error(`Invalid scheduled invoice job payload: ${key}`); + } + return value; +} + +export async function sendScheduledInvoice(job: BackgroundJob) { + const appUrl = + process.env.APP_INTERNAL_URL?.replace(/\/$/, "") ?? "http://app:3000"; + const secret = process.env.CRON_SECRET; + if (!secret) + throw new Error("CRON_SECRET is required for scheduled invoice delivery"); + + const response = await fetch(`${appUrl}/api/internal/jobs/send-invoice`, { + method: "POST", + headers: { + Authorization: `Bearer ${secret}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...job.payload, + jobId: job.id, + idempotencyKey: job.idempotencyKey, + invoiceId: requiredPayloadString(job, "invoiceId"), + actorUserId: requiredPayloadString(job, "actorUserId"), + }), + }); + const result = (await response.json().catch(() => ({}))) as { + error?: string; + }; + if (!response.ok) { + throw new Error( + result.error ?? `Invoice delivery request failed (${response.status})`, + ); + } + return result; +} diff --git a/apps/worker/tests/scheduled-invoice-send.test.ts b/apps/worker/tests/scheduled-invoice-send.test.ts new file mode 100644 index 0000000..771747a --- /dev/null +++ b/apps/worker/tests/scheduled-invoice-send.test.ts @@ -0,0 +1,88 @@ +/// + +import { afterEach, describe, expect, mock, test } from "bun:test"; + +import type { BackgroundJob } from "../../web/src/server/jobs/queue"; +import { sendScheduledInvoice } from "../src/send-invoice"; + +const originalFetch = globalThis.fetch; +const originalAppUrl = process.env.APP_INTERNAL_URL; +const originalSecret = process.env.CRON_SECRET; + +function scheduledJob(): BackgroundJob { + const now = new Date("2026-08-17T16:00:00.000Z"); + return { + id: "job-1", + type: "invoice.send_scheduled", + payload: { + invoiceId: "invoice-1", + actorUserId: "user-1", + customMessage: "Thanks!", + timeZone: "America/New_York", + }, + status: "processing", + idempotencyKey: "invoice.send_scheduled:invoice-1:once", + runAt: now, + attempts: 1, + maxAttempts: 5, + lockedAt: now, + lockedBy: "worker-1", + lastError: null, + completedAt: null, + createdAt: now, + updatedAt: now, + }; +} + +afterEach(() => { + globalThis.fetch = originalFetch; + if (originalAppUrl === undefined) delete process.env.APP_INTERNAL_URL; + else process.env.APP_INTERNAL_URL = originalAppUrl; + if (originalSecret === undefined) delete process.env.CRON_SECRET; + else process.env.CRON_SECRET = originalSecret; +}); + +describe("scheduled invoice delivery", () => { + test("calls the internal app endpoint with auth and an idempotency key", async () => { + process.env.APP_INTERNAL_URL = "http://app:3000/"; + process.env.CRON_SECRET = "worker-secret"; + const fetchMock = mock( + async (_url: string | URL | Request, _request?: RequestInit) => + Response.json({ success: true, emailId: "email-1" }), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await sendScheduledInvoice(scheduledJob()); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, request] = fetchMock.mock.calls[0]!; + expect(url).toBe("http://app:3000/api/internal/jobs/send-invoice"); + expect(request?.headers).toEqual({ + Authorization: "Bearer worker-secret", + "Content-Type": "application/json", + }); + expect(JSON.parse(String(request?.body))).toMatchObject({ + jobId: "job-1", + invoiceId: "invoice-1", + actorUserId: "user-1", + idempotencyKey: "invoice.send_scheduled:invoice-1:once", + timeZone: "America/New_York", + }); + }); + + test("surfaces retryable delivery errors to the worker", async () => { + process.env.APP_INTERNAL_URL = "http://app:3000"; + process.env.CRON_SECRET = "worker-secret"; + globalThis.fetch = mock( + async (_url: string | URL | Request, _request?: RequestInit) => + Response.json( + { error: "Email service is unavailable" }, + { status: 503 }, + ), + ) as unknown as typeof fetch; + + await expect(sendScheduledInvoice(scheduledJob())).rejects.toThrow( + "Email service is unavailable", + ); + }); +}); diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index c7d78e5..b5a86e0 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -68,7 +68,11 @@ services: WORKER_SCHEDULE_MS: ${WORKER_SCHEDULE_MS:-60000} RESEND_API_KEY: ${RESEND_API_KEY:-} RESEND_DOMAIN: ${RESEND_DOMAIN:-} + CRON_SECRET: ${CRON_SECRET:-} + APP_INTERNAL_URL: http://app:${APP_PORT:-3000} depends_on: + app: + condition: service_started db: condition: service_healthy restart: unless-stopped @@ -85,7 +89,10 @@ services: - beenvoice_pg_data:/var/lib/postgresql/data healthcheck: test: - ["CMD-SHELL", 'pg_isready -h 127.0.0.1 -p "$${POSTGRES_PORT}" -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"'] + [ + "CMD-SHELL", + 'pg_isready -h 127.0.0.1 -p "$${POSTGRES_PORT}" -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"', + ] interval: 5s timeout: 5s retries: 10 diff --git a/docker-compose.yml b/docker-compose.yml index acebe22..dab8863 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -64,7 +64,11 @@ services: WORKER_SCHEDULE_MS: ${WORKER_SCHEDULE_MS:-60000} RESEND_API_KEY: ${RESEND_API_KEY:-} RESEND_DOMAIN: ${RESEND_DOMAIN:-} + CRON_SECRET: ${CRON_SECRET:-} + APP_INTERNAL_URL: http://app:${APP_PORT:-3000} depends_on: + app: + condition: service_started db: condition: service_healthy restart: unless-stopped diff --git a/packages/domain/package.json b/packages/domain/package.json index 73b4c04..b35ea0d 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -8,7 +8,8 @@ "./expense-categories": "./src/expense-categories.ts", "./invoice-status": "./src/invoice-status.ts", "./receipt-parse": "./src/receipt-parse.ts", - "./time-clock": "./src/time-clock.ts" + "./time-clock": "./src/time-clock.ts", + "./time-zone": "./src/time-zone.ts" }, "scripts": { "build": "tsc --noEmit", diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index b08e8cb..597368f 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -2,3 +2,4 @@ export * from "./expense-categories"; export * from "./invoice-status"; export * from "./receipt-parse"; export * from "./time-clock"; +export * from "./time-zone"; diff --git a/packages/domain/src/time-zone.ts b/packages/domain/src/time-zone.ts new file mode 100644 index 0000000..893a251 --- /dev/null +++ b/packages/domain/src/time-zone.ts @@ -0,0 +1,44 @@ +const FALLBACK_TIME_ZONE = "UTC"; + +export function isValidTimeZone(value: string): boolean { + if (!value.trim()) return false; + try { + new Intl.DateTimeFormat("en-US", { timeZone: value }).format(0); + return true; + } catch { + return false; + } +} + +export function getLocalTimeZone(): string { + const resolved = Intl.DateTimeFormat().resolvedOptions().timeZone; + return resolved && isValidTimeZone(resolved) ? resolved : FALLBACK_TIME_ZONE; +} + +export function formatZonedDateTime( + value: Date | string | number, + timeZone = getLocalTimeZone(), + options: Intl.DateTimeFormatOptions = {}, +): string { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return "Invalid date"; + + return new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + ...options, + timeZone: isValidTimeZone(timeZone) ? timeZone : FALLBACK_TIME_ZONE, + }).format(date); +} + +export function getDefaultScheduledSendAt(now = new Date()): Date { + const result = new Date(now); + result.setMinutes(0, 0, 0); + result.setHours(result.getHours() + 1); + return result; +} + +export function toLocalDateTimeInputValue(value: Date): string { + const pad = (part: number) => String(part).padStart(2, "0"); + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}`; +} diff --git a/packages/domain/tests/domain.test.ts b/packages/domain/tests/domain.test.ts index 7d5b2b6..b74b4b8 100644 --- a/packages/domain/tests/domain.test.ts +++ b/packages/domain/tests/domain.test.ts @@ -1,6 +1,12 @@ /// import { describe, expect, test } from "bun:test"; +import { + formatZonedDateTime, + getDefaultScheduledSendAt, + isValidTimeZone, + toLocalDateTimeInputValue, +} from "../src/time-zone"; import { EXPENSE_CATEGORIES, formatElapsedSeconds, @@ -33,3 +39,25 @@ describe("shared domain behavior", () => { expect(receipt.items[0]?.name).toBe("Coffee"); }); }); + +describe("time-zone helpers", () => { + test("formats the same instant in the selected IANA time zone", () => { + const instant = new Date("2026-08-17T16:30:00.000Z"); + expect(formatZonedDateTime(instant, "America/New_York")).toContain( + "12:30 PM", + ); + expect(formatZonedDateTime(instant, "America/Los_Angeles")).toContain( + "9:30 AM", + ); + }); + + test("validates IANA time zones", () => { + expect(isValidTimeZone("America/New_York")).toBe(true); + expect(isValidTimeZone("not/a-zone")).toBe(false); + }); + + test("rounds the default schedule to the next local hour", () => { + const result = getDefaultScheduledSendAt(new Date(2026, 7, 17, 10, 42, 19)); + expect(toLocalDateTimeInputValue(result)).toBe("2026-08-17T11:00"); + }); +});