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
6 changes: 4 additions & 2 deletions src/features/auth/components/sign-up-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,16 @@ export default function SignUpForm() {
await authClient.signUp.email(
{
...data,
callbackURL: "/auth/email-verified",
},
{
headers: { "x-turnstile-token": turnstileToken },
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: authKeys.session() });
// manually remove query data to clear the cache and refetch
queryClient.removeQueries({ queryKey: authKeys.session() });

resetTurnstile();
navigate({ to: "/dashboard" });
navigate({ to: "/auth/verify-email" });

toast.success("Sign up successful");
},
Expand Down
15 changes: 7 additions & 8 deletions src/features/auth/components/social-auth-buttons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,24 +38,23 @@ export default function SocialAuthButtons() {

<div className="flex flex-col gap-3">
<Button
className="cursor-pointer"
disabled={loadingProvider !== null}
onClick={() => signIn("github")}
type="button"
variant="outline"
>
<Github className="mr-2 size-4" />
Continue with GitHub
{loadingProvider === "github"
? "Signing in with GitHub..."
: "Continue with GitHub"}
</Button>

<Button
className="cursor-pointer"
disabled={loadingProvider !== null}
onClick={() => signIn("google")}
type="button"
variant="outline"
>
<Chrome className="mr-2 size-4" />
Continue with Google
{loadingProvider === "google"
? "Signing in with Google..."
: "Continue with Google"}
</Button>
</div>
</div>
Expand Down
42 changes: 21 additions & 21 deletions src/features/jobs/definitions/email.job.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import { sendEmail } from "@/lib/email/send-email";
export const SEND_VERIFICATION_EMAIL = "email.send-verification";

export const SendVerificationEmailPayload = z.object({
userId: z.string(),
email: z.email(),
name: z.string(),
user: z.object({
id: z.string(),
email: z.email(),
name: z.string(),
}),
verifyUrl: z.url(),
});

Expand All @@ -29,24 +31,22 @@ export async function registerEmailJobs(boss: PgBoss) {
async ([job]) => {
const payload = SendVerificationEmailPayload.parse(job.data);

await sendEmail({
to: payload.email,
subject: "Verify your email",
template: (
<VerifyEmail name={payload.name} verifyUrl={payload.verifyUrl} />
),
});

return { sent: true, timestamp: new Date().toISOString() };
try {
await sendEmail({
to: payload.user.email,
subject: "Verify your email",
template: (
<VerifyEmail
name={payload.user.name}
verifyUrl={payload.verifyUrl}
/>
),
});

return { sent: true, timestamp: new Date().toISOString() };
} catch (error) {
throw new Error((error as Error).message);
Comment on lines +47 to +48
Copy link

Choose a reason for hiding this comment

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

style: Wrapping error loses stack trace - just rethrow the original error

Suggested change
} catch (error) {
throw new Error((error as Error).message);
} catch (error) {
throw error;
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/features/jobs/definitions/email.job.tsx
Line: 47:48

Comment:
**style:** Wrapping error loses stack trace - just rethrow the original error

```suggestion
      } catch (error) {
        throw error;
```

How can I resolve this? If you propose a fix, please make it concise.

}
}
);
}

export async function enqueueSendVerificationEmail(
boss: PgBoss,
payload: SendVerificationEmailPayload
) {
await boss.send(SEND_VERIFICATION_EMAIL, payload, {
priority: 1, // Higher priority for verification emails
});
}
15 changes: 15 additions & 0 deletions src/features/jobs/producers/email.producer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {
SEND_VERIFICATION_EMAIL,
type SendVerificationEmailPayload,
} from "@/features/jobs/definitions/email.job";
import { getQueueClient } from "@/lib/server/queue";

export async function enqueueSendVerificationEmail(
payload: SendVerificationEmailPayload
) {
const client = await getQueueClient();

await client.send(SEND_VERIFICATION_EMAIL, payload, {
priority: 1, // Higher priority for verification emails
});
}
12 changes: 12 additions & 0 deletions src/features/todos/components/todo-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export default function TodoSkeleton() {
return (
<div className="animate-pulse space-y-8 pt-4">
<div className="h-10 w-1/3 rounded bg-muted" />
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div className="h-16 rounded bg-muted" key={i} />
))}
</div>
</div>
);
}
4 changes: 2 additions & 2 deletions src/lib/email/send-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ export async function sendEmail({
to,
subject,
template,
from,
from = env.USESEND_FROM_EMAIL,
}: SendEmailOptions) {
const html = await render(template);

return useSend.emails.send({
from: from ?? env.EMAIL_FROM ?? "noreply@yourdomain.com",
from,
to: Array.isArray(to) ? to : [to],
subject,
html,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/email/use-send.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { UseSend } from "usesend-js";
import { env } from "@/lib/server/env";

export const useSend = new UseSend(env.USESEND_API_KEY);
export const useSend = new UseSend(env.USESEND_API_KEY, env.USESEND_BASE_URL);
12 changes: 11 additions & 1 deletion src/lib/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { betterAuth } from "better-auth/minimal";
import { admin } from "better-auth/plugins";
import { tanstackStartCookies } from "better-auth/tanstack-start";
import { enqueueSendVerificationEmail } from "@/features/jobs/producers/email.producer";
import { db } from "@/lib/server/db";
import { env } from "@/lib/server/env";

Expand Down Expand Up @@ -29,7 +30,16 @@ export const auth = betterAuth({
},
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
requireEmailVerification: true,
},
emailVerification: {
sendOnSignIn: true,
sendVerificationEmail: ({ user, url }) => {
return enqueueSendVerificationEmail({
user,
verifyUrl: url,
});
},
},
rateLimit: {
enabled: true,
Expand Down
7 changes: 6 additions & 1 deletion src/lib/server/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import {
import { todo } from "@/db/schema/todos";
import { env } from "@/lib/server/env";

const client = postgres(env.DATABASE_URL);
const client = postgres(env.DATABASE_URL, {
max: 10, // Maximum connections
idle_timeout: 20, // Seconds before idle connection is closed
connect_timeout: 10, // Connection timeout in seconds
prepare: false, // Disable prepared statements for serverless
});

export const db = drizzle(client, {
schema: { user, session, account, verification, rateLimit, todo },
Expand Down
78 changes: 62 additions & 16 deletions src/lib/server/queue.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,78 @@
import { PgBoss } from "pg-boss";
import { registerJobs } from "@/features/jobs/register";

let boss: PgBoss | null = null;
let producerClient: PgBoss | null = null;
let workerClient: PgBoss | null = null;

async function createQueue(databaseUrl: string) {
boss = new PgBoss(databaseUrl);
function getDatabaseUrl(): string {
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is not set");
}
return process.env.DATABASE_URL;
}

boss.on("error", (error) => {
// Wire up to your error tracking system here
console.error(error);
/**
* Get a queue client for enqueueing jobs (producer mode).
* This is lightweight - it only connects to pg-boss without registering handlers.
* Safe to use in the main app process.
*/
export async function getQueueClient(): Promise<PgBoss> {
if (producerClient) {
return producerClient;
}

producerClient = new PgBoss(getDatabaseUrl());

producerClient.on("error", (error) => {
console.error("[pg-boss] Producer client error:", error);
});

await boss.start();
await producerClient.start();

await registerJobs(boss);
console.log("[pg-boss] Producer client connected");
return producerClient;
}

console.log("[pg-boss] Queue started");
return boss;
/**
* Initialize the queue worker (worker mode).
* This connects to pg-boss AND registers all job handlers.
* Should only be called from the dedicated worker process.
*/
export async function initQueueWorker(): Promise<PgBoss> {
if (workerClient) {
return workerClient;
}

workerClient = new PgBoss(getDatabaseUrl());

workerClient.on("error", (error) => {
console.error("[pg-boss] Worker error:", error);
});

await workerClient.start();
await registerJobs(workerClient);

console.log("[pg-boss] Worker initialized with job handlers");
return workerClient;
}

export async function initQueue() {
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is not set");
/**
* Gracefully shutdown queue connections.
* Call this on process termination.
*/
export async function shutdownQueue(): Promise<void> {
const shutdownPromises: Promise<void>[] = [];

if (producerClient) {
shutdownPromises.push(producerClient.stop());
producerClient = null;
}

if (!boss) {
boss = await createQueue(process.env.DATABASE_URL as string);
if (workerClient) {
shutdownPromises.push(workerClient.stop());
workerClient = null;
}

return boss;
await Promise.all(shutdownPromises);
console.log("[pg-boss] Queue connections closed");
}
2 changes: 1 addition & 1 deletion src/routes/_authenticated.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function RouteComponent() {
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: authKeys.session() });
queryClient.removeQueries({ queryKey: authKeys.session() });
navigate({ to: "/" });
},
},
Expand Down
4 changes: 4 additions & 0 deletions src/routes/_authenticated/todos.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,19 @@ import { prefetchTodos } from "@/features/todos/api/todo-queries";
import TodoForm from "@/features/todos/components/todo-form";
import TodoItem from "@/features/todos/components/todo-item";
import Stats from "@/features/todos/components/todo-stats";
import TodoSkeleton from "@/features/todos/components/todo-skeleton";

export const Route = createFileRoute("/_authenticated/todos")({
component: TodosPage,
loader: async ({ context }) => {
await prefetchTodos(context.queryClient, context.user.id);
},
pendingComponent: TodoSkeleton,
ssr: false,
});



function TodosPage() {
const { user } = Route.useRouteContext();
const queryClient = useQueryClient();
Expand Down
25 changes: 19 additions & 6 deletions src/worker/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import { initQueue } from "@/lib/server/queue";
import { initQueueWorker, shutdownQueue } from "@/lib/server/queue";

export async function startQueueWorker() {
await initQueue();
console.log("[pg-boss] Queue worker started");
async function startWorker() {
await initQueueWorker();
console.log("[pg-boss] Queue worker started and listening for jobs");
}

startQueueWorker().catch((error) => {
console.error("[pg-boss] Error starting queue worker", error);
// Graceful shutdown handlers
process.on("SIGTERM", async () => {
console.log("[pg-boss] Received SIGTERM, shutting down...");
await shutdownQueue();
process.exit(0);
});

process.on("SIGINT", async () => {
console.log("[pg-boss] Received SIGINT, shutting down...");
await shutdownQueue();
process.exit(0);
});

startWorker().catch((error) => {
console.error("[pg-boss] Error starting queue worker:", error);
process.exit(1);
});