Prompt Pack
This Prompt Pack provides a complete 8-phase step-by-step instruction sequence for Lovable to scaffold Homestay, a production short-term rental marketplace. Each prompt targets an isolated product phase—covering database migrations, RLS security policies, authentication, listing management, Stripe booking flows, Edge Functions, and CI/CD pipelines.
Homestay runs on a modern serverless tech stack comprising React 18, Supabase (PostgreSQL 15, GoTrue Auth, Edge Functions, Storage, and Vault), Stripe Connect for payment routing, and Resend for transactional emails.
Prompt 1
This prompt sets up the entire database foundation for Homestay — every table, every rule about who can access what data, and the background processes that keep the system consistent. When this prompt is complete, you will have a fully structured database but no visible app yet. You do not need to understand the instructions below.
Paste everything below into your AI coding tool:
You are building Homestay, a two-sided short-term rental marketplace. This is the first prompt. Nothing has been built yet. You will create the complete database schema, all Row-Level Security policies, all database triggers, all indexes, the GiST exclusion constraint for double-booking prevention, and the shared utility modules that all Edge Functions will depend on.
Use Lovable's Plan mode to review every migration and RLS policy before executing. This is critical — RLS policy mistakes silently deny data access instead of showing errors, which is extremely difficult to debug later.
Step 1: Database Migrations
Create the following migration files in supabase/migrations/. Apply them in the numbered order listed. Each file must apply cleanly with no errors before proceeding to the next.
supabase/migrations/001_extensions.sql
Enable the btree_gist and pg_cron and pg_net extensions. These must exist before any other migration runs. Use CREATE EXTENSION IF NOT EXISTS for each.
supabase/migrations/002_enums.sql
Define all PostgreSQL enum types. Use CREATE TYPE ... AS ENUM for each. The types and their allowed values:
listing_status_enum: draft, active, inactive
booking_status_enum: pending, pending_approval, confirmed, declined, cancelled_by_host, cancelled_by_guest, payment_failed
payout_status_enum: pending, completed, failed
availability_rule_type_enum: blocked_range, min_stay, checkin_days
cancellation_policy_enum: flexible, moderate, strict
host_onboarding_status_enum: not_started, pending_verification, verified
deletion_status_enum: none, pending, completed
ccpa_request_type_enum: deletion, export
user_role_enum: guest, host, admin, support
supabase/migrations/003_tables.sql
Create the following tables with these exact column definitions:
profiles
id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE
full_name text
email text
phone text
avatar_url text
roles user_role_enum[] NOT NULL DEFAULT '{guest}'
stripe_customer_id text
stripe_connect_account_id text
host_onboarding_status host_onboarding_status_enum NOT NULL DEFAULT 'not_started'
deletion_status deletion_status_enum NOT NULL DEFAULT 'none'
deletion_requested_at timestamptz
do_not_sell boolean NOT NULL DEFAULT false
created_at timestamptz NOT NULL DEFAULT now()
updated_at timestamptz NOT NULL DEFAULT now()
listings
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
host_user_id uuid NOT NULL REFERENCES profiles(id)
title text NOT NULL
description text
location text NOT NULL
latitude numeric(9,6)
longitude numeric(9,6)
max_guests integer NOT NULL
base_price_cents integer NOT NULL
cancellation_policy cancellation_policy_enum NOT NULL DEFAULT 'moderate'
instant_book boolean NOT NULL DEFAULT false
house_rules text
status listing_status_enum NOT NULL DEFAULT 'draft'
created_at timestamptz NOT NULL DEFAULT now()
updated_at timestamptz NOT NULL DEFAULT now()
listing_amenities
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
listing_id uuid NOT NULL REFERENCES listings(id) ON DELETE CASCADE
amenity text NOT NULL
listing_photos
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
listing_id uuid NOT NULL REFERENCES listings(id) ON DELETE CASCADE
storage_path text NOT NULL
display_order integer NOT NULL DEFAULT 0
created_at timestamptz NOT NULL DEFAULT now()
availability_rules
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
listing_id uuid NOT NULL REFERENCES listings(id) ON DELETE CASCADE
rule_type availability_rule_type_enum NOT NULL
blocked_start date
blocked_end date
min_stay_nights integer
checkin_days_bitmask integer
created_at timestamptz NOT NULL DEFAULT now()
The checkin_days_bitmask column encodes allowed check-in days as a bitmask: bit 0 = Sunday, bit 1 = Monday, through bit 6 = Saturday.
pricing_overrides
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
listing_id uuid NOT NULL REFERENCES listings(id) ON DELETE CASCADE
override_type text NOT NULL
season_start date
season_end date
price_cents integer NOT NULL
created_at timestamptz NOT NULL DEFAULT now()
The override_type column accepts values seasonal and weekend.
bookings
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
listing_id uuid NOT NULL REFERENCES listings(id)
guest_user_id uuid NOT NULL REFERENCES profiles(id)
check_in_date date NOT NULL
check_out_date date NOT NULL
date_range tsrange GENERATED ALWAYS AS (tsrange(check_in_date::timestamp, check_out_date::timestamp)) STORED
num_guests integer NOT NULL
total_price_cents integer NOT NULL
service_fee_cents integer NOT NULL
status booking_status_enum NOT NULL DEFAULT 'pending'
payout_status payout_status_enum NOT NULL DEFAULT 'pending'
stripe_payment_intent_id text
stripe_transfer_id text
cancellation_reason text
created_at timestamptz NOT NULL DEFAULT now()
updated_at timestamptz NOT NULL DEFAULT now()
stripe_webhook_events
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
stripe_event_id text NOT NULL UNIQUE
event_type text NOT NULL
processed_at timestamptz NOT NULL DEFAULT now()
ccpa_requests
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
user_id uuid NOT NULL REFERENCES profiles(id)
request_type ccpa_request_type_enum NOT NULL
requested_at timestamptz NOT NULL DEFAULT now()
completed_at timestamptz
booking_rate_limit_checks
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
user_id uuid NOT NULL
attempted_at timestamptz NOT NULL DEFAULT now()
supabase/migrations/004_constraints_and_indexes.sql
Add the GiST exclusion constraint and all indexes. Use these exact definitions:
ALTER TABLE bookings ADD CONSTRAINT no_overlapping_bookings
EXCLUDE USING gist (
listing_id WITH =,
date_range WITH &&
)
WHERE (status IN ('pending', 'pending_approval', 'confirmed'));
CREATE INDEX idx_bookings_listing_dates ON bookings(listing_id, check_in_date, check_out_date);
CREATE INDEX idx_bookings_guest ON bookings(guest_user_id);
CREATE INDEX idx_bookings_payout ON bookings(payout_status) WHERE payout_status = 'pending';
CREATE INDEX idx_listings_search ON listings(location, max_guests, base_price_cents);
CREATE INDEX idx_availability_rules_listing ON availability_rules(listing_id);
CREATE INDEX idx_pricing_overrides_listing ON pricing_overrides(listing_id);
CREATE UNIQUE INDEX idx_stripe_events_idempotency ON stripe_webhook_events(stripe_event_id);
CREATE INDEX idx_profiles_deletion ON profiles(deletion_status) WHERE deletion_status = 'pending';
CREATE INDEX idx_booking_rate_limits ON booking_rate_limit_checks(user_id, attempted_at);
supabase/migrations/005_triggers.sql
Create three trigger functions and their associated triggers:
create_profile_for_user() — fires AFTER INSERT ON auth.users for each row. Inserts a row into profiles with id = NEW.id, email = NEW.email, roles = '{guest}'. All other columns take their defaults.
soft_delete_listing() — fires BEFORE DELETE ON listings for each row. Sets status = 'inactive' on the row and returns NULL to cancel the actual deletion.
set_updated_at() — fires BEFORE UPDATE on profiles, listings, and bookings for each row. Sets updated_at = now() and returns NEW.
supabase/migrations/006_rls.sql
Enable Row-Level Security and create all policies. Run this migration through Plan mode in Lovable before executing — review every policy carefully.
Enable RLS on every table: profiles, listings, listing_amenities, listing_photos, availability_rules, pricing_overrides, bookings, stripe_webhook_events, ccpa_requests, booking_rate_limit_checks.
profiles policies:
SELECT for role authenticated: auth.uid() = id
UPDATE for role authenticated: auth.uid() = id
- No
INSERT for authenticated — inserts are handled by the trigger via service role
- No
DELETE for any non-service role
listings policies:
SELECT for roles anon and authenticated: status = 'active'
SELECT (own) for role authenticated: auth.uid() = host_user_id
INSERT for role authenticated: auth.uid() = host_user_id
UPDATE for role authenticated: auth.uid() = host_user_id
DELETE for role authenticated: auth.uid() = host_user_id — the soft-delete trigger intercepts this and sets status to inactive rather than deleting the row
listing_photos policies:
SELECT (public) for roles anon and authenticated: the associated listing_id must reference a listing with status = 'active' (use a subquery)
SELECT (own host) for role authenticated: the associated listing_id must reference a listing where host_user_id = auth.uid()
INSERT, UPDATE, DELETE for role authenticated: the associated listing_id must reference a listing where host_user_id = auth.uid()
listing_amenities policies: Same structure as listing_photos.
availability_rules policies:
SELECT for role authenticated: listing host_user_id = auth.uid() via subquery
INSERT, UPDATE, DELETE for role authenticated: listing host_user_id = auth.uid() via subquery
pricing_overrides policies: Same structure as availability_rules.
bookings policies:
SELECT (guest) for role authenticated: auth.uid() = guest_user_id
SELECT (host) for role authenticated: the associated listing_id references a listing where host_user_id = auth.uid() (use a subquery)
- No
INSERT, UPDATE, or DELETE for any authenticated or anon role — all mutations go through Edge Functions using the service role
stripe_webhook_events policies: No policies for authenticated or anon. Service role only.
ccpa_requests policies:
SELECT for role authenticated: auth.uid() = user_id
INSERT for role authenticated: auth.uid() = user_id
- No
UPDATE or DELETE for any non-service role
booking_rate_limit_checks policies: No policies for authenticated or anon. Service role only.
supabase/migrations/007_postgres_functions.sql
Create the following server-side PostgreSQL functions. These are called by Edge Functions using the service-role client. All functions require SECURITY DEFINER so they run with the function owner's privileges regardless of caller:
cancel_pending_booking(p_booking_id uuid) RETURNS void
Updates the bookings table: set status = 'declined' where id = p_booking_id and status is either pending or pending_approval. This is a compensating action called when Stripe fails during booking creation.
confirm_booking(p_booking_id uuid, p_stripe_payment_intent_id text) RETURNS void
Updates bookings: set status = 'confirmed', stripe_payment_intent_id = p_stripe_payment_intent_id, updated_at = now() where id = p_booking_id.
get_pricing_for_range(p_listing_id uuid, p_check_in date, p_check_out date) RETURNS TABLE(night_date date, price_cents integer)
For each calendar night in the range (check-in inclusive, check-out exclusive — one row per night), determine the applicable price in this priority order:
- If a row exists in
pricing_overrides for this listing_id with override_type = 'seasonal' where the night date falls within season_start and season_end (inclusive), use that price_cents
- Else if
override_type = 'weekend' exists for this listing and the night date is a Friday or Saturday, use that price_cents
- Else use
base_price_cents from the listings table for this listing_id
Return one row per night with the resolved price.
scrub_user_pii(p_user_id uuid) RETURNS void
Updates profiles: set full_name = '[deleted]', email = '[deleted]', phone = NULL, avatar_url = NULL, deletion_status = 'completed', updated_at = now() where id = p_user_id. Does not delete the row.
purge_old_webhook_events() RETURNS integer
Deletes rows from stripe_webhook_events where processed_at is older than 90 days. Returns the count of deleted rows as an integer.
supabase/migrations/008_cron_jobs.sql
Register pg_cron jobs using cron.schedule. Each job invokes the corresponding Edge Function via net.http_post from the pg_net extension, with a CRON_SECRET bearer token in the Authorization header. The Edge Function URLs follow the pattern {SUPABASE_URL}/functions/v1/{function-name}.
Because the exact project URL is environment-specific, implement this migration so the URL is constructed from a Postgres configuration parameter (e.g. current_setting('app.supabase_url')) that is set at deploy time by the CI script before the migration runs. Document this requirement clearly in the migration file comment.
Register three jobs:
payout-scheduler: schedule 0 * * * * (every hour)
booking-expire: schedule */15 * * * * (every 15 minutes)
ccpa-scrubber: schedule 0 2 * * * (nightly at 02:00 UTC)
Step 2: Shared Edge Function Modules
Create the following files in supabase/functions/_shared/. These modules are imported by all Edge Functions. Use TypeScript throughout.
supabase/functions/_shared/supabase-clients.ts
Export two functions:
export function getServiceClient(): SupabaseClient
Returns a Supabase client initialized with SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY read from Deno.env. This client bypasses RLS and is used for all privileged database operations inside Edge Functions. Must be callable multiple times safely — either create a singleton or create a new instance per call (the tool may choose).
export function getUserClient(jwt: string): SupabaseClient
Returns a Supabase client initialized with the caller's JWT, using the anon key for the base client but overriding the Authorization header with the provided JWT. RLS applies to all queries made with this client.
supabase/functions/_shared/auth.ts
Export:
export async function requireAuth(req: Request): Promise<{ userId: string; roles: string[] }>
Extracts the Bearer token from the Authorization header of req. Validates the JWT. Fetches the caller's profiles row (specifically the roles column) via the service client. Throws AuthError if: no token is present, the token is invalid or expired, or no matching profile row exists. Returns { userId, roles } on success.
export class AuthError extends Error {
constructor(message: string)
}
Edge Functions catch AuthError and return an unauthorized response. The AuthError class must be exported so Edge Functions can use instanceof checks.
supabase/functions/_shared/vault.ts
Export:
export async function getSecret(name: string): Promise<string>
Retrieves a secret from Supabase Vault by the given name using the service client. Throws VaultError if the secret is not found or retrieval fails.
export class VaultError extends Error {}
supabase/functions/_shared/resend.ts
Export:
export interface EmailPayload {
to: string
subject: string
html: string
}
export async function sendEmail(payload: EmailPayload): Promise<void>
Retrieves RESEND_API_KEY and RESEND_FROM_ADDRESS from Vault. Sends the email via the Resend API using the retrieved key and from address. On any error (Vault retrieval failure, API call failure, or non-success response), logs the error details and returns without throwing — email failure must never cause an Edge Function to fail its primary operation.
supabase/functions/_shared/errors.ts
Export:
export function errorResponse(message: string, code: string, status: number): Response
Returns a Response with Content-Type: application/json, the given HTTP status, and a JSON body shaped as { error: { message, code } }. Must never include stack traces, Postgres error codes, Stripe error objects, or any internal detail.
export function successResponse(data: unknown, status?: number): Response
Returns a Response with Content-Type: application/json, the given status (defaulting to 200), and a JSON body shaped as { data }.
Both functions must include CORS headers allowing requests from the production frontend domain. The allowed origin value must be read from Deno.env (set it as FRONTEND_URL in Vault, already provisioned in setup).
supabase/functions/_shared/validation.ts
Export:
export function validateBody<T>(schema: ZodSchema<T>, body: unknown): T
Parses body against the provided Zod schema. On success, returns the typed, validated value. On failure, throws ValidationError with a fields property mapping field names to human-readable error messages.
export class ValidationError extends Error {
fields: Record<string, string>
}
Edge Functions catch ValidationError and return a response with the field-level messages in the error body using errorResponse.
Step 3: Apply Migrations
Apply all migrations to the homestay-dev Supabase project via Lovable's Supabase integration. Confirm each migration file is present in supabase/migrations/ and that the migrations apply in order without errors.
Do not build any frontend pages or components in this prompt.
Check before continuing (do this yourself, do not paste it):
- Open your Supabase
homestay-dev dashboard → Table Editor and confirm all tables exist: profiles, listings, listing_amenities, listing_photos, availability_rules, pricing_overrides, bookings, stripe_webhook_events, ccpa_requests, booking_rate_limit_checks.
- In the Supabase dashboard → Authentication, create a test user manually. Then go to Table Editor →
profiles and confirm a row was automatically created for that user with roles = {guest}.
- In the Supabase SQL Editor, run this statement and confirm it produces an error (the constraint is working):
INSERT INTO bookings (listing_id, guest_user_id, check_in_date, check_out_date, num_guests, total_price_cents, service_fee_cents, status) VALUES (gen_random_uuid(), gen_random_uuid(), '2025-01-01', '2025-01-05', 2, 10000, 1200, 'confirmed'), (gen_random_uuid(), gen_random_uuid(), '2025-01-03', '2025-01-07', 2, 10000, 1200, 'confirmed'); — it should fail only if you use the same listing_id for both rows. If something is wrong: tell Lovable which migration produced an error and paste the error message.
Prompt 2
Run this after Prompt 1 is complete.
This prompt builds the sign-up, sign-in, sign-out, and profile pages. When this prompt is complete, you will be able to create an account, verify your email, sign in, and edit your profile — and you will be redirected to sign-in if you try to visit a protected page without being logged in. You do not need to understand the instructions below.
Paste everything below into your AI coding tool:
You are building Homestay, a two-sided short-term rental marketplace. The database schema, RLS policies, triggers, and shared Edge Function modules from Prompt 1 are already in place. This prompt builds the authentication flows and profile management screens.
Use Plan mode in Lovable before generating any component that reads from or writes to the profiles table — confirm the data access pattern aligns with the profiles RLS policy before executing.
Do not build any listing, booking, host, or admin screens in this prompt.
Step 1: Supabase Client Singleton
src/lib/supabase.ts
Create and export a singleton Supabase client:
export const supabase: SupabaseClient
Initialize it with import.meta.env.VITE_SUPABASE_URL and import.meta.env.VITE_SUPABASE_ANON_KEY. This singleton is imported by all hooks and pages throughout the app. It must not be re-initialized on re-renders.
Step 2: Auth Hook
src/hooks/useAuth.ts
Export the following interface and hook:
export interface AuthUser {
id: string
email: string
roles: string[]
hostOnboardingStatus: 'not_started' | 'pending_verification' | 'verified'
deletionStatus: 'none' | 'pending' | 'completed'
}
export function useAuth(): {
user: AuthUser | null
loading: boolean
signUp: (email: string, password: string, fullName: string) => Promise<void>
signIn: (email: string, password: string) => Promise<void>
signOut: () => Promise<void>
refreshUser: () => Promise<void>
}
Behaviour:
- Subscribe to the Supabase auth state change event on mount; unsubscribe on unmount.
- When a session is established, fetch the authenticated user's
profiles row (columns: id, roles, host_onboarding_status, deletion_status) via PostgREST and populate AuthUser. The email value comes from the Supabase session, not the profiles row.
signUp calls Supabase auth sign-up with the provided email and password. It also passes fullName as user metadata so it is available during the profile setup step. Throws on any error.
signIn calls Supabase auth sign-in with email and password. Throws on any error.
signOut calls Supabase auth sign-out and clears user to null.
refreshUser re-fetches the profiles row and updates state. Called after profile edits or onboarding status changes.
- While the initial session check is in progress,
loading is true.
Step 3: Profile Hook
src/hooks/useProfile.ts
Export:
export interface Profile {
id: string
fullName: string | null
email: string | null
phone: string | null
avatarUrl: string | null
roles: string[]
hostOnboardingStatus: string
deletionStatus: string
doNotSell: boolean
}
export function useProfile(): {
profile: Profile | null
loading: boolean
error: string | null
updateProfile: (fields: Partial<Pick<Profile, 'fullName' | 'phone' | 'doNotSell'>>) => Promise<void>
}
Behaviour:
- Fetches the full profiles row for the currently authenticated user via PostgREST on mount. RLS ensures users can only read their own row.
updateProfile issues a PostgREST PATCH on the profiles table scoped to the authenticated user's id. Only the fields full_name, phone, and do_not_sell may be updated by this hook. Refreshes the local profile state on success.
- If the user is not authenticated,
profile is null.
Step 4: Guard Components
src/components/AuthGuard.tsx
export function AuthGuard({ children }: { children: ReactNode }): JSX.Element
Reads from useAuth(). While loading is true, renders a full-page loading spinner. If user is null, redirects to /sign-in using the router. Otherwise renders children.
src/components/HostGuard.tsx
export function HostGuard({ children }: { children: ReactNode }): JSX.Element
Reads from useAuth(). If user.roles does not include 'host', redirects to /. Otherwise renders children. Assumes it is always rendered inside AuthGuard so user is guaranteed non-null.
src/components/AdminGuard.tsx
export function AdminGuard({ children }: { children: ReactNode }): JSX.Element
Reads from useAuth(). If user.roles does not include 'admin' or 'support', redirects to /. Otherwise renders children. Assumes it is always rendered inside AuthGuard.
Step 5: Pages
src/pages/SignUpPage.tsx
A sign-up form with fields: full name, email address, and password. On submit, calls useAuth().signUp. On success, displays a confirmation message telling the user to check their email to verify their account — do not redirect immediately. If the Supabase auth error is any variant of an invalid credentials or existing user error, display the message "Something went wrong. Please check your details and try again." — never distinguish between "email already taken" and other errors, to prevent user enumeration.
src/pages/SignInPage.tsx
A sign-in form with fields: email address and password. On submit, calls useAuth().signIn. On success, redirects to /. On any error, display the message "Invalid email or password." regardless of the specific error type.
src/pages/ProfilePage.tsx
Displays the authenticated user's profile using useProfile. Shows editable fields: full name, phone number, and a "Do not sell my data" toggle. Shows non-editable role badges for each role in user.roles. Shows the current hostOnboardingStatus as a status label.
Includes two action buttons at the bottom of the page:
- "Request account deletion" — this button calls the
ccpa-request Edge Function (built in Prompt 7). For now, render the button but it does not need to do anything yet. Disable the button if profile.deletionStatus === 'pending'. Show the label "Deletion requested" instead when disabled.
- "Request my data export" — same placeholder treatment; wire it up in Prompt 7.
Save changes via useProfile().updateProfile on a "Save" button click. Show a success message on save.
Step 6: App Router
src/App.tsx
Define the full application route structure using React Router. Set up all routes now — pages that are not built yet render a placeholder component with the text "Coming soon":
/ → ListingsSearchPage (placeholder for now)
/sign-up → SignUpPage
/sign-in → SignInPage
/profile → AuthGuard wrapping ProfilePage
/listings/:id → ListingDetailPage (placeholder)
/bookings → AuthGuard wrapping GuestBookingsPage (placeholder)
/host → AuthGuard wrapping HostGuard wrapping HostDashboardPage (placeholder)
/host/listings/new → AuthGuard wrapping HostGuard wrapping ListingEditorPage (placeholder)
/host/listings/:id → AuthGuard wrapping HostGuard wrapping ListingEditorPage (placeholder)
/host/onboarding → AuthGuard wrapping HostGuard wrapping HostOnboardingPage (placeholder)
/host/onboarding-complete → AuthGuard wrapping HostGuard wrapping HostOnboardingPage (placeholder)
/host/bookings → AuthGuard wrapping HostGuard wrapping HostBookingsPage (placeholder)
/admin → AuthGuard wrapping AdminGuard wrapping AdminDashboardPage (placeholder)
/admin/users → AuthGuard wrapping AdminGuard wrapping AdminUsersPage (placeholder)
/admin/listings → AuthGuard wrapping AdminGuard wrapping AdminListingsPage (placeholder)
/admin/bookings → AuthGuard wrapping AdminGuard wrapping AdminBookingsPage (placeholder)
/admin/hosts → AuthGuard wrapping AdminGuard wrapping AdminHostsPage (placeholder)
Include a top-level navigation bar visible on all pages with links to /, a sign-in link (if not authenticated), and links to /profile and /bookings (if authenticated). The nav bar shows a sign-out button that calls useAuth().signOut.
Check before continuing (do this yourself, do not paste it):
- Visit the Lovable preview URL. You should see the navigation bar and a "Coming soon" placeholder at
/.
- Click sign up, create a new account with a real email address you can access, and confirm you receive a verification email. After verifying, sign in — you should be redirected to
/ and the nav bar should show your email or a profile link.
- Navigate directly to
/profile while signed out — you should be redirected to /sign-in. Sign in and return to /profile — you should see the profile form with your name and email.
- Edit your full name and save — reload the page and confirm the new name persists. If something is wrong: describe what you see and ask Lovable to fix the specific step that failed.
Prompt 3
Run this after Prompt 2 is complete.
This prompt builds the guest-facing listing discovery experience — a search page and a listing detail page. When this prompt is complete, any visitor (logged in or not) can browse listings, filter by location, guests, and price, and view full listing details including all photos. You do not need to understand the instructions below.
Paste everything below into your AI coding tool:
You are building Homestay, a two-sided short-term rental marketplace. The database schema, RLS policies, auth flows, and profile page from Prompts 1 and 2 are already in place. This prompt builds the guest-facing listing search and listing detail pages. All data reads go through PostgREST via the Supabase JS client — no Edge Functions are needed for these screens.
Use Plan mode before generating any component that queries the listings or listing_photos tables. Confirm the queries align with the RLS policies: unauthenticated users can read active listings and their photos; no other tables are accessible without authentication.
Do not build any booking, host management, or admin screens in this prompt.
Step 1: Listing Data Hooks
src/hooks/useListings.ts
Export:
export interface ListingSearchParams {
location?: string
checkIn?: string // ISO date string, e.g. "2025-08-01"
checkOut?: string // ISO date string
numGuests?: number
minPriceCents?: number
maxPriceCents?: number
}
export interface ListingSummary {
id: string
title: string
location: string
maxGuests: number
basePriceCents: number
coverPhotoUrl: string | null
instantBook: boolean
cancellationPolicy: string
}
export function useListings(params: ListingSearchParams): {
listings: ListingSummary[]
loading: boolean
error: string | null
}
Behaviour:
- Queries PostgREST for rows in
listings where status = 'active'.
- Applies additional filters when provided:
location uses a case-insensitive partial match against listings.location; numGuests filters to listings where max_guests >= numGuests; minPriceCents and maxPriceCents filter on base_price_cents.
checkIn and checkOut are collected by the search form but are not used to filter listings here — date availability is enforced at booking time. Pass them through so they can be forwarded to the booking flow when a guest clicks a listing.
- For each listing, fetches the
listing_photos row with the lowest display_order for that listing. Generates a signed read URL for that photo using the Supabase storage client. If no photo exists, coverPhotoUrl is null.
- Re-runs the query when
params changes.
src/hooks/useListingDetail.ts
Export:
export interface ListingDetail {
id: string
title: string
description: string | null
location: string
latitude: number | null
longitude: number | null
maxGuests: number
basePriceCents: number
cancellationPolicy: string
instantBook: boolean
houseRules: string | null
status: string
hostUserId: string
photos: Array<{ id: string; storageUrl: string; displayOrder: number }>
amenities: Array<{ id: string; amenity: string }>
}
export function useListingDetail(listingId: string): {
listing: ListingDetail | null
loading: boolean
error: string | null
}
Behaviour:
- Fetches the
listings row for the given listingId via PostgREST.
- Also fetches all
listing_photos rows for this listing ordered by display_order ascending, and all listing_amenities rows.
- Generates signed read URLs for all photos via the Supabase storage client.
- If the listing does not exist or
status != 'active', returns listing: null.
- Sets
error to a human-readable string on any fetch failure.
Step 2: Components
src/components/ListingCard.tsx
interface ListingCardProps {
listing: ListingSummary
onClick: () => void
}
export function ListingCard({ listing, onClick }: ListingCardProps): JSX.Element
Renders: the cover photo (or a placeholder image if coverPhotoUrl is null), the listing title, the location, the base price formatted as a dollar amount per night, and an "Instant Book" badge if listing.instantBook is true. The entire card is clickable and calls onClick.
src/components/PhotoCarousel.tsx
interface PhotoCarouselProps {
photos: Array<{ storageUrl: string; displayOrder: number }>
}
export function PhotoCarousel({ photos }: PhotoCarouselProps): JSX.Element
Renders photos sorted by displayOrder with previous and next navigation controls. If photos is empty, renders a placeholder. Shows the current photo index and total count.
Step 3: Pages
src/pages/ListingsSearchPage.tsx
Replace the placeholder at / with this page. It contains:
- A search bar with: a location text input, a date range picker (check-in and check-out date inputs), a guest count stepper (minimum 1), and a price range control with minimum and maximum inputs.
- A "Search" button that applies the current filter values to
useListings.
- A grid of
ListingCard components driven by the listings returned from useListings.
- When
loading is true, show a loading state in the grid area.
- When
listings is empty and loading is false, display: "No listings found. Try adjusting your filters."
- Clicking a
ListingCard navigates to /listings/:id, passing the selected checkIn, checkOut, and numGuests values as URL query parameters so the booking panel (built in Prompt 4) can pre-populate them.
src/pages/ListingDetailPage.tsx
Replace the placeholder at /listings/:id with this page. It contains:
- A
PhotoCarousel with all listing photos.
- The listing title, location, description, maximum guests, and base price per night.
- A list of amenities.
- House rules text (if present).
- The cancellation policy label: "Flexible", "Moderate", or "Strict".
- A "Book" button / booking panel area — for now, render the button but it does not need to do anything yet. It will be fully wired up in Prompt 4. Pre-populate the date and guest inputs from the URL query parameters (
checkIn, checkOut, numGuests) if present.
- If
loading is true, show a page-level loading state.
- If
listing is null after loading completes, show: "This listing is not available."
Check before continuing (do this yourself, do not paste it):
- Visit the Lovable preview URL at
/. The search page should display. If no listings exist yet in the database, the grid should show "No listings found."
- Using the Supabase dashboard SQL editor, insert a test listing with
status = 'active' directly into the listings table. Reload the search page — the listing card should appear.
- Click the listing card and confirm you are taken to the detail page at
/listings/:id. The page should show the listing title, location, and the message "No listings found" area replaced by listing detail. If something is wrong: tell Lovable exactly which element is missing or incorrect.
Prompt 4
Run this after Prompt 3 is complete.
This prompt builds the guest booking flow including Stripe payment collection, the booking history page, and host approval controls. It also creates four Edge Functions: the booking creation logic, the Stripe webhook handler, the booking cancellation handler, and the host approval/decline handler, plus the host Stripe Connect onboarding flow. When this prompt is complete, a guest can book a listing end-to-end, and a host can approve or decline manual-approval bookings. You do not need to understand the instructions below.
Paste everything below into your AI coding tool:
You are building Homestay, a two-sided short-term rental marketplace. The database schema, RLS policies, auth flows, profile page, listing search, and listing detail page from Prompts 1–3 are already in place. This prompt builds the booking flow, Stripe payment integration, and host Connect onboarding.
Use Plan mode before generating any component that reads from or writes to the bookings table. Confirm the queries align with the RLS policies: guests can only read their own bookings; all booking mutations go through Edge Functions using the service role.
Do not build the host listing editor, availability rules, pricing overrides, scheduled jobs, or admin screens in this prompt.
Step 1: Edge Functions
All Edge Functions in this prompt must:
- Import from
../_shared/supabase-clients.ts, ../_shared/auth.ts, ../_shared/vault.ts, ../_shared/resend.ts, ../_shared/errors.ts, and ../_shared/validation.ts as appropriate.
- Set CORS headers on all responses, allowing requests from the production frontend domain (read
FRONTEND_URL from Vault or Deno.env).
- Return
errorResponse for all error cases, successResponse for all success cases.
- Never return internal error details, stack traces, Postgres error codes, or Stripe error objects to the caller.
supabase/functions/booking-create/index.ts
Define the request schema using Zod:
listingId: UUID string
checkIn: string matching the pattern for an ISO date (four digits, hyphen, two digits, hyphen, two digits)
checkOut: string matching the same ISO date pattern
numGuests: integer, minimum 1
stripePaymentMethodId: non-empty string
Processing sequence — implement in this exact order:
Rate limit check: using the service client, count rows in booking_rate_limit_checks for this user where attempted_at > now() - interval '60 seconds'. If count ≥ 10, return errorResponse("Too many booking attempts. Please wait a moment and try again.", "RATE_LIMITED", 429). Otherwise, insert a new row recording this attempt.
Call requireAuth(req) to get { userId, roles }. Return an unauthorized response if it throws AuthError.
Validate the request body with the Zod schema. Return a validation error response if it throws ValidationError.
Additional validation (after schema parse):
checkOut must be strictly after checkIn — if not, return errorResponse("Check-out date must be after check-in date.", "INVALID_DATES", 400)
- The number of nights must not exceed 365 — if it does, return
errorResponse("Maximum stay is 365 nights.", "STAY_TOO_LONG", 400)
Fetch the listings row for listingId via service client. If not found or status != 'active', return errorResponse("Listing not found.", "LISTING_NOT_FOUND", 404).
Validate numGuests <= listings.max_guests — if not, return errorResponse("Guest count exceeds the listing's maximum.", "GUEST_COUNT_EXCEEDED", 400).
Validate availability rules for the listing. Using the service client, fetch all availability_rules rows for this listing. Check in this order:
- For each rule of type
blocked_range: if any night in the requested range falls within blocked_start to blocked_end (inclusive), return errorResponse("Some of the selected dates are blocked by the host.", "DATES_BLOCKED", 409).
- For the rule of type
min_stay (if any): if the number of requested nights is less than min_stay_nights, return errorResponse("This listing requires a minimum stay of N nights.", "MIN_STAY_REQUIRED", 400) (substitute N with the actual value).
- For the rule of type
checkin_days (if any): if the check-in day of the week is not permitted by checkin_days_bitmask, return errorResponse("Check-in is not permitted on the selected day.", "CHECKIN_DAY_NOT_PERMITTED", 400).
Retrieve SERVICE_FEE_PERCENTAGE from Vault. Call get_pricing_for_range(listingId, checkIn, checkOut) via the service client. Sum all returned price_cents values to get subtotalCents. Calculate serviceFeeCents = Math.floor(subtotalCents * percentage / 100). totalCents = subtotalCents + serviceFeeCents.
Insert a booking row via the service client with: listing_id, guest_user_id = userId, check_in_date, check_out_date, num_guests, total_price_cents = totalCents, service_fee_cents = serviceFeeCents, status = 'pending'. If the insert fails due to the GiST exclusion constraint (Postgres error code 23P01), return errorResponse("These dates are not available. Please select different dates.", "DATES_UNAVAILABLE", 409). Capture the new bookingId from the insert result.
Retrieve STRIPE_SECRET_KEY from Vault. Fetch the host's stripe_connect_account_id from the profiles table for listings.host_user_id.
Create a Stripe PaymentIntent:
- Amount:
totalCents
- Application fee amount:
serviceFeeCents
- Transfer destination: host's
stripe_connect_account_id
- Payment method:
stripePaymentMethodId
- If
listings.instant_book = true: confirm the PaymentIntent immediately with automatic capture
- If
listings.instant_book = false: create with manual capture (status will be requires_capture); do not confirm yet
- On any Stripe error: call
cancel_pending_booking(bookingId) via service client, then return errorResponse("Payment could not be processed. Please check your card details.", "PAYMENT_FAILED", 402)
If instant_book = true:
- Call
confirm_booking(bookingId, paymentIntentId) via service client
- Send a booking confirmation email to the guest via
sendEmail with subject "Your booking is confirmed" and relevant booking details in the body
- Return
successResponse({ bookingId, status: 'confirmed' }, 201)
If instant_book = false:
- Update the booking row:
status = 'pending_approval', stripe_payment_intent_id = paymentIntentId
- Send an "awaiting host approval" email to the guest
- Send a "new booking request" email to the host (fetch host email from their profile row)
- Return
successResponse({ bookingId, status: 'pending_approval' }, 201)
supabase/functions/stripe-webhook/index.ts
Processing sequence:
Read the raw request body as text (required for webhook signature verification — do not parse as JSON first).
Retrieve STRIPE_WEBHOOK_SIGNING_SECRET from Vault.
Verify the Stripe webhook signature using the raw body text and the signing secret. On verification failure: log the failure details server-side, then return successResponse({}) with status 200. This returns 200 (not an error status) because a non-200 response would cause Stripe to retry the event, and the request may not be from Stripe.
Parse the event from the raw body text as JSON.
Check the stripe_webhook_events table via service client for an existing row with stripe_event_id = event.id. If found, return successResponse({}) immediately (idempotency).
Insert a row into stripe_webhook_events: { stripe_event_id: event.id, event_type: event.type }.
Dispatch on event.type:
payment_intent.succeeded: Extract the paymentIntentId from the event. Query bookings for a row where stripe_payment_intent_id = paymentIntentId and status = 'confirmed'. If no such row exists, call confirm_booking(bookingId, paymentIntentId) for the matching pending booking row, send a confirmation email, and log an anomaly entry server-side (the Edge Function log is sufficient).
payment_intent.payment_failed: Find the matching booking by stripe_payment_intent_id. Update status = 'payment_failed'. Send a payment failure notification email to the guest.
transfer.failed: Find the matching booking by stripe_transfer_id. Update payout_status = 'failed'. Send an operator alert email via sendEmail to OPERATOR_ALERT_EMAIL (retrieved from Vault).
account.updated: Extract the connected account ID from the event. Update profiles where stripe_connect_account_id = event.account: set host_onboarding_status = 'verified'.
On any database error during dispatch: return a non-200 response so Stripe retries.
On success: return successResponse({}).
supabase/functions/booking-cancel/index.ts
Define the request schema using Zod:
bookingId: UUID string
initiatedBy: one of the values 'guest' or 'host'
Processing sequence:
requireAuth(req) → { userId, roles }. Return unauthorized on AuthError.
- Validate request body. Return validation error on
ValidationError.
- Fetch the booking row via service client. If not found or
status != 'confirmed', return errorResponse("Booking not found or cannot be cancelled.", "BOOKING_NOT_CANCELLABLE", 404).
- Fetch the listing row to get
host_user_id and cancellation_policy.
- If
initiatedBy = 'host': assert userId === listing.host_user_id. If not, return errorResponse("Not authorised to cancel this booking.", "FORBIDDEN", 403).
- If
initiatedBy = 'guest': assert userId === booking.guest_user_id. If not, return errorResponse("Not authorised to cancel this booking.", "FORBIDDEN", 403).
- Calculate refund amount:
- If
initiatedBy = 'host': refundAmountCents = booking.total_price_cents (full refund, unconditional)
- If
initiatedBy = 'guest':
- Days until check-in = difference between
check_in_date and today's date
- Policy
flexible: full refund if days until check-in ≥ 1, else 50% of total_price_cents
- Policy
moderate: full refund if days until check-in ≥ 5, else 50% of total_price_cents
- Policy
strict: 50% of total_price_cents if days until check-in ≥ 7, else 0
- Retrieve
STRIPE_SECRET_KEY from Vault. Call the Stripe Refund API with refundAmountCents and the booking's stripe_payment_intent_id. On Stripe failure: return errorResponse("Refund could not be processed. Please contact support.", "REFUND_FAILED", 500) — do not update the booking status.
- Update booking:
status = 'cancelled_by_host' or 'cancelled_by_guest'; cancellation_reason = initiatedBy.
- Send cancellation email to the guest. If
initiatedBy = 'guest', also send a notification email to the host.
- Return
successResponse({ bookingId, refundAmountCents }).
supabase/functions/booking-approve/index.ts
Define the request schema using Zod:
bookingId: UUID string
action: one of the values 'approve' or 'decline'
Processing sequence:
requireAuth(req) → { userId, roles }. Return unauthorized on AuthError.
- Validate request body.
- Fetch the booking row via service client. Assert
status = 'pending_approval'. If not, return errorResponse("Booking is not awaiting approval.", "INVALID_STATUS", 409).
- Fetch the listing. Assert
userId === listing.host_user_id. If not, return errorResponse("Not authorised.", "FORBIDDEN", 403).
- Retrieve
STRIPE_SECRET_KEY from Vault.
- If
action = 'approve':
- Capture the Stripe PaymentIntent identified by
booking.stripe_payment_intent_id
- On Stripe failure: return
errorResponse("Payment capture failed.", "PAYMENT_CAPTURE_FAILED", 500)
- Call
confirm_booking(bookingId, paymentIntentId) via service client
- Send booking confirmation email to the guest
- If
action = 'decline':
- Cancel the Stripe PaymentIntent
- Call
cancel_pending_booking(bookingId) via service client
- Send a booking decline email to the guest
- Return
successResponse({ bookingId, status: action === 'approve' ? 'confirmed' : 'declined' }).
supabase/functions/host-onboarding/index.ts
Handles two sub-routes via the action query parameter.
action = 'create-link':
requireAuth(req) → { userId }. Return unauthorized on AuthError.
- Retrieve
STRIPE_SECRET_KEY and FRONTEND_URL from Vault.
- Fetch the caller's profile row via service client.
- If
stripe_connect_account_id is null or empty: create a new Stripe Connect account. Store the resulting account ID on the profile: update profiles.stripe_connect_account_id.
- Create a Stripe Connect hosted onboarding link for the account, with:
- Return URL:
{FRONTEND_URL}/host/onboarding-complete
- Refresh URL:
{FRONTEND_URL}/host/onboarding
- Update profile:
host_onboarding_status = 'pending_verification'. Add 'host' to the roles array if not already present (use a Postgres array append operation).
- Return
successResponse({ onboardingUrl }).
action = 'complete':
requireAuth(req) → { userId }. Return unauthorized on AuthError.
- Fetch profile. If
host_onboarding_status = 'verified', return successResponse({ status: 'verified' }) immediately.
- Return
successResponse({ status: 'pending_verification' }). The actual status update to verified arrives via the stripe-webhook account.updated event.
Step 2: Stripe Frontend Library
src/lib/stripe.ts
Export:
export const stripePromise: Promise<Stripe | null>
Initialize using loadStripe from the Stripe.js library with import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY. This must be a module-level singleton — do not call loadStripe inside a component render function.
Step 3: Booking Hooks
src/hooks/useBooking.ts
Export:
export interface BookingRequest {
listingId: string
checkIn: string
checkOut: string
numGuests: number
stripePaymentMethodId: string
}
export interface BookingResult {
bookingId: string
status: 'confirmed' | 'pending_approval'
}
export function useBooking(): {
createBooking: (req: BookingRequest) => Promise<BookingResult>
cancelBooking: (bookingId: string, initiatedBy: 'guest' | 'host') => Promise<{ refundAmountCents: number }>
loading: boolean
error: string | null
}
Behaviour:
createBooking invokes the booking-create Edge Function with the request payload. The call must include the authenticated user's JWT. On success, returns BookingResult. On error, throws with a user-readable message mapped from these error codes: DATES_UNAVAILABLE → "These dates are not available. Please select different dates.", PAYMENT_FAILED → "Payment could not be processed. Please check your card details.", RATE_LIMITED → "You're making too many booking attempts. Please wait a moment.", MIN_STAY_REQUIRED and CHECKIN_DAY_NOT_PERMITTED → use the message from the error response directly.
cancelBooking invokes the booking-cancel Edge Function. On success, returns the refund amount. On error, throws with a user-readable message.
loading reflects whether either function is in progress.
error holds the last error message, or null.
src/hooks/useGuestBookings.ts
Export:
export interface GuestBooking {
id: string
listingId: string
listingTitle: string
checkIn: string
checkOut: string
numGuests: number
totalPriceCents: number
serviceFeeCents: number
status: string
payoutStatus: string
createdAt: string
}
export function useGuestBookings(): {
bookings: GuestBooking[]
loading: boolean
error: string | null
}
Behaviour:
- Queries PostgREST for rows in
bookings joined with listings (to get the listing title) where guest_user_id = auth.uid(), ordered by created_at descending. RLS ensures guests can only see their own bookings.
Step 4: Booking UI Components and Pages
src/components/BookingPanel.tsx
interface BookingPanelProps {
listing: ListingDetail
checkIn: string
checkOut: string
numGuests: number
onCheckInChange: (date: string) => void
onCheckOutChange: (date: string) => void
onNumGuestsChange: (count: number) => void
}
export function BookingPanel(props: BookingPanelProps): JSX.Element
Renders:
- Date inputs for check-in and check-out (pre-populated from props, editable).
- A guest count stepper bounded by 1 and
listing.maxGuests.
- A price breakdown section: calculated per-night cost, number of nights, service fee (displayed as a percentage), and total. Recalculates when dates or guest count change. Use the
listing.basePriceCents for the per-night estimate — the exact amount is calculated server-side; display this as an estimate.
- A Stripe
CardElement wrapped in a Stripe Elements provider using stripePromise. The card element must be rendered inside Stripe's hosted iframe — never collect raw card data outside it.
- A "Confirm Booking" button. On click:
- Call
stripe.createPaymentMethod({ type: 'card', card: cardElement }) to get a paymentMethodId
- Call
useBooking().createBooking(...) with the paymentMethodId
- On success with
status = 'confirmed': show "Booking confirmed! Check your email." message
- On success with
status = 'pending_approval': show "Booking request sent! The host will review your request within 24 hours."
- On error: display the error message returned from
useBooking
- While
useBooking().loading is true, disable the button and show a loading indicator.
Wire the BookingPanel into ListingDetailPage: render it below the listing description, pre-populated with the checkIn, checkOut, and numGuests values from the URL query parameters.
src/pages/GuestBookingsPage.tsx
Replace the placeholder at /bookings. Shows the authenticated guest's booking history from useGuestBookings. For each booking:
- Display: listing title, check-in and check-out dates, number of guests, total price (formatted as dollars), status label, and payout status.
- If
status = 'confirmed', show a "Cancel Booking" button. On click, show a confirmation dialog that displays the refund amount estimate based on the listing's cancellation policy (use the policy from the booking's listing). On confirm, call useBooking().cancelBooking(bookingId, 'guest'). Show the refund amount in a success message after cancellation.
- If
status = 'pending_approval', show a "Pending host approval" label. No cancel action from this page.
src/pages/host/HostOnboardingPage.tsx
Replace the placeholder at /host/onboarding and /host/onboarding-complete. Shows the current hostOnboardingStatus from useAuth():
- If
not_started or pending_verification: display an explanation of why Stripe Connect onboarding is required, and a "Connect with Stripe" button that calls the host-onboarding Edge Function with action = 'create-link' and then redirects the browser to the returned onboardingUrl.
- If
verified: display a success state — "Your account is verified. You can now publish listings."
- On the
/host/onboarding-complete route, call the Edge Function with action = 'complete' on mount and call useAuth().refreshUser() to pick up any status changes, then display the appropriate state.
Step 5: Stripe Webhook Setup Note
After deploying the stripe-webhook Edge Function, the function's URL is available in the Supabase dashboard under Edge Functions. You will need to register this URL as a Stripe webhook endpoint. Complete the following steps now in your Stripe test-mode dashboard:
- Go to Stripe → Developers → Webhooks → Add endpoint
- Set the endpoint URL to the
stripe-webhook Edge Function URL from your Supabase homestay-dev project
- Select these events:
payment_intent.succeeded, payment_intent.payment_failed, transfer.failed, account.updated
- Copy the signing secret and update the
STRIPE_WEBHOOK_SIGNING_SECRET value in your homestay-dev Supabase Vault
Check before continuing (do this yourself, do not paste it):
- Using a Stripe test card number, complete a booking on an instant-book listing end-to-end. Confirm the booking appears in the guest bookings page at
/bookings with status = confirmed.
- Cancel the confirmed booking from the guest bookings page. Confirm the status changes to
cancelled_by_guest and a refund amount is shown.
- In Stripe's test dashboard, use the webhook event simulator to send a
payment_intent.succeeded event and confirm the stripe-webhook function receives it (check Supabase Edge Function logs — no errors should appear).
- Begin the host Stripe Connect onboarding flow at
/host/onboarding and confirm you are redirected to Stripe's hosted onboarding page. If something is wrong: paste the error from the Supabase Edge Function logs into Lovable and ask it to fix the specific step.
Prompt 5
Run this after Prompt 4 is complete.
This prompt builds the complete host-side experience: a dashboard for managing listings, a full listing editor with photos, amenities, availability rules, and pricing overrides, and a host bookings management page. When this prompt is complete, a verified host can create and publish a listing, manage all its settings, and respond to booking requests. You do not need to understand the instructions below.
Paste everything below into your AI coding tool:
You are building Homestay, a two-sided short-term rental marketplace. Prompts 1–4 are complete: the database, auth, listing browse, booking flow, and host onboarding are all in place. This prompt builds the host-side listing management dashboard.
Use Plan mode before generating any component that reads from or writes to listings, listing_photos, listing_amenities, availability_rules, or pricing_overrides. Confirm each query aligns with the RLS policies for those tables — hosts can only access records linked to listings they own.
Do not build scheduled background jobs, admin screens, or CCPA flows in this prompt.
Step 1: Edge Function
supabase/functions/listing-photo-confirm/index.ts
Define the request schema using Zod:
listingId: UUID string
storagePath: non-empty string (the path within the photos bucket, e.g. pending/{listingId}/{uuid})
displayOrder: integer, minimum 0
Processing sequence:
requireAuth(req) → { userId }. Return unauthorized on AuthError.
- Validate request body. Return validation error on
ValidationError.
- Fetch the
listings row for listingId via service client. Assert listing.host_user_id === userId. If not, return errorResponse("Not authorised.", "FORBIDDEN", 403).
- Verify the Storage object exists at
storagePath in the photos bucket using the service client. If it does not exist, return errorResponse("Photo not found in storage. Please try uploading again.", "PHOTO_NOT_FOUND", 404).
- Construct the destination path by replacing the
pending/ prefix in storagePath with photos/ (e.g. pending/{listingId}/{uuid} → photos/{listingId}/{uuid}). Move the object by copying it to the new path and then deleting the source.
- Insert a row into
listing_photos: { listing_id: listingId, storage_path: destinationPath, display_order: displayOrder }. Capture the new photoId.
- Return
successResponse({ photoId, storagePath: destinationPath }).
Step 2: Host Data Hooks
src/hooks/useHostListings.ts
Export:
export interface HostListingSummary {
id: string
title: string
location: string
status: string
basePriceCents: number
instantBook: boolean
createdAt: string
}
export interface CreateListingFields {
title: string
description?: string
location: string
latitude?: number
longitude?: number
maxGuests: number
basePriceCents: number
cancellationPolicy: string
instantBook: boolean
houseRules?: string
}
export function useHostListings(): {
listings: HostListingSummary[]
loading: boolean
error: string | null
createListing: (fields: CreateListingFields) => Promise<{ id: string }>
updateListing: (id: string, fields: Partial<CreateListingFields>) => Promise<void>
setListingStatus: (id: string, status: 'active' | 'inactive') => Promise<void>
}
Behaviour:
- Fetches all listings for the authenticated user via PostgREST where
host_user_id = auth.uid(). The RLS SELECT (own) policy on listings makes this work — it returns drafts, active, and inactive listings.
createListing inserts a new listing row via PostgREST with status = 'draft'. RLS enforces that host_user_id is set to auth.uid().
updateListing patches the listing row via PostgREST. RLS enforces host ownership.
setListingStatus patches status on the listing. Before calling PostgREST for status = 'active', check the authenticated user's hostOnboardingStatus from useAuth(). If it is not 'verified', throw with message "You must complete Stripe Connect onboarding before publishing a listing." and do not make the API call.
src/hooks/useAvailabilityRules.ts
Export:
export interface AvailabilityRule {
id: string
listingId: string
ruleType: 'blocked_range' | 'min_stay' | 'checkin_days'
blockedStart?: string
blockedEnd?: string
minStayNights?: number
checkinDaysBitmask?: number
}
export function useAvailabilityRules(listingId: string): {
rules: AvailabilityRule[]
loading: boolean
addRule: (rule: Omit<AvailabilityRule, 'id' | 'listingId'>) => Promise<void>
deleteRule: (ruleId: string) => Promise<void>
}
Behaviour: All operations via PostgREST. RLS enforces host ownership through the listing_id → listings.host_user_id join. Refreshes the rules list after add or delete.
src/hooks/usePricingOverrides.ts
Export:
export interface PricingOverride {
id: string
listingId: string
overrideType: 'seasonal' | 'weekend'
seasonStart?: string
seasonEnd?: string
priceCents: number
}
export function usePricingOverrides(listingId: string): {
overrides: PricingOverride[]
loading: boolean
addOverride: (override: Omit<PricingOverride, 'id' | 'listingId'>) => Promise<void>
deleteOverride: (overrideId: string) => Promise<void>
}
Behaviour: All operations via PostgREST. Same RLS ownership model as availability rules.
src/hooks/useListingPhotos.ts
Export:
export function useListingPhotos(listingId: string): {
photos: Array<{ id: string; storageUrl: string; displayOrder: number }>
loading: boolean
uploadPhoto: (file: File, displayOrder: number) => Promise<void>
deletePhoto: (photoId: string, storagePath: string) => Promise<void>
reorderPhoto: (photoId: string, newDisplayOrder: number) => Promise<void>
}
Behaviour:
- Fetches
listing_photos rows for the listing via PostgREST, ordered by display_order. Generates signed read URLs for each photo.
uploadPhoto: (1) calls supabase.storage.createSignedUploadUrl targeting the path pending/{listingId}/{crypto.randomUUID()} in the photos bucket; (2) uploads the file directly to Storage using the signed upload URL; (3) calls the listing-photo-confirm Edge Function with the storage path and display order; (4) refreshes the photos list on success.
deletePhoto: deletes the listing_photos row via PostgREST (RLS enforces host ownership), then deletes the Storage object at storagePath.
reorderPhoto: patches display_order on the listing_photos row via PostgREST.
src/hooks/useAmenities.ts
Export:
export function useAmenities(listingId: string): {
amenities: Array<{ id: string; amenity: string }>
loading: boolean
addAmenity: (amenity: string) => Promise<void>
removeAmenity: (amenityId: string) => Promise<void>
}
Behaviour: All operations via PostgREST. RLS enforces host ownership.
src/hooks/useHostBookings.ts
Export:
export interface HostBooking {
id: string
guestUserId: string
guestName: string | null
checkIn: string
checkOut: string
numGuests: number
totalPriceCents: number
status: string
payoutStatus: string
createdAt: string
}
export function useHostBookings(listingId: string): {
bookings: HostBooking[]
loading: boolean
error: string | null
approveBooking: (bookingId: string) => Promise<void>
declineBooking: (bookingId: string) => Promise<void>
cancelBooking: (bookingId: string) => Promise<void>
}
Behaviour:
- Fetches bookings from PostgREST for the given
listingId, joined with profiles to get full_name as guestName. The RLS host SELECT policy on bookings permits this — it allows hosts to see bookings for their own listings.
approveBooking: invokes the booking-approve Edge Function with { bookingId, action: 'approve' }. Refreshes the list on success.
declineBooking: invokes the booking-approve Edge Function with { bookingId, action: 'decline' }. Refreshes the list on success.
cancelBooking: invokes the booking-cancel Edge Function with { bookingId, initiatedBy: 'host' }. Refreshes the list on success.
Step 3: Host Pages
src/pages/host/HostDashboardPage.tsx
Replace the placeholder at /host. Displays a list of the host's listings using useHostListings. For each listing, show: title, location, status badge, base price per night. Link each listing to /host/listings/:id. Include a prominent "Create New Listing" button that navigates to /host/listings/new. Show a count of upcoming bookings per listing as a summary stat (query from useHostBookings for each listing, counting bookings with status = 'confirmed' and check_in_date in the future).
src/pages/host/ListingEditorPage.tsx
Replace the placeholders at /host/listings/new and /host/listings/:id. When the route is /host/listings/new, the page creates a new listing on first save. When the route includes an existing ID, the page loads and edits that listing.
Structure the page as a tabbed editor with these tabs:
Basic Info: Fields for title (required), description, location (required), latitude, longitude, maximum guests (required, integer), base price per night in dollars (required, stored as cents), house rules. Save button at the bottom.
Photos: Grid of current photos with drag-to-reorder (update displayOrder via reorderPhoto on drop). Each photo has a delete button. An upload button opens a file picker that calls useListingPhotos().uploadPhoto. Show upload progress. Photos display in displayOrder order.
Amenities: List of existing amenities with a remove button for each. An input to add a new amenity by text. Uses useAmenities.
Availability Rules: Shows current rules. Form to add a new rule with a type selector:
blocked_range: date range picker for start and end
min_stay: integer input for minimum nights
checkin_days: a set of day-of-week checkboxes (Sunday through Saturday) that encode into the checkinDaysBitmask value. Bit 0 = Sunday, bit 1 = Monday, through bit 6 = Saturday.
Delete button for each existing rule.
Pricing Overrides: Shows current overrides. Form to add a new override:
- Type
seasonal: date range for season start and end, price per night in dollars
- Type
weekend: price per night in dollars (no dates required)
Delete button for each existing override.
Settings: Instant book toggle. Cancellation policy selector (Flexible / Moderate / Strict). Listing status control:
- If
status = 'draft': "Publish Listing" button that calls setListingStatus('active'). If hostOnboardingStatus != 'verified', show a warning: "Complete Stripe Connect onboarding to publish this listing" and disable the button.
- If
status = 'active': "Deactivate Listing" button that calls setListingStatus('inactive').
- If
status = 'inactive': "Reactivate Listing" button that calls setListingStatus('active').
src/pages/host/HostBookingsPage.tsx
Replace the placeholder at /host/bookings. Shows all bookings across all of the host's listings. For each booking, display: listing title (link to the listing), guest name, check-in and check-out dates, number of guests, total price, status, and payout status. Action buttons:
- If
status = 'pending_approval': "Approve" and "Decline" buttons.
- If
status = 'confirmed': "Cancel Booking" button with a confirmation dialog.
Use useHostBookings — call it for each of the host's listings from useHostListings and combine the results.
Check before continuing (do this yourself, do not paste it):
- Sign in as a verified host (complete the Stripe Connect onboarding flow from Prompt 4 first). Navigate to
/host and confirm the dashboard loads with a "Create New Listing" button.
- Create a listing, fill in all required fields, and save. Navigate to the Photos tab and upload at least one image — confirm it appears in the grid after upload.
- Navigate to the Settings tab and click "Publish Listing". Confirm the status changes to
active. Then visit the public search page at / — the listing should appear in search results.
- Try publishing a listing while logged in as a user whose
host_onboarding_status is not verified. The button should be disabled with the warning message shown. If something is wrong: describe what is missing or broken and ask Lovable to fix that specific tab or action.
Prompt 6
Run this after Prompt 5 is complete.
This prompt creates the three scheduled background functions that run automatically: one that pays out hosts after guests check in, one that cancels booking requests that hosts do not respond to within 24 hours, and one that handles user data deletion and export requests overnight. When this prompt is complete, these processes run on their own schedules without any manual action. You do not need to understand the instructions below.
Paste everything below into your AI coding tool:
You are building Homestay, a two-sided short-term rental marketplace. Prompts 1–5 are complete: the full database, auth, listing management, and booking flows are in place. This prompt creates three scheduled Edge Functions that are triggered automatically by pg_cron. These functions have no frontend component.
Do not build any admin UI, CCPA request endpoints, or CI/CD configuration in this prompt.
Step 1: Scheduled Edge Functions
All three functions share the same caller-verification contract: before performing any work, each function must verify that the request was made by pg_cron by checking the Authorization header for a Bearer token matching the CRON_SECRET value retrieved from Vault. If the token does not match, return errorResponse("Unauthorized", "UNAUTHORIZED", 401) immediately.
All three functions must be idempotent: running them multiple times produces no additional side effects on records already processed (no double-payouts, no double-scrubs, no duplicate Stripe calls).
supabase/functions/payout-scheduler/index.ts
Processing sequence:
- Verify caller via
CRON_SECRET as described above.
- Log run start to the Edge Function console.
- Retrieve
STRIPE_SECRET_KEY from Vault once for the entire run — do not retrieve it per booking.
- Query via service client: all
bookings rows where status = 'confirmed' AND check_in_date < NOW() AND payout_status = 'pending'.
- For each booking in the result:
a. Fetch the host's
stripe_connect_account_id from profiles where id = listings.host_user_id for this booking's listing_id.
b. Calculate the transfer amount: booking.total_price_cents - booking.service_fee_cents.
c. Create a Stripe Transfer: amount = transfer amount, destination = stripe_connect_account_id. The transfer represents the host's payout after the service fee has been deducted.
d. On Stripe success: update bookings: payout_status = 'completed', stripe_transfer_id = {transferId}.
e. On Stripe failure: update bookings: payout_status = 'failed'. Retrieve OPERATOR_ALERT_EMAIL from Vault and send an alert email via sendEmail with the booking ID and the Stripe error message (log the raw Stripe error server-side; only include the booking ID in the email body). Log the Stripe error to the Edge Function console.
- Log run completion with counts: total processed, completed successfully, failed.
- Return
successResponse({ processed: totalCount, completed: completedCount, failed: failedCount }).
supabase/functions/booking-expire/index.ts
Processing sequence:
- Verify caller via
CRON_SECRET.
- Retrieve
STRIPE_SECRET_KEY from Vault.
- Query via service client: all
bookings rows where status = 'pending_approval' AND created_at < NOW() - interval '24 hours'.
- For each booking:
a. Cancel the Stripe PaymentIntent identified by
booking.stripe_payment_intent_id.
b. On Stripe failure: log the error to the console. Still proceed to step c — do not leave the booking in pending_approval even if Stripe cancellation fails. Log for operator follow-up.
c. Call cancel_pending_booking(bookingId) via service client.
d. Fetch the guest's email from profiles where id = booking.guest_user_id. Send a "Your booking request has expired — the host did not respond within 24 hours" email via sendEmail.
- Return
successResponse({ expired: count }).
supabase/functions/ccpa-scrubber/index.ts
Processing sequence:
- Verify caller via
CRON_SECRET.
- Process export requests first:
a. Query
ccpa_requests via service client where request_type = 'export' AND completed_at IS NULL.
b. For each request:
- Fetch the user's profile row and all their booking rows (excluding
stripe_payment_intent_id and stripe_transfer_id) via service client.
- Assemble a JSON object containing the profile data and booking data.
- Write the JSON as a file to the
ccpa-exports Storage bucket at path exports/{userId}/{requestId}.json using the service client.
- Generate a signed URL for this file with a 1-hour expiry using the service client.
- Send the signed URL to the user's current profile email via
sendEmail with subject "Your Homestay data export is ready".
- Update the
ccpa_requests row: completed_at = now().
- Process deletion requests in batches of 50:
a. Query
profiles via service client where deletion_status = 'pending' AND deletion_requested_at < NOW() - interval '30 days' LIMIT 50.
b. For each user in the batch:
- Call
scrub_user_pii(userId) via service client. This is committed individually — a failure on one user must not prevent others from being processed. Wrap this in a try/catch; on failure, log the error and continue.
- If the profile row has a non-null
avatar_url, attempt to delete the Storage object at that path. If deletion fails, log the error but do not abort.
- Update the matching
ccpa_requests row for this user (if any): completed_at = now().
c. After processing each batch, wait 100ms before querying the next batch (if more than 50 users are pending, loop with this sleep).
- Purge old webhook events:
Call
purge_old_webhook_events() via the service client. Log the count of deleted rows.
- Return
successResponse({ exportedCount, scrubbedCount, purgedWebhookEvents }).
Step 2: Verify pg_cron Registration
The pg_cron jobs were registered in the 008_cron_jobs.sql migration from Prompt 1. Verify they are active:
- In the Supabase dashboard for
homestay-dev, go to Database → Extensions and confirm pg_cron is enabled.
- Run this SQL in the Supabase SQL editor:
SELECT jobname, schedule, active FROM cron.job; — confirm three jobs appear: payout-scheduler, booking-expire, and ccpa-scrubber, all with active = true.
- If the jobs are not registered, re-run the
008_cron_jobs.sql migration manually via the SQL editor, substituting the correct Edge Function URLs for your homestay-dev project and the CRON_SECRET value from Vault.
Check before continuing (do this yourself, do not paste it):
- In the Supabase SQL editor, manually call
SELECT net.http_post(url := '{your-homestay-dev-edge-function-url}/payout-scheduler', headers := '{"Authorization": "Bearer {your-CRON_SECRET}"}', body := '{}') substituting real values. Then check the Supabase Edge Function logs — the function should log a run start and completion with zero records processed (since no payouts are due yet in your dev environment). If something is wrong: paste the error from the Edge Function logs into Lovable and ask it to fix the specific function.
- Create a test booking with a past check-in date and
payout_status = 'pending' directly in the database, then invoke payout-scheduler manually again. Confirm payout_status changes to completed in the bookings table.
- Confirm
booking-expire and ccpa-scrubber respond with a success body when invoked with the correct CRON_SECRET (zero records processed is acceptable for a fresh dev environment).
Prompt 7
Run this after Prompt 6 is complete.
This prompt adds the operator admin dashboard and the CCPA request flows for guests. When this prompt is complete, admin users can view all users, listings, and bookings; retry failed payouts; activate or deactivate any listing; and support agents can view everything but cannot make changes. Guests can request deletion or export of their data from the profile page. You do not need to understand the instructions below.
Paste everything below into your AI coding tool:
You are building Homestay, a two-sided short-term rental marketplace. Prompts 1–6 are complete. This prompt adds the admin and support operator interface and the CCPA data request endpoints for guests.
Use Plan mode before generating any component that reads from the admin Edge Function — confirm the queries use the service-role client, not PostgREST with the user's JWT.
Do not build CI/CD pipelines, security headers configuration, or deployment workflows in this prompt.
Step 1: Edge Functions
supabase/functions/admin/index.ts
All admin Edge Function routes share this authorization pattern:
requireAuth(req) → { userId, roles }. Return unauthorized on AuthError.
- If
roles does not include 'admin' or 'support': return errorResponse("Forbidden", "FORBIDDEN", 403).
- For any write action (those explicitly marked below): if
roles does not include 'admin', return errorResponse("Forbidden", "FORBIDDEN", 403). The support role is read-only.
The action is determined by the action query parameter on the request URL.
action = 'list-users' (read):
- Accept optional query parameters
email and deletionStatus for filtering.
- Query
profiles via service client. Apply filters if provided: email as a case-insensitive partial match on profiles.email; deletionStatus as an exact match on deletion_status.
- Return
successResponse({ users }).
action = 'list-listings' (read):
- Accept optional query parameters
hostUserId and status for filtering.
- Query all
listings rows via service client (any status). Apply filters if provided.
- Return
successResponse({ listings }).
action = 'list-bookings' (read):
- Accept optional query parameters
listingId, guestUserId, and status for filtering.
- Query all
bookings rows via service client. Apply filters if provided.
- Return
successResponse({ bookings }).
action = 'retry-payout' (write, admin only):
- Validate request body with Zod schema:
{ bookingId: uuid string }.
- Fetch booking via service client. Assert
payout_status = 'failed'. If not, return errorResponse("Payout is not in failed status.", "INVALID_STATUS", 409).
- Retrieve
STRIPE_SECRET_KEY from Vault.
- Fetch host's
stripe_connect_account_id from profiles.
- Create a Stripe Transfer: amount =
booking.total_price_cents - booking.service_fee_cents, destination = stripe_connect_account_id.
- On success: update booking
payout_status = 'completed', stripe_transfer_id. Return successResponse({ bookingId, stripeTransferId }).
- On failure: return
errorResponse("Payout retry failed. Check Stripe for details.", "PAYOUT_RETRY_FAILED", 500).
action = 'flag-listing' (write, admin only):
- Validate request body with Zod schema:
{ listingId: uuid string, active: boolean }.
- Update
listings.status to 'active' if active = true, or 'inactive' if active = false. Use the service client — bypasses the soft-delete trigger since this is an UPDATE, not a DELETE.
- Return
successResponse({ listingId, status }).
action = 'list-hosts-by-verification' (read):
- Accept optional query parameter
hostOnboardingStatus for filtering.
- Query
profiles via service client where 'host' = ANY(roles). Apply filter if provided.
- Return
successResponse({ hosts }).
supabase/functions/ccpa-request/index.ts
Define the request schema using Zod:
requestType: one of 'deletion' or 'export'
Processing sequence:
requireAuth(req) → { userId }. Return unauthorized on AuthError.
- Validate request body. Return validation error on
ValidationError.
- If
requestType = 'deletion':
- Query
ccpa_requests via service client for an existing row where user_id = userId AND request_type = 'deletion' AND completed_at IS NULL. If found: return errorResponse("A deletion request is already pending for your account.", "REQUEST_ALREADY_PENDING", 409).
- Insert into
ccpa_requests: { user_id: userId, request_type: 'deletion', requested_at: now() }.
- Update
profiles via service client: deletion_status = 'pending', deletion_requested_at = now().
- Send confirmation email to the user: "Your account deletion request has been received. Your data will be removed within 30 days."
- If
requestType = 'export':
- Insert into
ccpa_requests: { user_id: userId, request_type: 'export', requested_at: now() }.
- Send confirmation email: "Your data export request has been received. You will receive a download link within 24 hours."
- Return
successResponse({ requestType, requestedAt: now().toISOString() }).
Step 2: Frontend Hooks
src/hooks/useAdmin.ts
Export:
export interface AdminUser {
id: string
email: string | null
fullName: string | null
roles: string[]
hostOnboardingStatus: string
deletionStatus: string
createdAt: string
}
export interface AdminListing {
id: string
title: string
hostUserId: string
status: string
location: string
createdAt: string
}
export interface AdminBooking {
id: string
listingId: string
guestUserId: string
checkIn: string
checkOut: string
status: string
payoutStatus: string
totalPriceCents: number
createdAt: string
}
export function useAdmin(): {
fetchUsers: (filters?: { email?: string; deletionStatus?: string }) => Promise<AdminUser[]>
fetchListings: (filters?: { hostUserId?: string; status?: string }) => Promise<AdminListing[]>
fetchBookings: (filters?: { listingId?: string; guestUserId?: string; status?: string }) => Promise<AdminBooking[]>
retryPayout: (bookingId: string) => Promise<{ stripeTransferId: string }>
flagListing: (listingId: string, active: boolean) => Promise<void>
loading: boolean
error: string | null
}
Behaviour: Each function invokes the admin Edge Function with the appropriate action query parameter and passes the filter values as query parameters or request body as needed. Includes the authenticated user's JWT in the request. On any error response with code FORBIDDEN, throw with message "You do not have permission to perform this action."
src/hooks/useCcpaRequest.ts
Export:
export function useCcpaRequest(): {
requestDeletion: () => Promise<void>
requestExport: () => Promise<void>
loading: boolean
error: string | null
}
Behaviour:
requestDeletion: invokes the ccpa-request Edge Function with requestType = 'deletion'. On REQUEST_ALREADY_PENDING error, sets error to "A deletion request is already pending for your account." without throwing.
requestExport: invokes the ccpa-request Edge Function with requestType = 'export'.
- On success, sets
error to null.
Step 3: Admin Pages
src/pages/admin/AdminDashboardPage.tsx
Replace the placeholder at /admin. Renders a top-level layout with navigation tabs for: Users, Listings, Bookings, Hosts. Each tab links to the corresponding admin sub-page. Show the current user's role as a label (Admin or Support).
src/pages/admin/AdminUsersPage.tsx
Replace the placeholder at /admin/users. Shows a filterable table of users from useAdmin().fetchUsers. Columns: email, full name, roles, onboarding status, deletion status, created date. Includes a filter input for email and a dropdown for deletion status. The table is read-only — no action buttons.
src/pages/admin/AdminListingsPage.tsx
Replace the placeholder at /admin/listings. Shows a filterable table of all listings from useAdmin().fetchListings. Columns: title, host ID, location, status, created date. Includes a status filter dropdown. For each listing, show an "Activate" or "Deactivate" button based on current status. These buttons call useAdmin().flagListing. Buttons are disabled (greyed out with a tooltip "Read-only access") for users whose role is support.
src/pages/admin/AdminBookingsPage.tsx
Replace the placeholder at /admin/bookings. Shows a filterable table of all bookings from useAdmin().fetchBookings. Columns: booking ID, listing ID, guest ID, check-in, check-out, status, payout status, total price. Includes filters for status and payout status. For bookings with payoutStatus = 'failed', show a "Retry Payout" button that calls useAdmin().retryPayout. The button is disabled for support role users.
src/pages/admin/AdminHostsPage.tsx
Replace the placeholder at /admin/hosts. Shows a filterable list of host profiles from useAdmin().fetchUsers filtered to include only users with 'host' in their roles, with an additional filter for hostOnboardingStatus. Read-only.
Step 4: Profile Page Updates
Update src/pages/ProfilePage.tsx to wire up the CCPA buttons that were placeholders in Prompt 2:
"Request account deletion" button: on click, show a confirmation dialog explaining that personal data will be deleted within 30 days and that booking history is retained for legal purposes. On confirm, call useCcpaRequest().requestDeletion. On success, show a confirmation message: "Your deletion request has been received." Disable the button and show "Deletion requested" if profile.deletionStatus === 'pending'.
"Request my data export" button: on click, call useCcpaRequest().requestExport. On success, show: "Your data export request has been received. You will receive an email with a download link within 24 hours."
Both buttons show a loading state while useCcpaRequest().loading is true.
Check before continuing (do this yourself, do not paste it):
- Sign in as a user with the
admin role (set this directly in the Supabase dashboard by updating the roles column on the profile row). Navigate to /admin/users — you should see the users table populated.
- Navigate to
/admin/listings and deactivate a listing using the Deactivate button. Confirm the listing no longer appears in the public search results at /.
- Sign in as a user with the
support role. Navigate to /admin/bookings — the "Retry Payout" button should be visible but disabled. Attempting to call the action directly should return a Forbidden error (check Edge Function logs). If something is wrong: describe the specific button or page that is incorrect and ask Lovable to fix it.
- From the profile page as a regular guest, click "Request account deletion" and confirm the dialog. Verify
deletion_status changes to pending in the Supabase dashboard under the profiles table.
Prompt 8
Run this after Prompt 7 is complete.
This prompt sets up the automated deployment pipelines, security headers, and test suite. When this prompt is complete, every code change is automatically tested before it can be merged, staging deploys happen automatically, production deploys require manual approval, and the production site returns the security headers required for PCI and general web security. You do not need to understand the instructions below.
Paste everything below into your AI coding tool:
You are building Homestay, a two-sided short-term rental marketplace. Prompts 1–7 are complete: the full product is built. This prompt adds the GitHub Actions CI/CD pipelines, integration tests, smoke tests, and Netlify security headers configuration. No new product features are added in this prompt.
Do not modify any existing Edge Function, page, hook, or migration file. Only create new files for workflows, tests, and deployment configuration.
Step 1: Integration Tests
supabase/tests/rls.test.ts
Write integration tests using the Deno test runner. These tests run against a local Supabase instance (started by the CI workflow). Each test must authenticate as a specific user using the Supabase JS client and assert the exact data visible from that perspective.
Tests to implement:
Unauthenticated access to listings: An unauthenticated client (anon key only) queries the listings table. Assert it receives only rows with status = 'active'. Assert it receives zero rows from bookings. Assert it receives zero rows from profiles.
Profile isolation: User A (authenticated) queries the profiles table. Assert it receives exactly their own profile row and no other rows.
Booking isolation: User A queries the bookings table. Assert it receives only bookings where guest_user_id matches User A's ID. Assert it receives zero rows from User B's bookings.
Host listing access: Host A queries the listings table. Assert it receives their own listings of any status (including drafts). Assert it does not receive Host B's draft listings.
Direct booking insert blocked: Authenticated User A attempts to insert a row directly into the bookings table via PostgREST. Assert the operation fails — no row should be inserted.
Listing soft delete: Host A issues a DELETE on one of their own listing rows via PostgREST. Assert the row still exists in the database. Assert the row's status is 'inactive'.
supabase/tests/booking-lock.test.ts
Write a concurrency test using the Deno test runner:
- Create a test listing with
instant_book = true and status = 'active' via the service-role client.
- Fire two concurrent
booking-create Edge Function invocations for the same listing and overlapping dates (use Promise.all to make them truly concurrent).
- Assert that exactly one response has a success status (status code in the 2xx range) with
status in the response body equal to 'confirmed' or 'pending_approval'.
- Assert that the other response has an error status with error code
DATES_UNAVAILABLE or BOOKING_CONFLICT.
- Query the
bookings table directly via service client. Assert exactly one row exists for that listing and date range with a status that is not 'declined'.
supabase/tests/ccpa.test.ts
Write an end-to-end CCPA test using the Deno test runner:
- Create a test user via the Supabase auth admin API (service role) with a known email.
- Insert a booking row for that user via service client.
- Call the
ccpa-request Edge Function as that user with requestType = 'deletion'. Assert a success response.
- Directly invoke
scrub_user_pii(userId) via the service client (bypassing the 30-day wait that applies in production).
- Query
profiles for that user. Assert full_name = '[deleted]', email = '[deleted]', phone IS NULL, avatar_url IS NULL, deletion_status = 'completed'.
- Query
bookings for that user's guest_user_id. Assert the booking row still exists (the row must be retained even after PII scrubbing).
supabase/tests/smoke.ts
Write smoke tests to run against a deployed environment (staging or production URL passed as an environment variable SMOKE_TEST_URL):
- A GET request to the PostgREST
listings endpoint at {SMOKE_TEST_URL}/rest/v1/listings. Assert a successful response with a JSON array body.
- A GET request to each deployed Edge Function URL (booking-create, stripe-webhook, booking-cancel, booking-approve, host-onboarding, listing-photo-confirm, payout-scheduler, booking-expire, ccpa-scrubber, admin, ccpa-request) with no Authorization header. Assert the response is an unauthorized error response — not a server error and not a "function not found" response.
- A POST request to
{SMOKE_TEST_URL}/functions/v1/stripe-webhook with a deliberately malformed body and a fake signature header. Assert the response status is 200 (the webhook handler returns 200 even on signature failure to prevent Stripe retry loops).
Step 2: GitHub Actions Workflows
.github/workflows/pr.yml
Trigger: on pull request opened, synchronized, or reopened targeting the main branch.
Steps:
- Check out the repository.
- Install the Supabase CLI.
- Run
supabase db lint against all files in supabase/migrations/. Fail the workflow if any lint errors are reported.
- Run Deno unit tests:
deno test supabase/functions/ — this runs any *.test.ts files found within the Edge Function source directories.
- Start a local Supabase instance using
supabase start.
- Apply all migrations to the local instance using
supabase db push --local.
- Run integration tests: execute
supabase/tests/rls.test.ts, supabase/tests/booking-lock.test.ts, and supabase/tests/ccpa.test.ts using the Deno test runner, with the local Supabase URL and service-role key injected as environment variables.
- Stop the local Supabase instance using
supabase stop.
The workflow must fail on any step error. PRs cannot be merged if this workflow fails.
.github/workflows/staging-deploy.yml
Trigger: on push to the main branch.
Steps:
- Check out the repository.
- Install the Supabase CLI.
- Set the Supabase project to the staging project reference using
SUPABASE_ACCESS_TOKEN and SUPABASE_STAGING_PROJECT_REF from GitHub secrets.
- Run
supabase db push targeting the staging project.
- Run
supabase functions deploy for all Edge Functions in supabase/functions/, targeting the staging project.
- Run smoke tests: execute
supabase/tests/smoke.ts with SMOKE_TEST_URL set to SUPABASE_STAGING_URL from GitHub secrets.
- Send a deployment notification email via Resend regardless of success or failure. The email goes to
OPERATOR_ALERT_EMAIL (stored as a GitHub secret) with subject "Homestay staging deploy: succeeded" or "Homestay staging deploy: failed" and the workflow run URL in the body. Use the Resend API directly from the workflow using RESEND_API_KEY from GitHub secrets.
.github/workflows/production-deploy.yml
Trigger: manual (workflow_dispatch only).
Environment: production — this environment requires reviewer approval in GitHub before the deploy steps execute.
Steps:
- Check out the repository.
- Install the Supabase CLI.
- Set the Supabase project to the production project reference.
- Run
supabase db push targeting the production project using SUPABASE_PROD_PROJECT_REF and SUPABASE_ACCESS_TOKEN.
- Run
supabase functions deploy for all Edge Functions, targeting the production project.
- Trigger the Netlify deploy hook by sending a POST request to the URL stored in
NETLIFY_DEPLOY_HOOK GitHub secret. This causes Netlify to build and deploy the frontend with production environment variables.
- Wait 60 seconds for the Netlify build to complete (use a fixed sleep — no webhook confirmation needed at this stage).
- Run smoke tests: execute
supabase/tests/smoke.ts with SMOKE_TEST_URL set to SUPABASE_PROD_URL.
- Send a deployment notification email on success or failure (same pattern as the staging workflow).
After setting up this workflow, add the NETLIFY_DEPLOY_HOOK secret to GitHub Actions. Find this value in your Netlify site → Site Settings → Build & Deploy → Build hooks → Create a hook named "GitHub Actions Production Deploy" and copy the URL.
Step 3: Security Headers
netlify.toml
Create this file in the project root. It configures security response headers for all responses served by the Netlify production site.
The headers to apply to all paths (/*):
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: a policy that allows scripts from 'self' and https://js.stripe.com; allows frames from https://js.stripe.com; allows connections to 'self', the Supabase project URL, and https://api.stripe.com; allows images from 'self', data:, and the Supabase Storage URL; allows styles from 'self' and 'unsafe-inline'; blocks everything else by default
The Supabase project URL and Supabase Storage URL must be injected as Netlify build environment variables during the Netlify deploy step. Configure the netlify.toml to reference these as environment variables using Netlify's [build.environment] section, and reference them in the Content-Security-Policy header value. Name the variables VITE_SUPABASE_URL (already configured in Netlify from the setup steps) — the storage URL follows the pattern {VITE_SUPABASE_URL}/storage/v1.
Also configure the Netlify build command and publish directory in netlify.toml:
- Build command:
npm run build
- Publish directory:
dist
Step 4: Post-Setup Instructions
After this prompt is complete, perform these final setup steps manually:
In GitHub → repository Settings → Secrets and Variables → Actions, add NETLIFY_DEPLOY_HOOK with the URL from your Netlify site build hook (created during the production deploy workflow setup above).
In your Stripe live-mode dashboard → Developers → Webhooks, create an endpoint pointing to the stripe-webhook Edge Function URL from your homestay-prod Supabase project. Add the same four events as the test-mode webhook. Copy the live signing secret into the STRIPE_WEBHOOK_SIGNING_SECRET Vault entry in homestay-prod.
Trigger the production deploy workflow manually from GitHub → Actions → production-deploy → Run workflow. Approve the deployment when the reviewer approval prompt appears.
Check before continuing (do this yourself, do not paste it):
- Open a pull request against
main in your GitHub repository (any small change). Confirm the PR workflow runs and all steps pass — the checks should appear on the PR page with green checkmarks.
- Merge the PR to
main. Confirm the staging deploy workflow runs automatically, deploys to staging, and sends a notification email to your operator alert address.
- After the staging deploy succeeds, manually trigger the production deploy workflow from GitHub Actions. Approve the reviewer prompt. Confirm the workflow completes and the Netlify site is updated.
- Visit the production Netlify site URL. Open your browser's developer tools → Network tab, click any request, and look at the response headers. Confirm you can see
Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, and Content-Security-Policy headers present on the response. If something is wrong: check the Netlify deploy log for errors and tell Lovable which header is missing or which workflow step failed.