Files
beenvoice/apps/web/src/server/api/routers/email.ts
T

197 lines
6.0 KiB
TypeScript

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 };
}),
});