import { isValidTimeZone } from "@beenvoice/domain/time-zone"; import { and, eq } from "drizzle-orm"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { getRequestOrigin } from "~/lib/app-url"; 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"; 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( emailOptionsSchema.extend({ scheduledAt: z.coerce.date(), timeZone: z .string() .min(1) .max(100) .refine(isValidTimeZone, "Invalid time zone"), }), ) .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), ), with: { client: true, items: true }, }); if (!invoice) throw new TRPCError({ code: "NOT_FOUND", message: "Invoice not found", }); if (!invoice.client?.email) { 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 TRPCError({ code: "BAD_REQUEST", message: "Add at least one line item before sending this invoice", }); } 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, }, }); if (!job) { throw new TRPCError({ code: "CONFLICT", message: "Unable to schedule invoice", }); } await ctx.db.transaction(async (tx) => { if ( invoice.scheduledSendJobId && invoice.scheduledSendStatus === "pending" ) { await tx .update(backgroundJobs) .set({ status: "cancelled", updatedAt: new Date() }) .where( and( eq(backgroundJobs.id, invoice.scheduledSendJobId), eq(backgroundJobs.status, "pending"), ), ); } 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, 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 }; }), });