Skip to content
Open
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
93 changes: 93 additions & 0 deletions apps/web/src/app/(dashboard)/admin/delete-ses-configuration.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"use client";

import { useState } from "react";
import { SesSetting } from "@prisma/client";
import { Trash } from "lucide-react";
Comment on lines +3 to +5
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Avoid Prisma value imports on the client; add an accessible label for the delete button

  • In a "use client" component, importing SesSetting as a value (import { SesSetting } from "@prisma/client";) can cause Prisma (a server‑side dependency) to be pulled into the browser bundle depending on TS/compiler settings. Since it’s only used for typing, switch to a type‑only import to keep the client bundle clean:
-import { SesSetting } from "@prisma/client";
+import type { SesSetting } from "@prisma/client";
  • The Trash icon trigger button is visually clear but has no accessible label, so screen readers won’t know what it does. Consider adding an aria-label or sr‑only text:
-        <Button
-          variant="ghost"
-          size="sm"
-          className="text-destructive hover:text-destructive"
-        >
-          <Trash className="h-4 w-4" />
-        </Button>
+        <Button
+          variant="ghost"
+          size="sm"
+          aria-label="Delete SES configuration"
+          className="text-destructive hover:text-destructive"
+        >
+          <Trash className="h-4 w-4" />
+        </Button>

The mutation wiring, toasts, and pending state handling otherwise look solid.

Also applies to: 21-49, 52-92


🌐 Web query:

Check how Next.js and TypeScript handle non-`import type` Prisma imports in client components (e.g., whether `@prisma/client` is tree-shaken or bundled by default when used only in type positions).

💡 Result:

Short answer: If you import @prisma/client (non‑type) from inside a Next.js client component, it becomes part of the client module graph and will be included in the client bundle (so it is not automatically erased/tree‑shaken away). Use type‑only imports (import type ...) or the Prisma browser/lightweight exports to avoid bundling server code. [1][2][3]

Evidence / pointers:

  • Next.js: once a module is marked "use client", everything imported into it is treated as client code and enters the client bundle boundary. [1]
  • Prisma docs: the standard generated client (client.ts / @prisma/client) contains transitive server/node dependencies and is not suitable for browser contexts; Prisma provides a browser/lightweight output and recommends explicit imports for client vs server usage. [2]
  • Community/Prisma issue: historically enums and other parts lived in the same bundle which prevented safe tree‑shaking from @prisma/client; this is a known concern and drives the need for separate/browser exports or type‑only imports. [3]

If you want, I can show a small example of the safe patterns (import type / importing Prisma browser enums) and what to avoid.


Avoid Prisma value imports on the client; add an accessible label for the delete button

The concerns in the original review are confirmed:

  • Non-import type imports of @prisma/client in "use client" components are bundled into the client bundle and are not tree-shaken away. Use type-only imports to keep server code out of the browser:
-import { SesSetting } from "@prisma/client";
+import type { SesSetting } from "@prisma/client";
  • The Trash icon trigger button lacks an accessible label. Add aria-label so screen readers announce its purpose:
-        <Button
-          variant="ghost"
-          size="sm"
-          className="text-destructive hover:text-destructive"
-        >
-          <Trash className="h-4 w-4" />
-        </Button>
+        <Button
+          variant="ghost"
+          size="sm"
+          aria-label="Delete SES configuration"
+          className="text-destructive hover:text-destructive"
+        >
+          <Trash className="h-4 w-4" />
+        </Button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { useState } from "react";
import { SesSetting } from "@prisma/client";
import { Trash } from "lucide-react";
import { useState } from "react";
import type { SesSetting } from "@prisma/client";
import { Trash } from "lucide-react";
🤖 Prompt for AI Agents
In apps/web/src/app/(dashboard)/admin/delete-ses-configuration.tsx around lines
3 to 5, the file currently imports SesSetting from @prisma/client which pulls
runtime code into the client bundle and the Trash icon button lacks an
accessible label; change the Prisma import to a type-only import (e.g., "import
type { SesSetting } from '@prisma/client'") so no runtime code is included, and
add an accessible label to the delete trigger (e.g., add an aria-label or
aria-labelledby attribute to the button describing its action such as "Delete
SES configuration") ensuring screen readers can announce its purpose.


import { Button } from "@usesend/ui/src/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { toast } from "@usesend/ui/src/toaster";

import { api } from "~/trpc/react";

export default function DeleteSesConfiguration({
setting,
}: {
setting: SesSetting;
}) {
const [open, setOpen] = useState(false);

const deleteSesSettings = api.admin.deleteSesSettings.useMutation();
const utils = api.useUtils();

const handleDelete = () => {
deleteSesSettings.mutate(
{ settingsId: setting.id },
{
onSuccess: () => {
utils.admin.invalidate();
toast.success("SES configuration deleted", {
description: `${setting.region} has been removed`,
});
setOpen(false);
},
onError: (error) => {
toast.error("Failed to delete SES configuration", {
description: error.message,
});
},
}
);
};

return (
<Dialog open={open} onOpenChange={(next) => setOpen(next)}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
>
<Trash className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete SES configuration</DialogTitle>
<DialogDescription>
This will delete the callback URL, SNS topic, and queues for the{" "}
<span className="font-semibold">{setting.region}</span> region.
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
disabled={deleteSesSettings.isPending}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
isLoading={deleteSesSettings.isPending}
showSpinner
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
6 changes: 5 additions & 1 deletion apps/web/src/app/(dashboard)/admin/ses-configurations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { api } from "~/trpc/react";
import Spinner from "@usesend/ui/src/spinner";
import EditSesConfiguration from "./edit-ses-configuration";
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
import DeleteSesConfiguration from "./delete-ses-configuration";

export default function SesConfigurations() {
const sesSettingsQuery = api.admin.getSesSettings.useQuery();
Expand Down Expand Up @@ -71,7 +72,10 @@ export default function SesConfigurations() {
<TableCell>{sesSetting.sesEmailRateLimit}</TableCell>
<TableCell>{sesSetting.transactionalQuota}%</TableCell>
<TableCell>
<EditSesConfiguration setting={sesSetting} />
<div className="flex items-center gap-1">
<EditSesConfiguration setting={sesSetting} />
<DeleteSesConfiguration setting={sesSetting} />
</div>
</TableCell>
</TableRow>
))
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/server/api/routers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,17 @@ export const adminRouter = createTRPCRouter({
});
}),

deleteSesSettings: adminProcedure
.input(
z.object({
settingsId: z.string(),
})
)
.mutation(async ({ input }) => {
await SesSettingsService.deleteSesSetting(input.settingsId);
return true;
}),

getSetting: adminProcedure
.input(
z.object({
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/server/aws/ses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
SendEmailCommand,
CreateConfigurationSetEventDestinationCommand,
CreateConfigurationSetCommand,
DeleteConfigurationSetCommand,
EventType,
GetAccountCommand,
CreateTenantResourceAssociationCommand,
Expand Down Expand Up @@ -310,3 +311,26 @@ export async function addWebhookConfiguration(
const response = await sesClient.send(command);
return response.$metadata.httpStatusCode === 200;
}

export async function deleteConfigurationSet(
configName: string,
region: string
) {
const sesClient = getSesClient(region);
const command = new DeleteConfigurationSetCommand({
ConfigurationSetName: configName,
});

try {
await sesClient.send(command);
} catch (error: any) {
if (error?.name === "NotFoundException") {
logger.warn(
{ configName, region },
"Configuration set not found while deleting"
);
return;
}
throw error;
}
}
21 changes: 21 additions & 0 deletions apps/web/src/server/service/email-queue-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,27 @@ export class EmailQueueService {
}
}

public static async removeRegion(region: string) {
logger.info({ region }, `[EmailQueueService]: Removing queues for region`);

const transactionalQueue = this.transactionalQueue.get(region);
const transactionalWorker = this.transactionalWorker.get(region);
const marketingQueue = this.marketingQueue.get(region);
const marketingWorker = this.marketingWorker.get(region);

await Promise.all([
transactionalWorker?.close(),
transactionalQueue?.close(),
marketingWorker?.close(),
marketingQueue?.close(),
]);

this.transactionalQueue.delete(region);
this.transactionalWorker.delete(region);
this.marketingQueue.delete(region);
this.marketingWorker.delete(region);
}

public static async queueEmail(
emailId: string,
teamId: number,
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/server/service/ses-settings-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,43 @@ export class SesSettingsService {
await this.invalidateCache();
}

public static async deleteSesSetting(id: string) {
await this.checkInitialized();

const setting = await db.sesSetting.findUnique({
where: { id },
});

if (!setting) {
throw new Error("SES setting not found");
}

const configNames = [
setting.configGeneral,
setting.configClick,
setting.configOpen,
setting.configFull,
].filter(Boolean) as string[];

await Promise.all(
configNames.map((configName) =>
ses.deleteConfigurationSet(configName, setting.region)
)
);

if (setting.topicArn) {
await sns.deleteTopic(setting.topicArn, setting.region);
}

await EmailQueueService.removeRegion(setting.region);

await db.sesSetting.delete({
where: { id },
});

await this.invalidateCache();
}

public static async checkInitialized() {
if (!this.initialized) {
await this.invalidateCache();
Expand Down