197 lines
6.0 KiB
TypeScript
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 };
|
|
}),
|
|
});
|