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
110 changes: 110 additions & 0 deletions apps/web/src/app/api/book/[username]/[slug]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { serviceClient } from '@/lib/supabase/service';
import { successResponse, errorResponse, handleApiError } from '@/lib/api';
import { FixedWindowRateLimiter, getClientIp } from '@/lib/rate-limit';
import {
BookingError,
availableSlots,
createBooking,
findActivePage,
findHostByUsername,
publicHost,
publicPage,
} from '@/lib/booking';
import { bookRequestSchema } from '@/lib/booking-validations';

interface RouteParams {
params: Promise<{ username: string; slug: string }>;
}

/** A guest can look as often as they like; booking is what gets abused. */
const bookingsByIp = new FixedWindowRateLimiter(10, 60 * 60_000);
const bookingsByEmail = new FixedWindowRateLimiter(5, 60 * 60_000);

const MAX_DAYS_PER_REQUEST = 31;

/**
* GET /api/book/[username]/[slug]?from=YYYY-MM-DD&days=7
*
* The slots this page can offer, as instants. `from` is a date in the page's
* own zone (defaulting to today there); the guest's browser groups the result
* by its own days. Anonymous.
*/
export async function GET(request: Request, { params }: RouteParams) {
try {
const { username, slug } = await params;
const { searchParams } = new URL(request.url);

const from = searchParams.get('from') ?? undefined;
if (from !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(from)) {
return errorResponse('from must be YYYY-MM-DD', 400);
}
const daysRaw = searchParams.get('days');
const days = daysRaw === null ? 7 : Number(daysRaw);
if (!Number.isInteger(days) || days < 1 || days > MAX_DAYS_PER_REQUEST) {
return errorResponse(`days must be between 1 and ${String(MAX_DAYS_PER_REQUEST)}`, 400);
}

const svc = serviceClient();
const host = await findHostByUsername(svc, username);
if (!host) return errorResponse('No such user', 404);
const page = await findActivePage(svc, host.id, slug);
if (!page) return errorResponse('No such booking page', 404);

const slots = await availableSlots(svc, page, from, days);
return successResponse({
host: publicHost(host),
page: publicPage(page),
from: from ?? null,
days,
slots,
});
} catch (error) {
return handleApiError(error);
}
}

/**
* POST /api/book/[username]/[slug] — take a slot.
*
* { "start": "2026-09-08T16:00:00.000Z", "name": "…", "email": "…", "notes": "…" }
*
* Creates the meeting, emails the guest their invite (join code, calendar
* links, RSVP) and tells the host. 409 when the time has gone since the guest
* looked. Anonymous, rate-limited by address and by email.
*/
export async function POST(request: Request, { params }: RouteParams) {
try {
const { username, slug } = await params;

const ip = getClientIp(request);
const byIp = bookingsByIp.check(ip);
if (!byIp.success) {
return errorResponse('Too many bookings from this address. Try again later.', 429);
}

const body: unknown = await request.json().catch(() => ({}));
const input = bookRequestSchema.parse(body);

const byEmail = bookingsByEmail.check(input.email);
if (!byEmail.success) {
return errorResponse('Too many bookings for this email. Try again later.', 429);
}

const svc = serviceClient();
const host = await findHostByUsername(svc, username);
if (!host) return errorResponse('No such user', 404);
const page = await findActivePage(svc, host.id, slug);
if (!page) return errorResponse('No such booking page', 404);

const booking = await createBooking(svc, host, page, {
start: input.start,
name: input.name,
email: input.email,
notes: input.notes,
});
return successResponse(booking, 201);
} catch (error) {
if (error instanceof BookingError) return errorResponse(error.message, error.status);
return handleApiError(error);
}
}
28 changes: 28 additions & 0 deletions apps/web/src/app/api/book/[username]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { serviceClient } from '@/lib/supabase/service';
import { successResponse, errorResponse, handleApiError } from '@/lib/api';
import { findHostByUsername, listActivePages, publicHost, publicPage } from '@/lib/booking';

interface RouteParams {
params: Promise<{ username: string }>;
}

/**
* GET /api/book/[username] — a host's booking pages, for anyone.
*
* Anonymous by design: this is the link a host hands out. Only active pages
* are shown, and only the fields a guest needs to pick one.
*/
export async function GET(_request: Request, { params }: RouteParams) {
try {
const { username } = await params;
const svc = serviceClient();

const host = await findHostByUsername(svc, username);
if (!host) return errorResponse('No such user', 404);

const pages = await listActivePages(svc, host.id);
return successResponse({ host: publicHost(host), pages: pages.map(publicPage) });
} catch (error) {
return handleApiError(error);
}
}
83 changes: 83 additions & 0 deletions apps/web/src/app/api/booking-pages/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */
import { createClient, getAuthenticatedUser } from '@/lib/supabase/server';
import { serviceClient } from '@/lib/supabase/service';
import { successResponse, errorResponse, handleApiError } from '@/lib/api';
import { bookingPageUpdateSchema } from '@/lib/booking-validations';

interface RouteParams {
params: Promise<{ id: string }>;
}

// PATCH /api/booking-pages/[id] — change any of a page's fields
export async function PATCH(request: Request, { params }: RouteParams) {
try {
const { id } = await params;
const body: unknown = await request.json().catch(() => ({}));
const input = bookingPageUpdateSchema.parse(body);

const supabase = await createClient();
const { user, error: authError } = await getAuthenticatedUser(supabase);
if (authError || !user) return errorResponse('Authentication required', 401);

const svc = serviceClient();

const update: Record<string, unknown> = { updated_at: new Date().toISOString() };
if (input.title !== undefined) update.title = input.title;
if (input.slug !== undefined) update.slug = input.slug;
if (input.description !== undefined) update.description = input.description;
if (input.durationMinutes !== undefined) update.duration_minutes = input.durationMinutes;
if (input.timezone !== undefined) update.timezone = input.timezone;
if (input.availability !== undefined) update.availability = input.availability;
if (input.bufferMinutes !== undefined) update.buffer_minutes = input.bufferMinutes;
if (input.minNoticeMinutes !== undefined) update.min_notice_minutes = input.minNoticeMinutes;
if (input.maxDaysAhead !== undefined) update.max_days_ahead = input.maxDaysAhead;
if (input.active !== undefined) update.active = input.active;

const { data, error } = await (svc as any)
.from('booking_pages')
.update(update)
.eq('id', id)
.eq('host_user_id', user.id)
.select()
.maybeSingle();

if (error) {
if (String(error.code) === '23505') {
return errorResponse('You already have a page with that slug', 409);
}
return errorResponse(String(error.message), 400);
}
if (!data) return errorResponse('Booking page not found', 404);

return successResponse(data);
} catch (error) {
return handleApiError(error);
}
}

// DELETE /api/booking-pages/[id] — remove a page; meetings already booked stay
export async function DELETE(_request: Request, { params }: RouteParams) {
try {
const { id } = await params;

const supabase = await createClient();
const { user, error: authError } = await getAuthenticatedUser(supabase);
if (authError || !user) return errorResponse('Authentication required', 401);

const svc = serviceClient();
const { data, error } = await (svc as any)
.from('booking_pages')
.delete()
.eq('id', id)
.eq('host_user_id', user.id)
.select('id')
.maybeSingle();

if (error) return errorResponse(String(error.message), 400);
if (!data) return errorResponse('Booking page not found', 404);

return successResponse({ deleted: true });
} catch (error) {
return handleApiError(error);
}
}
112 changes: 112 additions & 0 deletions apps/web/src/app/api/booking-pages/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */
import { createClient, getAuthenticatedUser } from '@/lib/supabase/server';
import { serviceClient } from '@/lib/supabase/service';
import { successResponse, errorResponse, handleApiError } from '@/lib/api';
import { bookingPageInputSchema, slugify } from '@/lib/booking-validations';

/**
* /api/booking-pages — the host's side of booking.
*
* A page is availability plus a duration under a slug. The public half
* (/api/book/<username>/<slug>) reads what is created here; nothing a guest
* does reaches this route.
*/

const MAX_PAGES_PER_HOST = 20;

// GET /api/booking-pages — every page this host owns, active or not
export async function GET() {
try {
const supabase = await createClient();
const { user, error: authError } = await getAuthenticatedUser(supabase);
if (authError || !user) return errorResponse('Authentication required', 401);

const svc = serviceClient();
const { data, error } = await (svc as any)
.from('booking_pages')
.select('*')
.eq('host_user_id', user.id)
.order('created_at', { ascending: true });
if (error) return errorResponse(String(error.message), 400);

const { data: profile } = await (svc as any)
.from('profiles')
.select('username')
.eq('id', user.id)
.maybeSingle();

return successResponse({
username: (profile?.username as string | null) ?? null,
pages: (data as unknown[] | null) ?? [],
});
} catch (error) {
return handleApiError(error);
}
}

// POST /api/booking-pages — create a page
export async function POST(request: Request) {
try {
const body: unknown = await request.json().catch(() => ({}));
const input = bookingPageInputSchema.parse(body);

const supabase = await createClient();
const { user, error: authError } = await getAuthenticatedUser(supabase);
if (authError || !user) return errorResponse('Authentication required', 401);

const svc = serviceClient();

// A page is reached by username, so a host without one has nowhere to be
// booked. Say so up front rather than creating a page nobody can open.
const { data: profile } = await (svc as any)
.from('profiles')
.select('username')
.eq('id', user.id)
.maybeSingle();
if (!profile?.username) {
return errorResponse(
'Set a username in Settings first — your booking link is /book/<username>/<slug>',
400
);
}

const { count } = await (svc as any)
.from('booking_pages')
.select('id', { count: 'exact', head: true })
.eq('host_user_id', user.id);
if ((count as number | null) !== null && (count as number) >= MAX_PAGES_PER_HOST) {
return errorResponse(`You already have ${String(MAX_PAGES_PER_HOST)} booking pages`, 400);
}

const slug = input.slug ?? slugify(input.title);

const { data, error } = await (svc as any)
.from('booking_pages')
.insert({
host_user_id: user.id,
slug,
title: input.title,
description: input.description ?? null,
duration_minutes: input.durationMinutes,
timezone: input.timezone,
availability: input.availability,
buffer_minutes: input.bufferMinutes,
min_notice_minutes: input.minNoticeMinutes,
max_days_ahead: input.maxDaysAhead,
active: input.active,
})
.select()
.single();

if (error) {
if (String(error.code) === '23505') {
return errorResponse(`You already have a page at /${slug} — pick another slug`, 409);
}
return errorResponse(String(error.message), 400);
}

return successResponse({ ...data, url: `/book/${profile.username as string}/${slug}` }, 201);
} catch (error) {
return handleApiError(error);
}
}
Loading
Loading