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
65 changes: 57 additions & 8 deletions apps/web/src/components/settings/AddSesSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ import { Button } from "@usesend/ui/src/button";
import Spinner from "@usesend/ui/src/spinner";
import { toast } from "@usesend/ui/src/toaster";
import { isLocalhost } from "~/utils/client";
import { sesRegionSchema } from "~/lib/zod/ses-setting-schema";

const FormSchema = z.object({
region: z.string(),
region: sesRegionSchema,
usesendUrl: z.string().url(),
sendRate: z.coerce.number(),
transactionalQuota: z.coerce.number().min(0).max(100),
Expand Down Expand Up @@ -48,14 +49,51 @@ export const AddSesSettings: React.FC<SesSettingsProps> = ({ onSuccess }) => {
export const AddSesSettingsForm: React.FC<SesSettingsProps> = ({
onSuccess,
}) => {
const addSesSettings = api.admin.addSesSettings.useMutation();
const defaultRegion = api.admin.getDefaultSesRegion.useQuery();

if (defaultRegion.isLoading) {
return (
<div className="flex min-h-[500px] items-center justify-center">
<Spinner className="h-5 w-5" />
</div>
);
}

if (!defaultRegion.data) {
return (
<div className="flex min-h-[500px] flex-col items-center justify-center gap-3 text-center">
<p className="text-sm text-muted-foreground" role="alert">
Failed to load the default AWS region.
</p>
<Button
type="button"
variant="outline"
onClick={() => void defaultRegion.refetch()}
>
Retry
</Button>
</div>
);
}

return (
<SesSettingsForm
defaultRegion={defaultRegion.data}
onSuccess={onSuccess}
/>
);
};

const SesSettingsForm: React.FC<
SesSettingsProps & { defaultRegion: string }
> = ({ defaultRegion, onSuccess }) => {
const addSesSettings = api.admin.addSesSettings.useMutation();
const utils = api.useUtils();

const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
region: "",
region: defaultRegion,
Comment on lines +52 to +96

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Add tests for the new settings-form states.

Cover loading, retry, the fetched default region, normalized valid input, invalid input, and a rejected quota request.

As per coding guidelines, behavior changes in apps/web require compatible unit or integration tests.

Also applies to: 141-148

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/settings/AddSesSettings.tsx` around lines 52 - 96,
Add compatible unit or integration tests for the AddSesSettings component and
SesSettingsForm covering loading, retry after a missing default region,
rendering the fetched default region, normalized valid input, invalid input, and
a rejected quota request. Reuse existing testing utilities and API-mocking
patterns from the surrounding apps/web tests, and assert the user-visible
outcomes for each state.

Source: Coding guidelines

usesendUrl: "",
sendRate: 1,
transactionalQuota: 50,
Expand Down Expand Up @@ -101,19 +139,30 @@ export const AddSesSettingsForm: React.FC<SesSettingsProps> = ({
}

const onRegionInputOutOfFocus = async () => {
const region = form.getValues("region");
const region = sesRegionSchema.safeParse(form.getValues("region"));

if (region.success) {
form.clearErrors("region");

if (region) {
const quota = await utils.admin.getQuotaForRegion.fetch({ region });
form.setValue("sendRate", quota ?? 1);
try {
const quota = await utils.admin.getQuotaForRegion.fetch({
region: region.data,
});
form.setValue("sendRate", quota ?? 1);
} catch {
form.setValue("sendRate", 1);
form.setError("region", {
message: "Unable to load the SES quota for this region",
});
}
}
};

return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className=" flex flex-col gap-8 w-full"
className=" flex min-h-[500px] flex-col gap-8 w-full"
>
<FormField
control={form.control}
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ export const env = createEnv({
GOOGLE_CLIENT_SECRET: z.string().optional(),
AWS_SES_ENDPOINT: z.string().optional(),
AWS_SNS_ENDPOINT: z.string().optional(),
AWS_DEFAULT_REGION: z.string().default("us-east-1"),
AWS_DEFAULT_REGION: z
.string()
.trim()
.min(1, "Region is required")
.default("us-east-1"),
API_RATE_LIMIT: z
.string()
.default("1")
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/lib/zod/ses-setting-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { z } from "zod";

export const sesRegionSchema = z.string().trim().min(1, "Region is required");

export function getValidSesRegions(regions: string[]) {
return [...new Set(regions.map((region) => region.trim()).filter(Boolean))];
}
24 changes: 24 additions & 0 deletions apps/web/src/lib/zod/ses-setting-schema.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import {
getValidSesRegions,
sesRegionSchema,
} from "~/lib/zod/ses-setting-schema";

describe("sesRegionSchema", () => {
it("rejects empty regions", () => {
expect(sesRegionSchema.safeParse("").success).toBe(false);
expect(sesRegionSchema.safeParse(" ").success).toBe(false);
});

it("normalizes valid regions", () => {
expect(sesRegionSchema.parse(" us-east-1 ")).toBe("us-east-1");
});
});

describe("getValidSesRegions", () => {
it("filters legacy empty regions and removes duplicates", () => {
expect(
getValidSesRegions(["", " ", "us-east-1", " us-east-1 ", "eu-west-1"]),
).toEqual(["us-east-1", "eu-west-1"]);
});
});
7 changes: 5 additions & 2 deletions apps/web/src/server/api/routers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { logger } from "~/server/logger/log";
import { UseSend } from "usesend-js";
import { isCloud } from "~/utils/common";
import { toPlainHtml } from "~/server/utils/email-content";
import { sesRegionSchema } from "~/lib/zod/ses-setting-schema";

const waitlistUserSelection = {
id: true,
Expand Down Expand Up @@ -67,10 +68,12 @@ export const adminRouter = createTRPCRouter({
return SesSettingsService.getAllSettings();
}),

getDefaultSesRegion: adminProcedure.query(() => env.AWS_DEFAULT_REGION),

getQuotaForRegion: adminProcedure
.input(
z.object({
region: z.string(),
region: sesRegionSchema,
}),
)
.query(async ({ input }) => {
Expand All @@ -81,7 +84,7 @@ export const adminRouter = createTRPCRouter({
addSesSettings: adminProcedure
.input(
z.object({
region: z.string(),
region: sesRegionSchema,
usesendUrl: z.string().url(),
sendRate: z.number(),
transactionalQuota: z.number(),
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/server/api/routers/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,19 @@ import {
} from "~/server/service/domain-service";
import { sendEmail } from "~/server/service/email-service";
import { SesSettingsService } from "~/server/service/ses-settings-service";
import {
getValidSesRegions,
sesRegionSchema,
} from "~/lib/zod/ses-setting-schema";

export const domainRouter = createTRPCRouter({
getAvailableRegions: protectedProcedure.query(async () => {
const settings = await SesSettingsService.getAllSettings();
return settings.map((setting) => setting.region);
return getValidSesRegions(settings.map((setting) => setting.region));
}),

createDomain: teamProcedure
.input(z.object({ name: z.string(), region: z.string() }))
.input(z.object({ name: z.string(), region: sesRegionSchema }))
.mutation(async ({ ctx, input }) => {
return createDomain(
ctx.team.id,
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/server/service/ses-settings-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { EventType } from "@aws-sdk/client-sesv2";
import { EmailQueueService } from "./email-queue-service";
import { smallNanoid } from "../nanoid";
import { logger } from "../logger/log";
import { sesRegionSchema } from "~/lib/zod/ses-setting-schema";

const GENERAL_EVENTS: EventType[] = [
"BOUNCE",
Expand All @@ -27,10 +28,12 @@ export class SesSettingsService {
public static async getSetting(
region = env.AWS_DEFAULT_REGION
): Promise<SesSetting | null> {
const normalizedRegion = sesRegionSchema.parse(region);

await this.checkInitialized();

if (this.cache[region]) {
return this.cache[region] as SesSetting;
if (this.cache[normalizedRegion]) {
return this.cache[normalizedRegion] as SesSetting;
Comment on lines +31 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Normalize cached settings before using normalized region keys.

getSetting and createSesSetting now use trimmed regions, but invalidateCache() still stores entries under raw persisted values. A legacy " us-east-1 " row therefore cannot be found by getSetting("us-east-1"), and the duplicate check can permit a second setting for the same normalized region. Normalize valid legacy keys while rebuilding this.cache, skip blank entries, and define collision handling.

Also applies to: 68-72

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/server/service/ses-settings-service.ts` around lines 31 - 36,
Update invalidateCache() to normalize persisted region keys with the same
trimming/schema rules used by getSetting() and createSesSetting(). Skip blank or
invalid regions while rebuilding this.cache, and define deterministic handling
when multiple raw keys normalize to the same region so getSetting() and
duplicate checks use the normalized entry.

}
return null;
}
Expand Down Expand Up @@ -62,6 +65,8 @@ export class SesSettingsService {
sendingRateLimit: number;
transactionalQuota: number;
}) {
region = sesRegionSchema.parse(region);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

await this.checkInitialized();
if (this.cache[region]) {
throw new Error(`SesSetting for region ${region} already exists`);
Expand Down
Loading