Skip to main content
RapidDev - Software Development Agency

How to Build a Online Test Booking with Lovable

Build an exam slot reservation system in Lovable where test takers browse available slots, reserve a seat with atomic capacity decrement, and receive an email confirmation with a unique code. A Postgres function handles the decrement atomically to prevent overbooking. An Edge Function sends confirmation emails via Resend. Build time is approximately 2 hours.

What you'll build

  • Exam catalog with test names, descriptions, duration, and per-slot seat capacity
  • Available slot listing with real-time remaining seat counts using shadcn/ui Cards
  • Atomic seat reservation via a Postgres function that decrements capacity in a single transaction
  • Unique confirmation codes generated at booking time and displayed post-reservation
  • Email confirmation sent via Resend through a Supabase Edge Function triggered on booking insert
  • Admin panel for creating exam slots and viewing all registrations per slot
  • Booking lookup page where candidates can retrieve their booking by confirmation code
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate16 min read2 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

Build an exam slot reservation system in Lovable where test takers browse available slots, reserve a seat with atomic capacity decrement, and receive an email confirmation with a unique code. A Postgres function handles the decrement atomically to prevent overbooking. An Edge Function sends confirmation emails via Resend. Build time is approximately 2 hours.

What you're building

The core technical challenge of a test booking system is preventing overbooking. If ten people are viewing the same slot with one seat remaining and all ten submit simultaneously, a naive decrement in application code would fail to protect the seat count. The solution is a Postgres function reserve_slot(p_slot_id uuid, ...) that runs as an atomic transaction: it reads the current remaining_seats count with a row lock (SELECT ... FOR UPDATE), checks that it is greater than zero, decrements it, inserts the booking row, and returns the confirmation code — all in a single database operation. If two calls race, one succeeds and one gets a serialization error.

The schema has three main tables: exams (the test itself, with name and default duration), exam_slots (a specific sitting of an exam at a date/time/location with a seat capacity), and bookings (one row per registered candidate for a slot, with their confirmation code).

The confirmation email flow uses a Supabase Edge Function set up as a database webhook trigger. When a new row is inserted into bookings, Supabase sends the new row's data to the Edge Function, which calls the Resend API to send a formatted confirmation email. This keeps email sending out of the frontend entirely.

Final result

A test booking system where candidates reserve seats without overbooking, receive instant email confirmations, and can look up their booking by code.

Tech stack

LovableFrontend app builder
SupabaseDatabase, Auth, Edge Functions
Supabase Edge FunctionsEmail confirmation via Resend (Deno)
ResendTransactional email delivery
shadcn/uiCard, Badge, Dialog, Table, Alert components
react-hook-form + zodBooking form validation

Prerequisites

  • Lovable Pro account for Edge Function generation
  • Supabase project with Auth enabled (for admin accounts)
  • Resend account and API key for sending confirmation emails
  • RESEND_API_KEY and SUPABASE_SERVICE_ROLE_KEY saved to Cloud tab → Secrets (no VITE_ prefix)
  • Optional: a test email address to verify confirmation delivery

Build steps

1

Create the exam booking schema with the atomic reserve function

Prompt Lovable to create the full schema including the reserve_slot Postgres function. This function is the heart of the system — it handles capacity management atomically.

prompt.txt
1Create a Supabase schema for an online test booking system.
2
3Tables:
4- exams: id (uuid pk), name (text), description (text), duration_minutes (int), passing_score (int), is_active (bool default true), created_at
5- exam_slots: id (uuid pk), exam_id (references exams), slot_datetime (timestamptz), location (text, or 'Online'), total_seats (int), remaining_seats (int), price_cents (int default 0), notes (text), is_active (bool default true), created_at
6- bookings: id (uuid pk), slot_id (references exam_slots), candidate_name (text not null), candidate_email (text not null), confirmation_code (text unique not null), status (text default 'confirmed' check: confirmed|cancelled|no_show), booked_at (timestamptz default now())
7
8Constraints:
9- CHECK (exam_slots.remaining_seats >= 0)
10- CHECK (exam_slots.remaining_seats <= exam_slots.total_seats)
11
12RLS:
13- exams: SELECT public
14- exam_slots: SELECT public; INSERT/UPDATE/DELETE where auth.uid() in (SELECT id FROM admins)
15- bookings: SELECT public (candidates need to look up by code); INSERT handled by reserve_slot function
16
17Create a table: admins (id uuid pk references auth.users)
18
19Create the atomic reserve function:
20CREATE OR REPLACE FUNCTION reserve_slot(
21 p_slot_id uuid,
22 p_candidate_name text,
23 p_candidate_email text
24) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER AS $$
25DECLARE
26 v_slot exam_slots;
27 v_code text;
28 v_booking_id uuid;
29BEGIN
30 SELECT * INTO v_slot FROM exam_slots WHERE id = p_slot_id AND is_active = true FOR UPDATE;
31 IF NOT FOUND THEN
32 RETURN jsonb_build_object('success', false, 'error', 'Slot not found');
33 END IF;
34 IF v_slot.remaining_seats <= 0 THEN
35 RETURN jsonb_build_object('success', false, 'error', 'No seats available');
36 END IF;
37 v_code := upper(substring(md5(random()::text || clock_timestamp()::text) from 1 for 8));
38 UPDATE exam_slots SET remaining_seats = remaining_seats - 1 WHERE id = p_slot_id;
39 INSERT INTO bookings (slot_id, candidate_name, candidate_email, confirmation_code)
40 VALUES (p_slot_id, p_candidate_name, p_candidate_email, v_code)
41 RETURNING id INTO v_booking_id;
42 RETURN jsonb_build_object('success', true, 'booking_id', v_booking_id, 'confirmation_code', v_code);
43END;
44$$;

Pro tip: The FOR UPDATE row lock in reserve_slot means concurrent calls queue behind each other at the database level. This is the correct approach for high-contention slots (popular exams). For low-traffic systems, a simpler check-and-decrement without FOR UPDATE is fine, but you risk overbooking under load.

Expected result: Tables are created with CHECK constraints on remaining_seats. The reserve_slot function exists and is callable via supabase.rpc('reserve_slot', {...}). TypeScript types are generated.

2

Build the exam slot listing page

Ask Lovable to build the public page where candidates browse available exam slots and see remaining seat counts. The page updates seat counts in real time using Supabase Realtime.

prompt.txt
1Build a public exam slot listing page at src/pages/ExamSlots.tsx.
2
3Requirements:
4- Fetch all active exams and their upcoming slots (slot_datetime > now(), is_active = true)
5- Group slots by exam using a join: exams with nested exam_slots
6- Render each exam as a section with an Accordion or simple heading
7- Each slot displayed as a Card with:
8 - Date and time (formatted: 'Monday June 9 at 10:00 AM')
9 - Location Badge (green for Online, gray for in-person)
10 - Total seats and remaining seats: '12 of 30 seats remaining'
11 - Price (free if price_cents = 0, otherwise '$X.XX')
12 - Remaining seats progress bar (shadcn/ui Progress component): remaining/total
13 - 'Book This Slot' Button disabled if remaining_seats = 0, shows 'Sold Out' Badge instead
14 - 'Fully Booked' Badge when remaining_seats = 0
15- 'Book This Slot' opens a booking Dialog (built in next step)
16- Subscribe to Supabase Realtime on exam_slots table. When remaining_seats changes, update the displayed count and progress bar without a full page reload
17- Add a search/filter Input at the top to filter by exam name

Pro tip: Ask Lovable to sort slots within each exam by slot_datetime ASC and show a 'Filling fast' Badge when remaining_seats <= total_seats * 0.2. This creates urgency and is accurate because it is driven by real data.

Expected result: The page shows all active exams with their upcoming slots. Remaining seat counts update in real time via Supabase Realtime. Full slots show a Sold Out badge and a disabled button.

3

Build the booking Dialog and confirmation flow

Ask Lovable to build the booking Dialog that collects candidate details and calls the reserve_slot RPC function. The confirmation screen shows the code prominently.

prompt.txt
1Build a BookingDialog component at src/components/BookingDialog.tsx.
2
3Props: slotId: string, examName: string, slotDatetime: string, open, onOpenChange, onSuccess: (code: string) => void
4
5Dialog content when booking is open (not yet confirmed):
6- Show slot summary at top: exam name, formatted date/time, location
7- Form fields (react-hook-form + zod):
8 - candidate_name: required, min 2 chars
9 - candidate_email: required, valid email format
10 - Checkbox: 'I confirm I meet the prerequisites for this exam'
11- 'Reserve My Seat' submit Button with loading spinner
12
13On submit:
14- Call supabase.rpc('reserve_slot', { p_slot_id: slotId, p_candidate_name, p_candidate_email })
15- If result.success is false:
16 - If error is 'No seats available': show Alert 'This slot just filled up. Please choose another.' and close dialog
17 - Other errors: show error message in the form
18- If result.success is true:
19 - Call onSuccess(result.confirmation_code)
20 - Close this dialog and show ConfirmationCard
21
22ConfirmationCard component (shown after successful booking):
23- Large checkmark icon
24- 'You are registered!' heading
25- Exam name and slot date/time
26- Confirmation code in a large monospace Alert: e.g. 'A3B7F2E9'
27- Instruction: 'Save this code. You will need it to manage your booking.'
28- 'Add to calendar' link that generates a .ics file download
29- 'Look up another booking' link

Expected result: The booking Dialog collects candidate details and calls reserve_slot. Success shows the confirmation code in a styled Alert. Overbooking attempts show a friendly error and the slot updates to Sold Out on the listing page.

4

Build the email confirmation Edge Function

Ask Lovable to create a Supabase Edge Function that sends a confirmation email when a new booking is created. Connect it as a database webhook trigger.

prompt.txt
1Create a Supabase Edge Function at supabase/functions/send-booking-confirmation/index.ts.
2
3The function receives a database webhook payload (POST request) when a new row is inserted into bookings.
4
5Payload shape (Supabase webhook): { type: 'INSERT', table: 'bookings', record: { id, slot_id, candidate_name, candidate_email, confirmation_code, booked_at } }
6
7Function logic:
81. Parse the webhook payload
92. Fetch exam and slot details using the slot_id: join exam_slots and exams to get exam name, slot_datetime, location
103. Format the email content:
11 - Subject: 'Your exam booking is confirmed: [exam name]'
12 - Body text: name, exam name, date/time, location, confirmation code (large), instruction to save the code
134. Call Resend API: POST https://api.resend.com/emails with Authorization: Bearer [RESEND_API_KEY from Deno.env]
145. Return 200 on success, log errors to console
15
16Email body HTML:
17- Keep it simple: use inline styles, no external CSS
18- Show confirmation code in a box with background #f3f4f6, monospace font, large size
19- Include a 'Manage your booking' link pointing to /booking-lookup?code=[confirmation_code]
20
21After deploying the function, set up the database webhook in Supabase:
22Dashboard Database Webhooks Create webhook Table: bookings, Event: INSERT, URL: [Edge Function URL]

Pro tip: Ask Lovable to add a SUPABASE_WEBHOOK_SECRET check at the top of the Edge Function. Supabase database webhooks support a secret header for verification. Generate a random secret, add it to Secrets as BOOKING_WEBHOOK_SECRET, and verify it in the function to prevent unauthorized calls.

Expected result: The Edge Function deploys. Creating a test booking triggers the webhook and sends an email to the candidate_email address. The email contains the formatted confirmation code and exam details.

5

Build the admin panel and booking lookup page

Ask Lovable to build the admin slot management page and a public booking lookup page where candidates can retrieve their registration using their confirmation code.

prompt.txt
1Build two pages:
2
31. Admin panel at src/pages/AdminPanel.tsx (requires admin role):
4- Show all exams with a 'Create Exam' Button
5- For each exam, show a DataTable of its slots with columns: datetime, location, seats remaining/total, booking count, active toggle
6- 'Add Slot' Button per exam opens a Dialog:
7 - Fields: slot_datetime (DatePicker + TimePicker), location, total_seats (number input), price_cents (number input labeled as dollars)
8 - Insert into exam_slots, set remaining_seats = total_seats
9- Each slot row has a 'View Registrations' Button that opens a Sheet listing all confirmed bookings for that slot: candidate name, email, confirmation code, booked_at
10- Admin can mark a booking as 'no_show' from the Sheet
11
122. Booking lookup page at src/pages/BookingLookup.tsx (public):
13- Single Input for confirmation code with a 'Find My Booking' Button
14- On submit: SELECT from bookings JOIN exam_slots JOIN exams WHERE confirmation_code = ? AND status = 'confirmed'
15- Show booking details Card: exam name, datetime, location, candidate name, status Badge
16- 'Cancel Booking' Button with AlertDialog on confirm, set status = 'cancelled' and INCREMENT exam_slots.remaining_seats by 1
17- If not found: show 'No booking found with that code'

Expected result: Admins can create exam slots and view all registrations. Candidates can look up and cancel their booking using just their confirmation code. Cancellation correctly restores the seat count.

Complete code

src/components/SlotCard.tsx
1import { format } from 'date-fns'
2import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
3import { Badge } from '@/components/ui/badge'
4import { Button } from '@/components/ui/button'
5import { Progress } from '@/components/ui/progress'
6
7type ExamSlot = {
8 id: string
9 slot_datetime: string
10 location: string
11 total_seats: number
12 remaining_seats: number
13 price_cents: number
14 notes: string | null
15}
16
17type SlotCardProps = {
18 slot: ExamSlot
19 examName: string
20 onBook: (slotId: string) => void
21}
22
23export function SlotCard({ slot, examName, onBook }: SlotCardProps) {
24 const isSoldOut = slot.remaining_seats === 0
25 const isFillingFast = !isSoldOut && slot.remaining_seats <= slot.total_seats * 0.2
26 const fillPercent = ((slot.total_seats - slot.remaining_seats) / slot.total_seats) * 100
27
28 return (
29 <Card className={isSoldOut ? 'opacity-60' : ''}>
30 <CardHeader className="pb-2">
31 <div className="flex items-start justify-between gap-2">
32 <div>
33 <p className="font-medium">
34 {format(new Date(slot.slot_datetime), 'EEEE, MMMM d \u2022 h:mm a')}
35 </p>
36 <p className="text-sm text-muted-foreground mt-0.5">{slot.location}</p>
37 </div>
38 <div className="flex flex-col items-end gap-1">
39 {isSoldOut && <Badge variant="destructive">Sold Out</Badge>}
40 {isFillingFast && <Badge variant="secondary">Filling Fast</Badge>}
41 {slot.location === 'Online' && <Badge variant="outline">Online</Badge>}
42 </div>
43 </div>
44 </CardHeader>
45 <CardContent className="pb-2">
46 <div className="space-y-2">
47 <div className="flex justify-between text-sm">
48 <span className="text-muted-foreground">Seats available</span>
49 <span className="font-medium">
50 {slot.remaining_seats} of {slot.total_seats}
51 </span>
52 </div>
53 <Progress value={fillPercent} className="h-2" />
54 {slot.notes && (
55 <p className="text-xs text-muted-foreground">{slot.notes}</p>
56 )}
57 </div>
58 </CardContent>
59 <CardFooter className="pt-2 flex items-center justify-between">
60 <span className="text-sm font-medium">
61 {slot.price_cents === 0 ? 'Free' : `$${(slot.price_cents / 100).toFixed(2)}`}
62 </span>
63 <Button
64 size="sm"
65 disabled={isSoldOut}
66 onClick={() => onBook(slot.id)}
67 >
68 {isSoldOut ? 'Sold Out' : 'Book This Slot'}
69 </Button>
70 </CardFooter>
71 </Card>
72 )
73}

Customization ideas

Waitlist when a slot fills up

Add a waitlist table with slot_id, candidate_email, and position. When a slot reaches 0 remaining_seats, the 'Book' button changes to 'Join Waitlist'. If a booking is cancelled, a Supabase trigger increments remaining_seats and calls an Edge Function that emails the next person on the waitlist with a time-limited booking link.

Stripe payment for paid exams

When price_cents > 0, clicking 'Book This Slot' calls an Edge Function that creates a Stripe Checkout session. On successful payment, a Stripe webhook calls the reserve_slot function. The booking confirmation page is the Stripe success URL. Free exams continue to call reserve_slot directly from the client.

QR code check-in for in-person exams

Include a QR code in the confirmation email that encodes the confirmation_code. Admins use a mobile-friendly check-in page where they can scan QR codes or manually enter codes to mark candidates as present. The check-in page updates the booking status to 'checked_in' and shows the candidate's name and exam details.

Proctored exam link delivery

Add an exam_link column to exam_slots that is hidden until 30 minutes before the slot_datetime. An Edge Function on a cron schedule sends the exam link email at that time to all confirmed candidates. The booking lookup page also reveals the link once the exam window opens.

Candidate score recording and certificate generation

Add a score (int) and passed (bool) column to bookings for admins to fill in after the exam. A certificate Edge Function generates a PDF or HTML certificate using the candidate name, exam name, score, and date when passed = true. Link this to the certificate-generator build.

Common pitfalls

Pitfall: Decrementing remaining_seats in application code instead of a database transaction

How to avoid: Always use the reserve_slot Postgres function with SELECT ... FOR UPDATE. The row lock ensures only one transaction reads and decrements at a time. The CHECK constraint (remaining_seats >= 0) is the last safety net.

Pitfall: Using RESEND_API_KEY as a VITE_ variable

How to avoid: Store RESEND_API_KEY in Cloud tab → Secrets WITHOUT the VITE_ prefix. Access it in the Edge Function with Deno.env.get('RESEND_API_KEY'). Never reference Resend from the frontend directly.

Pitfall: Not restoring remaining_seats when a booking is cancelled

How to avoid: Create a cancel_booking(p_confirmation_code text) Postgres function that sets status = 'cancelled' AND increments exam_slots.remaining_seats by 1 in the same transaction, with a check that the slot_datetime is still in the future.

Pitfall: Allowing public INSERT directly on the bookings table via RLS

How to avoid: Remove the INSERT policy from bookings. The reserve_slot function is SECURITY DEFINER so it runs with elevated permissions regardless of the caller's role. All bookings must go through the function.

Best practices

  • Use SELECT ... FOR UPDATE inside the reserve_slot Postgres function to acquire a row lock on the slot before checking and decrementing remaining_seats. This is the correct way to prevent race conditions in PostgreSQL.
  • Add a CHECK constraint (remaining_seats >= 0) on exam_slots as an additional safeguard. Even if application logic has a bug, the database will reject any operation that would make remaining_seats negative.
  • Make reserve_slot SECURITY DEFINER and callable by the anon role, but remove the direct INSERT policy on bookings. This forces all bookings through the atomic function.
  • Generate confirmation codes using a combination of random bytes and clock_timestamp() in the Postgres function so codes are unpredictable and collision-resistant.
  • Send confirmation emails asynchronously via a database webhook rather than calling Resend from the frontend. This means email delivery never blocks the booking confirmation UI and retries are handled server-side.
  • Subscribe to Supabase Realtime on exam_slots to keep the seat count live on the listing page. Candidates see accurate counts without refreshing, which reduces double-booking attempts.
  • Always increment remaining_seats in the same transaction as the booking cancellation, not as a separate call. Separate calls can leave counts inconsistent if the second call fails.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building an exam booking system in PostgreSQL (via Supabase). I need a function that atomically decrements a seat count and inserts a booking row. Help me write the full PL/pgSQL function reserve_slot(p_slot_id uuid, p_candidate_name text, p_candidate_email text) that: uses SELECT ... FOR UPDATE to lock the slot row, checks remaining_seats > 0, decrements remaining_seats, inserts into bookings with a generated confirmation code, and returns a jsonb result with success, booking_id, and confirmation_code fields. Show me how to generate a secure 8-character uppercase alphanumeric confirmation code in PL/pgSQL.

Lovable Prompt

Add a cancellation deadline feature. Add a cancellation_deadline_hours column (int, default 24) to exams. On the booking lookup page, show the cancellation deadline: 'You can cancel until [slot_datetime - cancellation_deadline_hours] hours before'. Disable the cancel button if now() > slot_datetime - cancellation_deadline_hours with a message 'Cancellation period has passed'. In the cancel_booking function, enforce this check at the database level too.

Build Prompt

In Supabase, set up a Database Webhook on the bookings table for INSERT events that calls my send-booking-confirmation Edge Function. Include a secret header for verification. Show me the exact steps: where to find Database Webhooks in the Supabase dashboard, what URL format to use for the Edge Function (it should be the /functions/v1/ URL), and what the webhook payload structure looks like so I can parse it correctly in my Deno function.

Frequently asked questions

Why use a Postgres function for the reservation instead of just doing it in the app?

Application code cannot safely check-and-decrement a counter under concurrent load. Two requests that both read remaining_seats = 1 will both see availability and both insert. The Postgres function with SELECT ... FOR UPDATE serializes access at the row level — only one transaction can hold the lock at a time, making overbooking impossible.

How do I call the reserve_slot function from Lovable?

Use the Supabase client's rpc method: const { data, error } = await supabase.rpc('reserve_slot', { p_slot_id: slotId, p_candidate_name: name, p_candidate_email: email }). The function returns a jsonb object. Check data.success — if true, data.confirmation_code contains the code to show the candidate.

What happens if the Resend email fails after the booking is confirmed?

The booking is already saved in the database — the email failure does not affect the reservation. The candidate's seat is secured. You should log the Resend error in the Edge Function console (visible in Supabase Logs). Add a retry mechanism by storing a sent_confirmation_email boolean on the bookings row and running a cron Edge Function that retries failed sends hourly.

Can I have the same candidate book multiple slots for different exams?

Yes. The schema allows multiple bookings per email across different exam slots. If you want to prevent a candidate from booking the same exam twice, add a unique constraint on bookings(slot_id, candidate_email) and check for that error in the booking Dialog with a message like 'You already have a booking for this exam.'

How do I set up the Supabase database webhook for email confirmation?

Go to Supabase Dashboard → Database → Webhooks → Create a new webhook. Select the bookings table and the INSERT event. Set the URL to your Edge Function URL (format: https://[project-ref].supabase.co/functions/v1/send-booking-confirmation). Add a secret header and verify it in the function. Supabase will send the new row data as a JSON POST body to the function on every booking insert.

How do I handle the case where a slot becomes available again after cancellation?

The cancel_booking Postgres function should UPDATE exam_slots SET remaining_seats = remaining_seats + 1 and UPDATE bookings SET status = 'cancelled' in the same transaction. Supabase Realtime will broadcast the exam_slots change, and the listing page will update the seat count and re-enable the Book button automatically.

Can candidates book for someone else — different name and email on the same account?

Yes. The booking form collects candidate_name and candidate_email without requiring the booker to be logged in. The booking belongs to the email entered, not a Supabase Auth account. If you want to restrict booking to logged-in users only, require auth before showing the Book button and pre-fill the email from auth.user.email.

Where can I get help if I need features like Stripe payments or a waitlist?

RapidDev builds full-featured Lovable applications. If you need payment integration, waitlist management, score recording, or certificate generation added to this booking system, reach out to discuss a scoped build.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.