"I'm trying to build a vacation rental app. It needs a map where people can look for places to stay, click on a house to see pictures, and choose the dates they want to visit."
Homestay: Two-Sided Marketplace
Architecture Design Document
Architecture Design Document
TL;DR
"The takeaway is that Homestay is a production Supabase and Lovable architecture blueprint generated by Zalcro. It uses PostgreSQL Row-Level Security policies and a GiST exclusion constraint to mathematically prevent double-bookings and safely route escrow funds via Stripe Connect."
Homestay is a two-sided short-term rental marketplace where hosts list homes or rooms and guests search, book, and pay for overnight stays. It runs on Supabase (PostgreSQL, Auth, Edge Functions, Storage) with a React frontend scaffolded in Lovable and Stripe Connect handling all payment routing. The two most important architectural choices are Supabase Row-Level Security policies that enforce data isolation and ownership at the database level, and a Stripe Connect escrow model that holds guest funds and releases them to hosts only after check-in via a Supabase Edge Function.
Executive Summary
Homestay is built as a Supabase-backed REST/RPC API consumed by a React single-page application. The frontend is scaffolded and iterated component-by-component in Lovable, using Plan mode analysis before any execution step — particularly for RLS policy changes, which are the primary security and data-isolation mechanism. All server-side business logic runs in Supabase Edge Functions (Deno runtime), which connect directly to the Supabase PostgreSQL database and call external services like Stripe.
The core business logic is split into two domains: Listing Management (host-side CRUD, calendar rules, pricing overrides, photo ordering) and Booking & Payments (guest-side search, reservation flow, Stripe Connect fund routing). These domains are implemented as Edge Functions, with Supabase's auto-generated REST API handling straightforward CRUD operations where RLS alone is sufficient to enforce access control.
The atomic availability lock — the hardest business rule in the system — is implemented natively at the database level using a PostgreSQL GiST exclusion constraint. This structurally rejects overlapping reservations, making double-bookings mathematically impossible. This prevents double bookings at the database level while keeping the locking logic server-side and out of reach of client manipulation.
The escrow model is enforced via Stripe: a PaymentIntent is created and captured at booking time, held in the platform's Stripe account, and transferred to the host's connected account after check-in by a scheduled Edge Function. Host cancellations trigger an unconditional full refund. Guest-side service fees are captured as Stripe's application fee on the charge.
CCPA compliance is handled via a soft-delete + PII-scrubbing Edge Function. PCI DSS scope is SAQ A — no raw card data ever touches the application or Edge Function layer.
The system targets hundreds of users with cost optimization as the primary constraint. Three environments (dev, staging, production) map to separate Supabase projects deployed via the Supabase CLI and GitHub Actions.
System Overview
graph TD
subgraph client["Client Layer"]
browser["Web Browser"]
react["React 18 SPA"]
lovable["Lovable Scaffold"]
end
subgraph supabase["Supabase Platform"]
subgraph sbAuth["Auth & API"]
gotrue["GoTrue Auth"]
postgrest["PostgREST API"]
end
subgraph sbData["Data Layer"]
pg["PostgreSQL 15"]
vault["Supabase Vault"]
pgcron["pg_cron Scheduler"]
end
subgraph sbStorage["Storage"]
storage["Supabase Storage"]
end
subgraph sbFn["Edge Functions"]
bookFn["booking-create"]
payoutFn["payout-scheduler"]
webhookFn["stripe-webhook"]
ccpaFn["ccpa-scrubber"]
adminFn["admin"]
otherFn["host-onboarding / cancel / approve / expire / photo-confirm"]
end
end
subgraph external["External Services"]
stripe["Stripe Connect"]
resend["Resend Email"]
end
subgraph deploy["CI/CD"]
gh["GitHub Actions"]
sbcli["Supabase CLI"]
end
browser --> react
lovable --> react
react --> gotrue
react --> postgrest
react --> bookFn
react --> adminFn
react --> otherFn
postgrest --> pg
gotrue --> pg
pgcron --> payoutFn
pgcron --> ccpaFn
pgcron --> otherFn
bookFn --> pg
bookFn --> vault
bookFn --> stripe
bookFn --> resend
payoutFn --> pg
payoutFn --> vault
payoutFn --> stripe
payoutFn --> resend
webhookFn --> pg
webhookFn --> vault
webhookFn --> stripe
ccpaFn --> pg
ccpaFn --> storage
ccpaFn --> resend
adminFn --> pg
adminFn --> stripe
otherFn --> pg
otherFn --> vault
otherFn --> stripe
otherFn --> resend
otherFn --> storage
stripe --> webhookFn
ccpaFn --> vault
gh --> sbcli
sbcli --> supabase
The system has four logical tiers:
Client layer — A React SPA scaffolded in Lovable. It communicates with Supabase using the Supabase JS client (v2) for auth, auto-generated table APIs, and Edge Function invocations. The Lovable development workflow uses Plan mode for RLS-sensitive changes before any code generation is executed. No direct Stripe API calls from the browser — only Stripe.js for card element rendering.
Supabase platform — The central backend. Supabase provides PostgreSQL 15 (the database), GoTrue (auth/JWT issuance), PostgREST (auto-generated REST API over tables), Realtime (optional, not used in v1), Storage (listing photos), and Edge Functions (Deno 1.x runtime for server-side business logic).
Edge Function layer — Deno-based serverless functions hosted on Supabase's infrastructure. They run with a service-role key scoped to server-side operations, bypassing RLS where needed for privileged actions (webhook processing, scheduled payouts, CCPA scrubbing). Exposed via Supabase's built-in function routing.
External integrations — Stripe Connect (payments, payouts, webhooks), Resend (transactional email), and Supabase Storage (listing photos, CCPA data exports).
Data flows directionally: a guest's search request hits PostgREST directly from the Supabase JS client, filtered by date/location/guest-count queries. A booking request hits a dedicated Edge Function that opens a serializable transaction, attempts to insert the booking row (blocked by a unique partial index on overlapping confirmed bookings), calls Stripe, and commits. If Stripe fails, the transaction rolls back and no booking record is created.
Architectural Constraints Summary
Lovable as the scaffolding tool drives the component-by-component development approach. Lovable's Plan mode is used before any execution step involving RLS policy changes — RLS policies silently deny data access if misconfigured, making pre-execution analysis essential. Each Lovable prompt targets a single, scoped component (a page, a form, a data hook) to minimize unintended side effects.
Supabase replaces AWS as the entire backend platform. This eliminates Lambda, API Gateway, RDS Proxy, SSM Parameter Store, SES, CloudWatch, and SAM. Supabase provides equivalent functionality — PostgreSQL (database), GoTrue (auth), PostgREST (API), Edge Functions (compute), Storage (object storage), and Vault (secrets) — as a managed service, dramatically reducing infrastructure surface area for an early-stage product.
RLS as the primary data isolation mechanism is the Supabase-idiomatic replacement for the previous architecture's Lambda Authorizer + role checks. Every table has RLS enabled. Policies are defined per operation (SELECT, INSERT, UPDATE, DELETE) and per role (authenticated user, service role). Because RLS is enforced at the database layer, it protects data even if application-layer logic has a bug — this is a stronger isolation guarantee than application-level role checks alone.
Atomic availability locking is handled by a PostgreSQL GiST exclusion constraint. Because Supabase natively supports the btree_gist extension, we can prevent date overlaps directly on the table using the && operator on a date range. This keeps the invariant database-native and removes the need for complex serializable RPC workarounds.
CCPA and PCI DSS constraints are unchanged in intent. PCI scope is SAQ A (Stripe.js, no card data in the application). CCPA deletion is a PII scrub via Edge Function, not a row delete, to preserve the 3-year financial retention requirement.
Cost optimization at small scale means Supabase's free or Pro tier is sufficient at launch. Edge Functions have no per-invocation infrastructure cost beyond Supabase's included limits. Supabase Storage replaces S3. Resend replaces SES. The total external service bill at hundreds-of-users scale is materially lower than the previous AWS-only architecture.
Output format is a Phased Lovable Prompt Pack, not a DAG Implementation Plan. Each phase maps to a set of Lovable prompts that scaffold a coherent slice of the product.
Component Architecture
Supabase Auth (GoTrue)
Handles user registration, login, token refresh, and JWT issuance. Users register with email and password. GoTrue issues short-lived JWTs (default 1-hour expiry) and manages refresh tokens. The JWT contains the user's UUID and role claim. No custom identity provider — GoTrue manages credentials natively.
On registration, a database trigger creates a corresponding row in the public profiles table with a default guest role. Host onboarding is a separate flow (Stripe Connect) that sets host_onboarding_status on the profile. The Supabase JS client handles token refresh transparently on the frontend.
PostgREST (Auto-Generated API)
Supabase exposes a REST API over every table in the public schema, filtered by the authenticated user's JWT. RLS policies on each table determine what rows each user can read or write. PostgREST is used for:
- Guest: browsing listings, viewing booking history
- Host: reading their own listings, availability rules, pricing overrides
- Admin: all reads (via service-role key in the Admin edge function)
PostgREST is not used for operations that require transactional guarantees (booking creation, payment processing) — those go through Edge Functions.
Supabase Edge Functions
Deno 1.x functions deployed alongside the Supabase project. They use the Supabase service-role client internally for privileged database access, bypassing RLS for server-controlled operations. All Edge Functions validate the caller's JWT before performing user-scoped operations. Edge Functions are organized by domain:
booking-create Edge Function:
The most critical function. Handles the full instant-book and manual-approval reservation flows. It validates the availability rules (blocked dates, min stay, check-in day bitmask) and attempts a direct INSERT into the bookings table using the service-role client. The insert is protected by the GiST exclusion constraint. If Postgres raises a constraint violation, it returns a structured conflict error. On success, the Edge Function calls Stripe to create and capture the PaymentIntent, then updates the booking status to confirmed. On Stripe failure, it calls a compensating PostgreSQL function to cancel the pending booking row. Sends confirmation email via Resend on success.
Availability rule evaluation logic: The attempt_booking Postgres function checks rules in sequence: (1) all requested nights fall outside any blocked date range for the listing, (2) the stay length meets or exceeds the listing's minimum-stay value, (3) the check-in day-of-week is present in the listing's allowed check-in days bitmask. All three must pass before the insert is attempted.
Pricing calculation logic: The Edge Function calculates the charge amount by iterating each calendar night in the requested range. For each night, it looks up applicable overrides in priority order: (1) a seasonal override if the date falls within a defined season range stored in pricing_overrides, (2) a weekend override if the date is Friday or Saturday, (3) the listing's base nightly rate. The sum of all per-night rates plus the guest service fee (a fixed percentage stored in Supabase Vault, default 12%) forms the total charge amount passed to Stripe.
booking-approve / booking-decline Edge Function:
Host-triggered approval for manual-approval listings. On approve, captures the Stripe PaymentIntent in requires_capture state and moves the booking to confirmed. On decline or timeout (checked by the booking-expire scheduled function), cancels the PaymentIntent and moves the booking to declined.
booking-cancel Edge Function:
Handles both host-initiated and guest-initiated cancellations. Host-initiated: unconditional full Stripe refund, booking moved to cancelled_by_host, guest notified via Resend. Guest-initiated: applies the listing's cancellation policy enum (flexible, moderate, strict), calculates refund amount based on days until check-in, calls Stripe refund API, updates booking status.
payout-scheduler Edge Function (scheduled):
Triggered by Supabase's pg_cron extension on an hourly schedule. Queries for confirmed bookings where check_in_date < NOW() and payout_status = pending. For each, calls Stripe to create a Transfer from the platform account to the host's connected Stripe account. Updates payout_status to completed and records the Stripe Transfer ID. Idempotent — checks payout_status before calling Stripe.
stripe-webhook Edge Function:
Receives Stripe webhook events at a public endpoint. Validates the Stripe webhook signature using the signing secret from Supabase Vault before processing. Handles: payment_intent.succeeded (recovery path — confirms booking if not already confirmed), payment_intent.payment_failed (moves booking to payment_failed), transfer.failed (logs error, sends operator alert via Resend), account.updated (updates host's Stripe verification status on the profile). Uses the stripe_webhook_events table for idempotency — if the Stripe event ID already exists, short-circuits and returns success.
ccpa-scrubber Edge Function (scheduled):
Triggered by pg_cron nightly at 02:00 UTC. Finds users with deletion_status = pending and deletion_requested_at older than 30 days. Scrubs all PII fields (name, email, phone, avatar URL) on the profile row, updating deletion_status = completed. Processes users in batches of 50 with a 100ms sleep between batches. Each user scrub is committed individually — a failure on one user does not roll back others. Also handles CCPA data export requests: assembles the user's data from the database, writes a JSON export file to Supabase Storage, and emails a time-limited signed URL to the user via Resend.
host-onboarding Edge Function:
Generates a Stripe Connect hosted onboarding URL for a user becoming a host. On Stripe redirect callback, exchanges the authorization code for a connected account ID and stores it on the profile. Sets host_onboarding_status = pending_verification.
admin Edge Function:
Internal endpoints gated by a role check against the admin or support claim in the JWT. Capabilities: view all users, listings, and bookings (using service-role database access); manually trigger a payout retry for a payout_status = failed booking; flag a listing as inactive; view hosts by verification status. Support agents are blocked from write operations by an explicit role check within the function.
listing-photo-confirm Edge Function:
After a host uploads a photo directly to Supabase Storage via a signed upload URL, this function confirms the upload by creating a row in listing_photos with the storage object path and display order. Validates that the storage object exists before writing the database row. Supabase Storage lifecycle rules handle orphaned objects (uploaded but never confirmed) after 24 hours via an object expiry policy on the pending/ path prefix.
booking-expire Edge Function (scheduled):
Triggered by pg_cron every 15 minutes. Queries for pending_approval bookings older than 24 hours and cancels them — cancels the Stripe PaymentIntent and moves the booking to declined. Guest receives an automated email via Resend.
Row-Level Security Policies
RLS is enabled on every table. The primary policies are:
profiles: Users can SELECT and UPDATE their own row (auth.uid() = id). Service role bypasses for admin and scrubber access. No user can DELETE their own profile row — deletion is handled by the CCPA scrubber Edge Function via service role.
listings: Authenticated users can SELECT active listings (status = active). Hosts can SELECT, INSERT, UPDATE, and DELETE their own listings (auth.uid() = host_user_id). DELETE is a soft delete (status set to inactive) enforced by an INSTEAD OF DELETE trigger rather than a true row deletion.
bookings: Guests can SELECT their own bookings (auth.uid() = guest_user_id). Hosts can SELECT bookings for their own listings (auth.uid() = listing.host_user_id via a join policy). Guests can INSERT bookings only through the booking-create Edge Function (which uses service role) — direct PostgREST INSERT on bookings is denied for all roles to force the transactional Edge Function path. Updates are only via Edge Functions (service role).
listing_photos, listing_amenities, pricing_overrides, availability_rules: Hosts can SELECT, INSERT, UPDATE, DELETE their own records (linked through listing_id → listings.host_user_id). Authenticated users can SELECT photos and amenities for active listings.
stripe_webhook_events, ccpa_requests: No direct client access. Service role only.
Lovable Scaffolding Approach
The frontend is built component-by-component in Lovable. Each prompt targets a single screen or feature slice. Before any prompt that generates database queries or RLS-sensitive operations, Plan mode is used to review the proposed data access pattern and confirm it aligns with the RLS policy. The Phased Lovable Prompt Pack (the output format of this architecture) structures prompts in dependency order: auth flows first, then listing browsing, then booking, then host management, then admin.
Data Architecture
Core Tables (Data Contracts)
| Entity | Attribute | Type / Constraints | Business Rule & Access Control |
|---|---|---|---|
profiles |
roles |
Array | RLS: Users can SELECT/UPDATE own row. Deletion restricted to CCPA scrubber. |
listings |
status |
Enum (draft, active, inactive) |
RLS: Public read for active. Host INSERT/UPDATE. Soft-delete via INSTEAD OF DELETE trigger. |
availability_rules |
rule_type |
Enum | Validates blocked ranges, min stay, and check-in days before booking execution. |
bookings |
date_range |
tsrange |
GiST Exclusion Constraint (&&): Structurally rejects overlapping time blocks. |
bookings |
status |
Enum | State machine: pending → confirmed → cancelled_by_host. RLS: Service-role execution only. |
stripe_webhook_events |
stripe_event_id |
String (Unique) | Idempotency log to prevent duplicate processing. Purged 90 days post-creation. |
ccpa_requests |
request_type |
Enum (deletion, export) |
Audit log for data lifecycle. Triggered by client, executed by nightly Edge Function. |
Atomic Availability Lock
The double-booking prevention mechanism is enforced directly at the database layer using a GiST Exclusion Constraint:
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bookings ADD CONSTRAINT no_overlapping_bookings
EXCLUDE USING gist (listing_id WITH =, tsrange(check_in_date, check_out_date) WITH &&)
WHERE (status IN ('pending', 'pending_approval', 'confirmed'));
This structural invariant guarantees that concurrent booking attempts for the same dates mathematically cannot succeed, regardless of whether they originate from an Edge Function, PostgREST, or the Supabase dashboard.
### Data Lifecycle
Bookings are never hard-deleted. Declined and expired bookings are retained for audit. Confirmed bookings are retained for 3 years per policy; beyond 3 years, a yearly Edge Function exports anonymized booking records to Supabase Storage and deletes them from the database. Refresh tokens are managed by GoTrue natively. Stripe webhook event log rows older than 90 days are purged nightly. Listing photos whose Storage objects are orphaned are cleaned up by a Supabase Storage lifecycle rule (24-hour expiry on the `pending/` prefix). User PII is scrubbed 30 days after a deletion request, but booking rows are retained with a null user reference.
### Indexing Strategy
- `bookings(listing_id, check_in_date, check_out_date)` — overlap queries and availability checks
- `bookings(guest_user_id)` — guest booking history
- `listings(location, max_guests, base_price_cents)` — search filter composite
- `availability_rules(listing_id)` — rule lookup per listing
- `pricing_overrides(listing_id)` — override lookup per listing
- `stripe_webhook_events(stripe_event_id)` — unique index for idempotency
- `profiles(deletion_status)` — nightly scrubber query
### Backup and Retention
Supabase managed backups: daily backups with 7-day retention on the Pro tier, point-in-time recovery (PITR) enabled on the Production project. Dev and staging projects use daily backups only. Supabase Storage versioning is not natively available — photo objects are treated as immutable (delete and re-upload for replacement). Database data is retained for 3 years per policy; archived booking rows past 3 years are exported to a Supabase Storage archive bucket and then deleted from the database.
## Integration Architecture
### Stripe Connect
**Purpose:** Payment processing, escrow, host onboarding, fund routing, and refunds.
**Auth:** The platform's Stripe secret key and webhook signing secret are stored in Supabase Vault (encrypted at rest) and retrieved by Edge Functions at invocation time. Host connected accounts are created via Stripe Connect's hosted onboarding (OAuth flow): the `host-onboarding` Edge Function generates the Stripe Connect onboarding URL, the host completes onboarding on Stripe's hosted page, and Stripe redirects back to the platform. The Edge Function exchanges the authorization code for the connected account ID and stores it on the profile.
**Operations performed:**
- Create and capture a PaymentIntent (charge guest at booking, with application fee for service fee capture) — performed by `booking-create`
- Create a PaymentIntent in `requires_capture` state (manual approval flow) — performed by `booking-create`
- Capture a PaymentIntent (on host approval) — performed by `booking-approve`
- Cancel a PaymentIntent (on decline or expiry) — performed by `booking-decline` and `booking-expire`
- Create a Refund (on cancellation) — performed by `booking-cancel`
- Create a Transfer (host payout post check-in) — performed by `payout-scheduler`
- Create a Connect onboarding link — performed by `host-onboarding`
- Retrieve Account status — performed by `stripe-webhook` on `account.updated` events
**Failure behavior:** If PaymentIntent creation fails during booking, the Edge Function calls the compensating Postgres function to cancel the pending booking row and returns a payment failure message to the guest. If a Transfer fails during payout, the `payout-scheduler` marks `payout_status = failed`, logs the error, and sends an operator alert via Resend. The operator retries via the `admin` Edge Function's payout retry endpoint. Stripe webhook delivery failures: Stripe retries for up to 3 days. Processing errors return a non-2xx response to Stripe to trigger retry. Stripe Connect onboarding failures leave the host in `host_onboarding_status = pending_verification` — they cannot publish a listing until `account.updated` confirms verification.
### Resend
**Purpose:** Transactional email — booking confirmation, cancellation, payout notification, CCPA data export link, host onboarding follow-up.
**Auth:** Resend API key stored in Supabase Vault, retrieved by Edge Functions at invocation time. Emails sent from a verified domain configured in Resend.
**Failure behavior:** Resend errors are logged by the Edge Function. Email delivery failure does not roll back a booking or payout — it is a non-critical side effect. Persistent failures are surfaced via the Edge Function's error log in the Supabase dashboard.
### Supabase Storage
**Purpose:** Listing photo storage and CCPA data export file staging.
**Auth:** Hosts generate a signed upload URL via the Supabase JS client (authenticated) pointing to a `pending/{listing_id}/{uuid}` path. The `listing-photo-confirm` Edge Function validates the upload and moves the object to the `photos/{listing_id}/{uuid}` path. The Storage bucket is private. The React frontend retrieves photos via signed read URLs generated by the Supabase JS client for authenticated users, or via public bucket policy for listing-detail pages accessible to unauthenticated browsers (search results). CCPA export files are written by the `ccpa-scrubber` Edge Function using the service-role client and emailed as time-limited signed URLs (1-hour expiry) via Resend.
**Failure behavior:** Signed URL generation is a local Supabase SDK operation and cannot fail in isolation. If the client upload fails, no database record is written — the client retries the upload independently. Orphaned `pending/` objects expire via Supabase Storage lifecycle policy after 24 hours.
### Supabase Vault
**Purpose:** Secrets storage — Stripe secret key, Stripe webhook signing secret, Resend API key, service fee percentage (default `12`).
**Auth:** Edge Functions access Vault secrets via the Supabase service-role client's Vault API. No secrets are stored in Edge Function environment variables or source code.
### pg_cron (Supabase Scheduled Jobs)
**Purpose:** Triggers the `payout-scheduler` (hourly), `booking-expire` (every 15 minutes), and `ccpa-scrubber` (nightly at 02:00 UTC) Edge Functions on their respective schedules.
**Auth:** pg_cron runs within the PostgreSQL instance with superuser privileges. It invokes Edge Functions via an authenticated HTTP call using the service-role key.
**Failure behavior:** If a scheduled invocation fails, pg_cron logs the failure in the `cron.job_run_details` table. Failed runs do not auto-retry — the next scheduled run picks up where the last one left off. Idempotency in each scheduled function ensures re-runs are safe.
## Security Architecture
### Input Validation
All Edge Function request bodies are validated using Zod schemas (v3.23) before reaching any business logic. Validation rejects unexpected fields, enforces type constraints, sanitizes string inputs (trim whitespace, enforce max lengths), and checks date range logic (check-out must be after check-in, max stay 365 nights) before any database operation. Validation errors return a structured error response without exposing internal schema details. PostgREST requests are validated implicitly by column type constraints and RLS policy predicates.
### Authentication and Authorization
GoTrue issues JWTs on login. The Supabase JS client attaches the JWT to every request (PostgREST and Edge Functions). Edge Functions validate the JWT using the Supabase JWT secret before performing any user-scoped operation. RLS policies enforce row-level authorization at the database layer for all PostgREST operations — a misconfigured application-layer check cannot bypass RLS. Role-based access for admin/support operations is enforced inside the `admin` Edge Function by checking the `roles` array on the caller's profile, fetched via service-role client.
### Row-Level Security as the Primary Isolation Boundary
Every table in the `public` schema has `ALTER TABLE ... ENABLE ROW LEVEL SECURITY`. Policies are defined per operation per table. The service-role key (used only in Edge Functions) bypasses RLS — this is intentional and necessary for privileged operations (payout, scrubbing, webhook processing). The anon key (used by unauthenticated browser sessions) has access only to public listing data via explicit SELECT policies on the `listings` and `listing_photos` tables.
### Secrets Management
No secrets in source code, Edge Function environment variables, or Lovable's project configuration. All secrets are stored in Supabase Vault. Edge Functions retrieve secrets at invocation time via the Vault API. The service-role key (used to bootstrap Vault access) is stored only in GitHub Actions secrets and the Supabase project settings — never committed to the repository.
### PCI DSS (SAQ A)
No card data touches any Edge Function or database table. Stripe.js on the React frontend collects card details in Stripe-hosted iframes. The backend handles only Stripe entity references (PaymentIntent IDs, Transfer IDs). The `stripe-webhook` Edge Function validates the Stripe signature before processing any event. This keeps the platform in SAQ A scope.
### CCPA
Data deletion is a first-class flow with a 30-day grace period. The `do_not_sell` flag is enforced by the Edge Function before any data is included in a CCPA export. User data export (right-to-know) is provided via a secure, time-limited (1-hour) Supabase Storage signed URL emailed to the verified address. All CCPA requests are logged in the `ccpa_requests` table for audit purposes.
### Web-Facing Security
- **HTTPS enforcement:** Supabase API endpoints and Edge Function URLs are HTTPS-only. The React SPA is hosted on a CDN (Lovable's default hosting or a custom domain via Netlify/Vercel) with HTTPS enforced and HTTP redirected.
- **CORS:** Edge Functions set explicit `Access-Control-Allow-Origin` headers matching the production frontend domain. No wildcard CORS in staging or production.
- **Security headers:** The frontend hosting layer (Lovable's CDN / Netlify / Vercel) is configured with `Strict-Transport-Security`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Content-Security-Policy` (restricting scripts to the CDN origin and the Stripe.js domain `js.stripe.com`), and `Referrer-Policy: strict-origin-when-cross-origin`.
- **SQL injection:** All database queries in Edge Functions use the Supabase client's parameterized query interface. No string interpolation in SQL. PostgREST uses parameterized queries natively.
- **Rate limiting:** Supabase's built-in rate limiting applies to auth endpoints. Edge Functions implement request-level rate limiting by checking a simple counter in the database for high-sensitivity endpoints (booking creation: maximum 10 attempts per user per minute).
### Error Handling Policy
Internal error details (Postgres error codes, Stripe error objects, stack traces) are logged server-side via Supabase Edge Function logs and never returned to the client. Client-facing error responses contain only a human-readable message and a stable error code string. The Lovable-generated frontend maps error codes to appropriate UI states.
### Lovable Plan Mode for RLS
Any Lovable prompt that involves querying a table, inserting data, or changing a data access pattern must be run through Plan mode first. Plan mode surfaces the proposed data access pattern, which is reviewed against the RLS policy for that table before execution. This prevents Lovable from generating client-side queries that silently return empty results due to RLS denials — a failure mode that is difficult to debug after the fact.
## Deployment Architecture
### Environments
Three Supabase projects: `homestay-dev`, `homestay-staging`, `homestay-prod`. Each project is an isolated Supabase environment with its own PostgreSQL database, auth configuration, Storage buckets, Edge Functions, and Vault secrets. Environment-specific configuration (Stripe keys, Resend API key, service fee percentage) is stored in each project's Vault independently.
### Infrastructure as Code
Supabase database schema, RLS policies, pg_cron jobs, and Edge Functions are managed via the **Supabase CLI** (v1.x). Migration files define all schema changes. The CLI's `supabase db push` command applies migrations to the target environment. Edge Functions are deployed via `supabase functions deploy`. No separate IaC tool (no SAM, no Terraform) — the Supabase CLI is the single source of truth for all infrastructure.
### CI/CD Pipeline (GitHub Actions)
Three workflows:
**PR workflow:** Runs on every pull request to `main`. Steps: install Supabase CLI, run `supabase db lint` on migration files, run unit tests for Edge Functions (Deno test runner), run integration tests against a local Supabase instance (via `supabase start` in the Actions runner). PRs cannot be merged if this workflow fails.
**Staging deploy:** Runs on every merge to `main`. Steps: `supabase db push` to `homestay-staging` → `supabase functions deploy` to staging → run smoke tests against the staging Supabase project URL → notify on success or failure (Resend email to the dev team).
**Production deploy:** Manual trigger only (`workflow_dispatch`). Steps: `supabase db push` to `homestay-prod` → `supabase functions deploy` to production → run smoke tests → notify team. Requires a GitHub environment protection rule with at least one required reviewer approval before the deploy step executes.
### Frontend Deployment
The React SPA is deployed from the Lovable project to Lovable's built-in hosting for dev and staging iterations. For production, the built React app is deployed to Netlify (or Vercel) via GitHub Actions on merge to `main`, with the production Supabase project URL and anon key injected as build-time environment variables. Security headers are configured via the platform's `_headers` file (Netlify) or `vercel.json`.
### Scaling
At the target scale (hundreds of users), Supabase's Pro tier PostgreSQL instance and Edge Function capacity are more than sufficient. Edge Functions scale automatically on Supabase's infrastructure. If traffic grows, the PostgreSQL instance can be scaled up via the Supabase dashboard without architectural changes. Supabase Storage handles frontend asset and photo delivery via CDN independently.
## Technology Stack
| Component | Technology | Version | Rationale |
|---|---|---|---|
| Frontend framework | React | 18.3 | Lovable's native scaffold target; mature ecosystem |
| Frontend scaffolding | Lovable | Current (2025) | Constraint; component-by-component AI scaffolding with Plan mode for RLS-sensitive changes |
| Frontend hosting | Lovable built-in / Netlify | N/A | Lovable for iteration; Netlify for production with custom domain and security headers |
| Backend platform | Supabase | Pro tier | Replaces entire AWS stack; provides PostgreSQL, auth, API, Edge Functions, Storage, Vault |
| Database | PostgreSQL | 15 (managed by Supabase) | ACID transactions, serializable isolation for booking lock, native Supabase integration |
| Auth | Supabase GoTrue | v2 (Supabase-managed) | Native Supabase auth; JWT issuance, refresh token management, email verification |
| Auto-generated API | Supabase PostgREST | v12 (Supabase-managed) | REST over PostgreSQL tables; gated by RLS policies |
| Edge Functions runtime | Deno | 1.x (Supabase-managed) | Supabase's native Edge Function runtime; TypeScript support, V8 isolates |
| Edge Function language | TypeScript | 5.x | Type safety across functions and shared utilities |
| Validation library | Zod | 3.23 | Runtime schema validation with TypeScript inference; used in Edge Functions |
| Object storage | Supabase Storage | N/A (Supabase-managed) | Photo storage, CCPA exports; integrated signed URL and lifecycle policies |
| Secrets management | Supabase Vault | N/A (Supabase-managed) | Encrypted secrets storage; replaces SSM Parameter Store |
| Scheduled jobs | pg_cron | 1.4 (Supabase extension) | Cron-based scheduling within PostgreSQL; triggers Edge Functions on schedule |
| Payments | Stripe Connect | API version 2024-06-20 | Constraint; handles PCI scope, escrow, and host payouts |
| Transactional email | Resend | v2 API | Simple API, generous free tier, better DX than SES at early stage |
| IaC / Deployment | Supabase CLI | 1.x | Schema migrations, RLS policies, Edge Function deployment; replaces AWS SAM |
| CI/CD | GitHub Actions | N/A | Migration linting, Edge Function tests, environment deploy pipeline |
| Logging | Supabase Edge Function Logs + Supabase Dashboard | N/A | Built-in structured logging for Edge Functions; replaces CloudWatch |
| Alerting | Resend email to operator + Supabase Log Drains (optional) | N/A | Operator alerts for payout failures and critical errors via Resend |
## Key Architectural Decisions
**ADR-1: Supabase as the full backend platform, replacing AWS**
Decided to use Supabase (PostgreSQL, GoTrue, PostgREST, Edge Functions, Storage, Vault) as the complete backend, replacing Lambda, API Gateway, RDS Proxy, SES, CloudWatch, and SSM Parameter Store. Alternatives: retain the AWS stack (higher infrastructure overhead, disproportiona-3te to scale), use Firebase (no relational database, poor fit for booking constraints), use a custom Node.js server (contradicts serverless constraint). Supabase provides all required capabilities with a dramatically smaller operational surface for an early-stage product, and aligns with Lovable's native integration.
**ADR-2: RLS policies as the primary data isolation mechanism**
Decided to use Supabase Row-Level Security policies to enforce data access control at the database layer for all PostgREST operations. Alternatives: application-layer role checks only (weaker — a bug in the application layer exposes data), no RLS (all data accessible to any authenticated user — unacceptable). RLS enforces isolation even if Edge Function or frontend logic is incorrect, providing a defense-in-depth guarantee. The tradeoff is that RLS policy bugs silently return empty results rather than errors, which is mitigated by Lovable's Plan mode review before any RLS-sensitive code generation.
**ADR-3: PostgreSQL GiST exclusion constraint for atomic booking lock**
Decided to implement double-booking prevention using a GiST exclusion constraint with the `&&` (overlap) operator. Alternatives: serializable transactions via RPC (unnecessary complexity), unique partial indexes (cannot accurately model date range overlaps). Supabase natively supports the `btree_gist` extension, making this the most robust, database-level defense against race conditions.
**ADR-4: Edge Functions for all transactional and privileged operations**
Decided to route all operations requiring multi-step transactions, Stripe API calls, or service-role database access through Edge Functions, while using PostgREST directly (via the Supabase JS client) for simple reads covered by RLS. Alternatives: all operations through Edge Functions (unnecessary overhead for simple reads), all operations through PostgREST (no server-side transaction boundary, Stripe calls exposed to client). This hybrid approach uses the right tool for each operation.
**ADR-5: Lovable with Plan mode for RLS-sensitive scaffolding**
Decided to use Lovable in Plan mode before executing any prompt that generates database queries or data access patterns. This is a workflow constraint, not a code constraint. Alternatives: execute all Lovable prompts without review (high risk of silent RLS denials breaking data access in ways that are hard to debug), avoid Lovable for data-access components (defeats the purpose of the tooling). Plan mode review adds a small amount of friction in exchange for catching RLS mismatches before they become production bugs.
**ADR-6: Resend for transactional email, replacing SES**
Decided to use Resend for all transactional email. Alternatives: AWS SES (no longer available — AWS dependency removed per constraint), SendGrid (higher cost, more complex setup). Resend offers a simple REST API, a generous free tier, and easy domain verification — appropriate for the scale and operational simplicity goals of this project.
**ADR-7: CCPA deletion as PII scrub, not row deletion**
Retained from the prior architecture. Booking rows are retained with PII scrubbed from the profile record rather than cascading-deleting booking rows. Alternatives: hard delete all user data (breaks 3-year financial retention requirement). PII scrub satisfies the CCPA deletion right while preserving the financial records the platform is legally required to retain.
**ADR-8: Phased Lovable Prompt Pack as the downstream output format**
The downstream deliverable from this ADD is a Phased Lovable Prompt Pack — a sequenced set of Lovable prompts organized by feature dependency. Phase 1: auth and profile. Phase 2: listing browse and search. Phase 3: host listing management. Phase 4: booking flow and payments. Phase 5: admin and CCPA flows. Each prompt targets a single component or screen and is preceded by a Plan mode step for any RLS-sensitive operation.
## Error Handling & Failure Modes
### GoTrue Auth
- **Invalid credentials:** GoTrue returns a generic "invalid login credentials" error. The frontend displays this without distinguishing between "user not found" and "wrong password" to prevent user enumeration.
- **Expired JWT:** Supabase JS client automatically attempts token refresh. If refresh fails (revoked refresh token, user deleted), the client clears the session and the user is redirected to login.
- **Brute force:** GoTrue has built-in rate limiting on auth endpoints. Additionally, the `login_attempts` table tracks consecutive failures — after 10 failures within 15 minutes for the same email, the `auth` Edge Function (if needed to supplement GoTrue) soft-locks the account for 15 minutes.
### `booking-create` Edge Function — Availability Lock
- **Overlap detected by serializable transaction:** The `attempt_booking` Postgres function raises an application exception. The Edge Function maps this to a "these dates are no longer available — please select different dates" user-facing error. No booking row is written.
- **Stripe PaymentIntent creation fails:** The Edge Function calls the compensating Postgres function to cancel the `pending` booking row. Guest receives a payment failure message. No confirmed booking exists.
- **Stripe succeeds but database confirm update fails (partial state):** The `stripe-webhook` Edge Function receives `payment_intent.succeeded`. If no `confirmed` booking row exists for that PaymentIntent ID, it creates one in `confirmed` status as the recovery path. A log entry flags this anomaly for operator review.
- **Manual approval timeout:** The `booking-expire` Edge Function (runs every 15 minutes) detects `pending_approval` bookings older than 24 hours, cancels the Stripe PaymentIntent, and moves the booking to `declined`. Guest receives an automated email.
### `stripe-webhook` Edge Function
- **Invalid webhook signature:** Rejected immediately. A log entry is written. A success response is returned to Stripe (to avoid Stripe treating a non-Stripe request as a delivery failure and retrying).
- **Duplicate event:** The `stripe_webhook_events` idempotency table catches this. Returns success without reprocessing.
- **Processing failure (database error):** Returns a non-2xx response to Stripe. Stripe retries for up to 3 days. Persistent failures surface in the Supabase Edge Function logs and trigger an operator alert via Resend.
### `payout-scheduler` Edge Function
- **Stripe Transfer failure:** Marks `payout_status = failed` on the booking. Logs the Stripe error. Sends an operator alert via Resend. Does not auto-retry — operator uses the `admin` Edge Function's payout retry endpoint. The next scheduled run skips bookings already in `payout_status = failed`.
- **pg_cron invocation failure:** The failure is logged in `cron.job_run_details`. The next scheduled run (one hour later) picks up all pending payouts. Idempotency ensures no double-payouts.
### `ccpa-scrubber` Edge Function
- **Scrub failure mid-batch:** Each user scrub is committed individually. A failure on user N does not roll back users before it. The failed user's `deletion_status` remains `pending` and is retried on the next nightly run.
- **Storage deletion failure (avatar photo):** Logged, does not block database PII scrubbing. The orphaned Storage object is eventually cleaned by the Storage lifecycle policy.
### RLS Policy Misconfiguration
- **Silent data denial:** A missing or incorrect RLS policy causes PostgREST to return an empty result set rather than an error. This is the primary RLS failure mode. Mitigation: Lovable Plan mode review before executing any data-access prompt; integration tests in CI that assert specific rows are returned for specific authenticated users.
- **Overly permissive policy:** A policy that grants too broad access exposes other users' data. Mitigation: RLS policies are reviewed in PR workflow via `supabase db lint`; integration tests assert that user A cannot read user B's bookings.
### Supabase Platform Availability
- **Supabase service degradation:** Edge Functions and PostgREST are unavailable. The frontend shows a "service temporarily unavailable" state. No data is corrupted — in-flight database transactions are rolled back by PostgreSQL on connection loss. Supabase's managed platform targets 99.9% uptime on the Pro tier, consistent with the under-1-hour unplanned downtime target.
- **Database connection limits:** Supabase's connection pooler (PgBouncer, built into the platform) manages connections from Edge Functions. No equivalent to RDS Proxy connection exhaustion is expected at the target scale.
### Search
- **No results:** Empty result set returned with a 200 response. Frontend renders a "no listings found" state.
- **Invalid date inputs:** Rejected at Zod validation layer in the Edge Function (if going through an Edge Function) or by PostgREST type constraints (if using the auto-generated API directly).
### Pre-Launch Verification
- **Stripe:** Test using Stripe test mode. Verify webhook delivery using the Stripe Dashboard webhook event simulator. Verify idempotency by replaying events. Test the PaymentIntent capture → database failure → webhook recovery path in staging.
- **Resend:** Test with verified recipient email in dev/staging. Verify domain in production.
- **RLS policies:** Run integration tests in CI that authenticate as specific users and assert exact row visibility. Verify that unauthenticated users can only see active listing data.
- **Atomic lock:** Integration test that fires two concurrent `booking-create` Edge Function calls for the same listing/dates and asserts exactly one succeeds.
- **CCPA flow:** Execute an end-to-end deletion request in staging and verify PII fields are scrubbed and booking rows are retained.
## Risks and Mitigations
**Risk 1: RLS policy misconfiguration causes silent data leaks or access denials**
Component: Supabase RLS policies, PostgREST API layer.
Impact: A too-permissive policy exposes other users' booking or profile data; a too-restrictive policy silently breaks a feature (e.g., hosts cannot see their own bookings). Both are high-severity and difficult to debug after launch.
Mitigation: Lovable Plan mode review before every data-access prompt. `supabase db lint` in the PR CI pipeline. Integration tests in CI that assert specific row visibility per authenticated user role. RLS policies are the first thing reviewed in any debugging session for a "data not showing" issue.
**Risk 2: Serializable transaction contention under concurrent bookings**
Component: `booking-create` Edge Function, `attempt_booking` Postgres function.
Impact: High concurrency on the same listing causes serialization failures (deadlocks or serialization errors), resulting in booking errors for users. At hundreds-of-users scale this is unlikely but possible for a popular listing.
Mitigation: The `attempt_booking` function retries the serializable transaction up to 3 times with exponential backoff before returning a conflict error. The Edge Function maps repeated conflicts to a "dates unavailable" message rather than a generic error. Load tested in staging before launch.
**Risk 3: Stripe PaymentIntent captured but booking confirmation update fails**
Component: `booking-create` Edge Function, `stripe-webhook` Edge Function.
Impact: Guest is charged but has no confirmed booking in the system. High-severity customer experience failure.
Mitigation: The `stripe-webhook` recovery path creates the booking row on receipt of `payment_intent.succeeded` if no confirmed row exists. A log entry flags the anomaly. This path is explicitly integration-tested in staging by simulating a database failure after Stripe capture.
**Risk 4: Lovable-generated code bypasses Edge Functions and writes directly to tables**
Component: Lovable scaffolding, booking and payment flows.
Impact: A Lovable prompt generates a client-side insert into the `bookings` table directly via PostgREST, bypassing the transactional Edge Function and the atomic lock. This allows double bookings.
Mitigation: The RLS policy on `bookings` explicitly denies direct INSERT from any non-service-role client. Any direct client insert fails at the database level regardless of what Lovable generates. The Phased Lovable Prompt Pack explicitly instructs that booking creation prompts target the Edge Function invocation path, not a table insert.
**Risk 5: pg_cron scheduled job reliability for payout and expiry flows**
Component: `payout-scheduler`, `booking-expire`, pg_cron.
Impact: A pg_cron job silently stops running (Supabase extension issue, misconfigured job) and payouts are delayed indefinitely or expired bookings are never cancelled, keeping their availability locks held.
Mitigation: Each scheduled Edge Function logs its run start, row count processed, and completion status. A Resend alert is sent to the operator if a scheduled function processes zero rows when rows are expected (e.g., payout scheduler finds no pending payouts after 3 consecutive hours when confirmed bookings exist with past check-in dates). The `cron.job_run_details` table is checked weekly as part of operational monitoring.
Implementation Plan
Implementation Plan
Environment Setup
What you'll need
Before any code gets written, there are a handful of accounts to create and settings to configure. Getting these right upfront means your coding agent has everything it needs and won't get blocked mid-build.
Accounts to create
- Supabase — supabase.com — Your entire backend: database, authentication, file storage, server-side functions, and secrets management. You'll create three separate projects here (dev, staging, and production).
- Stripe — stripe.com — Handles all money movement: charging guests, holding funds in escrow, paying out hosts, and processing refunds. You'll need to enable Stripe Connect for the host payout feature.
- Resend — resend.com — Sends transactional emails to guests and hosts (booking confirmations, cancellation notices, payout alerts, and CCPA export links).
- GitHub — github.com — Hosts your code and runs the automated pipelines that deploy changes to staging and production.
- Netlify — netlify.com — Hosts the production frontend with the security settings your platform requires. (Lovable handles dev and staging frontend hosting directly.)
- Lovable — lovable.dev — Where you'll build the frontend, screen by screen. Connected to your GitHub repo so changes stay in sync.
Before you start
Supabase:
- Create three Supabase projects:
homestay-dev,homestay-staging, andhomestay-prod. Keep each project's URL and API keys handy — you'll need them for each environment. - In each project, go to the database extensions panel and enable
btree_gistandpg_cron. These are needed for the double-booking lock and the scheduled background jobs. - In each project, enable point-in-time recovery (PITR) on
homestay-prod. Daily backups are sufficient for dev and staging. - In each project, go to Authentication → Settings and set the Site URL to the correct frontend URL for that environment (Lovable's URL for dev/staging, your custom domain for production).
- In each project, enable email confirmation for new user signups under Authentication → Email.
- In each project, create two Storage buckets: one named
photos(public read) and one namedccpa-exports(private). Set a lifecycle rule on thephotosbucket so objects under thepending/path prefix expire after 24 hours. - In each project's Vault, create secrets for: your Stripe secret key, your Stripe webhook signing secret, your Resend API key, and the service fee percentage (value:
12). Do this for each environment separately using that environment's Stripe and Resend credentials.
Stripe:
- Enable Stripe Connect in your Stripe dashboard under Settings → Connect. Choose the "Standard" account type for host onboarding.
- In test mode, create a webhook endpoint pointing to your
homestay-stagingEdge Function URL forstripe-webhook. Add these events:payment_intent.succeeded,payment_intent.payment_failed,transfer.failed,account.updated. Copy the signing secret into your staging Supabase Vault. - Repeat the webhook setup in live mode pointing to your
homestay-prodEdge Function URL, and copy the live signing secret into your production Vault.
Resend:
- Add and verify the domain you'll send emails from. This involves adding a few DNS records your domain registrar — Resend walks you through it step by step.
- Create an API key and store it in each Supabase project's Vault.
GitHub:
- Create a repository named
homestay. - Under Settings → Secrets and Variables → Actions, add secrets for each environment: the Supabase project URL, the Supabase service-role key, and the Supabase access token (used by the CLI to deploy). Name them with environment prefixes, e.g.
SUPABASE_DEV_URL,SUPABASE_STAGING_URL,SUPABASE_PROD_URL, and so on. - Under Settings → Environments, create a
productionenvironment and add at least one required reviewer — this person must approve before any production deploy runs.
Netlify:
- Connect your GitHub repo to a new Netlify site.
- Set the build command to whatever Lovable's exported React app uses, and the publish directory to
dist. - Add a
_headersfile to the project root (your coding agent will create this) — Netlify uses it to set the security response headers.
Lovable:
- Connect your Lovable project to the
homestayGitHub repo so every Lovable change commits to a branch. - In Lovable's Supabase integration settings, connect to your
homestay-devproject for local iteration.
Environment variables
Your coding agent will need these available during implementation. For Edge Functions, these are retrieved from Supabase Vault at runtime — not stored as environment variables in the function code. For the CI/CD pipeline and local CLI use, these go in GitHub Actions secrets or your local environment.
Supabase (per environment — repeat for dev, staging, prod):
SUPABASE_URL— Supabase dashboard → Project Settings → API → Project URLSUPABASE_ANON_KEY— Supabase dashboard → Project Settings → API → Project API Keys →anon publicSUPABASE_SERVICE_ROLE_KEY— Supabase dashboard → Project Settings → API → Project API Keys →service_role(keep this secret — never expose it in frontend code)SUPABASE_ACCESS_TOKEN— Supabase dashboard → Account → Access Tokens (used by the CLI in GitHub Actions to deploy)SUPABASE_JWT_SECRET— Supabase dashboard → Project Settings → API → JWT Settings → JWT Secret
Stripe (stored in Supabase Vault, named exactly as listed):
STRIPE_SECRET_KEY— Stripe dashboard → Developers → API Keys → Secret keySTRIPE_WEBHOOK_SIGNING_SECRET— Stripe dashboard → Developers → Webhooks → your endpoint → Signing secretSTRIPE_CONNECT_CLIENT_ID— Stripe dashboard → Settings → Connect → Client ID
Resend (stored in Supabase Vault):
RESEND_API_KEY— Resend dashboard → API Keys → your key
Application config (stored in Supabase Vault):
SERVICE_FEE_PERCENTAGE— Set to12(the guest-side service fee percentage, as a plain number string)OPERATOR_ALERT_EMAIL— The email address that receives payout failure and critical error alerts
Frontend build-time (GitHub Actions → Netlify for production):
VITE_SUPABASE_URL— Same value asSUPABASE_URLfor the production projectVITE_SUPABASE_ANON_KEY— Same value asSUPABASE_ANON_KEYfor the production projectVITE_STRIPE_PUBLISHABLE_KEY— Stripe dashboard → Developers → API Keys → Publishable key
Phases
Phase 1: Database Schema, RLS Policies, and Auth Foundation
This phase lays the complete data foundation for the entire platform. Every table, index, constraint, RLS policy, and database trigger is defined here in migration files — all subsequent phases consume these contracts without changing them. Auth is wired up through Supabase GoTrue, and the profile auto-creation trigger is established so every new user immediately has a profiles row.
Contracts
Migration files (executed in order)
supabase/migrations/001_extensions.sql
Enables btree_gist and pg_cron. No exported interface — prerequisite for subsequent migrations.
supabase/migrations/002_enums.sql
Defines all PostgreSQL enum types used across tables:
listing_status_enum: valuesdraft,active,inactivebooking_status_enum: valuespending,pending_approval,confirmed,declined,cancelled_by_host,cancelled_by_guest,payment_failedpayout_status_enum: valuespending,completed,failedavailability_rule_type_enum: valuesblocked_range,min_stay,checkin_dayscancellation_policy_enum: valuesflexible,moderate,stricthost_onboarding_status_enum: valuesnot_started,pending_verification,verifieddeletion_status_enum: valuesnone,pending,completedccpa_request_type_enum: valuesdeletion,exportuser_role_enum: valuesguest,host,admin,support
supabase/migrations/003_tables.sql
Creates all tables. Column definitions:
profiles:
id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADEfull_name textemail textphone textavatar_url textroles user_role_enum[] NOT NULL DEFAULT '{guest}'stripe_customer_id textstripe_connect_account_id texthost_onboarding_status host_onboarding_status_enum NOT NULL DEFAULT 'not_started'deletion_status deletion_status_enum NOT NULL DEFAULT 'none'deletion_requested_at timestamptzdo_not_sell boolean NOT NULL DEFAULT falsecreated_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 NULLdescription textlocation text NOT NULLlatitude numeric(9,6)longitude numeric(9,6)max_guests integer NOT NULLbase_price_cents integer NOT NULLcancellation_policy cancellation_policy_enum NOT NULL DEFAULT 'moderate'instant_book boolean NOT NULL DEFAULT falsehouse_rules textstatus 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 CASCADEamenity text NOT NULL
listing_photos:
id uuid PRIMARY KEY DEFAULT gen_random_uuid()listing_id uuid NOT NULL REFERENCES listings(id) ON DELETE CASCADEstorage_path text NOT NULLdisplay_order integer NOT NULL DEFAULT 0created_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 CASCADErule_type availability_rule_type_enum NOT NULLblocked_start dateblocked_end datemin_stay_nights integercheckin_days_bitmask integer— bitmask where bit 0 = Sunday, bit 6 = Saturdaycreated_at timestamptz NOT NULL DEFAULT now()
pricing_overrides:
id uuid PRIMARY KEY DEFAULT gen_random_uuid()listing_id uuid NOT NULL REFERENCES listings(id) ON DELETE CASCADEoverride_type text NOT NULL— values:seasonal,weekendseason_start dateseason_end dateprice_cents integer NOT NULLcreated_at timestamptz NOT NULL DEFAULT now()
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 NULLcheck_out_date date NOT NULLdate_range tsrange GENERATED ALWAYS AS (tsrange(check_in_date::timestamp, check_out_date::timestamp)) STOREDnum_guests integer NOT NULLtotal_price_cents integer NOT NULLservice_fee_cents integer NOT NULLstatus booking_status_enum NOT NULL DEFAULT 'pending'payout_status payout_status_enum NOT NULL DEFAULT 'pending'stripe_payment_intent_id textstripe_transfer_id textcancellation_reason textcreated_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 UNIQUEevent_type text NOT NULLprocessed_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 NULLrequested_at timestamptz NOT NULL DEFAULT now()completed_at timestamptz
supabase/migrations/004_constraints_and_indexes.sql
Adds the GiST exclusion constraint and all indexes:
-- Atomic availability lock
ALTER TABLE bookings ADD CONSTRAINT no_overlapping_bookings
EXCLUDE USING gist (
listing_id WITH =,
date_range WITH &&
)
WHERE (status IN ('pending', 'pending_approval', 'confirmed'));
-- Indexes
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';
supabase/migrations/005_triggers.sql
Two triggers:
handle_new_user: firesAFTER INSERT ON auth.users, inserts a row intoprofileswithid = NEW.id,email = NEW.email,roles = '{guest}'. Function name:create_profile_for_user().handle_listing_soft_delete: firesBEFORE DELETE ON listings, setsstatus = 'inactive'and returnsNULL(cancels the delete). Function name:soft_delete_listing().handle_updated_at: firesBEFORE UPDATEonprofiles,listings, andbookings, setsupdated_at = now(). Function name:set_updated_at().
supabase/migrations/006_rls.sql
Enables RLS and creates all policies. Exact policy definitions:
profiles:
ENABLE ROW LEVEL SECURITYSELECT:auth.uid() = id(role: authenticated)UPDATE:auth.uid() = id(role: authenticated)- No
INSERTpolicy for authenticated users — insert handled by trigger via service role - No
DELETEpolicy for any non-service role
listings:
ENABLE ROW LEVEL SECURITYSELECT(public):status = 'active'(role: anon, authenticated)SELECT(own):auth.uid() = host_user_id(role: authenticated)INSERT:auth.uid() = host_user_id(role: authenticated)UPDATE:auth.uid() = host_user_id(role: authenticated)DELETE:auth.uid() = host_user_id(role: authenticated) — soft-deleted by trigger
listing_photos:
ENABLE ROW LEVEL SECURITYSELECT(public): listing must havestatus = 'active'(subquery join to listings) (role: anon, authenticated)SELECT(own host): listinghost_user_id = auth.uid()(role: authenticated)INSERT,UPDATE,DELETE: listinghost_user_id = auth.uid()(role: authenticated)
listing_amenities:
- Same policy structure as
listing_photos
availability_rules:
ENABLE ROW LEVEL SECURITYSELECT: listinghost_user_id = auth.uid()(role: authenticated)INSERT,UPDATE,DELETE: listinghost_user_id = auth.uid()(role: authenticated)
pricing_overrides:
- Same policy structure as
availability_rules
bookings:
ENABLE ROW LEVEL SECURITYSELECT(guest):auth.uid() = guest_user_id(role: authenticated)SELECT(host): listinghost_user_id = auth.uid()via subquery (role: authenticated)- No
INSERT,UPDATE, orDELETEfor any authenticated or anon role — all mutations via service role in Edge Functions only
stripe_webhook_events:
ENABLE ROW LEVEL SECURITY- No policies for authenticated or anon — service role only
ccpa_requests:
ENABLE ROW LEVEL SECURITYSELECT,INSERT:auth.uid() = user_id(role: authenticated)- No
UPDATEorDELETEfor any non-service role
supabase/migrations/007_postgres_functions.sql
Server-side PostgreSQL functions called by Edge Functions:
cancel_pending_booking(p_booking_id uuid) RETURNS void
- Updates
bookingssetstatus = 'declined'whereid = p_booking_idandstatus IN ('pending', 'pending_approval') - Called as a compensating action when Stripe fails during booking creation
confirm_booking(p_booking_id uuid, p_stripe_payment_intent_id text) RETURNS void
- Updates
bookingssetstatus = 'confirmed',stripe_payment_intent_id = p_stripe_payment_intent_id,updated_at = now()whereid = 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), looks up price in priority order: seasonal override (date falls within
season_start/season_end), weekend override (Friday or Saturday), base rate fromlistings.base_price_cents - Returns one row per night
scrub_user_pii(p_user_id uuid) RETURNS void
- Updates
profilessetfull_name = '[deleted]',email = '[deleted]',phone = NULL,avatar_url = NULL,deletion_status = 'completed',updated_at = now()whereid = p_user_id - Does not delete the row
purge_old_webhook_events() RETURNS integer
- Deletes rows from
stripe_webhook_eventswhereprocessed_at < now() - interval '90 days' - Returns count of deleted rows
supabase/migrations/008_cron_jobs.sql
Registers pg_cron jobs (the Edge Function URLs use each project's function base URL, injected at migration time via a SQL variable or handled at deploy time by the CI script):
payout-scheduler: every hour —SELECT cron.schedule('payout-scheduler', '0 * * * *', ...)booking-expire: every 15 minutes —SELECT cron.schedule('booking-expire', '*/15 * * * *', ...)ccpa-scrubber: nightly 02:00 UTC —SELECT cron.schedule('ccpa-scrubber', '0 2 * * *', ...)
Each job calls the corresponding Edge Function URL via net.http_post (using the pg_net extension) with the service-role key in the Authorization header.
Shared Edge Function module
supabase/functions/_shared/supabase-clients.ts
export function getServiceClient(): SupabaseClient
// Returns a Supabase client initialized with SUPABASE_URL and
// SUPABASE_SERVICE_ROLE_KEY (read from Deno.env). Used by all
// Edge Functions for privileged database access.
export function getUserClient(jwt: string): SupabaseClient
// Returns a Supabase client initialized with the caller's JWT,
// scoped to authenticated-user access (RLS applies).
supabase/functions/_shared/auth.ts
export async function requireAuth(req: Request): Promise<{ userId: string; roles: string[] }>
// Extracts the Bearer token from the Authorization header.
// Validates the JWT against SUPABASE_JWT_SECRET.
// Fetches the caller's profile row (roles array) via service client.
// Throws AuthError if no token, invalid token, or user not found.
// Returns { userId, roles }.
export class AuthError extends Error {
constructor(message: string)
// Signals authentication/authorization failure.
// Edge Functions catch this and return a 401 response.
}
supabase/functions/_shared/vault.ts
export async function getSecret(name: string): Promise<string>
// Retrieves a secret from Supabase Vault by 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 interface EmailPayload {
to: string
subject: string
html: string
}
export async function sendEmail(payload: EmailPayload): Promise<void>
// Retrieves RESEND_API_KEY from Vault.
// Sends the email via Resend API.
// Logs and swallows errors — email failure does not throw.
// Sends from the verified domain address stored as RESEND_FROM_ADDRESS in Vault.
supabase/functions/_shared/errors.ts
export function errorResponse(message: string, code: string, status: number): Response
// Returns a Response with JSON body { error: { message, code } }
// and the given HTTP status.
// Never includes stack traces, Postgres error codes, or Stripe error details.
export function successResponse(data: unknown, status = 200): Response
// Returns a Response with JSON body { data } and the given HTTP status.
supabase/functions/_shared/validation.ts
export function validateBody<T>(schema: ZodSchema<T>, body: unknown): T
// Parses body against schema. Throws ValidationError with field-level
// messages on failure.
export class ValidationError extends Error {
fields: Record<string, string>
}
Deliverables:
- All 8 migration files present in
supabase/migrations/and applying cleanly against a fresh Supabase project viasupabase db push - GiST exclusion constraint confirmed active — a direct SQL attempt to insert two overlapping
confirmedbookings for the same listing is rejected - Profile row auto-created on user registration (confirmed by inserting a test auth user and asserting the
profilesrow exists) - All RLS policies verified: unauthenticated client can read active listings and no other table; authenticated user A cannot read user B's bookings or profile
- All shared Edge Function modules present and TypeScript-valid (no compile errors)
supabase db lintpasses with no warnings
Phase 2: Auth Flows and Profile Management (Frontend + Edge Function)
This phase delivers the guest-facing authentication screens (sign up, log in, log out, email verification) and the shared profile page where both guests and hosts manage their personal details. The Supabase JS client handles token refresh transparently. Because these screens read and write the profiles table, all Lovable prompts in this phase go through Plan mode before execution.
Contracts
Frontend modules
src/lib/supabase.ts
export const supabase: SupabaseClient
// Initialized with VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY.
// Singleton — imported by all hooks and pages.
src/hooks/useAuth.ts
export interface AuthUser {
id: string
email: string
roles: string[] // from profiles.roles
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>
// Calls supabase.auth.signUp. Throws on error.
signIn: (email: string, password: string) => Promise<void>
// Calls supabase.auth.signInWithPassword. Throws on error.
signOut: () => Promise<void>
// Calls supabase.auth.signOut.
refreshUser: () => Promise<void>
// Re-fetches the profiles row and updates state.
}
// Subscribes to supabase.auth.onAuthStateChange.
// On session change, fetches the profiles row via PostgREST
// (SELECT id, roles, host_onboarding_status, deletion_status)
// and populates AuthUser.
src/hooks/useProfile.ts
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>
// Issues a PostgREST PATCH on profiles where id = auth.uid().
// RLS enforces ownership. Throws on error.
}
src/pages/SignUpPage.tsx
Form: email, password, full name. On submit calls useAuth().signUp. On success, shows "check your email" confirmation state. Maps GoTrue errors to friendly messages. No user enumeration — "invalid login credentials" is the generic error for both sign-up and sign-in failures.
src/pages/SignInPage.tsx
Form: email, password. On submit calls useAuth().signIn. On success, redirects to /. Maps GoTrue error to generic "invalid email or password" message.
src/pages/ProfilePage.tsx
Displays and edits fullName, phone, doNotSell. Uses useProfile. Shows current role badges (guest/host). Avatar upload is wired in Phase 3 (photo upload shared component). Shows a "Request account deletion" button that calls the ccpa-request Edge Function (implemented in Phase 7).
src/components/AuthGuard.tsx
export function AuthGuard({ children }: { children: ReactNode }): JSX.Element
// Reads from useAuth(). If loading, renders a loading spinner.
// If user is null, redirects to /sign-in.
// 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.
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.
src/App.tsx
Defines all client-side routes:
/→ListingsSearchPage(Phase 3)/sign-up→SignUpPage/sign-in→SignInPage/profile→AuthGuard→ProfilePage/listings/:id→ListingDetailPage(Phase 3)/bookings→AuthGuard→GuestBookingsPage(Phase 4)/host/*→AuthGuard→HostGuard→ host sub-routes (Phase 5)/admin/*→AuthGuard→AdminGuard→ admin sub-routes (Phase 7)
Deliverables:
- User can sign up, receive a verification email, verify, and sign in
profilesrow is present after sign-up withroles = '{guest}'- Authenticated user can update
fullNameandphoneon the profile page; changes persist and are visible on reload - Unauthenticated user visiting
/profileis redirected to/sign-in AuthGuard,HostGuard, andAdminGuardredirect correctly for each role scenario
Phase 3: Listing Browse, Search, and Detail (Frontend)
This phase delivers the guest-facing listing discovery experience: a search/filter page and a listing detail page. All data reads go through PostgREST via the Supabase JS client — no Edge Function is needed for reads because RLS policies on listings and listing_photos permit public SELECT on active listings. Lovable prompts for the data-fetching hooks run through Plan mode to confirm the queries align with the listings RLS policies.
Contracts
Frontend modules
src/hooks/useListings.ts
export interface ListingSearchParams {
location?: string // partial match on listings.location
checkIn?: string // ISO date string
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 // signed URL for first listing_photos row by display_order
instantBook: boolean
cancellationPolicy: string
}
export function useListings(params: ListingSearchParams): {
listings: ListingSummary[]
loading: boolean
error: string | null
}
// Queries PostgREST: SELECT from listings WHERE status = 'active'
// AND (location ilike '%{params.location}%' if provided)
// AND (max_guests >= params.numGuests if provided)
// AND (base_price_cents >= params.minPriceCents if provided)
// AND (base_price_cents <= params.maxPriceCents if provided)
// Joins to listing_photos to get the lowest display_order photo per listing.
// Generates a signed read URL for each cover photo via supabase.storage.
// Date availability filtering is not performed client-side —
// the booking flow enforces availability. Search returns all active listings
// matching the non-date filters; the booking step rejects unavailable dates.
src/hooks/useListingDetail.ts
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
}
// Queries PostgREST for the listing row, joined with listing_photos
// (ordered by display_order) and listing_amenities.
// Generates signed read URLs for all photos.
// Returns null if listing not found or status != 'active'.
src/pages/ListingsSearchPage.tsx
Search bar (location text input), date range picker (check-in / check-out), guest count selector, price range sliders. Results rendered as a grid of ListingCard components. Empty state shows "No listings found." Driven by useListings.
src/pages/ListingDetailPage.tsx
Full listing detail: photo carousel, title, location, description, amenities list, house rules, per-night price, cancellation policy label. "Book" button opens the booking panel (wired in Phase 4). Uses useListingDetail.
src/components/ListingCard.tsx
interface ListingCardProps {
listing: ListingSummary
onClick: () => void
}
// Renders cover photo, title, location, base price per night, instant book badge.
src/components/PhotoCarousel.tsx
interface PhotoCarouselProps {
photos: Array<{ storageUrl: string; displayOrder: number }>
}
// Renders photos in display_order order with prev/next navigation.
Deliverables:
- Unauthenticated user can visit
/and see all active listings - Search filters (location, guest count, price range) narrow the result set correctly
- Clicking a listing navigates to
/listings/:idand shows full detail including all photos and amenities - No data from non-active listings is visible to any client
- Signed photo URLs resolve and render correctly
Phase 4: Booking Flow and Guest Payments (Frontend + Edge Functions)
This phase delivers the complete guest booking experience and the Stripe payment integration. Three Edge Functions are implemented: booking-create (the most critical), booking-cancel (guest-initiated path), and host-onboarding (needed so hosts can be verified before the host management phase). The stripe-webhook Edge Function is also implemented here because it is the recovery path for booking-create. Stripe.js renders card collection in the browser; no card data touches any Edge Function or database table.
Contracts
Edge Functions
supabase/functions/booking-create/index.ts
Zod request schema:
const BookingCreateSchema = z.object({
listingId: z.string().uuid(),
checkIn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
checkOut: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
numGuests: z.number().int().min(1),
stripePaymentMethodId: z.string(),
})
Additional validation (after schema parse, before database):
checkOutmust be aftercheckIn- Stay length must not exceed 365 nights
numGuestsmust not exceedlistings.max_guests
Processing sequence:
requireAuth(req)→{ userId, roles }- Validate request body with
BookingCreateSchema - Retrieve
SERVICE_FEE_PERCENTAGEfrom Vault - Call
get_pricing_for_range(listingId, checkIn, checkOut)→ array of{ night_date, price_cents }; sum tosubtotalCents; calculateserviceFeeCents = floor(subtotalCents * percentage / 100);totalCents = subtotalCents + serviceFeeCents - Execute Direct Insert: Validate availability rules (blocked dates, min stay, check-in day bitmask). If valid, attempt a direct
INSERTinto thebookingstable using the Supabase service-role client.- On PostgreSQL exclusion constraint violation (error code
23P01): returnerrorResponse("These dates are not available", "DATES_UNAVAILABLE", 409)
- On PostgreSQL exclusion constraint violation (error code
- Retrieve
STRIPE_SECRET_KEYfrom Vault - Create Stripe PaymentIntent:
- Amount:
totalCents - Application fee:
serviceFeeCents - Transfer destination: host's
stripe_connect_account_id(fetched fromprofiles) - If
listings.instant_book = true: create and immediately confirm withstripePaymentMethodId; capture mode =automatic - If
listings.instant_book = false: create withstripePaymentMethodId; capture mode =manual(status =requires_capture); do not confirm yet - On Stripe failure: call
cancel_pending_booking(bookingId); returnerrorResponse("Payment could not be processed", "PAYMENT_FAILED", 402)
- Amount:
- If
instant_book = true: callconfirm_booking(bookingId, paymentIntentId); send confirmation email viasendEmail - If
instant_book = false: update bookingstatus = 'pending_approval', storestripe_payment_intent_id; send "awaiting host approval" email to guest; send "new booking request" email to host - Return
successResponse({ bookingId, status })with status 201
Rate limiting: before step 1, check a count of booking-create attempts by this user in the last 60 seconds using a booking_rate_limits table (or an in-memory counter backed by the database). If count ≥ 10, return errorResponse("Too many booking attempts", "RATE_LIMITED", 429).
supabase/functions/stripe-webhook/index.ts
Processing sequence:
- Read raw request body as text (required for signature verification)
- Retrieve
STRIPE_WEBHOOK_SIGNING_SECRETfrom Vault - Verify Stripe webhook signature — on failure, log the attempt and return
successResponse({})(200, to avoid Stripe retry on non-Stripe requests) - Parse event JSON
- Check
stripe_webhook_eventsfor existingstripe_event_id— if found, returnsuccessResponse({})immediately - Insert
stripe_event_id,event_typeintostripe_webhook_events - Dispatch by
event.type:payment_intent.succeeded: if noconfirmedbooking exists for thispaymentIntentId, callconfirm_booking(bookingId, paymentIntentId); send confirmation email; log anomalypayment_intent.payment_failed: update bookingstatus = 'payment_failed'; send failure email to guesttransfer.failed: update bookingpayout_status = 'failed'; send operator alert emailaccount.updated: updateprofiles.host_onboarding_status = 'verified'wherestripe_connect_account_id = event.account
- On database error during dispatch: return a non-2xx response (Stripe retries)
- Return
successResponse({})on success
supabase/functions/booking-cancel/index.ts
Zod request schema:
const BookingCancelSchema = z.object({
bookingId: z.string().uuid(),
initiatedBy: z.enum(['guest', 'host']),
})
Processing sequence:
requireAuth(req)→{ userId, roles }- Validate request body
- Fetch booking row via service client; assert it exists and
status = 'confirmed' - If
initiatedBy = 'host': assertuserIdmatcheslistings.host_user_idfor the booking - If
initiatedBy = 'guest': assertuserId = booking.guest_user_id - If
initiatedBy = 'host': refund amount =booking.total_price_cents(full refund) - If
initiatedBy = 'guest': calculate refund based onlistings.cancellation_policy:flexible: full refund if ≥ 1 day before check-in, else 50%moderate: full refund if ≥ 5 days before check-in, else 50%strict: 50% refund if ≥ 7 days before check-in, else 0%
- Call Stripe Refund API with calculated amount and
stripe_payment_intent_id - On Stripe failure: return
errorResponse("Refund could not be processed", "REFUND_FAILED", 500)— do not update booking status - Update booking
statustocancelled_by_hostorcancelled_by_guest; recordcancellation_reason - Send cancellation email to guest (and host if guest-initiated)
- Return
successResponse({ bookingId, refundAmountCents })
Same error handling contract as Phase 1's Vault access and same Resend email pattern as booking-create.
supabase/functions/booking-approve/index.ts
Zod request schema:
const BookingApproveSchema = z.object({
bookingId: z.string().uuid(),
action: z.enum(['approve', 'decline']),
})
Processing sequence:
requireAuth(req)→{ userId, roles }- Validate request body
- Fetch booking row; assert
status = 'pending_approval'; assertuserIdmatcheslistings.host_user_id - If
action = 'approve': capture the Stripe PaymentIntent; callconfirm_booking(bookingId, paymentIntentId); send confirmation email to guest - If
action = 'decline': cancel the Stripe PaymentIntent; callcancel_pending_booking(bookingId); send decline email to guest - Return
successResponse({ bookingId, status })
Same error handling contract as booking-create.
supabase/functions/host-onboarding/index.ts
Two sub-routes distinguished by query parameter action:
action = 'create-link':
requireAuth(req)→{ userId }- Retrieve
STRIPE_SECRET_KEYfrom Vault - Create a Stripe Connect account (if
stripe_connect_account_idnot already on profile) and a hosted onboarding link with return URL{FRONTEND_URL}/host/onboarding-completeand refresh URL{FRONTEND_URL}/host/onboarding - Store
stripe_connect_account_idon profile if newly created; sethost_onboarding_status = 'pending_verification'; add'host'toprofiles.rolesarray - Return
successResponse({ onboardingUrl })
action = 'complete' (called from the return URL redirect handler):
requireAuth(req)- Fetch profile; if
host_onboarding_status = 'verified', return success immediately (idempotent) - Return
successResponse({ status: 'pending_verification' })— actual verification update comes viastripe-webhookaccount.updatedevent
Additional migration
supabase/migrations/009_booking_rate_limits.sql
Creates booking_rate_limit_checks(user_id uuid, attempted_at timestamptz) table with an index on (user_id, attempted_at). RLS: no authenticated access — service role only. The Edge Function inserts a row on each attempt and counts rows in the last 60 seconds.
Frontend modules
src/hooks/useBooking.ts
export interface BookingRequest {
listingId: string
checkIn: string // ISO date
checkOut: string // ISO date
numGuests: number
stripePaymentMethodId: string
}
export interface BookingResult {
bookingId: string
status: 'confirmed' | 'pending_approval'
}
export function useBooking(): {
createBooking: (req: BookingRequest) => Promise<BookingResult>
// Invokes the booking-create Edge Function.
// Throws with a user-readable message on error codes:
// DATES_UNAVAILABLE, BOOKING_CONFLICT, PAYMENT_FAILED, RATE_LIMITED
cancelBooking: (bookingId: string, initiatedBy: 'guest' | 'host') => Promise<{ refundAmountCents: number }>
// Invokes booking-cancel Edge Function.
loading: boolean
error: string | null
}
src/hooks/useGuestBookings.ts
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
}
// Queries PostgREST: SELECT bookings joined with listings (for title)
// WHERE guest_user_id = auth.uid(), ordered by created_at DESC.
// RLS enforces guest can only see their own bookings.
src/components/BookingPanel.tsx
interface BookingPanelProps {
listing: ListingDetail
checkIn: string
checkOut: string
numGuests: number
}
// Renders price breakdown (per night × nights + service fee = total).
// Renders Stripe card element (via Stripe.js Elements).
// On submit: calls stripe.createPaymentMethod(), then useBooking().createBooking().
// Shows appropriate state for instant-book vs. pending approval.
// Maps error codes to user-facing messages.
src/pages/GuestBookingsPage.tsx
Lists all bookings from useGuestBookings. For each confirmed booking, shows a "Cancel" button that calls useBooking().cancelBooking. Shows refund estimate before confirmation. Cancellation of non-confirmed bookings is not available from this page.
src/lib/stripe.ts
export const stripePromise: Promise<Stripe | null>
// Initialized with VITE_STRIPE_PUBLISHABLE_KEY.
// Singleton — imported by BookingPanel and payment components.
Deliverables:
- Guest can complete a booking for an instant-book listing end-to-end: select dates, enter card details via Stripe Elements, confirm payment, see booking in their bookings list with
status = confirmed - Guest can initiate a booking for a manual-approval listing and see
status = pending_approval - Host can approve or decline a pending booking via
booking-approve - Guest can cancel a confirmed booking and receive the correct refund amount based on the listing's cancellation policy
stripe-webhookcorrectly handlespayment_intent.succeededandaccount.updatedevents; idempotency check prevents duplicate processing- A host completing Stripe Connect onboarding is redirected correctly and their profile
rolesarray includeshost - Two concurrent
booking-createcalls for the same listing and dates result in exactly one confirmed booking and one conflict error
Phase 5: Host Listing Management (Frontend + Edge Functions)
This phase delivers the host-side dashboard: creating and editing listings, managing availability rules and pricing overrides, uploading photos, and toggling listing status. All host-facing writes go through PostgREST (gated by RLS) except photo confirmation, which goes through the listing-photo-confirm Edge Function. Hosts must have host_onboarding_status = verified before they can publish a listing — this is enforced in the ListingEditorPage UI and in the listing status update path.
Contracts
Edge Function
supabase/functions/listing-photo-confirm/index.ts
Zod request schema:
const PhotoConfirmSchema = z.object({
listingId: z.string().uuid(),
storagePath: z.string().min(1), // path within the photos bucket, e.g. "pending/{listingId}/{uuid}"
displayOrder: z.number().int().min(0),
})
Processing sequence:
requireAuth(req)→{ userId }- Validate request body
- Assert caller is the host of
listingId(fetch listing via service client; comparehost_user_id) - Check that the Storage object exists at
storagePathusing the service client — on failure returnerrorResponse("Photo not found in storage", "PHOTO_NOT_FOUND", 404) - Move object from
pending/{listingId}/{uuid}path tophotos/{listingId}/{uuid}(copy then delete source, or use the Storage move API if available) - Insert row into
listing_photos:{ listing_id, storage_path: newPath, display_order } - Return
successResponse({ photoId, storagePath: newPath })
Same error handling contract as Phase 4's Edge Functions for Vault and auth failures.
Frontend modules
src/hooks/useHostListings.ts
export interface HostListingSummary {
id: string
title: string
location: string
status: string
basePriceCents: number
instantBook: boolean
createdAt: string
}
export function useHostListings(): {
listings: HostListingSummary[]
loading: boolean
error: string | null
createListing: (fields: CreateListingFields) => Promise<{ id: string }>
// POSTs to PostgREST listings table. RLS ensures host_user_id = auth.uid().
updateListing: (id: string, fields: Partial<CreateListingFields>) => Promise<void>
// PATCHes the listings row. RLS enforces ownership.
setListingStatus: (id: string, status: 'active' | 'inactive') => Promise<void>
// PATCHes listings.status. Enforces host_onboarding_status = 'verified'
// client-side before allowing status = 'active'.
}
export interface CreateListingFields {
title: string
description?: string
location: string
latitude?: number
longitude?: number
maxGuests: number
basePriceCents: number
cancellationPolicy: string
instantBook: boolean
houseRules?: string
}
src/hooks/useAvailabilityRules.ts
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>
// All via PostgREST. RLS enforces host ownership through listing_id join.
}
src/hooks/usePricingOverrides.ts
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>
}
src/hooks/useListingPhotos.ts
export function useListingPhotos(listingId: string): {
photos: Array<{ id: string; storageUrl: string; displayOrder: number }>
loading: boolean
uploadPhoto: (file: File, displayOrder: number) => Promise<void>
// 1. Generates a signed upload URL via supabase.storage.createSignedUploadUrl
// targeting path `pending/{listingId}/{crypto.randomUUID()}`
// 2. Uploads file directly to Storage using the signed URL
// 3. Calls listing-photo-confirm Edge Function with storagePath and displayOrder
// 4. Refreshes photos list on success
deletePhoto: (photoId: string, storagePath: string) => Promise<void>
// Deletes the listing_photos row via PostgREST (RLS enforces host ownership).
// Deletes the Storage object via supabase.storage.remove.
reorderPhoto: (photoId: string, newDisplayOrder: number) => Promise<void>
// PATCHes listing_photos.display_order via PostgREST.
}
src/hooks/useAmenities.ts
export function useAmenities(listingId: string): {
amenities: Array<{ id: string; amenity: string }>
loading: boolean
addAmenity: (amenity: string) => Promise<void>
removeAmenity: (amenityId: string) => Promise<void>
}
src/hooks/useHostBookings.ts
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>
// Invokes booking-approve Edge Function with action = 'approve'
declineBooking: (bookingId: string) => Promise<void>
// Invokes booking-approve Edge Function with action = 'decline'
cancelBooking: (bookingId: string) => Promise<void>
// Invokes booking-cancel Edge Function with initiatedBy = 'host'
}
// Queries PostgREST: SELECT from bookings joined with profiles (for guest name)
// WHERE listing.host_user_id = auth.uid(), for a given listingId.
// RLS host SELECT policy enforces this.
src/pages/host/HostDashboardPage.tsx
Lists all host listings from useHostListings. Quick stats per listing (upcoming bookings count). "Create new listing" button navigates to /host/listings/new.
src/pages/host/ListingEditorPage.tsx
Tabbed editor with sections: Basic Info, Photos, Amenities, Availability Rules, Pricing Overrides, Settings (instant book toggle, cancellation policy, listing status). Uses all host hooks above. Warns the host if host_onboarding_status != 'verified' and disables the "Set to Active" action. On first save, creates the listing with status = 'draft'.
src/pages/host/HostOnboardingPage.tsx
Shows current onboarding status. If not_started or pending_verification, renders a "Connect with Stripe" button that calls host-onboarding Edge Function with action = 'create-link' and redirects to the returned onboardingUrl. If verified, shows a success state.
src/pages/host/HostBookingsPage.tsx
Lists bookings for all host listings. Approve/decline actions for pending_approval bookings. Cancel action for confirmed bookings. Uses useHostBookings.
Deliverables:
- Host can create a listing, add photos (uploaded to Storage, confirmed via Edge Function), add amenities, set availability rules, set pricing overrides, and publish it (set to
active) - Photos render in the correct
display_orderon the listing detail page - Host cannot set a listing to
activebefore completing Stripe Connect onboarding (host_onboarding_status = verified) - Host can view, approve, decline, and cancel bookings via
HostBookingsPage - Soft-delete trigger fires correctly — a host deleting a listing via PostgREST results in
status = 'inactive', not a missing row - Availability rule evaluation in the
booking-createEdge Function correctly rejects bookings that violate ablocked_range,min_stay, orcheckin_daysrule for the listing before attempting the database insert
Phase 6: Scheduled Background Jobs (Edge Functions)
This phase implements the three scheduled Edge Functions that run autonomously: payout-scheduler, booking-expire, and ccpa-scrubber. These functions are triggered by the pg_cron jobs registered in Phase 1's migration. All three are idempotent — re-running them produces no additional side effects on records already processed.
Contracts
Edge Functions
supabase/functions/payout-scheduler/index.ts
Processing sequence:
- Verify request originates from pg_cron (check for a shared secret in the Authorization header, stored in Vault as
CRON_SECRET; reject other callers with 401) - Log run start
- Query via service client: SELECT bookings WHERE
status = 'confirmed'ANDcheck_in_date < NOW()ANDpayout_status = 'pending' - For each booking:
a. Retrieve host's
stripe_connect_account_idfrom profiles b. RetrieveSTRIPE_SECRET_KEYfrom Vault (cached for the run, not re-fetched per booking) c. Create a Stripe Transfer: amount =total_price_cents - service_fee_cents, destination =stripe_connect_account_idd. On success: UPDATE bookings SETpayout_status = 'completed',stripe_transfer_id = transferIde. On Stripe failure: UPDATE bookings SETpayout_status = 'failed'; send operator alert viasendEmail; log Stripe error (not returned to caller) - Log run completion with count of processed, completed, and failed records
- Return
successResponse({ processed, completed, failed })
If zero bookings are found when confirmed bookings with past check-in dates exist (detected by a separate query for status = 'confirmed' AND check_in_date < NOW()' count > 0 but pending payout count = 0 after 3 consecutive hours), sends operator alert. This sentinel logic is best-effort — a failed run is logged in cron.job_run_details.
supabase/functions/booking-expire/index.ts
Processing sequence:
- Verify pg_cron caller via
CRON_SECRET(same contract aspayout-scheduler) - Query: SELECT bookings WHERE
status = 'pending_approval'ANDcreated_at < NOW() - interval '24 hours' - For each booking:
a. Cancel the Stripe PaymentIntent (
stripe_payment_intent_id) b. Callcancel_pending_booking(bookingId)c. Send "booking request expired" email to guest viasendEmaild. On Stripe failure: log error; still callcancel_pending_booking(do not leave booking inpending_approval— Stripe cancellation can be retried by operator) - Return
successResponse({ expired: count })
Same error handling contract as payout-scheduler for Vault and Stripe access.
supabase/functions/ccpa-scrubber/index.ts
Processing sequence:
- Verify pg_cron caller via
CRON_SECRET - Handle export requests first:
a. Query
ccpa_requestsWHERErequest_type = 'export'ANDcompleted_at IS NULLb. For each: assemble user data (profile row, booking rows excluding payment details) via service client; write JSON toccpa-exportsbucket at pathexports/{userId}/{requestId}.json; generate a 1-hour signed URL; send viasendEmailto the user's current email; updateccpa_requests.completed_at = now() - Handle deletion requests in batches of 50:
a. Query
profilesWHEREdeletion_status = 'pending'ANDdeletion_requested_at < NOW() - interval '30 days'LIMIT 50 b. For each user:- Call
scrub_user_pii(userId)— committed individually - Attempt to delete avatar Storage object if
avatar_urlis set; log failure but do not abort - Update
ccpa_requestsfor this user:completed_at = now() - On failure: log error; continue to next user (no rollback of others) c. Sleep 100ms between batches
- Call
- Call
purge_old_webhook_events()to clean upstripe_webhook_eventsrows older than 90 days - Return
successResponse({ exportedCount, scrubbedCount, purgedWebhookEvents })
Same error handling contract as payout-scheduler for Vault and auth.
Additional migration
supabase/migrations/010_cron_secret.sql
Adds CRON_SECRET to Supabase Vault setup instructions (documented in the migration comment — the actual secret value is set manually in each project's Vault, not stored in the migration file). No schema change required.
Deliverables:
payout-schedulercorrectly transfers funds for all confirmed bookings with past check-in dates andpayout_status = pending; markspayout_status = completedon success andfailedon Stripe error; sends operator alert on transfer failurebooking-expirecancels allpending_approvalbookings older than 24 hours, cancels their PaymentIntents, and emails the guestsccpa-scrubberscrubs PII for users withdeletion_status = pendingolder than 30 days, retaining booking rows with nulled user references; generates and emails export files forexportrequests- All three functions are idempotent — running them twice produces no duplicate Stripe calls or double-scrubs
- pg_cron jobs invoke each function on the correct schedule (verified by checking
cron.job_run_detailsafter manual schedule trigger in staging)
Phase 7: Admin, Support, and CCPA Request Flows (Frontend + Edge Functions)
This phase delivers the operator-facing admin interface, the CCPA deletion and export request endpoints, and the internal tools for support agents. The admin Edge Function gates all privileged operations behind a role check. Support agents can read but not write. A guest-facing "Request account deletion" flow and "Request data export" flow are added to the profile page.
Contracts
Edge Functions
supabase/functions/admin/index.ts
Sub-routes distinguished by action query parameter. All routes:
requireAuth(req)→{ userId, roles }- Assert
rolesincludesadminorsupport; if neither, returnerrorResponse("Forbidden", "FORBIDDEN", 403) - For write operations (payout retry, flag listing, user management): additionally assert
rolesincludesadmin;supportrole returnserrorResponse("Forbidden", "FORBIDDEN", 403)for write actions
Actions:
action = 'list-users' (read):
- Query profiles via service client with optional
emailordeletion_statusfilter - Return
successResponse({ users: Profile[] })
action = 'list-listings' (read):
- Query all listings (any status) with optional
host_user_idorstatusfilter - Return
successResponse({ listings: ListingSummary[] })
action = 'list-bookings' (read):
- Query all bookings with optional
listing_id,guest_user_id, orstatusfilter - Return
successResponse({ bookings: Booking[] })
action = 'retry-payout' (write, admin only):
Zod schema: { bookingId: z.string().uuid() }
- Fetch booking; assert
payout_status = 'failed' - Retrieve
STRIPE_SECRET_KEYfrom Vault - Create Stripe Transfer (same logic as
payout-schedulerstep 4c) - On success: UPDATE
payout_status = 'completed',stripe_transfer_id - On failure: return
errorResponse("Payout retry failed", "PAYOUT_RETRY_FAILED", 500) - Return
successResponse({ bookingId, stripeTransferId })
action = 'flag-listing' (write, admin only):
Zod schema: { listingId: z.string().uuid(), active: z.boolean() }
- UPDATE
listings.status = active ? 'active' : 'inactive' - Return
successResponse({ listingId, status })
action = 'list-hosts-by-verification' (read):
- Query profiles WHERE
'host' = ANY(roles)with optionalhost_onboarding_statusfilter - Return
successResponse({ hosts: Profile[] })
Same error handling contract as Phase 4's Edge Functions for Vault and auth failures.
supabase/functions/ccpa-request/index.ts
Zod request schema:
const CcpaRequestSchema = z.object({
requestType: z.enum(['deletion', 'export']),
})
Processing sequence:
requireAuth(req)→{ userId }- Validate request body
- If
requestType = 'deletion':- Check no existing
ccpa_requestsrow withrequest_type = 'deletion'ANDcompleted_at IS NULLfor this user — if found, returnerrorResponse("A deletion request is already pending", "REQUEST_ALREADY_PENDING", 409) - INSERT into
ccpa_requests:{ user_id, request_type: 'deletion', requested_at: now() } - UPDATE
profiles.deletion_status = 'pending',deletion_requested_at = now() - Send confirmation email to user via
sendEmail
- Check no existing
- If
requestType = 'export':- INSERT into
ccpa_requests:{ user_id, request_type: 'export', requested_at: now() } - Send "export request received" email; actual export is processed by
ccpa-scrubberon next nightly run
- INSERT into
- Return
successResponse({ requestType, requestedAt })
Frontend modules
src/hooks/useAdmin.ts
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>
// All call the admin Edge Function with appropriate action parameter.
loading: boolean
error: string | null
}
src/hooks/useCcpaRequest.ts
export function useCcpaRequest(): {
requestDeletion: () => Promise<void>
// Calls ccpa-request Edge Function with requestType = 'deletion'
requestExport: () => Promise<void>
// Calls ccpa-request Edge Function with requestType = 'export'
loading: boolean
error: string | null
}
src/pages/admin/AdminDashboardPage.tsx
Top-level admin layout with navigation tabs: Users, Listings, Bookings, Hosts.
src/pages/admin/AdminUsersPage.tsx
Searchable user list from useAdmin().fetchUsers. Displays roles, deletion status. Read-only for support agents.
src/pages/admin/AdminListingsPage.tsx
Filterable listings table. "Activate" / "Deactivate" buttons call useAdmin().flagListing. Disabled for support agents (role check client-side; also enforced server-side).
src/pages/admin/AdminBookingsPage.tsx
Filterable bookings table. "Retry Payout" button for payout_status = failed bookings calls useAdmin().retryPayout. Disabled for support agents.
src/pages/admin/AdminHostsPage.tsx
Lists hosts by host_onboarding_status. Read-only.
Updates to src/pages/ProfilePage.tsx (from Phase 2):
Adds "Request account deletion" and "Request my data export" buttons that use useCcpaRequest. Deletion button shows a confirmation dialog and is disabled if a deletion request is already pending (deletion_status = 'pending').
Deliverables:
- Admin user can view all users, listings, bookings, and hosts via the admin dashboard
- Support agent can view all data but all write actions return a
FORBIDDENerror both client-side (button disabled) and server-side (role assertion in Edge Function) - Admin can retry a failed payout and see
payout_statusupdate tocompletedon success - Admin can activate or deactivate any listing regardless of host ownership
- Guest can submit a CCPA deletion request from their profile page;
deletion_statusupdates topending; attempting a second deletion request returns a conflict response - Guest can submit a CCPA export request; the nightly
ccpa-scrubberrun processes it and emails a signed URL - End-to-end CCPA deletion flow verified in staging: PII fields scrubbed, booking rows retained with null user PII,
deletion_status = completed
Phase 8: CI/CD Pipeline, Security Headers, and Deployment
This phase wires up the three GitHub Actions workflows, configures Netlify security headers, and verifies the complete three-environment deployment pipeline. No new product features are added. This phase makes the system production-deployable and verifiably secure at the transport layer.
Contracts
GitHub Actions workflow files
.github/workflows/pr.yml
Triggers on: pull request to main
Steps:
- Checkout repo
- Install Supabase CLI
- Run
supabase db lintagainst all migration files — fails the workflow on any lint error - Run Deno unit tests for all Edge Functions (
deno test supabase/functions/) - Start a local Supabase instance (
supabase start) - Apply migrations to the local instance (
supabase db push --local) - Run integration tests (see test contracts below)
- Stop local Supabase instance
.github/workflows/staging-deploy.yml
Triggers on: push to main
Steps:
- Checkout repo
- Install Supabase CLI
supabase db pushtargetinghomestay-stagingusingSUPABASE_STAGING_URLandSUPABASE_ACCESS_TOKENsupabase functions deploy --project-ref {stagingRef}for all Edge Functions- Run smoke tests against the staging URL (see smoke test contracts below)
- On success or failure: send notification email to operator via Resend (using
RESEND_API_KEYGitHub secret)
.github/workflows/production-deploy.yml
Triggers on: workflow_dispatch (manual trigger only)
Environment: production (requires GitHub environment approval before deploy step)
Steps:
- Checkout repo
- Install Supabase CLI
supabase db pushtargetinghomestay-prodsupabase functions deploy --project-ref {prodRef}for all Edge Functions- Trigger Netlify deploy hook (POST to Netlify deploy hook URL stored as
NETLIFY_DEPLOY_HOOKGitHub secret) — Netlify builds and deploys the frontend with production env vars - Run smoke tests against the production URL
- Send deployment notification email
Integration tests
supabase/tests/rls.test.ts
- Assert unauthenticated client can SELECT active listings and listing photos; cannot SELECT bookings, profiles, or any other table
- Assert user A (authenticated) can SELECT their own profile; cannot SELECT user B's profile
- Assert user A cannot SELECT user B's bookings
- Assert host can SELECT their own listings (including drafts); cannot SELECT another host's draft listings
- Assert direct INSERT into bookings as authenticated user is rejected (no RLS policy permits it)
- Assert direct DELETE on listings as authenticated user results in status = 'inactive' (soft delete trigger), not a missing row
supabase/tests/booking-lock.test.ts
- Fire two concurrent
booking-createfunction invocations for the same listing and overlapping dates - Assert exactly one returns a 201 with
status = 'confirmed'or'pending_approval' - Assert the other returns a 409 with code
DATES_UNAVAILABLEorBOOKING_CONFLICT - Assert there is exactly one booking row in the database for those dates and listing with a non-declined status
supabase/tests/ccpa.test.ts
- Create a test user with a booking
- Submit a deletion request via
ccpa-request - Manually invoke
ccpa-scrubber(bypassing the 30-day wait in test mode by directly calling the scrub function with a service-role client) - Assert
profilesrow hasfull_name = '[deleted]',email = '[deleted]',phone = null - Assert the booking row still exists with the original
guest_user_idreference intact
Smoke tests
supabase/tests/smoke.ts
- GET request to the PostgREST listings endpoint — assert 200 and a valid JSON array
- GET request to each deployed Edge Function URL with no auth — assert 401 (not 404 or 500), confirming the function is deployed and running
- POST to
stripe-webhookwith a malformed signature — assert 200 response (signature failure returns 200 to Stripe, not 4xx)
Security headers
netlify.toml (or public/_headers for Netlify)
Applied to all responses from the production Netlify site:
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: default-src 'self'; script-src 'self' https://js.stripe.com; frame-src https://js.stripe.com; connect-src 'self' {SUPABASE_URL} https://api.stripe.com; img-src 'self' data: {SUPABASE_STORAGE_URL}; style-src 'self' 'unsafe-inline'
The {SUPABASE_URL} and {SUPABASE_STORAGE_URL} placeholders are replaced with the production Supabase project URL during the Netlify build step.
Deliverables:
- PR workflow blocks merge on any migration lint error, Edge Function unit test failure, or integration test failure
- Staging deploy runs automatically on merge to
mainand smoke tests pass against the staging Supabase project - Production deploy requires manual trigger and reviewer approval; smoke tests pass against production
- Netlify production site returns all required security headers (verified with a browser developer tools network inspection or
curl -I) - RLS integration tests pass: correct row visibility per user role, direct booking INSERT denied for authenticated users
- Concurrent booking lock test passes: exactly one booking created for overlapping dates
Prompt Pack
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,inactivebooking_status_enum:pending,pending_approval,confirmed,declined,cancelled_by_host,cancelled_by_guest,payment_failedpayout_status_enum:pending,completed,failedavailability_rule_type_enum:blocked_range,min_stay,checkin_dayscancellation_policy_enum:flexible,moderate,stricthost_onboarding_status_enum:not_started,pending_verification,verifieddeletion_status_enum:none,pending,completedccpa_request_type_enum:deletion,exportuser_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 CASCADEfull_name textemail textphone textavatar_url textroles user_role_enum[] NOT NULL DEFAULT '{guest}'stripe_customer_id textstripe_connect_account_id texthost_onboarding_status host_onboarding_status_enum NOT NULL DEFAULT 'not_started'deletion_status deletion_status_enum NOT NULL DEFAULT 'none'deletion_requested_at timestamptzdo_not_sell boolean NOT NULL DEFAULT falsecreated_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 NULLdescription textlocation text NOT NULLlatitude numeric(9,6)longitude numeric(9,6)max_guests integer NOT NULLbase_price_cents integer NOT NULLcancellation_policy cancellation_policy_enum NOT NULL DEFAULT 'moderate'instant_book boolean NOT NULL DEFAULT falsehouse_rules textstatus 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 CASCADEamenity text NOT NULL
listing_photos
id uuid PRIMARY KEY DEFAULT gen_random_uuid()listing_id uuid NOT NULL REFERENCES listings(id) ON DELETE CASCADEstorage_path text NOT NULLdisplay_order integer NOT NULL DEFAULT 0created_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 CASCADErule_type availability_rule_type_enum NOT NULLblocked_start dateblocked_end datemin_stay_nights integercheckin_days_bitmask integercreated_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 CASCADEoverride_type text NOT NULLseason_start dateseason_end dateprice_cents integer NOT NULLcreated_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 NULLcheck_out_date date NOT NULLdate_range tsrange GENERATED ALWAYS AS (tsrange(check_in_date::timestamp, check_out_date::timestamp)) STOREDnum_guests integer NOT NULLtotal_price_cents integer NOT NULLservice_fee_cents integer NOT NULLstatus booking_status_enum NOT NULL DEFAULT 'pending'payout_status payout_status_enum NOT NULL DEFAULT 'pending'stripe_payment_intent_id textstripe_transfer_id textcancellation_reason textcreated_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 UNIQUEevent_type text NOT NULLprocessed_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 NULLrequested_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 NULLattempted_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:
SELECTfor roleauthenticated:auth.uid() = idUPDATEfor roleauthenticated:auth.uid() = id- No
INSERTforauthenticated— inserts are handled by the trigger via service role - No
DELETEfor any non-service role
listings policies:
SELECTfor rolesanonandauthenticated:status = 'active'SELECT(own) for roleauthenticated:auth.uid() = host_user_idINSERTfor roleauthenticated:auth.uid() = host_user_idUPDATEfor roleauthenticated:auth.uid() = host_user_idDELETEfor roleauthenticated:auth.uid() = host_user_id— the soft-delete trigger intercepts this and sets status toinactiverather than deleting the row
listing_photos policies:
SELECT(public) for rolesanonandauthenticated: the associatedlisting_idmust reference a listing withstatus = 'active'(use a subquery)SELECT(own host) for roleauthenticated: the associatedlisting_idmust reference a listing wherehost_user_id = auth.uid()INSERT,UPDATE,DELETEfor roleauthenticated: the associatedlisting_idmust reference a listing wherehost_user_id = auth.uid()
listing_amenities policies: Same structure as listing_photos.
availability_rules policies:
SELECTfor roleauthenticated: listinghost_user_id = auth.uid()via subqueryINSERT,UPDATE,DELETEfor roleauthenticated: listinghost_user_id = auth.uid()via subquery
pricing_overrides policies: Same structure as availability_rules.
bookings policies:
SELECT(guest) for roleauthenticated:auth.uid() = guest_user_idSELECT(host) for roleauthenticated: the associatedlisting_idreferences a listing wherehost_user_id = auth.uid()(use a subquery)- No
INSERT,UPDATE, orDELETEfor anyauthenticatedoranonrole — 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:
SELECTfor roleauthenticated:auth.uid() = user_idINSERTfor roleauthenticated:auth.uid() = user_id- No
UPDATEorDELETEfor 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_overridesfor thislisting_idwithoverride_type = 'seasonal'where the night date falls withinseason_startandseason_end(inclusive), use thatprice_cents - Else if
override_type = 'weekend'exists for this listing and the night date is a Friday or Saturday, use thatprice_cents - Else use
base_price_centsfrom thelistingstable for thislisting_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: schedule0 * * * *(every hour)booking-expire: schedule*/15 * * * *(every 15 minutes)ccpa-scrubber: schedule0 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-devdashboard → 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 →
profilesand confirm a row was automatically created for that user withroles = {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 samelisting_idfor 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
profilesrow (columns:id,roles,host_onboarding_status,deletion_status) via PostgREST and populateAuthUser. Theemailvalue comes from the Supabase session, not the profiles row. signUpcalls Supabase auth sign-up with the provided email and password. It also passesfullNameas user metadata so it is available during the profile setup step. Throws on any error.signIncalls Supabase auth sign-in with email and password. Throws on any error.signOutcalls Supabase auth sign-out and clearsusertonull.refreshUserre-fetches the profiles row and updates state. Called after profile edits or onboarding status changes.- While the initial session check is in progress,
loadingistrue.
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.
updateProfileissues a PostgREST PATCH on theprofilestable scoped to the authenticated user'sid. Only the fieldsfull_name,phone, anddo_not_sellmay be updated by this hook. Refreshes the local profile state on success.- If the user is not authenticated,
profileisnull.
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-requestEdge Function (built in Prompt 7). For now, render the button but it does not need to do anything yet. Disable the button ifprofile.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→AuthGuardwrappingProfilePage/listings/:id→ListingDetailPage(placeholder)/bookings→AuthGuardwrappingGuestBookingsPage(placeholder)/host→AuthGuardwrappingHostGuardwrappingHostDashboardPage(placeholder)/host/listings/new→AuthGuardwrappingHostGuardwrappingListingEditorPage(placeholder)/host/listings/:id→AuthGuardwrappingHostGuardwrappingListingEditorPage(placeholder)/host/onboarding→AuthGuardwrappingHostGuardwrappingHostOnboardingPage(placeholder)/host/onboarding-complete→AuthGuardwrappingHostGuardwrappingHostOnboardingPage(placeholder)/host/bookings→AuthGuardwrappingHostGuardwrappingHostBookingsPage(placeholder)/admin→AuthGuardwrappingAdminGuardwrappingAdminDashboardPage(placeholder)/admin/users→AuthGuardwrappingAdminGuardwrappingAdminUsersPage(placeholder)/admin/listings→AuthGuardwrappingAdminGuardwrappingAdminListingsPage(placeholder)/admin/bookings→AuthGuardwrappingAdminGuardwrappingAdminBookingsPage(placeholder)/admin/hosts→AuthGuardwrappingAdminGuardwrappingAdminHostsPage(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
/profilewhile 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
listingswherestatus = 'active'. - Applies additional filters when provided:
locationuses a case-insensitive partial match againstlistings.location;numGuestsfilters to listings wheremax_guests >= numGuests;minPriceCentsandmaxPriceCentsfilter onbase_price_cents. checkInandcheckOutare 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_photosrow with the lowestdisplay_orderfor that listing. Generates a signed read URL for that photo using the Supabase storage client. If no photo exists,coverPhotoUrlisnull. - Re-runs the query when
paramschanges.
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
listingsrow for the givenlistingIdvia PostgREST. - Also fetches all
listing_photosrows for this listing ordered bydisplay_orderascending, and alllisting_amenitiesrows. - Generates signed read URLs for all photos via the Supabase storage client.
- If the listing does not exist or
status != 'active', returnslisting: null. - Sets
errorto 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
ListingCardcomponents driven by the listings returned fromuseListings. - When
loadingis true, show a loading state in the grid area. - When
listingsis empty andloadingis false, display: "No listings found. Try adjusting your filters." - Clicking a
ListingCardnavigates to/listings/:id, passing the selectedcheckIn,checkOut, andnumGuestsvalues 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
PhotoCarouselwith 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
loadingis true, show a page-level loading state. - If
listingis 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 thelistingstable. 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.tsas appropriate. - Set CORS headers on all responses, allowing requests from the production frontend domain (read
FRONTEND_URLfrom Vault orDeno.env). - Return
errorResponsefor all error cases,successResponsefor 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 stringcheckIn: string matching the pattern for an ISO date (four digits, hyphen, two digits, hyphen, two digits)checkOut: string matching the same ISO date patternnumGuests: integer, minimum 1stripePaymentMethodId: non-empty string
Processing sequence — implement in this exact order:
Rate limit check: using the service client, count rows in
booking_rate_limit_checksfor this user whereattempted_at > now() - interval '60 seconds'. If count ≥ 10, returnerrorResponse("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 throwsAuthError.Validate the request body with the Zod schema. Return a validation error response if it throws
ValidationError.Additional validation (after schema parse):
checkOutmust be strictly aftercheckIn— if not, returnerrorResponse("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
listingsrow forlistingIdvia service client. If not found orstatus != 'active', returnerrorResponse("Listing not found.", "LISTING_NOT_FOUND", 404).Validate
numGuests <= listings.max_guests— if not, returnerrorResponse("Guest count exceeds the listing's maximum.", "GUEST_COUNT_EXCEEDED", 400).Validate availability rules for the listing. Using the service client, fetch all
availability_rulesrows for this listing. Check in this order:- For each rule of type
blocked_range: if any night in the requested range falls withinblocked_starttoblocked_end(inclusive), returnerrorResponse("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 thanmin_stay_nights, returnerrorResponse("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 bycheckin_days_bitmask, returnerrorResponse("Check-in is not permitted on the selected day.", "CHECKIN_DAY_NOT_PERMITTED", 400).
- For each rule of type
Retrieve
SERVICE_FEE_PERCENTAGEfrom Vault. Callget_pricing_for_range(listingId, checkIn, checkOut)via the service client. Sum all returnedprice_centsvalues to getsubtotalCents. CalculateserviceFeeCents = 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 code23P01), returnerrorResponse("These dates are not available. Please select different dates.", "DATES_UNAVAILABLE", 409). Capture the newbookingIdfrom the insert result.Retrieve
STRIPE_SECRET_KEYfrom Vault. Fetch the host'sstripe_connect_account_idfrom theprofilestable forlistings.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 berequires_capture); do not confirm yet - On any Stripe error: call
cancel_pending_booking(bookingId)via service client, then returnerrorResponse("Payment could not be processed. Please check your card details.", "PAYMENT_FAILED", 402)
- Amount:
If
instant_book = true:- Call
confirm_booking(bookingId, paymentIntentId)via service client - Send a booking confirmation email to the guest via
sendEmailwith subject "Your booking is confirmed" and relevant booking details in the body - Return
successResponse({ bookingId, status: 'confirmed' }, 201)
- Call
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)
- Update the booking row:
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_SECRETfrom 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_eventstable via service client for an existing row withstripe_event_id = event.id. If found, returnsuccessResponse({})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 thepaymentIntentIdfrom the event. Querybookingsfor a row wherestripe_payment_intent_id = paymentIntentIdandstatus = 'confirmed'. If no such row exists, callconfirm_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 bystripe_payment_intent_id. Updatestatus = 'payment_failed'. Send a payment failure notification email to the guest.transfer.failed: Find the matching booking bystripe_transfer_id. Updatepayout_status = 'failed'. Send an operator alert email viasendEmailtoOPERATOR_ALERT_EMAIL(retrieved from Vault).account.updated: Extract the connected account ID from the event. Updateprofileswherestripe_connect_account_id = event.account: sethost_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 stringinitiatedBy: one of the values'guest'or'host'
Processing sequence:
requireAuth(req)→{ userId, roles }. Return unauthorized onAuthError.- Validate request body. Return validation error on
ValidationError. - Fetch the booking row via service client. If not found or
status != 'confirmed', returnerrorResponse("Booking not found or cannot be cancelled.", "BOOKING_NOT_CANCELLABLE", 404). - Fetch the listing row to get
host_user_idandcancellation_policy. - If
initiatedBy = 'host': assertuserId === listing.host_user_id. If not, returnerrorResponse("Not authorised to cancel this booking.", "FORBIDDEN", 403). - If
initiatedBy = 'guest': assertuserId === booking.guest_user_id. If not, returnerrorResponse("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_dateand today's date - Policy
flexible: full refund if days until check-in ≥ 1, else 50% oftotal_price_cents - Policy
moderate: full refund if days until check-in ≥ 5, else 50% oftotal_price_cents - Policy
strict: 50% oftotal_price_centsif days until check-in ≥ 7, else 0
- Days until check-in = difference between
- If
- Retrieve
STRIPE_SECRET_KEYfrom Vault. Call the Stripe Refund API withrefundAmountCentsand the booking'sstripe_payment_intent_id. On Stripe failure: returnerrorResponse("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 stringaction: one of the values'approve'or'decline'
Processing sequence:
requireAuth(req)→{ userId, roles }. Return unauthorized onAuthError.- Validate request body.
- Fetch the booking row via service client. Assert
status = 'pending_approval'. If not, returnerrorResponse("Booking is not awaiting approval.", "INVALID_STATUS", 409). - Fetch the listing. Assert
userId === listing.host_user_id. If not, returnerrorResponse("Not authorised.", "FORBIDDEN", 403). - Retrieve
STRIPE_SECRET_KEYfrom 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
- Capture the Stripe PaymentIntent identified by
- 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 onAuthError.- Retrieve
STRIPE_SECRET_KEYandFRONTEND_URLfrom Vault. - Fetch the caller's profile row via service client.
- If
stripe_connect_account_idis null or empty: create a new Stripe Connect account. Store the resulting account ID on the profile: updateprofiles.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
- Return URL:
- Update profile:
host_onboarding_status = 'pending_verification'. Add'host'to therolesarray if not already present (use a Postgres array append operation). - Return
successResponse({ onboardingUrl }).
action = 'complete':
requireAuth(req)→{ userId }. Return unauthorized onAuthError.- Fetch profile. If
host_onboarding_status = 'verified', returnsuccessResponse({ status: 'verified' })immediately. - Return
successResponse({ status: 'pending_verification' }). The actual status update toverifiedarrives via thestripe-webhookaccount.updatedevent.
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:
createBookinginvokes thebooking-createEdge Function with the request payload. The call must include the authenticated user's JWT. On success, returnsBookingResult. 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_REQUIREDandCHECKIN_DAY_NOT_PERMITTED→ use the message from the error response directly.cancelBookinginvokes thebooking-cancelEdge Function. On success, returns the refund amount. On error, throws with a user-readable message.loadingreflects whether either function is in progress.errorholds the last error message, ornull.
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
bookingsjoined withlistings(to get the listing title) whereguest_user_id = auth.uid(), ordered bycreated_atdescending. 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.basePriceCentsfor the per-night estimate — the exact amount is calculated server-side; display this as an estimate. - A Stripe
CardElementwrapped in a StripeElementsprovider usingstripePromise. 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 apaymentMethodId - Call
useBooking().createBooking(...)with thepaymentMethodId - 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
- Call
- While
useBooking().loadingis 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, calluseBooking().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_startedorpending_verification: display an explanation of why Stripe Connect onboarding is required, and a "Connect with Stripe" button that calls thehost-onboardingEdge Function withaction = 'create-link'and then redirects the browser to the returnedonboardingUrl. - If
verified: display a success state — "Your account is verified. You can now publish listings." - On the
/host/onboarding-completeroute, call the Edge Function withaction = 'complete'on mount and calluseAuth().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-webhookEdge Function URL from your Supabasehomestay-devproject - Select these events:
payment_intent.succeeded,payment_intent.payment_failed,transfer.failed,account.updated - Copy the signing secret and update the
STRIPE_WEBHOOK_SIGNING_SECRETvalue in yourhomestay-devSupabase 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
/bookingswithstatus = confirmed. - Cancel the confirmed booking from the guest bookings page. Confirm the status changes to
cancelled_by_guestand a refund amount is shown. - In Stripe's test dashboard, use the webhook event simulator to send a
payment_intent.succeededevent and confirm thestripe-webhookfunction receives it (check Supabase Edge Function logs — no errors should appear). - Begin the host Stripe Connect onboarding flow at
/host/onboardingand 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 stringstoragePath: 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 onAuthError.- Validate request body. Return validation error on
ValidationError. - Fetch the
listingsrow forlistingIdvia service client. Assertlisting.host_user_id === userId. If not, returnerrorResponse("Not authorised.", "FORBIDDEN", 403). - Verify the Storage object exists at
storagePathin thephotosbucket using the service client. If it does not exist, returnerrorResponse("Photo not found in storage. Please try uploading again.", "PHOTO_NOT_FOUND", 404). - Construct the destination path by replacing the
pending/prefix instoragePathwithphotos/(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 newphotoId. - 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 RLSSELECT (own)policy onlistingsmakes this work — it returns drafts, active, and inactive listings. createListinginserts a new listing row via PostgREST withstatus = 'draft'. RLS enforces thathost_user_idis set toauth.uid().updateListingpatches the listing row via PostgREST. RLS enforces host ownership.setListingStatuspatchesstatuson the listing. Before calling PostgREST forstatus = 'active', check the authenticated user'shostOnboardingStatusfromuseAuth(). 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_photosrows for the listing via PostgREST, ordered bydisplay_order. Generates signed read URLs for each photo. uploadPhoto: (1) callssupabase.storage.createSignedUploadUrltargeting the pathpending/{listingId}/{crypto.randomUUID()}in thephotosbucket; (2) uploads the file directly to Storage using the signed upload URL; (3) calls thelisting-photo-confirmEdge Function with the storage path and display order; (4) refreshes the photos list on success.deletePhoto: deletes thelisting_photosrow via PostgREST (RLS enforces host ownership), then deletes the Storage object atstoragePath.reorderPhoto: patchesdisplay_orderon thelisting_photosrow 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 withprofilesto getfull_nameasguestName. The RLS host SELECT policy onbookingspermits this — it allows hosts to see bookings for their own listings. approveBooking: invokes thebooking-approveEdge Function with{ bookingId, action: 'approve' }. Refreshes the list on success.declineBooking: invokes thebooking-approveEdge Function with{ bookingId, action: 'decline' }. Refreshes the list on success.cancelBooking: invokes thebooking-cancelEdge 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
displayOrderviareorderPhotoon drop). Each photo has a delete button. An upload button opens a file picker that callsuseListingPhotos().uploadPhoto. Show upload progress. Photos display indisplayOrderorder.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 endmin_stay: integer input for minimum nightscheckin_days: a set of day-of-week checkboxes (Sunday through Saturday) that encode into thecheckinDaysBitmaskvalue. 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.
- Type
Settings: Instant book toggle. Cancellation policy selector (Flexible / Moderate / Strict). Listing status control:
- If
status = 'draft': "Publish Listing" button that callssetListingStatus('active'). IfhostOnboardingStatus != 'verified', show a warning: "Complete Stripe Connect onboarding to publish this listing" and disable the button. - If
status = 'active': "Deactivate Listing" button that callssetListingStatus('inactive'). - If
status = 'inactive': "Reactivate Listing" button that callssetListingStatus('active').
- If
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
/hostand 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_statusis notverified. 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_SECRETas described above. - Log run start to the Edge Function console.
- Retrieve
STRIPE_SECRET_KEYfrom Vault once for the entire run — do not retrieve it per booking. - Query via service client: all
bookingsrows wherestatus = 'confirmed'ANDcheck_in_date < NOW()ANDpayout_status = 'pending'. - For each booking in the result:
a. Fetch the host's
stripe_connect_account_idfromprofileswhereid = listings.host_user_idfor this booking'slisting_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: updatebookings:payout_status = 'completed',stripe_transfer_id = {transferId}. e. On Stripe failure: updatebookings:payout_status = 'failed'. RetrieveOPERATOR_ALERT_EMAILfrom Vault and send an alert email viasendEmailwith 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_KEYfrom Vault. - Query via service client: all
bookingsrows wherestatus = 'pending_approval'ANDcreated_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 inpending_approvaleven if Stripe cancellation fails. Log for operator follow-up. c. Callcancel_pending_booking(bookingId)via service client. d. Fetch the guest's email fromprofileswhereid = booking.guest_user_id. Send a "Your booking request has expired — the host did not respond within 24 hours" email viasendEmail. - Return
successResponse({ expired: count }).
supabase/functions/ccpa-scrubber/index.ts
Processing sequence:
- Verify caller via
CRON_SECRET. - Process export requests first:
a. Query
ccpa_requestsvia service client whererequest_type = 'export'ANDcompleted_at IS NULL. b. For each request:- Fetch the user's profile row and all their booking rows (excluding
stripe_payment_intent_idandstripe_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-exportsStorage bucket at pathexports/{userId}/{requestId}.jsonusing 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
sendEmailwith subject "Your Homestay data export is ready". - Update the
ccpa_requestsrow:completed_at = now().
- Fetch the user's profile row and all their booking rows (excluding
- Process deletion requests in batches of 50:
a. Query
profilesvia service client wheredeletion_status = 'pending'ANDdeletion_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_requestsrow 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).
- Call
- 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 confirmpg_cronis enabled. - Run this SQL in the Supabase SQL editor:
SELECT jobname, schedule, active FROM cron.job;— confirm three jobs appear:payout-scheduler,booking-expire, andccpa-scrubber, all withactive = true. - If the jobs are not registered, re-run the
008_cron_jobs.sqlmigration manually via the SQL editor, substituting the correct Edge Function URLs for yourhomestay-devproject and theCRON_SECRETvalue 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 invokepayout-schedulermanually again. Confirmpayout_statuschanges tocompletedin thebookingstable. - Confirm
booking-expireandccpa-scrubberrespond with a success body when invoked with the correctCRON_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 onAuthError.- If
rolesdoes not include'admin'or'support': returnerrorResponse("Forbidden", "FORBIDDEN", 403). - For any write action (those explicitly marked below): if
rolesdoes not include'admin', returnerrorResponse("Forbidden", "FORBIDDEN", 403). Thesupportrole is read-only.
The action is determined by the action query parameter on the request URL.
action = 'list-users' (read):
- Accept optional query parameters
emailanddeletionStatusfor filtering. - Query
profilesvia service client. Apply filters if provided:emailas a case-insensitive partial match onprofiles.email;deletionStatusas an exact match ondeletion_status. - Return
successResponse({ users }).
action = 'list-listings' (read):
- Accept optional query parameters
hostUserIdandstatusfor filtering. - Query all
listingsrows via service client (any status). Apply filters if provided. - Return
successResponse({ listings }).
action = 'list-bookings' (read):
- Accept optional query parameters
listingId,guestUserId, andstatusfor filtering. - Query all
bookingsrows 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, returnerrorResponse("Payout is not in failed status.", "INVALID_STATUS", 409). - Retrieve
STRIPE_SECRET_KEYfrom Vault. - Fetch host's
stripe_connect_account_idfromprofiles. - 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. ReturnsuccessResponse({ 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.statusto'active'ifactive = true, or'inactive'ifactive = 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
hostOnboardingStatusfor filtering. - Query
profilesvia 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 onAuthError.- Validate request body. Return validation error on
ValidationError. - If
requestType = 'deletion':- Query
ccpa_requestsvia service client for an existing row whereuser_id = userIdANDrequest_type = 'deletion'ANDcompleted_at IS NULL. If found: returnerrorResponse("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
profilesvia 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."
- Query
- 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."
- Insert into
- 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 theccpa-requestEdge Function withrequestType = 'deletion'. OnREQUEST_ALREADY_PENDINGerror, setserrorto "A deletion request is already pending for your account." without throwing.requestExport: invokes theccpa-requestEdge Function withrequestType = 'export'.- On success, sets
errortonull.
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" ifprofile.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
adminrole (set this directly in the Supabase dashboard by updating therolescolumn on the profile row). Navigate to/admin/users— you should see the users table populated. - Navigate to
/admin/listingsand 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
supportrole. 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_statuschanges topendingin the Supabase dashboard under theprofilestable.
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
listingstable. Assert it receives only rows withstatus = 'active'. Assert it receives zero rows frombookings. Assert it receives zero rows fromprofiles.Profile isolation: User A (authenticated) queries the
profilestable. Assert it receives exactly their own profile row and no other rows.Booking isolation: User A queries the
bookingstable. Assert it receives only bookings whereguest_user_idmatches User A's ID. Assert it receives zero rows from User B's bookings.Host listing access: Host A queries the
listingstable. 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
bookingstable 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
statusis'inactive'.
supabase/tests/booking-lock.test.ts
Write a concurrency test using the Deno test runner:
- Create a test listing with
instant_book = trueandstatus = 'active'via the service-role client. - Fire two concurrent
booking-createEdge Function invocations for the same listing and overlapping dates (usePromise.allto make them truly concurrent). - Assert that exactly one response has a success status (status code in the 2xx range) with
statusin the response body equal to'confirmed'or'pending_approval'. - Assert that the other response has an error status with error code
DATES_UNAVAILABLEorBOOKING_CONFLICT. - Query the
bookingstable 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-requestEdge Function as that user withrequestType = '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
profilesfor that user. Assertfull_name = '[deleted]',email = '[deleted]',phone IS NULL,avatar_url IS NULL,deletion_status = 'completed'. - Query
bookingsfor that user'sguest_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
listingsendpoint 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-webhookwith 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 lintagainst all files insupabase/migrations/. Fail the workflow if any lint errors are reported. - Run Deno unit tests:
deno test supabase/functions/— this runs any*.test.tsfiles 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, andsupabase/tests/ccpa.test.tsusing 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_TOKENandSUPABASE_STAGING_PROJECT_REFfrom GitHub secrets. - Run
supabase db pushtargeting the staging project. - Run
supabase functions deployfor all Edge Functions insupabase/functions/, targeting the staging project. - Run smoke tests: execute
supabase/tests/smoke.tswithSMOKE_TEST_URLset toSUPABASE_STAGING_URLfrom 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 usingRESEND_API_KEYfrom 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 pushtargeting the production project usingSUPABASE_PROD_PROJECT_REFandSUPABASE_ACCESS_TOKEN. - Run
supabase functions deployfor all Edge Functions, targeting the production project. - Trigger the Netlify deploy hook by sending a POST request to the URL stored in
NETLIFY_DEPLOY_HOOKGitHub 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.tswithSMOKE_TEST_URLset toSUPABASE_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; preloadX-Content-Type-Options:nosniffX-Frame-Options:DENYReferrer-Policy:strict-origin-when-cross-originContent-Security-Policy: a policy that allows scripts from'self'andhttps://js.stripe.com; allows frames fromhttps://js.stripe.com; allows connections to'self', the Supabase project URL, andhttps://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_HOOKwith 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-webhookEdge Function URL from yourhomestay-prodSupabase project. Add the same four events as the test-mode webhook. Copy the live signing secret into theSTRIPE_WEBHOOK_SIGNING_SECRETVault entry inhomestay-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
mainin 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, andContent-Security-Policyheaders 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.
Guide
Before You Build
The Before You Build guide details the prerequisite service accounts, database extension configurations, Supabase Vault secrets, and GitHub Actions repository secrets required prior to executing Prompt 1 of the Homestay Lovable Prompt Pack generated by Zalcro.
How to Use This Prompt Pack
This Prompt Pack is a sequence of ready-to-paste instructions for Lovable, your AI coding tool. Each prompt builds one piece of Homestay. You paste them one at a time, in order, and confirm each piece is working before moving to the next. You do not need to understand the technical details inside each prompt — they are written for Lovable, not for you.
Lovable works best when you:
- Use Plan mode before executing any prompt that touches the database or data access. Lovable will show you what it intends to do — review it before clicking Execute. This is especially important for prompts that involve Row-Level Security (RLS) policies, because a misconfigured policy silently hides data rather than showing an error.
- Paste the full prompt text, including every line. Lovable processes the whole prompt at once.
- Wait for Lovable to finish completely before checking the result or moving to the next prompt.
- If something looks wrong after a prompt, describe the problem to Lovable in the chat and ask it to fix it — do not move to the next prompt until it is resolved.
- Connect a GitHub repository before starting so every change is saved and you can roll back if needed.
What You're Building
Homestay is a short-term rental marketplace where hosts list homes or rooms and guests search, book, and pay for overnight stays. Hosts receive payouts automatically after guests check in, and the platform handles all payment routing, refunds, and cancellations. Guests and hosts share a single account with role-based access, and the platform includes an admin dashboard for operators and support staff.
This Pack Contains 8 Prompts
| Prompt | What it delivers |
|---|---|
| Prompt 1 | Complete database schema, all RLS policies, triggers, indexes, and shared Edge Function utilities |
| Prompt 2 | Sign-up, sign-in, sign-out, email verification, and the profile page |
| Prompt 3 | Guest-facing listing search page and listing detail page |
| Prompt 4 | Guest booking flow, Stripe payment collection, booking history, and host approval/decline |
| Prompt 5 | Host dashboard, listing editor, photo uploads, availability rules, and pricing overrides |
| Prompt 6 | Scheduled background jobs: automatic payouts, booking expiry, and CCPA data scrubbing |
| Prompt 7 | Admin and support dashboard, and guest-facing CCPA deletion and data export requests |
| Prompt 8 | GitHub Actions CI/CD pipelines, Netlify security headers, integration tests, and smoke tests |
Before You Run Prompt 1: Complete These Steps
Work through every item below before opening Lovable. Prompt 1 will fail or behave incorrectly if any of these are missing.
1. Create Accounts
You need active accounts on all of these services before you start:
- Supabase — supabase.com
- Stripe — stripe.com
- Resend — resend.com
- GitHub — github.com
- Netlify — netlify.com
2. Set Up Your Supabase Projects
Create three separate Supabase projects: homestay-dev, homestay-staging, and homestay-prod. You will use homestay-dev for all Lovable development. Repeat the following steps in each project:
In each project's database settings:
- Go to Database → Extensions and enable
btree_gist - Go to Database → Extensions and enable
pg_cron - Go to Database → Extensions and enable
pg_net(required for pg_cron to call Edge Functions)
In each project's authentication settings:
- Go to Authentication → URL Configuration and set the Site URL to the correct frontend URL for that environment (Lovable's preview URL for dev, your custom domain for production)
- Go to Authentication → Email and enable "Confirm email" so new users must verify before signing in
In each project's storage settings:
- Create a bucket named
photoswith public read access enabled - Create a bucket named
ccpa-exportswith public read access disabled (private) - On the
photosbucket, set a lifecycle rule so objects whose path starts withpending/expire after 24 hours
In each project's Vault (Database → Vault → Secrets): Create the following secrets. Use the exact names listed — your Edge Functions will look for them by these exact names:
STRIPE_SECRET_KEY— your Stripe secret key for that environment (test key for dev/staging, live key for prod)STRIPE_WEBHOOK_SIGNING_SECRET— from the Stripe webhook endpoint you will create in step 4 belowSTRIPE_CONNECT_CLIENT_ID— from Stripe → Settings → Connect → Client IDRESEND_API_KEY— from Resend → API KeysRESEND_FROM_ADDRESS— the verified email address you will send from (e.g.noreply@yourdomain.com)SERVICE_FEE_PERCENTAGE— enter the value12(no percent sign)OPERATOR_ALERT_EMAIL— the email address that receives payout failure alertsFRONTEND_URL— the full URL of the frontend for that environment (e.g. your Lovable preview URL for dev)CRON_SECRET— generate a long random string (e.g. 40+ random characters); this protects your scheduled jobs
On homestay-prod only:
- Go to Database → Backups and enable Point-in-Time Recovery (PITR)
3. Connect Lovable to Supabase
In your Lovable project settings, connect Lovable to your homestay-dev Supabase project. Lovable uses this connection to apply database migrations and interact with your database during development.
4. Set Up Stripe
- In your Stripe dashboard, go to Settings → Connect and enable Connect. Choose "Standard" accounts for host onboarding.
- In test mode, go to Developers → Webhooks and create a new endpoint. You will need the URL of your
stripe-webhookEdge Function — this URL is not available until Prompt 4 creates the function. Skip the webhook endpoint step for now and complete it as part of Prompt 4's verification steps. - In live mode, repeat the webhook setup after Prompt 4 is deployed to production.
5. Set Up Resend
- In the Resend dashboard, go to Domains and add the domain you will send emails from. Follow Resend's instructions to add the required DNS records at your domain registrar. Wait for the domain to verify (usually a few minutes to an hour).
- Go to API Keys and create a new API key. Store it in each Supabase project's Vault as
RESEND_API_KEY.
6. Set Up GitHub
- Create a new repository named
homestay. - Connect your Lovable project to this repository (Lovable → Settings → GitHub).
- In the GitHub repository, go to Settings → Secrets and Variables → Actions. Add the following secrets:
| Secret name | Where to find the value |
|---|---|
SUPABASE_DEV_URL |
Supabase homestay-dev → Project Settings → API → Project URL |
SUPABASE_STAGING_URL |
Supabase homestay-staging → Project Settings → API → Project URL |
SUPABASE_PROD_URL |
Supabase homestay-prod → Project Settings → API → Project URL |
SUPABASE_DEV_SERVICE_ROLE_KEY |
Supabase homestay-dev → Project Settings → API → service_role key |
SUPABASE_STAGING_SERVICE_ROLE_KEY |
Supabase homestay-staging → Project Settings → API → service_role key |
SUPABASE_PROD_SERVICE_ROLE_KEY |
Supabase homestay-prod → Project Settings → API → service_role key |
SUPABASE_ACCESS_TOKEN |
Supabase dashboard → Account (top right) → Access Tokens |
SUPABASE_STAGING_PROJECT_REF |
Supabase homestay-staging → Project Settings → General → Reference ID |
SUPABASE_PROD_PROJECT_REF |
Supabase homestay-prod → Project Settings → General → Reference ID |
RESEND_API_KEY |
Resend dashboard → API Keys |
NETLIFY_DEPLOY_HOOK |
Set up in Prompt 8 — skip for now |
- In the GitHub repository, go to Settings → Environments and create an environment named
production. Add at least one required reviewer — this person must approve before any production deployment runs.
7. Set Up Netlify
- Go to your Netlify dashboard and create a new site connected to your
homestayGitHub repository. - Set the build command to
npm run buildand the publish directory todist. - In Netlify → Site settings → Environment variables, add:
| Variable name | Where to find the value |
|---|---|
VITE_SUPABASE_URL |
Supabase homestay-prod → Project Settings → API → Project URL |
VITE_SUPABASE_ANON_KEY |
Supabase homestay-prod → Project Settings → API → anon public key |
VITE_STRIPE_PUBLISHABLE_KEY |
Stripe dashboard → Developers → API Keys → Publishable key (live mode) |
Environment Variables Reference
These are all the secrets and configuration values used across the project. Edge Function secrets are stored in Supabase Vault (not in code or environment variables). Frontend values are stored as build-time environment variables.
Supabase Vault secrets (set in each project's Vault with these exact names):
| Secret name | Source |
|---|---|
STRIPE_SECRET_KEY |
Stripe → Developers → API Keys → Secret key |
STRIPE_WEBHOOK_SIGNING_SECRET |
Stripe → Developers → Webhooks → your endpoint → Signing secret |
STRIPE_CONNECT_CLIENT_ID |
Stripe → Settings → Connect → Client ID |
RESEND_API_KEY |
Resend → API Keys |
RESEND_FROM_ADDRESS |
The verified sender address you configured in Resend |
SERVICE_FEE_PERCENTAGE |
Set to 12 |
OPERATOR_ALERT_EMAIL |
The email address for operator alerts |
FRONTEND_URL |
The full base URL of the frontend for that environment |
CRON_SECRET |
A long random string you generate; must be identical across all three projects |
Frontend build-time variables (set in Netlify for production; Lovable's Supabase integration handles dev):
| Variable name | Source |
|---|---|
VITE_SUPABASE_URL |
Supabase homestay-prod → Project Settings → API → Project URL |
VITE_SUPABASE_ANON_KEY |
Supabase homestay-prod → Project Settings → API → anon public key |
VITE_STRIPE_PUBLISHABLE_KEY |
Stripe → Developers → API Keys → Publishable key |
GitHub Actions secrets (set in the repository's Secrets panel):
All values listed in step 6 above, plus NETLIFY_DEPLOY_HOOK added during Prompt 8.