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
60 changes: 60 additions & 0 deletions apps/app/app/actions/__tests__/booking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ import { createBooking } from "../booking";
import { db } from "@repo/db";
import type { Booking, EventType } from "@prisma/client";

const { mockEmailSend } = vi.hoisted(() => ({
mockEmailSend: vi.fn(),
}));

vi.mock("resend", () => ({
Resend: class MockResend {
emails = { send: mockEmailSend };
},
}));

vi.mock("@repo/db", () => ({
db: {
eventType: {
Expand All @@ -12,6 +22,9 @@ vi.mock("@repo/db", () => ({
findFirst: vi.fn(),
create: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
},
}));

Expand All @@ -22,6 +35,17 @@ vi.mock("next/cache", () => ({
describe("createBooking Server Action", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.RESEND_API_KEY = "re_test_key";
vi.mocked(db.user.findUnique).mockResolvedValue({
id: "usr_host",
name: "Test Host",
username: "test-host",
email: "host@example.com",
} as never);
mockEmailSend.mockResolvedValue({
data: { id: "email_123" },
error: null,
});
});

const validPayload = {
Expand Down Expand Up @@ -62,6 +86,7 @@ describe("createBooking Server Action", () => {
duration: 30,
userId: "usr_host",
isArchived: false,
title: "Discovery Call",
} as unknown as EventType);

vi.mocked(db.booking.findFirst).mockResolvedValue({
Expand All @@ -81,6 +106,7 @@ describe("createBooking Server Action", () => {
duration: 30,
userId: "usr_host",
isArchived: false,
title: "Discovery Call",
} as unknown as EventType);

vi.mocked(db.booking.findFirst).mockResolvedValue(null);
Expand All @@ -92,6 +118,10 @@ describe("createBooking Server Action", () => {
expect(response.success).toBe(true);
if (response.success) {
expect(response.bookingId).toBe("bk_new_789");
expect(response.emailDelivery).toEqual({
status: "SENT",
recipient: "alice@example.com",
});
}
});

Expand All @@ -102,6 +132,7 @@ describe("createBooking Server Action", () => {
duration: 30,
userId: "usr_host",
isArchived: false,
title: "Discovery Call",
} as unknown as EventType);

// 2. Mock that the slot is completely free
Expand Down Expand Up @@ -135,6 +166,7 @@ describe("createBooking Server Action", () => {
duration: 30,
userId: "usr_host",
isArchived: false,
title: "Discovery Call",
} as unknown as EventType);
vi.mocked(db.booking.findFirst).mockResolvedValue(null);
vi.mocked(db.booking.create).mockResolvedValue({
Expand All @@ -159,4 +191,32 @@ describe("createBooking Server Action", () => {
}),
);
});

it("keeps the booking successful when confirmation email delivery fails", async () => {
vi.mocked(db.eventType.findUnique).mockResolvedValue({
id: "evt_123",
duration: 30,
userId: "usr_host",
isArchived: false,
title: "Discovery Call",
} as unknown as EventType);
vi.mocked(db.booking.findFirst).mockResolvedValue(null);
vi.mocked(db.booking.create).mockResolvedValue({
id: "bk_email_failed",
} as unknown as Booking);
mockEmailSend.mockResolvedValue({
data: null,
error: { message: "Provider unavailable" },
});
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});

const response = await createBooking(validPayload);

expect(response.success).toBe(true);
if (response.success) {
expect(response.bookingId).toBe("bk_email_failed");
expect(response.emailDelivery.status).toBe("FAILED");
}
consoleSpy.mockRestore();
});
});
140 changes: 133 additions & 7 deletions apps/app/app/actions/booking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,25 @@
import { db } from "@repo/db";
import { createBookingSchema, type CreateBookingInput } from "@/lib/schemas";
import { revalidatePath } from "next/cache";
import { Resend } from "resend";
import { createEvent, type EventAttributes } from "ics";
import { BookingConfirmationEmail } from "@/components/emails/booking-confirmation";
import { render } from "@react-email/components";
import { formatInTimeZone } from "date-fns-tz";

export type EmailDeliveryResult = {
status: "SENT" | "FAILED";
recipient: string;
};

export type CreateBookingSuccess = {
success: true;
bookingId: string;
emailDelivery: EmailDeliveryResult;
};

export type CreateBookingResult =
| { success: true; bookingId: string }
| CreateBookingSuccess
| { success: false; error: string; fieldErrors?: Record<string, string[]> };

export async function createBooking(
Expand All @@ -32,16 +48,26 @@ export async function createBooking(
} = result.data;

try {
// 1. Fetch EventType details to determine duration & active status
// 1. Fetch EventType AND Host details
const eventType = await db.eventType.findUnique({
where: { id: eventTypeId },
select: { duration: true, userId: true, isArchived: true },
select: { duration: true, userId: true, isArchived: true, title: true },
});

const host = await db.user.findUnique({
where: { id: hostId },
select: { name: true, username: true, email: true },
});

if (!eventType || eventType.isArchived || eventType.userId !== hostId) {
if (
!eventType ||
eventType.isArchived ||
eventType.userId !== hostId ||
!host
) {
return {
success: false,
error: "This event type is no longer active or available.",
error: "This event type or host is no longer active or available.",
};
}

Expand All @@ -50,7 +76,7 @@ export async function createBooking(
startDateTime.getTime() + eventType.duration * 60 * 1000,
);

// 2. Prevent Double Booking / Race Condition (Check overlapping bookings)
// 2. Prevent Double Booking
const conflictingBooking = await db.booking.findFirst({
where: {
hostId,
Expand All @@ -70,7 +96,10 @@ export async function createBooking(
};
}

// 3. Create Booking Record in Database
// 3. Generate a Unique ID for Calendar Sync (Idempotency)
const icsUid = crypto.randomUUID() + "@slotsyncro.com";

// 4. Create Booking Record in Database
const newBooking = await db.booking.create({
data: {
eventTypeId,
Expand All @@ -82,14 +111,111 @@ export async function createBooking(
startTime: startDateTime,
endTime: endDateTime,
status: "ACCEPTED",
icsUid, // ✨ Save the unique calendar ID
},
});

// 5. Generate the .ics Calendar File
const icsEvent: EventAttributes = {
start: [
startDateTime.getUTCFullYear(),
startDateTime.getUTCMonth() + 1, // ics months are 1-indexed
startDateTime.getUTCDate(),
startDateTime.getUTCHours(),
startDateTime.getUTCMinutes(),
],
end: [
endDateTime.getUTCFullYear(),
endDateTime.getUTCMonth() + 1,
endDateTime.getUTCDate(),
endDateTime.getUTCHours(),
endDateTime.getUTCMinutes(),
],
startInputType: "utc",
startOutputType: "utc",
title: `${eventType.title} with ${host.name || host.username}`,
description: guestNotes || "Scheduled via SlotSyncro",
attendees: [{ name: guestName, email: guestEmail }],
uid: icsUid,
...(host.email
? {
organizer: {
name: host.name || host.username || "Host",
email: host.email,
},
}
: {}),
};

const { value: icsContent, error: icsError } = createEvent(icsEvent);
if (icsError) console.error("ICS Generation Error:", icsError);

// Email delivery is a secondary side effect. Once the booking exists, an
// email provider failure must not make the client retry the booking itself.
let emailDelivery: EmailDeliveryResult = {
status: "FAILED",
recipient: guestEmail,
};

if (icsContent) {
try {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
throw new Error("RESEND_API_KEY is not configured");
}

const emailHtml = await render(
BookingConfirmationEmail({
guestName,
hostName: host.name || host.username || "Host",
eventTitle: eventType.title,
date: formatInTimeZone(
startDateTime,
guestTimeZone,
"EEEE, MMMM d, yyyy",
),
time: formatInTimeZone(
startDateTime,
guestTimeZone,
"h:mm a zzz",
),
}),
);

const resend = new Resend(apiKey);
const { error: resendError } = await resend.emails.send({
from: "SlotSyncro <onboarding@resend.dev>", // Testing domain
to: guestEmail,
subject: `Confirmed: ${eventType.title} with ${host.name || host.username}`,
html: emailHtml,
attachments: [
{
filename: "invite.ics",
content: Buffer.from(icsContent).toString("base64"),
},
],
});

if (resendError) {
throw new Error(resendError.message);
}

emailDelivery = { status: "SENT", recipient: guestEmail };
} catch (emailError) {
console.error(
`Booking ${newBooking.id} was created, but confirmation email delivery failed:`,
emailError,
);
}
}

revalidatePath("/[username]/[slug]", "page");
revalidatePath("/(dashboard)/bookings", "page");

return {
success: true,
bookingId: newBooking.id,
emailDelivery,
};
} catch (error) {
console.error("Failed to create booking:", error);
Expand Down
11 changes: 7 additions & 4 deletions apps/app/components/booking/booking-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { useTransition } from "react";
import { useForm, type Resolver } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createBookingSchema, type CreateBookingInput } from "@/lib/schemas";
import { createBooking } from "@/app/actions/booking";
import {
createBooking,
type CreateBookingSuccess,
} from "@/app/actions/booking";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
Expand All @@ -23,7 +26,7 @@ interface BookingFormProps {
guestTimeZone: string;
loggedInUser?: { id: string; name: string; email: string } | null;
onBack: () => void;
onSuccess: (bookingId: string) => void;
onSuccess: (confirmation: CreateBookingSuccess) => void;
}

export function BookingForm({
Expand Down Expand Up @@ -66,7 +69,7 @@ export function BookingForm({
startTransition(async () => {
const response = await createBooking(data);
if (response.success) {
onSuccess(response.bookingId);
onSuccess(response);
} else {
if (response.fieldErrors) {
Object.entries(response.fieldErrors).forEach(([field, messages]) => {
Expand Down Expand Up @@ -180,7 +183,7 @@ export function BookingForm({
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Scheduling Meeting...
Confirming booking and sending invitation...
</>
) : (
"Confirm & Schedule"
Expand Down
Loading
Loading