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
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- Add double opt-in supporting columns
ALTER TABLE "Domain" ADD COLUMN "defaultFrom" TEXT;

ALTER TABLE "ContactBook"
ADD COLUMN "defaultDomainId" INTEGER,
ADD COLUMN "doubleOptInEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "doubleOptInTemplateId" TEXT;

-- Indexes for new foreign keys
CREATE INDEX "ContactBook_defaultDomainId_idx" ON "ContactBook"("defaultDomainId");
CREATE INDEX "ContactBook_doubleOptInTemplateId_idx" ON "ContactBook"("doubleOptInTemplateId");

-- Foreign key constraints
ALTER TABLE "ContactBook"
ADD CONSTRAINT "ContactBook_defaultDomainId_fkey" FOREIGN KEY ("defaultDomainId") REFERENCES "Domain"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ContactBook"
ADD CONSTRAINT "ContactBook_doubleOptInTemplateId_fkey" FOREIGN KEY ("doubleOptInTemplateId") REFERENCES "Template"("id") ON DELETE SET NULL ON UPDATE CASCADE;
54 changes: 32 additions & 22 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -177,26 +177,28 @@ enum DomainStatus {
}

model Domain {
id Int @id @default(autoincrement())
name String @unique
id Int @id @default(autoincrement())
name String @unique
teamId Int
status DomainStatus @default(PENDING)
region String @default("us-east-1")
clickTracking Boolean @default(false)
openTracking Boolean @default(false)
status DomainStatus @default(PENDING)
region String @default("us-east-1")
clickTracking Boolean @default(false)
openTracking Boolean @default(false)
publicKey String
dkimSelector String? @default("usesend")
dkimSelector String? @default("usesend")
dkimStatus String?
spfDetails String?
dmarcAdded Boolean @default(false)
dmarcAdded Boolean @default(false)
errorMessage String?
subdomain String?
sesTenantId String?
isVerifying Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
isVerifying Boolean @default(false)
defaultFrom String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
apiKeys ApiKey[]
ContactBook ContactBook[]
}

enum ApiPermission {
Expand Down Expand Up @@ -279,17 +281,24 @@ model EmailEvent {
}

model ContactBook {
id String @id @default(cuid())
name String
teamId Int
properties Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
emoji String @default("📙")
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contacts Contact[]
id String @id @default(cuid())
name String
teamId Int
properties Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
emoji String @default("📙")
defaultDomainId Int?
doubleOptInEnabled Boolean @default(false)
doubleOptInTemplateId String?
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contacts Contact[]
defaultDomain Domain? @relation(fields: [defaultDomainId], references: [id], onDelete: SetNull, onUpdate: Cascade)
doubleOptInTemplate Template? @relation(fields: [doubleOptInTemplateId], references: [id], onDelete: SetNull, onUpdate: Cascade)

@@index([teamId])
@@index([defaultDomainId])
@@index([doubleOptInTemplateId])
}

enum UnsubscribeReason {
Expand Down Expand Up @@ -369,7 +378,8 @@ model Template {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
ContactBook ContactBook[]

@@index([createdAt(sort: Desc)])
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ export default function ContactsPage({
</Breadcrumb>
</div>
<div className="flex gap-4">
<Link href={`/contacts/${contactBookId}/settings`}>
<Button variant="outline" type="button">
Settings
</Button>
</Link>
Comment on lines +123 to +127
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

Fix invalid interactive nesting: inside .

A Button inside a Link renders a button inside an anchor, which is invalid and harms a11y. Use the Button’s asChild to render the anchor as the button.

-          <Link href={`/contacts/${contactBookId}/settings`}>
-            <Button variant="outline" type="button">
-              Settings
-            </Button>
-          </Link>
+          <Button asChild variant="outline">
+            <Link href={`/contacts/${contactBookId}/settings`}>Settings</Link>
+          </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
<Link href={`/contacts/${contactBookId}/settings`}>
<Button variant="outline" type="button">
Settings
</Button>
</Link>
<Button asChild variant="outline">
<Link href={`/contacts/${contactBookId}/settings`}>Settings</Link>
</Button>
🤖 Prompt for AI Agents
In apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx around lines
123 to 127, the current code nests a <Button> inside a <Link>, creating an
invalid <button> inside <a> structure; change to use the Button's asChild prop
so the anchor is rendered as the button: replace the Link-wrapping-Button
pattern with a Button that has asChild and contains the Link (keep the href),
remove the type="button" since the rendered element will be an anchor, and
ensure styling/variant props remain on Button while accessibility and focus
behavior are preserved.

<AddContact contactBookId={contactBookId} />
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
"use client";

import { use, useEffect } from "react";
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

Do not use React’s use() in a client component

use() is not supported in client components. Also, params shouldn’t be a Promise here. This will break at runtime/build.

-import { use, useEffect } from "react";
+import { useEffect } from "react";
📝 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 { use, useEffect } from "react";
import { useEffect } from "react";
🤖 Prompt for AI Agents
In apps/web/src/app/(dashboard)/contacts/[contactBookId]/settings/page.tsx
around line 3, remove the import and usage of React's experimental use() because
it is not supported in client components and params must not be treated as a
Promise; either convert this page to a server component (remove any "use client"
directive, make the page component async, await any data/fetches directly and
use params synchronously) or keep it a client component and instead accept
resolved data/params from a parent server component (resolve fetches server-side
and pass results as props), and delete the use import and any use() calls so
nothing treats params as a Promise at runtime.

import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Switch } from "@usesend/ui/src/switch";
import { Button } from "@usesend/ui/src/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
import { api } from "~/trpc/react";
import { toast } from "@usesend/ui/src/toaster";
import { Skeleton } from "@usesend/ui/src/skeleton";

const schema = z
.object({
doubleOptInEnabled: z.boolean(),
defaultDomainId: z.string().nullable(),
doubleOptInTemplateId: z.string().nullable(),
})
.superRefine((value, ctx) => {
if (!value.doubleOptInEnabled) {
return;
}

if (!value.defaultDomainId) {
ctx.addIssue({
path: ["defaultDomainId"],
code: z.ZodIssueCode.custom,
message: "Choose a verified domain",
});
}

if (!value.doubleOptInTemplateId) {
ctx.addIssue({
path: ["doubleOptInTemplateId"],
code: z.ZodIssueCode.custom,
message: "Select a confirmation template",
});
}
});

export default function ContactBookSettingsPage({
params,
}: {
params: Promise<{ contactBookId: string }>;
}) {
const { contactBookId } = use(params);

const utils = api.useUtils();
Comment on lines +56 to +63
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

Fix component props: accept params directly and drop use(params)

Align with Next.js App Router conventions for client components.

-export default function ContactBookSettingsPage({
-  params,
-}: {
-  params: Promise<{ contactBookId: string }>;
-}) {
-  const { contactBookId } = use(params);
+export default function ContactBookSettingsPage({
+  params,
+}: {
+  params: { contactBookId: string };
+}) {
+  const { contactBookId } = params;
📝 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
export default function ContactBookSettingsPage({
params,
}: {
params: Promise<{ contactBookId: string }>;
}) {
const { contactBookId } = use(params);
const utils = api.useUtils();
export default function ContactBookSettingsPage({
params,
}: {
params: { contactBookId: string };
}) {
const { contactBookId } = params;
const utils = api.useUtils();
🤖 Prompt for AI Agents
In apps/web/src/app/(dashboard)/contacts/[contactBookId]/settings/page.tsx
around lines 56-63, the component currently types params as a Promise and calls
use(params); change the component to accept params directly as { params: {
contactBookId: string } } (not a Promise), remove the use(params) call, and read
contactBookId from params.contactBookId; update the function signature/type
accordingly so the component follows Next.js App Router conventions for client
components.


const settingsQuery = api.contacts.getContactBookSettings.useQuery({
contactBookId,
});

const updateMutation = api.contacts.updateContactBook.useMutation({
onSuccess: async () => {
await Promise.all([
utils.contacts.getContactBookSettings.invalidate({ contactBookId }),
utils.contacts.getContactBookDetails.invalidate({ contactBookId }),
]);
toast.success("Settings updated");
},
onError: (error) => {
toast.error(error.message);
},
});

const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
doubleOptInEnabled: false,
defaultDomainId: null,
doubleOptInTemplateId: null,
},
});

useEffect(() => {
if (!settingsQuery.data) return;
const { contactBook } = settingsQuery.data;
form.reset({
doubleOptInEnabled: contactBook.doubleOptInEnabled,
defaultDomainId: contactBook.defaultDomainId
? String(contactBook.defaultDomainId)
: null,
doubleOptInTemplateId: contactBook.doubleOptInTemplateId,
});
}, [settingsQuery.data, form]);

const onSubmit = form.handleSubmit((values) => {
updateMutation.mutate({
contactBookId,
doubleOptInEnabled: values.doubleOptInEnabled,
defaultDomainId: values.defaultDomainId
? Number(values.defaultDomainId)
: null,
doubleOptInTemplateId: values.doubleOptInTemplateId,
});
});

if (settingsQuery.isLoading || !settingsQuery.data) {
return (
<div className="space-y-6">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
);
}

const { domains, templates } = settingsQuery.data;

const disableSelectorsBase =
!form.watch("doubleOptInEnabled") || domains.length === 0;
const disableDomainSelect = disableSelectorsBase;
const disableTemplateSelect = disableSelectorsBase || templates.length === 0;

return (
<div className="max-w-2xl space-y-10">
<div>
<h1 className="text-2xl font-semibold">Double opt-in</h1>
<p className="text-sm text-muted-foreground">
Require new contacts to confirm their email address before they are
subscribed.
</p>
</div>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-8">
<FormField
control={form.control}
name="doubleOptInEnabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div>
<FormLabel>Require confirmation</FormLabel>
<p className="text-sm text-muted-foreground">
Send a confirmation email when contacts are added via the
API.
</p>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>

<FormField
control={form.control}
name="defaultDomainId"
render={({ field }) => (
<FormItem>
<FormLabel>From domain</FormLabel>
<FormControl>
<Select
value={field.value ?? undefined}
onValueChange={(value) => field.onChange(value)}
disabled={disableDomainSelect}
>
<SelectTrigger>
<SelectValue placeholder="Select a verified domain" />
</SelectTrigger>
<SelectContent>
{domains.map((domain) => (
<SelectItem key={domain.id} value={String(domain.id)}>
{domain.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
{domains.length === 0 ? (
<p className="text-sm text-muted-foreground">
Add a verified domain before enabling double opt-in.
</p>
) : (
<FormMessage />
)}
</FormItem>
)}
/>

<FormField
control={form.control}
name="doubleOptInTemplateId"
render={({ field }) => (
<FormItem>
<FormLabel>Confirmation email</FormLabel>
<FormControl>
<Select
value={field.value ?? undefined}
onValueChange={(value) => field.onChange(value)}
disabled={disableTemplateSelect}
>
<SelectTrigger>
<SelectValue placeholder="Select a template" />
</SelectTrigger>
<SelectContent>
{templates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<p className="text-sm text-muted-foreground">
Templates must include the {"{{verificationUrl}}"} placeholder.
{templates.length === 0
? " Create or publish a template before enabling double opt-in."
: ""}
</p>
<FormMessage />
</FormItem>
)}
/>

<div className="flex justify-end">
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? "Saving..." : "Save changes"}
</Button>
</div>
</form>
</Form>
</div>
);
}
Loading