"I want to make a website like Airbnb. People should be able to list their spaces for rent, and others should be able to search for places, pick dates on a calendar, and pay to book them."
Rentify
Architecture Design Document
Architecture Design Document
TL;DR
"The takeaway is that this architecture uses a PostgreSQL GiST exclusion constraint and Stripe Connect to mathematically prevent double-bookings and safely route funds in a serverless environment."
Rentify 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 entirely on AWS Serverless (Lambda, API Gateway, RDS Proxy, PostgreSQL) with Stripe Connect handling all payment routing. The two most important architectural choices are an atomic database-level availability lock that makes double-bookings physically impossible, and a Stripe Connect escrow model that holds guest funds and releases them to hosts only after check-in.
Executive Summary
Rentify is built as a serverless REST API backend consumed by a web frontend. Every request flows through AWS API Gateway to a Node.js Lambda function layer, which connects to PostgreSQL via RDS Proxy. The RDS Proxy pool is the critical bridge between the stateless Lambda execution environment and the stateful relational database — it prevents connection exhaustion at scale.
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 logical service modules within the Lambda layer, not as separate deployed functions per route — this keeps cold-start surface area small and simplifies local development under SDD/Cursor.
The escrow model is enforced entirely 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 the check-in timestamp passes. Host cancellations trigger an unconditional full refund with no operator intervention required. Guest-side service fees are captured as Stripe's application fee on the charge.
CCPA compliance is handled via a soft-delete + data-scrubbing flow: user records are flagged for deletion, PII fields are overwritten with null values on a scheduled Lambda, and booking history is retained in anonymized form for the 3-year retention window. PCI DSS scope is SAQ A — no raw card data ever touches the application layer.
The system targets hundreds of users in a single AWS region with cost optimization as the primary scaling constraint. Three environments (dev, staging, production) are deployed via GitHub Actions with environment-specific parameter sets stored in AWS SSM Parameter Store.
System Overview
Architecture Diagram
graph LR
subgraph client["Client Layer"]
browser["Web Browser"]
react["React 18 SPA"]
end
subgraph cdn["CDN & Edge"]
cf["CloudFront"]
s3web["S3 - Static Hosting"]
end
subgraph apigw["API Layer"]
agw["API Gateway REST"]
auth_lam["Lambda Authorizer"]
end
subgraph compute["Lambda Functions"]
authFn["Auth Lambda"]
listFn["Listings Lambda"]
searchFn["Search Lambda"]
bookFn["Bookings Lambda"]
subgraph scheduled["Scheduled Lambdas"]
payoutFn["Payout Scheduler"]
ccpaFn["CCPA Scrubber"]
end
webhookFn["Payments Webhook"]
userFn["Users Lambda"]
adminFn["Admin Lambda"]
end
subgraph data["Data Layer"]
proxy["RDS Proxy"]
rds["PostgreSQL 15 RDS"]
end
subgraph storage["Storage & Config"]
s3photos["S3 - Photos & Exports"]
ssm["SSM Parameter Store"]
end
subgraph external["External Services"]
stripe["Stripe Connect"]
ses["AWS SES"]
cw["CloudWatch Logs"]
eb["EventBridge Scheduler"]
sns["SNS - Alerting"]
end
browser --> react
react --> cf
cf --> s3web
react --> agw
agw --> auth_lam
auth_lam --> agw
agw --> authFn
agw --> listFn
agw --> searchFn
agw --> bookFn
agw --> webhookFn
agw --> userFn
agw --> adminFn
eb --> payoutFn
eb --> ccpaFn
authFn --> proxy
listFn --> proxy
searchFn --> proxy
bookFn --> proxy
webhookFn --> proxy
userFn --> proxy
adminFn --> proxy
payoutFn --> proxy
ccpaFn --> proxy
proxy --> rds
listFn --> s3photos
ccpaFn --> s3photos
userFn --> s3photos
authFn --> ssm
bookFn --> ssm
webhookFn --> ssm
bookFn --> stripe
payoutFn --> stripe
webhookFn --> stripe
listFn --> stripe
stripe --> agw
bookFn --> ses
payoutFn --> ses
ccpaFn --> ses
authFn --> cw
bookFn --> cw
payoutFn --> cw
ccpaFn --> cw
cw --> sns
sns --> ses
The system has five logical tiers:
Client layer — A React single-page application hosted on S3 + CloudFront. It communicates exclusively with API Gateway over HTTPS. No direct AWS service calls from the browser.
API Gateway — A single REST API with JWT authorizer (via a Lambda authorizer function). Routes requests to the correct Lambda handler. Throttling and CORS are configured at this layer.
Lambda compute layer — Node.js functions organized into handler modules: Auth, Listings, Search, Bookings, Payments (webhooks), Users (profile + CCPA), and Admin. Each module is a separate Lambda function to allow independent scaling and deployment. They share a common database client layer and a shared utilities package (deployed as a Lambda Layer).
Data layer — PostgreSQL 15 on AWS RDS (single db.t3.medium instance per environment), accessed exclusively through RDS Proxy. The Proxy manages the connection pool and provides IAM authentication to the Lambda functions, eliminating database credentials from function environment variables.
External integrations — Stripe Connect (payments, payouts, webhooks) and AWS-native services: SES for transactional email, CloudWatch for structured logging and alerting, S3 for listing photo storage, SSM Parameter Store for secrets.
Data flows directionally: a guest's search request hits API Gateway → Search Lambda → RDS Proxy → PostgreSQL (read). A booking request hits API Gateway → Bookings Lambda → begins a database transaction that acquires a row-level lock on the availability record, then calls Stripe to create and capture the PaymentIntent, then commits the booking row — all within the same logical operation. If Stripe fails, the transaction rolls back and no booking record is created.
Architectural Constraints Summary
Serverless on AWS is non-negotiable. This drove the RDS Proxy requirement — Lambda's ephemeral execution model would exhaust PostgreSQL's connection limit within minutes without a connection pooler in front of it. Every component outside the database and S3 is stateless by design.
Atomic availability locking is the hardest business rule in the system. PostgreSQL's GiST Exclusion Constraint using the && overlap operator on a tsrange column is the mechanism. This cannot be handled at the application layer alone — application-level pessimistic locking is vulnerable to external scripts and administrative operations, whereas the GiST constraint provides an immutable database-level invariant against double bookings so that concurrent Lambda invocations cannot possibly proceed with overlapping slots.
Stripe Connect escrow drives the entire payment data model. The platform never stores card data (SAQ A scope), but it does store Stripe entity references: PaymentIntent IDs, Transfer IDs, and connected account IDs. These are stored in the booking and payout records in PostgreSQL.
CCPA compliance adds a data lifecycle requirement that conflicts slightly with the 3-year retention rule. The resolution: a booking row is never deleted, but all PII columns on the user record (name, email, phone, profile photo) are scrubbed. The booking row retains only anonymized financial and calendar data, which satisfies both the retention requirement (platform revenue records) and the deletion right (no personal data remains traceable to the user).
Cost optimization at small scale means a single RDS instance per environment (not Multi-AZ in dev/staging), a single Lambda function per logical domain (not per route), and S3 + CloudFront for the frontend rather than a dedicated server. Multi-AZ is enabled only in production to meet the under-1-hour unplanned downtime target.
Single account with role toggle means the user table carries both host and guest attributes, and a roles array field determines what the user can do. No separate user tables — just a role set that is additive (a user becomes a host by onboarding onto Stripe Connect, not by creating a new account).
Component Architecture
API Gateway
A single REST API deployed per environment. The JWT Lambda Authorizer runs before every protected route, validates the token, and injects the decoded user ID and roles into the Lambda event context. Public routes (search, listing detail, auth) bypass the authorizer. Throttling is set to 1,000 requests per second burst, 500 steady-state per environment — sized for small-scale and adjustable via SSM. CORS is configured at API Gateway level with an explicit allowed-origins list per environment.
Lambda Authorizer
Validates JWTs signed by the Auth Lambda using a shared HMAC secret stored in SSM. Caches authorization results for 5 minutes to reduce cold overhead. On failure, returns a deny policy — the calling function never executes.
Auth Lambda
Handles user registration, login, and token refresh. On registration, creates the user record in PostgreSQL with a guest role by default. Issues short-lived access tokens (15-minute expiry) and long-lived refresh tokens (30-day expiry) stored in the refresh_tokens table. Password hashing uses bcrypt with a work factor of 12. No third-party identity provider — credentials are managed in-house.
Listings Lambda
All host-side listing operations: create, read, update, delete a listing; manage the availability calendar (blocked dates, minimum stay, allowed check-in days); manage per-night pricing with weekend and seasonal override rules; manage photos (upload URL generation, ordering, deletion); manage amenities and house rules; toggle instant-book vs. manual approval. Photo uploads use S3 pre-signed URLs — the Lambda generates the URL and returns it to the client; the client uploads directly to S3. The Lambda then records the S3 object key and display order in the listing_photos table.
Availability rule evaluation logic: When a host sets availability rules, they are stored as structured records (blocked date ranges, minimum-stay integers, allowed check-in weekday bitmask). When the Bookings Lambda evaluates a booking request, it queries these rules and applies them in sequence: (1) check that all requested nights fall outside blocked ranges, (2) check that the stay length meets the minimum, (3) check that the check-in day is permitted. All three must pass before the atomic lock is attempted.
Pricing calculation logic: The nightly rate for a stay is calculated by iterating each calendar night in the requested range and looking up applicable overrides in priority order: (1) seasonal override if the date falls within a defined season range, (2) weekend override if the date is Friday or Saturday, (3) base nightly rate. The sum of all per-night rates plus the guest service fee (a fixed percentage stored in SSM, default 12%) forms the total charge amount passed to Stripe.
Search Lambda
Handles guest-side listing discovery. Accepts filters: destination (matched against a text location field using PostgreSQL ILIKE), check-in/check-out dates, guest count, and price range. Returns paginated listing summaries with availability pre-checked against the bookings table. Search is intentionally simple — no geospatial indexing, no Elasticsearch. A composite PostgreSQL index on (location, max_guests, base_price) covers the filter columns. Availability is checked by a subquery that excludes listing IDs with any confirmed booking overlapping the requested dates. Default page size is 20 listings; maximum is 100.
Bookings Lambda
The most complex component. Handles the full reservation lifecycle:
Booking creation (instant-book path):
- Validate the requested dates pass availability rule evaluation (Listings logic, called as an internal module).
- Open a PostgreSQL serializable transaction.
- Attempt to insert the booking row. The PostgreSQL GiST exclusion constraint on the
bookingstable (tsrange(check_in_date, check_out_date) WITH &&) automatically enforces the lock. If an overlap exists, it fails with a constraint violation error returned to the guest. - Check that no confirmed booking overlaps the requested dates (double-check inside the lock).
- Insert a
bookingsrow inpendingstatus. - Call Stripe to create and capture a PaymentIntent for the total amount with the platform application fee. If Stripe returns success, update the booking status to
confirmedand commit. If Stripe fails, roll back — no booking row is persisted. - Send a confirmation email via SES.
Booking creation (manual approval path):
Steps 1–5 above, then instead of capturing payment, create a Stripe PaymentIntent in requires_capture state (authorized but not settled). The booking remains in pending_approval status. The host has 24 hours to accept or decline via the Bookings Lambda's approval endpoint. On acceptance, the PaymentIntent is captured and the booking moves to confirmed. On decline or timeout (a scheduled Lambda checks for expired approvals every 15 minutes), the PaymentIntent is cancelled and the booking moves to declined, freeing the availability lock.
Cancellation (host-initiated): Update booking to cancelled_by_host. Immediately call Stripe to issue a full refund against the captured PaymentIntent. Send email to guest. No operator approval required.
Cancellation (guest-initiated): Apply the host's cancellation policy stored on the listing (flexible, moderate, or strict — stored as an enum). Calculate refund amount based on policy and days until check-in. Call Stripe refund API for the calculated amount. Update booking status.
Post-check-in payout: A scheduled Lambda (EventBridge rule, runs every hour) queries for confirmed bookings where check_in_date < NOW() and payout_status = pending. For each, it calls Stripe to create a Transfer from the platform account to the host's connected Stripe account for the host payout amount (total charge minus application fee). Updates payout_status to completed and records the Transfer ID.
Payments Lambda (Webhook Handler)
Receives Stripe webhook events. Validates the webhook signature using the Stripe signing secret stored in SSM. Handles: payment_intent.succeeded (confirms booking if not already confirmed), payment_intent.payment_failed (moves booking to payment_failed status), transfer.failed (logs and triggers a Slack/email alert for manual operator review), account.updated (updates host's Stripe account verification status in the database). Webhook endpoint is unauthenticated at the API Gateway level but signature-validated at the function level.
Users Lambda
Handles profile management and CCPA flows. CCPA deletion: marks the user record with deletion_requested_at timestamp and deletion_status = pending. A scheduled Lambda (EventBridge, runs nightly) finds all users with deletion_status = pending and deletion_requested_at older than 30 days (grace period), scrubs all PII fields on the user record, updates deletion_status = completed. Booking rows with that user's ID are retained but the foreign key to the user record points to a scrubbed shell. CCPA opt-out (sale/sharing of data): sets a do_not_sell boolean on the user record; this flag is checked before any data is included in any analytics or third-party export.
Admin Lambda
Internal-only endpoints accessible only to users with the admin or support roles. Admin capabilities: view all users, listings, and bookings; manually trigger a payout retry; flag a listing as inactive; impersonate a user (read-only context for support agents). Support agents have read-only access — all write operations in the Admin Lambda check that the caller's role is admin, not support.
Payout Scheduler Lambda
An EventBridge-triggered Lambda (every hour) that handles post-check-in payout initiation. Separate from the Bookings Lambda to isolate the scheduled execution context from the request-response context. Idempotent — it checks payout_status before calling Stripe, so re-runs on the same booking are safe.
CCPA Scrubber Lambda
An EventBridge-triggered Lambda (nightly at 02:00 UTC) that performs PII scrubbing for users past their deletion grace period. Also handles a data export request flow (CCPA right-to-know): when a user requests their data export, this Lambda assembles the export payload from PostgreSQL, writes it to a time-limited S3 pre-signed URL, and emails the link to the user via SES.
Data Architecture
Core Tables
users — One row per account. Stores hashed password, email, display name, phone, profile photo S3 key, roles array (guest, host, admin, support), Stripe connected account ID (null until host onboarding), deletion_requested_at, deletion_status, do_not_sell flag. Soft-deleted via PII scrubbing, never hard-deleted.
refresh_tokens — One row per active refresh token. Stores a hashed token value, the user ID, expiry timestamp, and a revoked flag. Cleaned up by a nightly job that removes expired rows.
listings — One row per listing. References the host user ID. Stores location text, description, max guests, property type enum (entire_home, room), base nightly price, instant-book boolean, cancellation policy enum, status enum (draft, active, inactive). Never hard-deleted — status set to inactive on deletion.
listing_photos — One row per photo. References listing ID. Stores S3 object key, display order integer, alt text.
listing_amenities — Junction table: listing ID × amenity enum value.
Data Contracts
| Entity | Attribute | Type / Constraints | Business Rule & Access Control |
|---|---|---|---|
| availability_rules | rule_type |
ENUM ('blocked_range', 'min_stay', 'checkin_days') |
Defines how rules apply to listing availability. Hosts only. |
| availability_rules | start_date / end_date |
DATE |
Applies to blocked_range. Checked before booking. |
| availability_rules | value / bitmask |
INT / BIT(7) |
Stores min nights or check-in days mask. |
| bookings | listing_id / guest_user_id |
UUID, FK |
Links guest to listing. |
| bookings | stay_range |
tsrange, GiST Exclusion Constraint (&&) |
Guarantees atomic prevention of overlapping bookings at the database schema level. |
| bookings | status |
ENUM ('pending', 'confirmed', 'declined'...) |
Managed strictly by Lambda flows. |
| bookings | stripe_pi_id |
VARCHAR |
Stripe PaymentIntent ID used for capturing funds. |
| bookings | payout_status |
ENUM ('pending', 'completed', 'failed') |
Controls Stripe Connect Transfer execution. |
pricing_overrides — One row per override per listing. Override type enum: weekend, seasonal. Stores date range (nullable for weekend type) and override nightly price.
refunds — One row per refund event. References booking ID. Stores Stripe Refund ID, amount, reason enum, created timestamp.
stripe_webhook_events — Idempotency log. Stores Stripe event ID and processing status. The webhook handler checks this table before processing — if the event ID already exists, it short-circuits and returns success. Prevents duplicate processing on Stripe retries.
Data Lifecycle
New bookings enter at pending or pending_approval and transition through the status machine. Confirmed bookings are retained indefinitely (anonymized after user deletion request). Declined and expired bookings are retained for audit. Refresh tokens older than 30 days are purged nightly. Stripe webhook event log rows older than 90 days are purged nightly. Listing photos whose S3 objects are orphaned (listing deleted) are cleaned up by the nightly CCPA Scrubber Lambda, which also handles S3 object deletion for scrubbed user profile photos.
Indexing Strategy
bookings(listing_id, check_in_date, check_out_date)— overlap queriesbookings(guest_user_id)— guest booking historylistings(location, max_guests, base_price)— search filter compositeavailability_rules(listing_id)— rule lookup per listingpricing_overrides(listing_id)— override lookup per listingstripe_webhook_events(stripe_event_id)— unique index for idempotency
Backup and Retention
RDS automated backups: 7-day retention in dev/staging, 35-day in production. Point-in-time recovery enabled in production. S3 versioning enabled on the photo bucket. Database data retained for 3 years per policy; archived booking rows past 3 years are exported to S3 Glacier via a yearly Lambda job and then deleted from RDS.
Integration Architecture
Stripe Connect
Purpose: Payment processing, escrow, host onboarding, fund routing, and refunds.
Auth: The platform's Stripe secret key is stored in SSM Parameter Store and retrieved at Lambda cold start. Host connected accounts are created via Stripe Connect's hosted onboarding (OAuth flow): the Listings Lambda generates a Stripe Connect onboarding URL, the host completes onboarding on Stripe's hosted page, and Stripe redirects back to the platform with an authorization code. The Auth/Users Lambda exchanges the code for the connected account ID and stores it on the user record.
Operations:
- Create PaymentIntent (charge guest at booking, with application fee for service fee capture)
- Capture PaymentIntent (on host approval for manual-approval listings)
- Cancel PaymentIntent (on decline or expiry)
- Create Refund (on host or guest cancellation)
- Create Transfer (host payout post check-in)
- Create Connect onboarding link (host registration)
- Retrieve Account (check host verification status)
Failure behavior: If PaymentIntent creation fails, the booking transaction rolls back — no booking is created and the guest sees a payment failure message. If a Transfer fails (payout), the Payout Scheduler Lambda marks payout_status = failed, logs the error to CloudWatch, and sends an alert to the operator Slack channel. The operator must manually retry via the Admin Lambda's payout retry endpoint. Stripe Connect onboarding failures leave the user in an unverified host state — they cannot publish a listing until account.updated webhook confirms verification.
AWS SES
Purpose: Transactional email — booking confirmation, booking cancellation, payout notification, CCPA data export link.
Auth: Lambda execution role has ses:SendEmail permission via IAM. Emails are sent from a verified SES domain.
Failure behavior: SES errors are logged to CloudWatch. Email delivery failure does not roll back a booking or payout — it is treated as a non-critical side effect. A CloudWatch alarm triggers if SES error rate exceeds 5% over a 5-minute window.
AWS S3
Purpose: Listing photo storage, CCPA data export file staging.
Auth: Lambda generates pre-signed PUT URLs for client-side photo upload. The S3 bucket is private; no public access. CloudFront serves photos via an Origin Access Control (OAC) policy — the bucket grants read access only to the CloudFront distribution.
Failure behavior: Pre-signed URL generation is a local operation (no S3 call required) and cannot fail. If the client's upload to S3 fails, the photo record is not written to the database — the client must retry the upload and then call the Listings Lambda to confirm the upload. Orphaned S3 objects (upload initiated but never confirmed) are cleaned up by an S3 lifecycle rule that deletes unconfirmed objects after 24 hours (objects in the pending/ prefix are moved to this prefix on URL generation and renamed to the photos/ prefix on confirmation).
AWS CloudWatch
Purpose: Structured logging, error alerting.
Auth: Lambda execution role has CloudWatch Logs write permissions via IAM.
Operations: All Lambda functions emit structured JSON logs using AWS Lambda Powertools for TypeScript (@aws-lambda-powertools/logger, tracer, metrics). This provides cold-start tracing and correlation ID injection from API Gateway through to Stripe webhooks. A CloudWatch Log Metric Filter detects "level":"error" log entries. A CloudWatch Alarm on this metric triggers an SNS topic that sends email and posts to a Slack webhook (the Slack webhook URL is stored in SSM). Alert threshold: more than 5 errors in a 1-minute window.
AWS SSM Parameter Store
Purpose: Secrets and environment-specific configuration.
Auth: Lambda execution role has ssm:GetParameter and ssm:GetParametersByPath permissions scoped to the /rentify/{env}/ path prefix.
Parameters stored: Database connection string (SecureString), Stripe secret key (SecureString), Stripe webhook signing secret (SecureString), JWT signing secret (SecureString), Slack webhook URL (SecureString), service fee percentage (String, default 12), throttle limits (String), allowed CORS origins (String).
Retrieval: Parameters are retrieved at Lambda cold start and cached in the execution context for the lifetime of the warm instance. Re-retrieved on each cold start.
Security Architecture
Input Validation
All request bodies are validated by a shared validation layer (using Zod schemas deployed in the Lambda Layer) before reaching any business logic. Validation rejects unexpected fields, enforces type constraints, and sanitizes string fields (trim whitespace, enforce maximum lengths). Validation errors return a structured error response without exposing internal schema details. Date range inputs are checked for logical consistency (check-out must be after check-in, maximum stay 365 nights) at the validation layer before any database query is executed.
Authentication and Authorization
JWTs are validated by the Lambda Authorizer on every protected route. The authorizer injects user ID and roles into the Lambda event context — business logic trusts this injected context rather than re-reading from the database. Role-based access is enforced at the handler level: host operations check for host role, admin operations check for admin role. Support agents (support role) are explicitly denied write operations via a role check in the Admin Lambda. Users can only modify their own resources — every write operation validates that the resource's owner ID matches the caller's injected user ID.
Secrets Management
No secrets in environment variables or code. All secrets are stored in SSM Parameter Store as SecureString (encrypted with the default AWS KMS key). Lambda execution roles are scoped to the minimum required SSM paths. Database credentials are not used directly — RDS Proxy uses IAM authentication, and Lambda functions authenticate to the Proxy via IAM tokens generated at runtime.
PCI DSS (SAQ A)
No card data touches the application layer. Stripe.js on the frontend collects card details directly in Stripe-hosted iframes. The backend only handles Stripe token references (PaymentIntent IDs). The Stripe webhook handler validates signatures 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 at the data export layer. User data export (right-to-know) is provided via a secure, time-limited S3 pre-signed URL emailed to the verified address. CCPA requests are logged in a dedicated ccpa_requests table with timestamp, request type, and processing status for audit purposes.
Web-Facing Security
- HTTPS enforcement: API Gateway enforces HTTPS. CloudFront redirects HTTP to HTTPS. No HTTP endpoints.
- CORS: API Gateway is configured with an explicit
Access-Control-Allow-Originlist per environment (no wildcard in staging or production). Preflight responses are cached for 600 seconds. - Security headers: CloudFront response headers policy enforces
Strict-Transport-Security(max-age 31536000, includeSubDomains),X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Content-Security-Policy(restricts script sources to the CDN and Stripe.js domain), andReferrer-Policy: strict-origin-when-cross-origin. - Lambda function URLs: Not used — all invocations go through API Gateway to maintain the authorizer and throttling layer.
- SQL injection: All database queries use parameterized statements via the
pgdriver. No string interpolation in SQL. - Rate limiting: API Gateway usage plans apply per-IP throttling (100 requests per minute per IP on auth endpoints, 500 per minute on all other endpoints).
Error Handling Policy
Internal error details (stack traces, SQL errors, Stripe error codes) are logged to CloudWatch but never returned to the client. Client-facing error responses contain only a human-readable message and an error code. Error codes are documented so the frontend can render appropriate UI.
Deployment Architecture
Environments
Three environments: dev, staging, production. Each environment has its own:
- API Gateway stage
- Lambda function set (deployed from the same codebase with
ENVparameter) - RDS instance (dev: db.t3.micro, staging: db.t3.micro, production: db.t3.medium)
- RDS Proxy (one per environment, IAM auth enabled)
- S3 buckets (photos, exports)
- SSM parameter path prefix (
/rentify/dev/,/rentify/staging/,/rentify/prod/) - CloudFront distribution
Production RDS has Multi-AZ enabled. Dev and staging are single-AZ.
Infrastructure as Code
All AWS resources are defined in AWS SAM (Serverless Application Model) templates. SAM is chosen over CDK for its direct alignment with Lambda + API Gateway patterns and its native support for local Lambda invocation (used during Cursor-driven development). The SAM template is the single source of truth for all infrastructure. Environment-specific parameter overrides are passed at deploy time via SAM config files (samconfig.toml).
CI/CD Pipeline (GitHub Actions)
Three workflows:
PR workflow: Runs on every pull request to main. Steps: install dependencies, run unit tests, run integration tests against a local PostgreSQL instance (via Docker in the Actions runner), lint, and SAM build validation. PRs cannot be merged if this workflow fails.
Staging deploy: Runs on every merge to main. Steps: SAM build → SAM deploy to staging environment → run smoke tests against the staging API Gateway endpoint → notify Slack on success or failure.
Production deploy: Manual trigger only (GitHub Actions workflow_dispatch). Steps: SAM build → SAM deploy to production → run smoke tests → notify Slack. Requires a GitHub environment protection rule with at least one required reviewer approval before the deploy step executes.
Lambda Configuration
- Runtime: Node.js 22.x
- Memory: 512 MB default (Auth, Users, Admin), 1024 MB (Bookings, Payments webhook), 256 MB (Search, scheduled Lambdas)
- Timeout: 29 seconds (API Gateway maximum) for request-response functions; 5 minutes for scheduled Lambdas
- Concurrency: Reserved concurrency of 50 on the Bookings Lambda to prevent RDS Proxy connection exhaustion. All other functions use unreserved concurrency with a per-account limit set at 200 to cap costs.
- Dead Letter Queue: SQS DLQ configured on all scheduled Lambdas. Failures are captured and alert via CloudWatch.
Scaling
At the target scale (hundreds of users), the RDS Proxy pool with a db.t3.medium instance is sufficient. The Proxy is configured with a maximum of 100 connections to the RDS instance, distributed across Lambda concurrency. If traffic grows, the RDS instance class is the first scaling lever — horizontal Lambda scaling is automatic. CloudFront handles all static asset scaling independently of the Lambda tier.
Technology Stack
| Component | Technology | Version | Rationale |
|---|---|---|---|
| Frontend framework | React | 18.3 | Mature ecosystem, Cursor tooling support |
| Frontend hosting | AWS S3 + CloudFront | N/A | Zero-server static hosting, cost-optimal |
| API layer | AWS API Gateway | REST API (v1) | Native Lambda integration, JWT authorizer support, throttling |
| Compute | AWS Lambda | N/A (Node.js 22.x runtime) | Serverless constraint; Node.js chosen for JS/TS full-stack consistency |
| Language | TypeScript | 5.5 | Type safety across Lambda handlers and shared layer; Cursor benefits from strong types |
| Database | PostgreSQL | 15 | ACID transactions required for atomic booking lock; RDS Proxy compatibility |
| Database hosting | AWS RDS | db.t3.medium (prod) | Managed Postgres; RDS Proxy required by constraint |
| Connection pooling | AWS RDS Proxy | N/A | Prevents Lambda connection exhaustion; IAM auth eliminates credential exposure |
| Validation library | Zod | 3.23 | Runtime schema validation with TypeScript inference; deployed in Lambda Layer |
| Database client | node-postgres (pg) | 8.12 | Lightweight, well-maintained, supports parameterized queries |
| Payments | Stripe Connect | API version 2024-06-20 | Constraint; handles PCI scope, escrow, and host payouts |
| AWS SES | v2 API | Native AWS, cost-optimal at small scale | |
| Object storage | AWS S3 | N/A | Photo storage + CCPA exports; native CloudFront OAC |
| Secrets | AWS SSM Parameter Store | N/A | SecureString for credentials; IAM-scoped Lambda access |
| IaC | AWS SAM | 1.120 | Native Lambda/API Gateway support; local invoke for SDD/Cursor workflow |
| CI/CD | GitHub Actions | N/A | Constraint implied by GitHub; native SAM deploy action available |
| Logging | AWS CloudWatch Logs | N/A | Native Lambda destination; metric filters for alerting |
| Alerting | CloudWatch Alarms + SNS → SES + Slack webhook | N/A | Email and Slack alerts per constraint |
| Scheduling | AWS EventBridge Scheduler | N/A | Triggers payout and CCPA scrubber Lambdas on cron |
Key Architectural Decisions
ADR-1: Single Lambda per logical domain, not per route Decided to deploy one Lambda function per domain module (Bookings, Listings, Search, etc.) rather than one per API route. Alternatives: monolithic single Lambda (too large, poor isolation), per-route Lambdas (cold start multiplication, operational overhead). Single-per-domain balances isolation with operational simplicity at small scale. Routing within the Lambda uses a lightweight internal router (path + method matching).
ADR-2: PostgreSQL GiST Exclusion Constraint for booking locks
Decided to use a PostgreSQL GiST Exclusion Constraint using the && overlap operator on a tsrange column to mathematically prevent double bookings. Alternatives: application-level mutex (not safe across Lambda instances), pessimistic locking with SELECT FOR UPDATE (vulnerable to external scripts, manual operations, and developer error). A GiST exclusion constraint is the only mechanism that provides an immutable database-level invariant against double bookings at the schema level.
ADR-3: Stripe PaymentIntent captured at booking, Transfer created post check-in Decided to capture the guest payment immediately at booking and hold funds in the platform Stripe account, transferring to the host only after check-in. Alternatives: authorize-only until check-in (authorization windows expire, card may decline at capture, complex retry logic). Immediate capture with delayed transfer is the standard escrow pattern for short-term rental marketplaces and is directly supported by Stripe Connect's model.
ADR-4: AWS SAM for IaC over CDK or Terraform
Decided to use AWS SAM. Alternatives: CDK (more powerful but higher learning curve, less aligned with the Cursor/SDD quick-iteration workflow), Terraform (excellent for multi-cloud, unnecessary here, requires separate state management). SAM's sam local invoke capability is directly useful for the SDD development loop in Cursor without additional tooling.
ADR-5: No geospatial search — text ILIKE on location field Decided to implement search as a text match against a freeform location string. Alternatives: PostGIS with lat/lng coordinates and radius search (correct for a mature product, disproportionate for hundreds of users at launch). ILIKE search covers city/neighborhood-level discovery at launch scale. PostGIS can be added as a targeted migration when user volume demands it.
ADR-6: CCPA deletion as PII scrub, not row deletion Decided to retain booking rows indefinitely with PII scrubbed from the user record rather than cascading-delete booking rows on user deletion. Alternatives: hard delete all user data (breaks booking history integrity, violates 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-7: RDS Proxy with IAM authentication Decided to use RDS Proxy with IAM-based authentication from Lambda rather than storing database credentials in environment variables or SSM. Alternatives: direct Lambda-to-RDS with credentials in SSM (still requires rotating secrets, direct connections exhaust PostgreSQL's connection limit). IAM auth via RDS Proxy eliminates credential rotation risk and solves the connection exhaustion problem simultaneously.
Error Handling & Failure Modes
Auth Lambda
- Invalid credentials: Return a generic "invalid email or password" message. Do not distinguish between "user not found" and "wrong password" to prevent user enumeration.
- Expired refresh token: Return a "session expired, please log in again" error. Client must redirect to login.
- Brute force: API Gateway rate limiting (100 requests/minute per IP on auth endpoints) is the first defense. After 10 consecutive failed login attempts for the same email within 15 minutes, the account is soft-locked for 15 minutes (tracked in a
login_attemptstable).
Bookings Lambda — Availability Lock
Q: What happens if there is lock contention because another booking is in-flight? A: The PostgreSQL GiST exclusion constraint blocks the insert. The transaction is rolled back and the guest receives a "this listing is no longer available for these dates — please try again" message. No partial state is written.
Q: What happens if Stripe PaymentIntent creation fails? A: The database transaction has not yet committed. Roll back. Guest receives a payment failure message with the Stripe decline reason (mapped to a user-friendly string). No booking record exists.
Q: How does Rentify recover if a Stripe PaymentIntent capture succeeds but the database commit fails?
A: This is a critical partial-state scenario. The PaymentIntent is captured but no booking row exists. The Payments Lambda webhook handler receives payment_intent.succeeded and looks up the booking by PaymentIntent ID — if no booking row exists, it creates one in confirmed status. This is the recovery path. A CloudWatch alert fires if this scenario is detected (no booking row for a succeeded PaymentIntent after 60 seconds).
Q: What happens if a manual booking approval times out after 24 hours?
A: The Approval Timeout scheduled Lambda (EventBridge, every 15 minutes) queries pending_approval bookings older than 24 hours. For each, it cancels the Stripe PaymentIntent and moves the booking to declined. Guest receives an automated email. The availability lock is released.
Payments Lambda (Webhook)
- Invalid webhook signature: Reject immediately, log to CloudWatch, return a success HTTP response to Stripe (to prevent Stripe from retrying a genuinely bad request from a non-Stripe source).
- Duplicate event: The
stripe_webhook_eventsidempotency table catches this. Return success without reprocessing. - Event processing failure (database error during webhook handling): Return a failure response to Stripe. Stripe will retry the event up to 3 days. The failure is logged to CloudWatch and triggers an alert if it persists across retries.
Payout Scheduler Lambda
- Stripe Transfer failure: Mark
payout_status = failedon the booking row. Log the Stripe error. Send an operator alert via Slack/email. The Admin Lambda exposes a manual retry endpoint. The scheduled Lambda will not re-attempt automatically — operator must review and retry. - Lambda invocation failure (cold start crash, timeout): The SQS DLQ captures the failed invocation. CloudWatch alarm on DLQ message count fires immediately. The payout is delayed until the operator resolves the issue and the next hourly run succeeds.
CCPA Scrubber Lambda
- Scrubbing job failure mid-run: The scrubber processes users one at a time in a loop. Each user scrub is committed individually. A failure on user N does not roll back users 1 through N-1. The failed user's
deletion_statusremainspendingand is retried on the next nightly run. This is intentional — partial scrubs are safe because each field is independently overwritten. - S3 deletion failure (profile photo): Logged to CloudWatch, does not block PII scrubbing of the database record. A separate S3 lifecycle rule ensures orphaned objects are eventually deleted.
Search Lambda
- No results found: Return an empty result set with a 200 response. The frontend renders a "no listings found" state.
- Invalid date inputs (check-out before check-in, past dates): Rejected at the validation layer before the database is queried.
- Database timeout on search query: If the query exceeds 10 seconds (set as a
statement_timeouton the search connection), it is cancelled and the user receives a "search is temporarily unavailable" error. A CloudWatch metric tracks query duration.
RDS Proxy / Database
- RDS Proxy connection pool exhaustion: Lambda invocations that cannot acquire a connection fail with a connection timeout. The Bookings Lambda reserved concurrency limit (50) is sized to stay within the Proxy's pool capacity. If pool exhaustion occurs despite this, the error is logged and the user receives a "service temporarily unavailable" message.
- RDS failover (production Multi-AZ): RDS Proxy handles the failover transparently. Connections in-flight during failover will error and must be retried by the application. Lambda handlers retry database operations up to 3 times with 500ms backoff before returning an error to the caller. Target recovery time is under 60 seconds, within the 1-hour unplanned downtime SLA.
Pre-Launch Verification
- Stripe integration: Test using Stripe's test mode with test card numbers. Verify webhook delivery using the Stripe Dashboard's webhook event simulator. Verify idempotency by replaying events via the simulator.
- SES integration: Test using SES sandbox mode (verified email addresses only) in dev and staging. Verify domain in production before launch.
- CCPA flow: Execute an end-to-end deletion request in staging and verify PII fields are scrubbed and booking rows are retained.
- Atomic lock: Write an integration test that fires two concurrent booking requests for the same listing/dates and asserts that exactly one succeeds.
Risks and Mitigations
Risk 1: PaymentIntent captured but booking row commit fails (partial payment state)
Component: Bookings Lambda, Stripe integration boundary.
Impact: Guest is charged but has no confirmed booking. High-severity customer experience failure.
Mitigation: The webhook-based recovery path (ADR-3, Payments Lambda section) handles this by creating the booking row on receipt of payment_intent.succeeded if no row exists. A CloudWatch alarm fires within 60 seconds of the anomaly so an operator can verify recovery occurred. This scenario is tested pre-launch via a simulated database commit failure in staging.
Risk 2: RDS connection exhaustion during traffic spikes
Component: RDS Proxy, Lambda concurrency configuration.
Impact: All Lambda functions fail to connect to the database, causing a complete service outage.
Mitigation: Reserved concurrency on the Bookings Lambda (50) caps the most connection-intensive function. A per-account Lambda concurrency cap (200) bounds total connections. The RDS Proxy's max connections (100) is sized above the expected peak. CloudWatch alarms on DatabaseConnections metric alert before saturation.
Risk 3: Stripe Connect host onboarding abandonment leaves listings unpublishable
Component: Listings Lambda, Stripe Connect onboarding flow.
Impact: Hosts begin onboarding, abandon midway, and cannot publish listings. Operator has no visibility into incomplete onboarding without manual Stripe Dashboard inspection.
Mitigation: The account.updated webhook updates the host's verification status in the database. The Admin Lambda exposes a view of hosts by verification status. A follow-up email is sent via SES 24 hours after onboarding initiation if verification is still incomplete (triggered by the nightly CCPA Scrubber Lambda, which also checks this status).
Risk 4: Cold start latency on the Bookings Lambda degrades booking UX Component: Bookings Lambda. Impact: First request after a cold start may take 2–4 seconds, which is noticeable during booking — the most conversion-critical flow. Mitigation: The Bookings Lambda is configured with 1024 MB memory (higher memory reduces cold start duration). Provisioned Concurrency is left off by default (cost optimization) but is documented as the first lever to pull if cold starts become a user-reported issue. The Lambda Layer (shared utilities, Zod, pg client) is pre-compiled to minimize initialization time.
Risk 5: CCPA scrubber running during peak hours causes database contention
Component: CCPA Scrubber Lambda, RDS.
Impact: Nightly bulk UPDATE operations on the users table cause lock contention for simultaneous user logins or profile reads.
Mitigation: The scrubber runs at 02:00 UTC (low-traffic window). It processes users in batches of 50 with a 100ms sleep between batches to avoid sustained lock pressure. A statement_timeout of 30 seconds is set on the scrubber's connection, preventing a runaway scrub query from blocking other operations.
Implementation Plan
Implementation Plan
This document outlines the global configuration, environment setup, and prerequisite steps for the architecture. Provide this implementation plan to your coding agent as foundational context before beginning work on the specific execution tickets.
Environment Setup
What you'll need
Before any code gets written, a few accounts and configuration steps need to be in place. This section covers everything you need to set up once so the coding agent has a working foundation to build on.
Accounts to create
- AWS — aws.amazon.com — hosts everything: the API, database, file storage, email, and the frontend. Nearly every piece of this project lives here.
- Stripe — dashboard.stripe.com/register — handles all payments, host payouts, and the Connect onboarding flow for hosts. You'll need to enable Connect (covered below).
- GitHub — github.com/signup — stores the code and runs the automated deployment pipelines.
Before you start
AWS setup
- Create an IAM user or role for deployments with programmatic access. Attach the
AdministratorAccesspolicy for initial setup — you can tighten this after the infrastructure is stable. - In the AWS Console, navigate to SES and verify your sending domain (the domain you'll send booking confirmation emails from). While in sandbox mode, also verify at least one recipient email address for testing.
- In SES, request production access when you're ready to send to real users — this requires a brief AWS support request explaining your use case.
- In the AWS Console, confirm that your chosen region (e.g.
us-east-1) has RDS Proxy available — it's supported in all major regions but worth confirming. - In Parameter Store (under AWS Systems Manager), create the following SecureString parameters under the
/rentify/dev/path prefix to start. You'll duplicate these under/rentify/staging/and/rentify/prod/later. See the environment variables list below for the full set.
Stripe setup
- In the Stripe Dashboard, go to Settings → Connect Settings and enable Connect. Choose "Platform or marketplace" as the account type.
- Under Developers → Webhooks, create a webhook endpoint pointing to your API Gateway webhook URL (you'll have this after Phase 1 infrastructure is deployed). Subscribe to these events:
payment_intent.succeeded,payment_intent.payment_failed,transfer.failed,account.updated. - Copy the webhook signing secret shown after creating the endpoint — you'll need it for SSM.
- Toggle Stripe into Test Mode for development. All keys and webhook secrets have separate test and live versions — make sure you're using test keys in dev and staging.
GitHub setup
- Create a new repository for the project.
- Under Settings → Secrets and Variables → Actions, add your AWS credentials (
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY, andAWS_REGION) as repository secrets so the deployment workflows can authenticate. - Under Settings → Environments, create three environments named
dev,staging, andproduction. On theproductionenvironment, enable "Required reviewers" and add at least one reviewer. This gates production deploys behind a human approval step.
Environment variables
These are the values the coding agent will read from SSM Parameter Store at runtime. Add them all under /rentify/dev/ to start. All sensitive values should be created as SecureString; non-sensitive config values can be String.
DATABASE_URL— The full PostgreSQL connection string for your RDS instance, found in the RDS Console → your database → Connectivity & security. Use the RDS Proxy endpoint, not the direct RDS endpoint.STRIPE_SECRET_KEY— Stripe Dashboard → Developers → API Keys → Secret key (use the test key for dev/staging)STRIPE_WEBHOOK_SECRET— Stripe Dashboard → Developers → Webhooks → your endpoint → Signing secretSTRIPE_CONNECT_CLIENT_ID— Stripe Dashboard → Connect Settings → Your platform's client IDJWT_SECRET— A long random string you generate (at least 64 characters). Used to sign and verify login tokens. Generate one with a password manager or a random string generator.SLACK_WEBHOOK_URL— Create an incoming webhook in your Slack workspace under Apps → Incoming Webhooks, then copy the URLSES_FROM_ADDRESS— The verified email address or domain identity you set up in SES (e.g.no-reply@yourdomain.com)SERVICE_FEE_PERCENTAGE— Set to12to start (this is the guest-side service fee percentage)ALLOWED_CORS_ORIGINS— The URL of your frontend (e.g.http://localhost:3000for dev, your CloudFront URL for staging/prod)THROTTLE_BURST_LIMIT— Set to1000for production, lower for dev (e.g.100)THROTTLE_STEADY_STATE— Set to500for production, lower for dev (e.g.50)
Phases
Phase 1: Infrastructure Foundation and Shared Layer
This phase establishes the complete AWS infrastructure and the shared Lambda Layer that every subsequent phase depends on. It delivers a deployable SAM template, a working RDS PostgreSQL database reachable through RDS Proxy, all S3 buckets, SSM parameter paths, and the shared TypeScript utilities package (database client, logger, Zod schemas, error types) that all Lambda handlers import. Nothing in later phases works without this foundation.
Contracts
SAM Template (template.yaml)
Defines all AWS resources. Later phases add Lambda function resources to this file — they never create separate templates. The template is parameterized by Environment (dev | staging | production) and reads all secrets from SSM at deploy time.
Resources declared in this phase:
RdsInstance— PostgreSQL 15,db.t3.microfor dev/staging,db.t3.mediumfor production, Multi-AZ enabled only whenEnvironment=productionRdsProxy— IAM authentication enabled, connected toRdsInstance, max connections 100PhotosBucket— private S3 bucket, versioning enabled, S3 lifecycle rule: objects underpending/prefix deleted after 24 hoursExportsBucket— private S3 bucket for CCPA data exportsSharedLambdaLayer— bundles the compiledshared/package; referenced by ARN in every Lambda function resource added in later phasesRentifyApi— API Gateway REST API with aStageNamematchingEnvironment; CORS configured withAllowedOriginsfrom SSM, preflight cache 600 seconds; throttle burst and steady-state from SSM- CloudFront distribution for the frontend S3 bucket (added as
FrontendBucket) with OAC, HTTPS-only, and the security response headers policy (Strict-Transport-Securitymax-age 31536000 includeSubDomains,X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Content-Security-Policyrestricting scripts to the CDN origin andhttps://js.stripe.com,Referrer-Policy: strict-origin-when-cross-origin)
Database schema (infrastructure/migrations/)
Migration files run in sequence. File naming convention: 001_initial_schema.sql, 002_...sql. All tables and indexes for the full application are defined here so later phases find the schema in place.
Tables created:
users
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
email VARCHAR(255) UNIQUE NOT NULL
password_hash VARCHAR(255) NOT NULL
display_name VARCHAR(255)
phone VARCHAR(50)
profile_photo_key VARCHAR(500)
roles TEXT[] NOT NULL DEFAULT '{guest}'
stripe_account_id VARCHAR(255)
stripe_account_verified BOOLEAN NOT NULL DEFAULT false
deletion_requested_at TIMESTAMPTZ
deletion_status VARCHAR(50) NOT NULL DEFAULT 'none'
-- values: 'none' | 'pending' | 'completed'
do_not_sell BOOLEAN NOT NULL DEFAULT false
login_attempt_count INT NOT NULL DEFAULT 0
login_locked_until TIMESTAMPTZ
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
refresh_tokens
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
user_id UUID NOT NULL REFERENCES users(id)
token_hash VARCHAR(255) NOT NULL
expires_at TIMESTAMPTZ NOT NULL
revoked BOOLEAN NOT NULL DEFAULT false
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
listings
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
host_user_id UUID NOT NULL REFERENCES users(id)
title VARCHAR(255) NOT NULL
description TEXT
location VARCHAR(500) NOT NULL
property_type VARCHAR(50) NOT NULL
-- values: 'entire_home' | 'room'
max_guests INT NOT NULL
base_price NUMERIC(10,2) NOT NULL
instant_book BOOLEAN NOT NULL DEFAULT false
cancellation_policy VARCHAR(50) NOT NULL DEFAULT 'flexible'
-- values: 'flexible' | 'moderate' | 'strict'
status VARCHAR(50) NOT NULL DEFAULT 'draft'
-- values: 'draft' | 'active' | 'inactive'
house_rules TEXT
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
listing_photos
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
listing_id UUID NOT NULL REFERENCES listings(id)
s3_key VARCHAR(500) NOT NULL
display_order INT NOT NULL
alt_text VARCHAR(500)
confirmed BOOLEAN NOT NULL DEFAULT false
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
listing_amenities
listing_id UUID NOT NULL REFERENCES listings(id)
amenity VARCHAR(100) NOT NULL
PRIMARY KEY (listing_id, amenity)
availability_rules
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
listing_id UUID NOT NULL REFERENCES listings(id)
rule_type VARCHAR(50) NOT NULL
-- values: 'blocked_range' | 'min_stay' | 'checkin_days'
start_date DATE
end_date DATE
value INT
bitmask BIT(7)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
pricing_overrides
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
listing_id UUID NOT NULL REFERENCES listings(id)
override_type VARCHAR(50) NOT NULL
-- values: 'weekend' | 'seasonal'
start_date DATE
end_date DATE
price NUMERIC(10,2) NOT NULL
created_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 users(id)
stay_range tsrange NOT NULL
check_in_date DATE NOT NULL
check_out_date DATE NOT NULL
status VARCHAR(50) NOT NULL DEFAULT 'pending'
-- values: 'pending' | 'pending_approval' | 'confirmed'
-- | 'declined' | 'cancelled_by_host' | 'cancelled_by_guest'
-- | 'payment_failed'
total_amount NUMERIC(10,2) NOT NULL
host_payout_amount NUMERIC(10,2) NOT NULL
service_fee NUMERIC(10,2) NOT NULL
stripe_pi_id VARCHAR(255)
payout_status VARCHAR(50) NOT NULL DEFAULT 'pending'
-- values: 'pending' | 'completed' | 'failed' | 'not_applicable'
stripe_transfer_id VARCHAR(255)
approval_expires_at TIMESTAMPTZ
nights INT NOT NULL
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
GiST exclusion constraint on bookings:
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bookings ADD CONSTRAINT no_overlapping_bookings
EXCLUDE USING gist (listing_id WITH =, stay_range WITH &&)
WHERE (status IN ('pending', 'pending_approval', 'confirmed'));
refunds
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
booking_id UUID NOT NULL REFERENCES bookings(id)
stripe_refund_id VARCHAR(255) NOT NULL
amount NUMERIC(10,2) NOT NULL
reason VARCHAR(100) NOT NULL
-- values: 'host_cancellation' | 'guest_cancellation' | 'operator'
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
stripe_webhook_events
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
stripe_event_id VARCHAR(255) NOT NULL
status VARCHAR(50) NOT NULL DEFAULT 'processed'
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
Unique index: CREATE UNIQUE INDEX ON stripe_webhook_events(stripe_event_id);
ccpa_requests
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
user_id UUID NOT NULL REFERENCES users(id)
request_type VARCHAR(50) NOT NULL
-- values: 'deletion' | 'export' | 'opt_out'
status VARCHAR(50) NOT NULL DEFAULT 'pending'
-- values: 'pending' | 'completed' | 'failed'
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
All indexes from the ADD's indexing strategy are created in the migration:
CREATE INDEX ON bookings(listing_id, check_in_date, check_out_date);
CREATE INDEX ON bookings(guest_user_id);
CREATE INDEX ON listings(location, max_guests, base_price);
CREATE INDEX ON availability_rules(listing_id);
CREATE INDEX ON pricing_overrides(listing_id);
Shared Lambda Layer (packages/shared/)
packages/shared/src/db.ts
Exports a single database client factory. All Lambda handlers call this at cold start and cache the result.
getDbClient(): pg.Pool
- Reads
DATABASE_URLfrom the SSM parameter cache (seeconfig.tsbelow) - Returns a
pg.Poolconfigured for the Lambda execution context (small pool size — 1 to 5 connections per instance) - On connection error: logs via the shared logger, throws
DatabaseConnectionError
packages/shared/src/config.ts
Exports a config loader that retrieves all SSM parameters at cold start and caches them for the lifetime of the warm instance.
getConfig(): Promise<AppConfig>
AppConfig shape:
{
databaseUrl: string
stripeSecretKey: string
stripeWebhookSecret: string
stripeConnectClientId: string
jwtSecret: string
slackWebhookUrl: string
sesFromAddress: string
serviceFeePercentage: number // parsed from string SSM value
allowedCorsOrigins: string[] // parsed from comma-separated SSM value
}
- Reads all values from
/rentify/{env}/path prefix usingssm:GetParametersByPath - Throws
ConfigurationErrorif any required parameter is absent
packages/shared/src/errors.ts
Exports the canonical error types used across all Lambda handlers. Later phases never define new error classes — they throw these:
class RentifyError extends Error {
constructor(public code: string, message: string, public httpStatus: number)
}
class ValidationError extends RentifyError // code: 'VALIDATION_ERROR', status 400
class AuthenticationError extends RentifyError // code: 'AUTHENTICATION_ERROR', status 401
class AuthorizationError extends RentifyError // code: 'AUTHORIZATION_ERROR', status 403
class NotFoundError extends RentifyError // code: 'NOT_FOUND', status 404
class ConflictError extends RentifyError // code: 'CONFLICT', status 409
// used for GiST constraint violations and duplicate bookings
class PaymentError extends RentifyError // code: 'PAYMENT_ERROR', status 402
class DatabaseConnectionError extends RentifyError // code: 'DB_CONNECTION_ERROR', status 503
class ConfigurationError extends RentifyError // code: 'CONFIG_ERROR', status 500
class ExternalServiceError extends RentifyError // code: 'EXTERNAL_SERVICE_ERROR', status 502
packages/shared/src/logger.ts
Thin wrapper around @aws-lambda-powertools/logger that pre-configures structured JSON output and correlation ID injection. Exported as a singleton logger instance. All Lambda handlers import this — they never construct their own Logger instances.
const logger: Logger // pre-configured Powertools Logger
packages/shared/src/response.ts
Exports two functions used by every Lambda handler to build API Gateway proxy responses:
successResponse(body: unknown, statusCode?: number): APIGatewayProxyResult
// statusCode defaults to 200
// body is JSON.stringify'd; Content-Type: application/json
errorResponse(error: RentifyError | Error): APIGatewayProxyResult
// RentifyError: uses error.httpStatus and error.code
// Unknown Error: status 500, code 'INTERNAL_ERROR'
// Response body: { error: { code: string, message: string } }
// Never includes stack traces or internal details
packages/shared/src/validation.ts
Exports a validate helper that wraps Zod parsing and throws ValidationError on failure:
validate<T>(schema: z.ZodSchema<T>, input: unknown): T
// On Zod parse failure: throws ValidationError with human-readable message
// On success: returns the parsed, typed value
Also exports all Zod schemas used by multiple Lambda handlers. Per-handler schemas that are never shared are defined within that handler's module, not here.
Shared schemas:
uuidSchema: z.string().uuid()
dateSchema: z.string().regex(/^\d{4}-\d{2}-\d{2}$/) // YYYY-MM-DD
paginationSchema: z.object({ page: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(100).default(20) })
packages/shared/src/email.ts
Exports a single email-sending function used by all Lambda handlers that send transactional email. SES errors are logged but do not throw — they are treated as non-critical:
sendEmail(params: {
to: string
subject: string
htmlBody: string
textBody: string
}): Promise<void>
// Logs SES errors via logger; does not re-throw
packages/shared/src/index.ts
Re-exports everything from the above modules as named exports. Lambda handlers import from @rentify/shared.
samconfig.toml
Three environment stacks (dev, staging, production) with environment-specific parameter overrides. No environment-specific logic lives in code — only in this config file and SSM.
Deliverables
- SAM template deploys successfully to dev with
sam deploy - RDS instance and RDS Proxy are reachable; all migration SQL runs without error
- S3 buckets exist with correct policies and lifecycle rules
- CloudFront distribution serves a placeholder page from the frontend S3 bucket with security headers visible in browser dev tools
packages/shared/compiles without TypeScript errorsgetConfig()returns a populatedAppConfigwhen called from a test Lambda invocation with SSM parameters populatedgetDbClient()returns a connected pool that can executeSELECT 1
Phase 2: Authentication
This phase delivers the Auth Lambda and the Lambda Authorizer — the security gate for every subsequent phase. No other Lambda handler can be protected until the Authorizer is deployed and the JWT contract is established. This phase also defines the exact JWT payload shape that every later handler reads from the authorizer-injected event context.
Contracts
JWT payload shape
This shape is the contract between the Auth Lambda (which signs tokens) and the Lambda Authorizer (which verifies and forwards them). Every later phase reads user identity from this injected context — never from the database at request time.
type JwtPayload = {
sub: string // user UUID
email: string
roles: string[] // e.g. ['guest'] or ['guest', 'host']
iat: number
exp: number
}
The authorizer injects the decoded payload into the Lambda event's requestContext.authorizer under the key claims. Handler modules access identity as:
const userId = event.requestContext.authorizer.claims.sub as string
const roles = JSON.parse(event.requestContext.authorizer.claims.roles) as string[]
Note: API Gateway serializes all authorizer context values to strings. The roles array is serialized with JSON.stringify before injection and must be parsed with JSON.parse in every handler. This is a fixed contract — no phase changes this serialization behavior.
Auth Lambda (functions/auth/)
functions/auth/src/handlers/register.ts
register(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
Input (validated with validate()):
{ email: string, password: string, displayName: string }
Steps in order:
- Validate input.
passwordmust be at least 8 characters. - Check for existing user with that email — if found, throw
ConflictError('EMAIL_IN_USE', 'An account with this email already exists', 409). - Hash password with bcrypt, work factor 12.
- Insert user row with
roles = ['guest'],deletion_status = 'none'. - Issue access token (15-minute expiry) and refresh token (30-day expiry). Store hashed refresh token in
refresh_tokenstable. - Return
{ accessToken, refreshToken, user: { id, email, displayName, roles } }.
Error handling: database errors throw DatabaseConnectionError; unexpected errors return errorResponse with status 500.
functions/auth/src/handlers/login.ts
login(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
Input:
{ email: string, password: string }
Steps in order:
- Validate input.
- Look up user by email. Regardless of whether the user exists, run the same code path to prevent timing-based enumeration — always call
bcrypt.compare, even against a dummy hash if the user is not found. - Check
login_locked_until— if in the future, returnAuthenticationErrorwith a generic "invalid email or password" message (do not reveal the lock). - If password does not match: increment
login_attempt_count. If count reaches 10, setlogin_locked_until = now() + 15 minutes. Return genericAuthenticationError. - On success: reset
login_attempt_countto 0, clearlogin_locked_until. - Issue and return tokens identically to
register.
functions/auth/src/handlers/refresh.ts
refreshToken(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
Input:
{ refreshToken: string }
Steps in order:
- Hash the incoming token and look up a matching, non-revoked row in
refresh_tokenswhereexpires_at > now(). - If not found or revoked: throw
AuthenticationError('SESSION_EXPIRED', 'Session expired, please log in again', 401). - Revoke the old refresh token (set
revoked = true). - Issue a new access token and refresh token. Store new refresh token hash. Return both.
functions/auth/src/handlers/logout.ts
logout(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
Protected route (passes through authorizer). Input:
{ refreshToken: string }
Steps: hash the token, set revoked = true on the matching row. Return { success: true }.
functions/auth/src/router.ts
Routes incoming API Gateway events by httpMethod and path to the correct handler. Exports a single Lambda handler entry point:
handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
Route table:
POST /auth/register→registerPOST /auth/login→loginPOST /auth/refresh→refreshTokenPOST /auth/logout→logout(protected)
Unmatched routes return NotFoundError.
Lambda Authorizer (functions/authorizer/)
functions/authorizer/src/index.ts
handler(event: APIGatewayAuthorizerEvent): Promise<APIGatewayAuthorizerResult>
Steps in order:
- Extract the
Authorization: Bearer <token>header. - Verify the JWT signature using
JWT_SECRETfrom config. Check expiry. - On valid token: return an
Allowpolicy for the resource ARN, with theJwtPayloadfields stringified into the authorizer context (sub,email,rolesasJSON.stringify(roles)). - On invalid or missing token: return a
Denypolicy. The calling Lambda never executes.
Authorizer result cache TTL: 300 seconds (5 minutes), as specified in the SAM template's AuthorizerResultTtlInSeconds property.
Deliverables
POST /auth/registercreates a user row and returns tokensPOST /auth/loginreturns tokens; brute-force lock engages after 10 failed attempts within 15 minutesPOST /auth/refreshissues new tokens and revokes the old refresh token- A protected test route returns 401 when called without a token and 200 when called with a valid token
- Two concurrent registration requests with the same email result in exactly one user row (ConflictError on the second)
Phase 3: Listing Management
This phase delivers the Listings Lambda — all host-side operations for creating and managing listings, including availability rules, pricing overrides, photo management, amenities, and Stripe Connect host onboarding. The Search Lambda in Phase 4 reads from the same tables this phase writes, so the full listing data shape is finalized here.
Contracts
Listing response shape
Every endpoint that returns a listing uses this canonical shape. The Search Lambda in Phase 4 returns ListingSummary (a subset). Both are defined here so Phase 4 imports them.
type Listing = {
id: string
hostUserId: string
title: string
description: string | null
location: string
propertyType: 'entire_home' | 'room'
maxGuests: number
basePrice: number
instantBook: boolean
cancellationPolicy: 'flexible' | 'moderate' | 'strict'
status: 'draft' | 'active' | 'inactive'
houseRules: string | null
photos: ListingPhoto[]
amenities: string[]
availabilityRules: AvailabilityRule[]
pricingOverrides: PricingOverride[]
createdAt: string // ISO 8601
updatedAt: string
}
type ListingPhoto = {
id: string
s3Key: string
displayOrder: number
altText: string | null
}
type AvailabilityRule = {
id: string
ruleType: 'blocked_range' | 'min_stay' | 'checkin_days'
startDate: string | null // YYYY-MM-DD
endDate: string | null
value: number | null
bitmask: string | null // 7-character bit string e.g. '0110000'
}
type PricingOverride = {
id: string
overrideType: 'weekend' | 'seasonal'
startDate: string | null
endDate: string | null
price: number
}
type ListingSummary = Pick<Listing, 'id' | 'title' | 'location' | 'propertyType' | 'maxGuests' | 'basePrice' | 'instantBook' | 'cancellationPolicy' | 'status'> & {
coverPhotoKey: string | null // s3Key of the photo with display_order = 1
}
Availability rule evaluation (functions/listings/src/lib/availability.ts)
This module is also imported by the Bookings Lambda in Phase 5. It must not import anything from functions/listings/ that is specific to listing management — only shared types and the db client.
evaluateAvailabilityRules(params: {
listingId: string
checkInDate: string // YYYY-MM-DD
checkOutDate: string // YYYY-MM-DD
db: pg.Pool
}): Promise<void>
- Fetches all
availability_rulesrows forlistingId - Applies rules in order:
- Blocked ranges: all nights in the requested stay must fall outside every
blocked_rangerule's date range. If any night overlaps, throwsConflictError('DATES_BLOCKED', 'These dates are not available', 409). - Minimum stay: the number of nights must be ≥
valueon anymin_stayrule. If not, throwsConflictError('MIN_STAY', 'Minimum stay requirement not met', 409). - Check-in days: the check-in weekday bit (0 = Sunday … 6 = Saturday) must be set in the
bitmaskof anycheckin_daysrule. If not, throwsConflictError('CHECKIN_DAY', 'Check-in on this day is not permitted', 409).
- Blocked ranges: all nights in the requested stay must fall outside every
- Returns
voidon success.
Pricing calculation (functions/listings/src/lib/pricing.ts)
Also imported by the Bookings Lambda in Phase 5.
calculateStayPrice(params: {
listingId: string
checkInDate: string // YYYY-MM-DD
checkOutDate: string // YYYY-MM-DD
db: pg.Pool
serviceFeePercentage: number
}): Promise<PriceBreakdown>
type PriceBreakdown = {
nightlyRates: Array<{ date: string, rate: number }>
subtotal: number // sum of nightly rates
serviceFee: number // subtotal * (serviceFeePercentage / 100), rounded to 2 decimal places
total: number // subtotal + serviceFee
hostPayoutAmount: number // subtotal (service fee stays on platform)
}
- Iterates each calendar night from
checkInDateup to but not includingcheckOutDate - For each night, resolves price in priority order: (1) seasonal override if the night falls within a
seasonaloverride's date range, (2) weekend override if the night is Friday or Saturday and aweekendoverride exists, (3)base_pricefrom the listing row - All monetary values rounded to 2 decimal places
Listings Lambda (functions/listings/)
functions/listings/src/router.ts
Single Lambda entry point:
handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
Route table (all routes protected unless noted):
POST /listings→createListingGET /listings/:id→getListing(public)PUT /listings/:id→updateListingDELETE /listings/:id→deleteListingPOST /listings/:id/photos/upload-url→getPhotoUploadUrlPOST /listings/:id/photos/confirm→confirmPhotoUploadDELETE /listings/:id/photos/:photoId→deletePhotoPUT /listings/:id/photos/order→updatePhotoOrderPOST /listings/:id/amenities→setAmenitiesPOST /listings/:id/availability-rules→setAvailabilityRulesPOST /listings/:id/pricing-overrides→setPricingOverridesGET /connect/onboarding-url→getStripeOnboardingUrlGET /connect/callback→handleStripeCallback(public — Stripe redirects here)
functions/listings/src/handlers/listings.ts
createListing(event): Promise<APIGatewayProxyResult>
- Requires
hostrole; throwsAuthorizationErrorif caller lacks it - Input:
{ title, description?, location, propertyType, maxGuests, basePrice, instantBook?, cancellationPolicy?, houseRules? } - Inserts listing row with
status = 'draft' - Returns
Listing
getListing(event): Promise<APIGatewayProxyResult>
- Public route; no role check
- Fetches listing with photos, amenities, availability rules, pricing overrides
- Returns
Listing; throwsNotFoundErrorif not found
updateListing(event): Promise<APIGatewayProxyResult>
- Requires
hostrole; validateshost_user_id = callerId - Input: any subset of listing fields (partial update)
- Returns updated
Listing
deleteListing(event): Promise<APIGatewayProxyResult>
- Requires
hostrole; validates ownership - Sets
status = 'inactive'(never hard-deletes) - Returns
{ success: true }
functions/listings/src/handlers/photos.ts
getPhotoUploadUrl(event): Promise<APIGatewayProxyResult>
- Requires
hostrole; validates listing ownership - Input:
{ filename: string, contentType: string } - Generates S3 pre-signed PUT URL for key
pending/{listingId}/{uuid}.{ext}, expires in 15 minutes - Inserts unconfirmed
listing_photosrow (confirmed = false) with thepending/key - Returns
{ uploadUrl: string, photoId: string, s3Key: string }
confirmPhotoUpload(event): Promise<APIGatewayProxyResult>
- Input:
{ photoId: string } - Verifies that the S3 object exists at the
pending/key (calls S3 HeadObject) - Copies the object to
photos/{listingId}/{uuid}.{ext}, deletes thepending/object - Updates
listing_photosrow: setss3_keyto thephotos/key,confirmed = true - Returns
{ photo: ListingPhoto }
deletePhoto(event): Promise<APIGatewayProxyResult>
- Validates ownership; deletes S3 object and
listing_photosrow - Returns
{ success: true }
updatePhotoOrder(event): Promise<APIGatewayProxyResult>
- Input:
{ orderedPhotoIds: string[] }— full ordered list of photo IDs for the listing - Updates
display_orderon each photo in a single transaction - Returns updated
photos: ListingPhoto[]
functions/listings/src/handlers/attributes.ts
setAmenities(event): Promise<APIGatewayProxyResult>
- Input:
{ amenities: string[] } - Replaces all
listing_amenitiesrows for the listing in a transaction (delete then insert) - Returns
{ amenities: string[] }
setAvailabilityRules(event): Promise<APIGatewayProxyResult>
- Input:
{ rules: Array<{ ruleType, startDate?, endDate?, value?, bitmask? }> } - Validates that
blocked_rangerules havestartDateandendDate;min_stayrules havevalue;checkin_daysrules have a 7-characterbitmask - Replaces all
availability_rulesfor the listing in a transaction - Returns
{ rules: AvailabilityRule[] }
setPricingOverrides(event): Promise<APIGatewayProxyResult>
- Input:
{ overrides: Array<{ overrideType, startDate?, endDate?, price }> } - Validates that
seasonaloverrides havestartDateandendDate - Replaces all
pricing_overridesfor the listing in a transaction - Returns
{ overrides: PricingOverride[] }
functions/listings/src/handlers/connect.ts
getStripeOnboardingUrl(event): Promise<APIGatewayProxyResult>
- Requires authentication; user must have
guestorhostrole (any authenticated user can start host onboarding) - Creates a Stripe Connect account (if
stripe_account_idis null on the user) or uses the existing one - Generates a Stripe Connect account link (onboarding type) with
return_urlandrefresh_urlpointing to the frontend - Returns
{ onboardingUrl: string }
handleStripeCallback(event): Promise<APIGatewayProxyResult>
- Public route; called by Stripe redirect after onboarding
- No action needed here — the actual verification update arrives via the
account.updatedwebhook in Phase 6. This handler simply redirects the user to the frontend with a query param indicating they should wait for verification. - Returns a 302 redirect to the frontend onboarding-status page
Error handling for all Listings handlers: database errors use the same error handling contract as Phase 1's db.ts. Ownership violations throw AuthorizationError. Missing resources throw NotFoundError. Stripe API errors throw ExternalServiceError, logged via logger.
Deliverables
- Host can create, read, update, and soft-delete a listing
- Host can upload a photo via pre-signed URL, confirm it, reorder photos, and delete a photo
- Host can set amenities, availability rules, and pricing overrides (full replace semantics)
evaluateAvailabilityRulesrejects a blocked date, a stay shorter than the minimum, and a disallowed check-in day in unit testscalculateStayPricereturns correct per-night breakdown applying seasonal, weekend, and base-rate priority in unit tests- Stripe Connect onboarding URL is generated and returned for an authenticated user
Phase 4: Search
This phase delivers the Search Lambda — the guest-facing listing discovery endpoint. It reads from tables established in Phase 1 and Phase 3, and imports ListingSummary from Phase 3's contracts. No writes occur in this phase.
Contracts
Search Lambda (functions/search/)
functions/search/src/index.ts
Single Lambda entry point:
handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
One public route: GET /search
functions/search/src/handlers/search.ts
searchListings(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
Query parameters (all optional):
{
location?: string // matched with ILIKE on listings.location
checkIn?: string // YYYY-MM-DD
checkOut?: string // YYYY-MM-DD
guests?: string // parsed to int
minPrice?: string // parsed to number
maxPrice?: string // parsed to number
page?: string // parsed to int, default 1
limit?: string // parsed to int, default 20, max 100
}
Validation (applied before any database query):
- If
checkInis provided,checkOutmust also be provided and must be aftercheckIn guests,minPrice,maxPrice,page,limitmust be positive numbers when provided- Past
checkIndates are rejected withValidationError
Query logic:
- Build a parameterized SQL query against
listingswherestatus = 'active' - Apply filters in order:
ILIKE '%' || $location || '%'onlocationif provided;max_guests >= $guestsif provided;base_price >= $minPrice AND base_price <= $maxPriceif provided - If
checkInandcheckOutare provided, add a subquery that excludeslisting_idvalues where a confirmed booking'sstay_rangeoverlaps the requested range:AND id NOT IN ( SELECT listing_id FROM bookings WHERE status IN ('confirmed', 'pending', 'pending_approval') AND stay_range && tsrange($checkIn::date, $checkOut::date) ) - Apply
statement_timeoutof 10 seconds on the search connection before executing - Apply
LIMIT $limit OFFSET ($page - 1) * $limit - Return results as
ListingSummary[](cover photo is thelisting_photosrow withdisplay_order = 1andconfirmed = true)
Response shape:
{
listings: ListingSummary[]
pagination: {
page: number
limit: number
total: number // COUNT(*) of the unfiltered query for this filter set
hasMore: boolean
}
}
On database timeout (query cancelled by statement_timeout): return ExternalServiceError('SEARCH_UNAVAILABLE', 'Search is temporarily unavailable', 503). On no results: return empty listings: [] with pagination, status 200.
Deliverables
GET /searchwith no filters returns paginated active listingsGET /search?location=Austinreturns only listings with "Austin" in the location fieldGET /search?checkIn=2025-08-01&checkOut=2025-08-05excludes listings with a confirmed booking overlapping those datesGET /search?checkIn=2025-08-05&checkOut=2025-08-01returns a 400 ValidationError- Integration test: create two listings, create a confirmed booking on listing A for a date range, search that range — only listing B is returned
Phase 5: Booking and Payments Core
This phase delivers the Bookings Lambda — the most complex component in the system. It implements the full reservation lifecycle: instant-book and manual-approval creation, host acceptance/decline, guest and host cancellation, and the approval timeout path. It imports evaluateAvailabilityRules and calculateStayPrice from Phase 3. The Stripe integration for transfers and the Payout Scheduler are in Phase 7.
Contracts
Booking response shape
type Booking = {
id: string
listingId: string
guestUserId: string
checkInDate: string // YYYY-MM-DD
checkOutDate: string
status: 'pending' | 'pending_approval' | 'confirmed' | 'declined'
| 'cancelled_by_host' | 'cancelled_by_guest' | 'payment_failed'
nights: number
totalAmount: number
serviceFee: number
hostPayoutAmount: number
stripePaymentIntentId: string | null
payoutStatus: 'pending' | 'completed' | 'failed' | 'not_applicable'
createdAt: string
updatedAt: string
}
Bookings Lambda (functions/bookings/)
functions/bookings/src/router.ts
Single Lambda entry point (all routes protected):
handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
Route table:
POST /bookings→createBookingGET /bookings/:id→getBookingGET /bookings→listUserBookings(guest's own bookings)GET /host/bookings→listHostBookings(bookings for host's listings)POST /bookings/:id/approve→approveBookingPOST /bookings/:id/decline→declineBookingPOST /bookings/:id/cancel→cancelBooking
functions/bookings/src/handlers/create.ts
createBooking(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
Input:
{
listingId: string
checkInDate: string // YYYY-MM-DD
checkOutDate: string
guestCount: number
}
Validation:
checkOutmust be aftercheckIn; stay must be between 1 and 365 nightsguestCountmust be a positive integer
Steps in order:
- Fetch the listing row. Throw
NotFoundErrorif not found orstatus !== 'active'. - Validate
guestCount <= listing.max_guests; throwValidationErrorif not. - Call
evaluateAvailabilityRules({ listingId, checkInDate, checkOutDate, db }). ThrowsConflictErroron rule violation. - Call
calculateStayPrice({ listingId, checkInDate, checkOutDate, db, serviceFeePercentage })to getPriceBreakdown. - Open a PostgreSQL serializable transaction.
- Insert the
bookingsrow withstatus = 'pending'(instant-book) orstatus = 'pending_approval'(manual-approval). If the GiST exclusion constraint fires, PostgreSQL raises a constraint violation error — catch it, roll back, and throwConflictError('DATES_UNAVAILABLE', 'This listing is no longer available for these dates — please try again', 409). - Set
approval_expires_at = now() + 24 hoursif manual-approval.
Instant-book path only — steps after insert, still within the transaction:
8. Call Stripe to create and immediately capture a PaymentIntent for priceBreakdown.total (in cents) with application_fee_amount = priceBreakdown.serviceFee in cents, transfer_data.destination = listing host's stripe_account_id. If Stripe returns a payment failure, roll back the transaction. Throw PaymentError with a user-friendly message (map Stripe decline codes to human-readable strings — do not forward Stripe's raw error).
9. If Stripe succeeds: update the booking row status = 'confirmed', stripe_pi_id = paymentIntent.id.
10. Commit the transaction.
11. Send confirmation email via sendEmail to the guest. SES error does not roll back.
12. Return Booking.
Manual-approval path — steps after insert:
8. Call Stripe to create a PaymentIntent in requires_capture state (authorize only, do not capture). Store stripe_pi_id on the booking row. Commit the transaction.
9. Send a notification email to the host that a booking request is pending approval.
10. Return Booking with status = 'pending_approval'.
functions/bookings/src/handlers/approval.ts
approveBooking(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
- Caller must have
hostrole; must own the listing referenced by the booking - Booking must be in
pending_approvalstatus andapproval_expires_atmust be in the future; otherwise throwConflictError - Capture the Stripe PaymentIntent. On Stripe failure: update booking to
payment_failed, throwPaymentError - On success: update booking to
confirmed. Send confirmation email to guest. - Return
Booking
declineBooking(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
- Caller must have
hostrole and own the listing - Booking must be in
pending_approvalstatus - Cancel the Stripe PaymentIntent (releases the authorization)
- Update booking to
declined - Send decline notification email to guest
- Return
Booking
functions/bookings/src/handlers/cancel.ts
cancelBooking(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
- Caller is either the guest who made the booking or the host of the listing
- Booking must be in
confirmedorpending_approvalstatus; otherwise throwConflictError
Host-initiated cancellation:
- Update booking to
cancelled_by_host - Call Stripe Refund API for the full
total_amountagainst the PaymentIntent - Insert row in
refundstable withreason = 'host_cancellation' - Send email to guest confirming full refund
- Return
Booking
Guest-initiated cancellation:
- Read
cancellation_policyfrom the listing (flexible|moderate|strict) - Calculate refund amount:
flexible: full refund if check-in is more than 24 hours away; no refund otherwisemoderate: full refund if check-in is more than 5 days away; 50% refund if 1–5 days; no refund if less than 1 daystrict: 50% refund if check-in is more than 7 days away; no refund otherwise- All refund amounts are calculated against
total_amount(including service fee for full refunds; service fee retained by platform for partial refunds — calculate refund againsthost_payout_amount + service_fee_if_applicable)
- Call Stripe Refund API for the calculated amount (0 = no Stripe call needed)
- Insert row in
refundstable withreason = 'guest_cancellation' - Update booking to
cancelled_by_guest - Send cancellation confirmation email to guest
- Return
Booking
functions/bookings/src/handlers/read.ts
getBooking(event): Promise<APIGatewayProxyResult>
- Caller must be the guest on the booking, the host of the listing, or have
adminorsupportrole - Returns
Booking
listUserBookings(event): Promise<APIGatewayProxyResult>
- Returns all bookings where
guest_user_id = callerId, paginated - Response:
{ bookings: Booking[], pagination: { page, limit, total, hasMore } }
listHostBookings(event): Promise<APIGatewayProxyResult>
- Requires
hostrole - Returns all bookings for listings where
host_user_id = callerId, paginated - Response:
{ bookings: Booking[], pagination: { page, limit, total, hasMore } }
functions/bookings/src/lib/approvalTimeout.ts
This module is called by the Approval Timeout Scheduler in Phase 7 and by the Bookings Lambda's internal logic. It is defined here because its behavior is part of the booking state machine.
expireStaleApprovals(db: pg.Pool, stripe: Stripe): Promise<{ expired: number }>
- Queries
bookingswherestatus = 'pending_approval'andapproval_expires_at < now() - For each: cancel the Stripe PaymentIntent; update booking to
declined; send decline email to guest - Processes bookings individually; a failure on one booking is logged and does not stop processing of the rest
- Returns
{ expired: number }— count of bookings expired
Error handling for all Bookings handlers: same database error handling contract as Phase 1's db.ts. Stripe errors on capture or refund throw PaymentError with user-safe messages; raw Stripe error details are logged via logger but not returned to the client.
Deliverables
POST /bookingswith an instant-book listing creates a confirmed booking and charges Stripe; concurrent requests for the same dates result in exactly one success and oneConflictError('DATES_UNAVAILABLE')POST /bookingswith a manual-approval listing creates apending_approvalbooking with a Stripe authorization hold- Host can approve (captures payment →
confirmed) or decline (cancels authorization →declined) - Host cancellation issues a full Stripe refund; guest cancellation applies the correct policy amount
expireStaleApprovalscorrectly identifies and declines bookings past their approval window in an integration test
Phase 6: Payments Webhook Handler and Users Lambda
This phase delivers two independent Lambda functions. The Payments Lambda (webhook handler) handles Stripe event callbacks and the recovery path for the partial-payment scenario. The Users Lambda handles profile management and the CCPA request flows. These are grouped into one phase because neither depends on the other and both close out the core request-response surface area before the scheduled Lambda work in Phase 7.
Contracts
Payments Lambda — Webhook Handler (functions/payments/)
functions/payments/src/index.ts
Single Lambda entry point (unauthenticated at API Gateway — no JWT authorizer on this route):
handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
Route: POST /webhooks/stripe
functions/payments/src/handlers/webhook.ts
handleStripeWebhook(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
Steps in order:
- Extract the raw request body (as a string — must not be parsed before signature verification).
- Validate the Stripe webhook signature using
STRIPE_WEBHOOK_SECRETfrom config. On invalid signature: log the attempt vialogger, return a 200 response (to prevent Stripe from retrying a request from a non-Stripe source — this behavior departs from a straightforward reject because returning a non-2xx would cause Stripe to retry indefinitely). - Check the
stripe_webhook_eventstable for the event ID. If found, return 200 immediately (idempotency short-circuit). - Insert the event ID into
stripe_webhook_eventswithstatus = 'processed'. - Route to the correct handler by
event.type:
payment_intent.succeeded:
- Look up booking by
stripe_pi_id - If booking exists and
status !== 'confirmed': update toconfirmed(recovery path for partial-payment scenario) - If no booking exists: log a
WARN-level structured log entry ({ event: 'orphaned_payment_intent', stripePaymentIntentId, timestamp }) and publish a CloudWatch custom metricOrphanedPaymentIntent— this is the alertable anomaly described in the ADD's Risk 1. The CloudWatch alarm for this metric is defined in the SAM template. - Return 200
payment_intent.payment_failed:
- Look up booking by
stripe_pi_id - If found: update
status = 'payment_failed' - Return 200
transfer.failed:
- Look up booking by
stripe_transfer_id - If found: update
payout_status = 'failed' - Log error via
loggerwith full Stripe error details - Send operator alert email via
sendEmailand post to Slack webhook URL from config - Return 200
account.updated:
- Extract the connected account ID and verification status from the Stripe event payload
- Update
stripe_account_verifiedon the matching user row - If verified: add
hostto the user'srolesarray if not already present - Return 200
On database error during event processing: log the error, return a non-200 response so Stripe retries. Do not insert into stripe_webhook_events if the processing failed — the idempotency row should only exist for successfully processed events. This means the insert in step 4 must be part of the same transaction as the event handling, committed only on success.
Users Lambda (functions/users/)
functions/users/src/router.ts
Single Lambda entry point:
handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
Route table (all protected):
GET /users/me→getProfilePUT /users/me→updateProfilePOST /users/me/ccpa/deletion→requestDeletionPOST /users/me/ccpa/export→requestExportPOST /users/me/ccpa/opt-out→optOut
functions/users/src/handlers/profile.ts
getProfile(event): Promise<APIGatewayProxyResult>
Returns:
{
id: string
email: string
displayName: string | null
phone: string | null
profilePhotoKey: string | null
roles: string[]
stripeAccountId: string | null
stripeAccountVerified: boolean
doNotSell: boolean
createdAt: string
}
updateProfile(event): Promise<APIGatewayProxyResult>
Input (all optional):
{ displayName?: string, phone?: string, profilePhotoKey?: string }
profilePhotoKeymust be a valid S3 key in thephotos/prefix (validated by checking the key format, not by calling S3)- Updates user row; returns updated profile shape from
getProfile
functions/users/src/handlers/ccpa.ts
requestDeletion(event): Promise<APIGatewayProxyResult>
- Sets
deletion_requested_at = now(),deletion_status = 'pending'on the user row - Inserts a
ccpa_requestsrow withrequest_type = 'deletion',status = 'pending' - Returns
{ message: 'Deletion request received. Your data will be removed within 30 days.', requestId: string }
requestExport(event): Promise<APIGatewayProxyResult>
- Inserts a
ccpa_requestsrow withrequest_type = 'export',status = 'pending' - The actual export assembly is handled by the CCPA Scrubber Lambda in Phase 7; this endpoint only queues the request
- Returns
{ message: 'Export request received. You will receive an email with your data within 24 hours.', requestId: string }
optOut(event): Promise<APIGatewayProxyResult>
- Sets
do_not_sell = trueon the user row - Inserts a
ccpa_requestsrow withrequest_type = 'opt_out',status = 'completed'(immediate) - Returns
{ message: 'You have been opted out of data sharing.', doNotSell: true }
Error handling for Users and Payments handlers: same database error contract as Phase 1's db.ts.
Deliverables
- Stripe webhook for
payment_intent.succeededwith no matching booking row creates a CloudWatch metric event and does not throw an unhandled error - Webhook handler returns 200 on a duplicate event ID without reprocessing
- Webhook handler returns 200 (not 4xx) on an invalid signature
requestDeletionsetsdeletion_status = 'pending'and inserts a CCPA request rowoptOutsetsdo_not_sell = truegetProfileandupdateProfilereturn and modify the correct fields
Phase 7: Scheduled Lambdas
This phase delivers the three EventBridge-triggered Lambda functions: the Payout Scheduler (hourly), the Approval Timeout Scheduler (every 15 minutes), and the CCPA Scrubber (nightly at 02:00 UTC). It also delivers the data archive job (yearly). Each scheduled Lambda is independently deployable and idempotent — re-running it produces the same outcome as running it once.
Contracts
Payout Scheduler Lambda (functions/payoutScheduler/)
functions/payoutScheduler/src/index.ts
EventBridge-triggered entry point:
handler(event: ScheduledEvent): Promise<void>
functions/payoutScheduler/src/payout.ts
runPayoutSchedule(db: pg.Pool, stripe: Stripe): Promise<{ attempted: number, succeeded: number, failed: number }>
Steps in order:
- Query
bookingswherestatus = 'confirmed'andcheck_in_date < now()andpayout_status = 'pending' - For each booking:
a. Fetch the host's
stripe_account_idfrom theuserstable b. Verifystripe_account_verified = true; if not, skip and log a warning c. Call Stripe to create a Transfer forhost_payout_amount(in cents) to the host's connected Stripe account, with thebooking_idas the transfer's metadata key for traceability d. On Transfer success: updatepayout_status = 'completed',stripe_transfer_id = transfer.ide. On Transfer failure: updatepayout_status = 'failed'; log full Stripe error vialogger; send operator alert email viasendEmailand post to Slack webhook - Each booking is processed in its own try/catch — a failure on one does not stop the rest
- Return
{ attempted, succeeded, failed }
Idempotency guarantee: the payout_status = 'pending' check in step 1 means a booking is never processed twice. If the Lambda re-runs within the same hour (e.g. due to EventBridge duplicate invocation), already-completed payouts are not in the query results.
functions/payoutScheduler/src/approvalTimeout.ts
handler(event: ScheduledEvent): Promise<void>
Calls expireStaleApprovals(db, stripe) from Phase 5's functions/bookings/src/lib/approvalTimeout.ts. The Approval Timeout Scheduler Lambda imports and invokes that function directly — no logic is duplicated.
SAM template adds two EventBridge rules:
- Payout Scheduler: every hour on the hour
- Approval Timeout Scheduler: every 15 minutes
Both scheduled Lambdas have an SQS DLQ configured. A CloudWatch alarm on DLQ message count fires immediately on any failed invocation.
CCPA Scrubber Lambda (functions/ccpaScrubber/)
functions/ccpaScrubber/src/index.ts
EventBridge-triggered entry point (cron: 0 2 * * ? * — 02:00 UTC nightly):
handler(event: ScheduledEvent): Promise<void>
functions/ccpaScrubber/src/scrub.ts
runScrubJob(db: pg.Pool, s3: S3Client): Promise<{ scrubbed: number, failed: number }>
Steps in order:
- Query users where
deletion_status = 'pending'anddeletion_requested_at < now() - interval '30 days' - Process in batches of 50. Between each batch, pause 100ms.
- For each user, in a transaction:
a. Set
email = null,display_name = null,phone = null,profile_photo_key = null,password_hash = 'SCRUBBED'on the user row b. Setdeletion_status = 'completed'c. Update the correspondingccpa_requestsrow tostatus = 'completed'd. Commit the transaction e. Attempt to delete the profile photo S3 object ifprofile_photo_keywas set. On S3 failure: log and continue — the database scrub is not rolled back. - Return
{ scrubbed, failed }
Each user is processed in its own try/catch. A failure on one user leaves deletion_status = 'pending' and it will be retried on the next nightly run.
runExportJob(db: pg.Pool, s3: S3Client, ses: SESClient): Promise<{ exported: number, failed: number }>
Steps in order:
- Query
ccpa_requestswhererequest_type = 'export'andstatus = 'pending' - For each request:
a. Fetch the user's profile, bookings, and CCPA request history from the database
b. Assemble export payload as JSON:
{ profile: {...}, bookings: Booking[], ccpaRequests: [...] }c. Write the JSON to S3 at keyexports/{userId}/{requestId}.jsond. Generate a pre-signed GET URL for the S3 object, expiring in 48 hours e. Send email to the user's current email address (or skip if email is null — user already scrubbed) with the pre-signed URL f. Updateccpa_requestsrow tostatus = 'completed' - Return
{ exported, failed }
functions/ccpaScrubber/src/hostFollowUp.ts
Also runs as part of the nightly job:
sendHostOnboardingFollowUps(db: pg.Pool): Promise<void>
- Queries users where
stripe_account_id IS NOT NULLandstripe_account_verified = falseand the onboarding link was requested more than 24 hours ago (useupdated_aton the user row as the proxy — theupdated_attimestamp is set whenstripe_account_idis first written) - Sends a follow-up email via
sendEmailreminding the host to complete onboarding - Sends at most one follow-up per user per day (check
updated_aton the user row — if it was updated within the last 24 hours by this function, skip)
functions/ccpaScrubber/src/archiveOldBookings.ts
Runs yearly (separate EventBridge rule: first day of the year at 03:00 UTC):
archiveOldBookings(db: pg.Pool, s3: S3Client): Promise<{ archived: number }>
- Queries
bookingswherecheck_out_date < now() - interval '3 years' - Exports matching rows as a JSON file to S3 at key
archives/bookings/{year}.json - Deletes the exported rows from the
bookingstable in batches of 500 - Returns
{ archived: number }
Deliverables
- Payout Scheduler processes a confirmed, post-check-in booking with
payout_status = 'pending'; after one run,payout_status = 'completed'andstripe_transfer_idis populated; a second run does not attempt a duplicate transfer - Approval Timeout Scheduler expires a
pending_approvalbooking past its 24-hour window: booking moves todeclined, Stripe authorization is cancelled - CCPA Scrubber scrubs PII fields on a user past the 30-day grace period; booking rows for that user are retained; user's
deletion_status = 'completed' - CCPA export job assembles and emails a pre-signed S3 URL to a user with a pending export request
- Host follow-up email is sent to an unverified host 24 hours after onboarding initiation
- All three scheduled Lambdas have SQS DLQs configured in the SAM template and visible in the AWS Console
Phase 8: Admin Lambda
This phase delivers the Admin Lambda — internal-only endpoints for platform operators and support agents. It reads from all tables established in earlier phases and writes only via narrowly scoped operations (listing deactivation, payout retry). Support agents are explicitly read-only.
Contracts
Admin Lambda (functions/admin/)
functions/admin/src/router.ts
Single Lambda entry point:
handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
All routes require admin or support role. Write operations additionally check for admin role and throw AuthorizationError if only support is present.
Route table:
GET /admin/users→listUsersGET /admin/users/:id→getUserGET /admin/listings→listListingsGET /admin/listings/:id→getListingPUT /admin/listings/:id/deactivate→deactivateListing(admin only)GET /admin/bookings→listBookingsGET /admin/bookings/:id→getBookingPOST /admin/bookings/:id/retry-payout→retryPayout(admin only)GET /admin/users/:id/impersonate→impersonateUser
functions/admin/src/handlers/users.ts
listUsers(event): Promise<APIGatewayProxyResult>
Query parameters: page, limit, deletionStatus?, roles? (comma-separated filter)
Returns paginated array of user profiles (same shape as getProfile from Phase 6, plus loginAttemptCount and loginLockedUntil for operator visibility).
getUser(event): Promise<APIGatewayProxyResult>
Returns the full user profile shape plus loginAttemptCount and loginLockedUntil.
functions/admin/src/handlers/listings.ts
listListings(event): Promise<APIGatewayProxyResult>
Query parameters: page, limit, status?, hostUserId?
Returns paginated ListingSummary[].
getListing(event): Promise<APIGatewayProxyResult>
Returns the full Listing shape from Phase 3.
deactivateListing(event): Promise<APIGatewayProxyResult>
- Admin only
- Sets
status = 'inactive'on the listing - Returns
{ success: true, listingId: string }
functions/admin/src/handlers/bookings.ts
listBookings(event): Promise<APIGatewayProxyResult>
Query parameters: page, limit, status?, listingId?, guestUserId?
Returns paginated Booking[].
getBooking(event): Promise<APIGatewayProxyResult>
Returns Booking.
retryPayout(event): Promise<APIGatewayProxyResult>
- Admin only
- Booking must have
payout_status = 'failed'; throwsConflictErrorif not - Resets
payout_status = 'pending'on the booking row - Calls
runPayoutSchedulefrom Phase 7's payout module, scoped to just this booking ID (the function accepts an optionalbookingIds: string[]filter added for this purpose — the SAM template's payout scheduler always calls it without the filter, this endpoint calls it with[bookingId]) - Returns
{ success: true, booking: Booking }
functions/admin/src/handlers/impersonate.ts
impersonateUser(event): Promise<APIGatewayProxyResult>
- Available to both
adminandsupportroles - Fetches the target user's profile and their last 10 bookings
- Returns a read-only context payload — does not issue a token for the target user, does not allow any write on their behalf
- Response:
{ user: UserProfile, recentBookings: Booking[], note: 'Read-only support context' }
Error handling: same database error contract as Phase 1's db.ts. AuthorizationError is thrown for any write operation attempted by a support role caller.
Deliverables
GET /admin/usersreturns paginated users when called with anadmintokenGET /admin/usersreturns 403 when called with aguestorhosttokenPUT /admin/listings/:id/deactivatesets the listing inactive when called with anadmintoken; returns 403 when called with asupporttokenPOST /admin/bookings/:id/retry-payoutresets a failed payout to pending and triggers the transferGET /admin/users/:id/impersonatereturns a read-only context for bothadminandsupportcallers
Phase 9: Frontend
This phase delivers the React 18 SPA — the browser-facing client that consumes the API built in Phases 2–8. The frontend calls API Gateway over HTTPS and uses Stripe.js for card collection. No direct AWS service calls from the browser.
Contracts
Project structure (frontend/)
frontend/
src/
api/ # API client modules (one per Lambda domain)
components/ # Shared UI components
pages/ # Route-level page components
hooks/ # Custom React hooks
store/ # Auth state (token storage and refresh logic)
types/ # Shared TypeScript types (imported from backend contracts)
utils/ # Date formatting, currency formatting, error display
public/
index.html
API client layer (frontend/src/api/)
One module per backend domain. All modules share a single base fetchApi function that:
- Attaches
Authorization: Bearer <accessToken>on every request - Intercepts 401 responses: attempts a token refresh via
POST /auth/refresh, retries the original request once with the new token, redirects to login if refresh fails - Returns typed response data or throws a structured
ApiError
// frontend/src/api/client.ts
fetchApi<T>(path: string, options?: RequestInit): Promise<T>
class ApiError extends Error {
constructor(public code: string, message: string, public status: number)
}
Domain modules and their exported functions:
frontend/src/api/auth.ts
register(body: { email, password, displayName }): Promise<AuthResponse>
login(body: { email, password }): Promise<AuthResponse>
refresh(body: { refreshToken }): Promise<AuthResponse>
logout(body: { refreshToken }): Promise<void>
type AuthResponse = { accessToken: string, refreshToken: string, user: UserProfile }
frontend/src/api/listings.ts
createListing(body: CreateListingInput): Promise<Listing>
getListing(id: string): Promise<Listing>
updateListing(id: string, body: Partial<CreateListingInput>): Promise<Listing>
deleteListing(id: string): Promise<void>
getPhotoUploadUrl(listingId: string, body: { filename, contentType }): Promise<{ uploadUrl, photoId, s3Key }>
confirmPhotoUpload(listingId: string, body: { photoId }): Promise<{ photo: ListingPhoto }>
deletePhoto(listingId: string, photoId: string): Promise<void>
updatePhotoOrder(listingId: string, body: { orderedPhotoIds: string[] }): Promise<{ photos: ListingPhoto[] }>
setAmenities(listingId: string, body: { amenities: string[] }): Promise<{ amenities: string[] }>
setAvailabilityRules(listingId: string, body: { rules: AvailabilityRule[] }): Promise<{ rules: AvailabilityRule[] }>
setPricingOverrides(listingId: string, body: { overrides: PricingOverride[] }): Promise<{ overrides: PricingOverride[] }>
getStripeOnboardingUrl(): Promise<{ onboardingUrl: string }>
frontend/src/api/search.ts
searchListings(params: SearchParams): Promise<SearchResponse>
type SearchParams = { location?: string, checkIn?: string, checkOut?: string, guests?: number, minPrice?: number, maxPrice?: number, page?: number, limit?: number }
type SearchResponse = { listings: ListingSummary[], pagination: { page, limit, total, hasMore } }
frontend/src/api/bookings.ts
createBooking(body: { listingId, checkInDate, checkOutDate, guestCount }): Promise<Booking>
getBooking(id: string): Promise<Booking>
listUserBookings(params?: { page?, limit? }): Promise<{ bookings: Booking[], pagination: PaginationMeta }>
listHostBookings(params?: { page?, limit? }): Promise<{ bookings: Booking[], pagination: PaginationMeta }>
approveBooking(id: string): Promise<Booking>
declineBooking(id: string): Promise<Booking>
cancelBooking(id: string): Promise<Booking>
frontend/src/api/users.ts
getProfile(): Promise<UserProfile>
updateProfile(body: { displayName?, phone?, profilePhotoKey? }): Promise<UserProfile>
requestDeletion(): Promise<{ message: string, requestId: string }>
requestExport(): Promise<{ message: string, requestId: string }>
optOut(): Promise<{ message: string, doNotSell: boolean }>
Auth store (frontend/src/store/authStore.ts)
Manages access token, refresh token, and user profile in memory (access token) and localStorage (refresh token only — access token is never persisted to storage to minimize XSS exposure surface).
type AuthStore = {
accessToken: string | null
user: UserProfile | null
isAuthenticated: boolean
login(accessToken: string, refreshToken: string, user: UserProfile): void
logout(): void
setAccessToken(token: string): void // called by the refresh interceptor
}
Pages (frontend/src/pages/)
Page components correspond to user-facing flows. Each page is a route component. Routing is handled by React Router v6.
Page list and their API calls:
HomePage— Search bar; callssearchListingson submit; rendersListingCardgridSearchResultsPage— URL-parameterized search; callssearchListings; pagination controlsListingDetailPage— callsgetListing; renders photos, amenities, pricing, availability; booking formBookingConfirmPage— Stripe.js payment element (hosted iframe); callscreateBookingon submitGuestBookingsPage— callslistUserBookings; renders booking status cardsHostDashboardPage— callslistHostBookings; links to listing managementHostListingFormPage— creates or edits a listing; callscreateListing/updateListing; photo upload flow with progress indicatorHostOnboardingPage— callsgetStripeOnboardingUrl; redirects browser to Stripe-hosted onboardingProfilePage— callsgetProfile/updateProfile; CCPA controls (requestDeletion,requestExport,optOut)LoginPage— callslogin; on success stores tokens via auth storeRegisterPage— callsregisterAdminPage— visible only toadminorsupportrole; calls admin API endpoints
Photo upload flow (frontend/src/hooks/usePhotoUpload.ts)
usePhotoUpload(listingId: string): {
upload(file: File): Promise<ListingPhoto>
uploading: boolean
error: string | null
}
Steps within upload:
- Call
getPhotoUploadUrlwithfile.nameandfile.type PUTthe file directly to the returned
Tickets
Tickets Document
Here is your step-by-step ticket backlog to build Rentify. Whether you're pasting these sequentially into Claude Code or importing them into Linear for your team, these tickets provide the exact implementation steps to bring this architecture to life.
Phase 1: Infrastructure Foundation and Shared Layer
Ticket 1.1: Foundational AWS Resources (S3, CloudFront, API Gateway)
Objective: Define the core non-database AWS resources in the SAM template, including S3 buckets for storage, a CloudFront distribution for the frontend, and the API Gateway endpoint.
Context: This ticket establishes the initial infrastructure skeleton as defined in the Architecture Design Document. Creating the S3 buckets, CloudFront distribution, and API Gateway first provides the basic storage and request-handling layers upon which all other services will depend. This work is part of the template.yaml contract.
Technical Directives:
- All resources must be defined within a single AWS SAM
template.yamlfile. - The CloudFront distribution must be configured with an Origin Access Control (OAC) to privately access the frontend S3 bucket.
- The CloudFront response headers policy must be configured to enforce
Strict-Transport-Security,X-Content-Type-Options,X-Frame-Options,Content-Security-Policy, andReferrer-Policyas specified in the implementation plan. - The
Content-Security-Policymust restrict script sources to the CDN origin and the Stripe.js domain. - The API Gateway must be configured to read CORS allowed origins, throttle burst limits, and throttle steady-state rates from SSM Parameter Store.
- The
PhotosBucketS3 resource must include a lifecycle rule to delete objects under thepending/prefix after 24 hours.
Scope Boundaries:
- AWS SAM template (
template.yaml) - S3 resources
- CloudFront resources
- API Gateway resources
Acceptance Criteria:
- The SAM template can be successfully deployed to an AWS account.
- Three S3 buckets (
PhotosBucket,ExportsBucket,FrontendBucket) are created. - The
PhotosBuckethas a lifecycle rule configured for thepending/prefix. - A CloudFront distribution is created and points to the
FrontendBucket. - When accessing the CloudFront URL, the browser receives the specified security headers in the response.
- An API Gateway REST API is created with CORS and throttling configurations placeholder-ready for SSM parameters.
Dependencies: None
Ticket 1.2: Database Infrastructure (RDS, RDS Proxy)
Objective: Add the PostgreSQL RDS instance and RDS Proxy resources to the SAM template.
Context: This ticket provisions the stateful data layer as specified in the Architecture Design Document. The RDS Proxy is a critical component that enables stateless Lambda functions to communicate with the PostgreSQL database without exhausting connections. This completes the core data infrastructure required by all application logic.
Technical Directives:
- The RDS instance must be PostgreSQL version 15.
- The SAM template must conditionally enable Multi-AZ for the RDS instance only when the deployment environment is
production. - The RDS instance size must be parameterized for different environments (
db.t3.microfor non-production,db.t3.mediumfor production). - The RDS Proxy must be configured to use IAM authentication, connecting Lambda functions to the database without stored credentials.
- The RDS Proxy must be connected to the created RDS instance and configured with a maximum of 100 connections.
Scope Boundaries:
- AWS SAM template (
template.yaml) - RDS resources
- RDS Proxy resources
Acceptance Criteria:
- The SAM template successfully deploys the new RDS and RDS Proxy resources.
- An RDS instance running PostgreSQL 15 is active.
- An RDS Proxy is created and shows as connected to the RDS instance in the AWS console.
- The RDS Proxy is configured for IAM database authentication.
Dependencies:
- Ticket 1.1
Ticket 1.3: Database Schema and Migrations
Objective: Create the complete set of SQL migration files to define the entire application database schema, including all tables, indexes, and constraints.
Context: This ticket implements the full data architecture specified in the ADD. By defining the entire schema upfront, subsequent development phases can build features against a stable and complete data model. The GiST exclusion constraint on the bookings table is the most critical piece, as it mathematically prevents double-bookings.
Technical Directives:
- Create SQL migration files under an
infrastructure/migrations/directory. - The schema must exactly match all tables, columns, types, and constraints specified in the
Target Phasedocumentation. - The
btree_gistextension must be enabled before creating the GiST exclusion constraint. - The GiST exclusion constraint on the
bookingstable must prevent overlappingstay_rangevalues for the samelisting_idfor bookings inpending,pending_approval, orconfirmedstatus. - All indexes specified in the
Indexing Strategysection of the ADD must be created. - A unique index must be created on the
stripe_event_idcolumn of thestripe_webhook_eventstable.
Scope Boundaries:
- Database schema (SQL migration files)
Acceptance Criteria:
- The SQL migration scripts execute successfully against the provisioned PostgreSQL database from start to finish.
- All specified tables (
users,bookings,listings, etc.) are created with the correct columns and types. - The
no_overlapping_bookingsexclusion constraint exists on thebookingstable. - All specified indexes are present on their respective tables.
- An attempt to insert two identical, overlapping bookings for the same listing fails with a database constraint violation.
Dependencies:
- Ticket 1.2
Ticket 1.4: Shared Utilities and Error Handling (packages/shared)
Objective: Implement the core, non-I/O modules of the shared Lambda Layer package, including custom errors, structured logger, API response helpers, and validation logic.
Context: This ticket creates the foundational code utilities that all Lambda functions will use for consistency in error handling, logging, and response formatting, as outlined in the ADD. Establishing these patterns in a shared layer prevents code duplication and enforces architectural standards across all microservices.
Technical Directives:
- Create a new TypeScript package at
packages/shared/. - Implement all custom error classes (
RentifyError,ValidationError, etc.) inerrors.tsexactly as defined in the phase contract. - Implement the
logger.tsmodule as a thin wrapper around@aws-lambda-powertools/loggerthat exports a pre-configured singleton instance for structured JSON logging. - Implement
response.tswithsuccessResponseanderrorResponsefunctions that build valid API Gateway proxy results. TheerrorResponsefunction must not expose internal error details in the response body. - Implement
validation.tswith avalidatehelper that wraps Zod parsing and throws the customValidationErroron failure. - Export all shared Zod schemas (
uuidSchema,dateSchema,paginationSchema) fromvalidation.ts.
Scope Boundaries:
packages/shared/TypeScript module
Acceptance Criteria:
- The
packages/shared/package compiles without TypeScript errors. - Unit tests confirm that
errorResponsecorrectly formatsRentifyErrorinstances and genericErrorinstances into the specified JSON structure. - Unit tests confirm that the
validatefunction returns the parsed value on success and throws aValidationErroron failure.
Dependencies: None
Ticket 1.5: Shared I/O and Configuration Utilities (packages/shared)
Objective: Implement the I/O-dependent modules of the shared Lambda Layer package for configuration, database access, and email.
Context: This ticket builds on the core utilities to provide standardized ways for Lambda functions to interact with external services like SSM, RDS, and SES. Centralizing this logic in the shared layer, as per the ADD, ensures all functions use the same caching, connection, and error handling strategies for these critical dependencies.
Technical Directives:
- Implement the
config.tsmodule with agetConfigfunction that fetches parameters from an SSM path and caches the result. It must throw aConfigurationErrorif required parameters are missing. - Implement the
db.tsmodule with agetDbClientfunction that returns a configuredpg.Poolinstance. It must throw aDatabaseConnectionErroron connection failure. - Implement the
email.tsmodule with asendEmailfunction that sends email via the AWS SES API. SES errors must be logged but not re-thrown. - The
index.tsfile must re-export all public functions and types from all modules in thepackages/shared/directory.
Scope Boundaries:
packages/shared/TypeScript module
Acceptance Criteria:
- The
packages/shared/package compiles without TypeScript errors. - In an integration test,
getConfigsuccessfully fetches and parses parameters from SSM. - In an integration test,
getDbClientsuccessfully connects to the RDS Proxy endpoint and executes a simple query. - In an integration test,
sendEmailsuccessfully sends a test email using the SES sandbox.
Dependencies:
- Ticket 1.4
Ticket 1.6: Lambda Layer Definition and Build Process
Objective: Define the SharedLambdaLayer resource in the SAM template and configure the build process to package the shared TypeScript code into it.
Context: This ticket connects the shared code from the previous tickets to the AWS infrastructure. The Lambda Layer is a key part of the architecture (ADR-1), allowing for code reuse and smaller Lambda function deployment packages. This step makes the shared utilities available to all future Lambda functions.
Technical Directives:
- Add a
SharedLambdaLayerresource of typeAWS::Serverless::LayerVersiontotemplate.yaml. - The layer resource's content URI must point to a build artifact containing the compiled JavaScript and
node_modulesfor thepackages/shareddirectory. - Configure the SAM build process (e.g., via
Metadataintemplate.yamlor a build script) to automatically transpile the TypeScript frompackages/shared/to JavaScript before deployment.
Scope Boundaries:
- AWS SAM template (
template.yaml) - Build configuration (e.g.,
package.jsonscripts, SAM metadata)
Acceptance Criteria:
- Running
sam buildsuccessfully transpiles thepackages/sharedTypeScript code and prepares the layer artifact. - Running
sam deploysuccessfully creates or updates theSharedLambdaLayerin AWS Lambda. - The created Lambda Layer contains the compiled JavaScript files corresponding to the shared package modules.
Dependencies:
- Ticket 1.1
- Ticket 1.5
Ticket 1.7: Environment Configuration and Deployment Verification
Objective: Create the samconfig.toml file to manage environment-specific deployment parameters and perform a full deployment of the entire infrastructure stack.
Context: This final ticket ties all previous work together by defining the environment-specific configurations required for deployment. It ensures that the entire foundation is deployable and functional, providing a stable base for the application-level features that will be built in subsequent phases.
Technical Directives:
- Create a
samconfig.tomlfile at the root of the project. - Define deployment configurations for
dev,staging, andproductionenvironments. - Specify parameter overrides for all environment-specific values in the SAM template, such as RDS instance size, Multi-AZ setting, and references to SSM parameter paths.
- The configuration must successfully deploy the entire stack defined in the preceding tickets.
Scope Boundaries:
samconfig.tomlfile- Deployment process
Acceptance Criteria:
- The command
sam deploy --stack-name rentify-dev --config-env devcompletes successfully. - All AWS resources defined in previous tickets (S3 buckets, CloudFront, API Gateway, RDS, RDS Proxy, Lambda Layer) are present and correctly configured in the
devenvironment. - The RDS instance is accessible via the RDS Proxy.
- The CloudFront distribution serves a placeholder file from its S3 origin.
- The created
SharedLambdaLayeris available to be attached to Lambda functions in theFollowing Phase.
Dependencies:
- Ticket 1.2
- Ticket 1.3
- Ticket 1.6
Phase 2: Authentication
Ticket 2.1: Implement JWT Signing and Verification Utility
Objective: This ticket creates a shared utility for generating and validating JSON Web Tokens (JWTs) according to the project's defined payload structure.
Context: This utility is a core security component required by both the Auth Lambda and the Lambda Authorizer. The Auth Lambda will use it to sign tokens upon successful registration or login, and the Lambda Authorizer will use it to verify the signature of incoming tokens. It implements the standard JWT payload shape defined in the Authentication phase contract.
Technical Directives:
- Create a utility function to sign a JWT payload, which must include
sub,email,roles,iat, andexpclaims. - Create a utility function to verify a JWT's signature and expiry.
- The signing and verification process must use the
JWT_SECRETretrieved from the shared configuration module. - The utility should handle access tokens with a 15-minute expiry and refresh tokens with a 30-day expiry.
Scope Boundaries:
- Shared Lambda Layer (
packages/shared/)
Acceptance Criteria:
- The signing function, when given a user ID, email, and roles, produces a valid JWT string.
- The verification function, when given a valid token, returns the decoded payload.
- The verification function rejects a token with an invalid signature.
- The verification function rejects a token that is expired.
- The verification function rejects a token with a missing required claim.
Dependencies: None
Ticket 2.2: Implement User Registration Handler
Objective: This ticket creates the POST /auth/register endpoint to allow new users to create an account.
Objective: This ticket creates the POST /auth/register endpoint, enabling new users to create an account by providing an email, password, and display name.
Context: The registration handler is the primary entry point for users into the Rentify platform. As described in the ADD, it creates the user record with default guest permissions and issues the initial set of authentication tokens. This handler is part of the Auth Lambda.
Technical Directives:
- The handler must validate that the input email is a valid format and the password is at least 8 characters long.
- Use the shared
validatehelper for input validation and throw aValidationErrorfor invalid data. - Before creating a user, query the database to ensure an account with the provided email does not already exist. If it does, throw the
ConflictErroras specified in the phase contract. - Hash the user's password using bcrypt with a work factor of 12 before storing it in the
userstable. - On successful user creation, insert a new row into the
userstable with the default role ofguest. - Generate a new access token and a new refresh token using the JWT utility.
- Hash the refresh token and store it in the
refresh_tokenstable, associated with the new user ID. - The response body must conform to the contract:
{ accessToken, refreshToken, user: { id, email, displayName, roles } }.
Scope Boundaries:
- Auth Lambda (
functions/auth/)
Acceptance Criteria:
- A
POSTrequest to/auth/registerwith a unique email, valid password, and display name results in a new row in theuserstable. - The
password_hashcolumn in the newusersrow contains a valid bcrypt hash. - A new row is created in the
refresh_tokenstable linked to the new user. - The API returns a valid access token, refresh token, and user object.
- A
POSTrequest with an email that already exists returns a 409 Conflict response with the codeEMAIL_IN_USE. - A
POSTrequest with a password shorter than 8 characters returns a 400 Bad Request response.
Dependencies: Ticket 2.1
Ticket 2.3: Implement User Login Handler with Brute-Force Protection
Objective: This ticket creates the POST /auth/login endpoint to authenticate existing users and implements an account lockout mechanism to prevent brute-force attacks.
Context: This handler provides the primary authentication mechanism for the system. Following the security requirements in the ADD, it includes logic to track failed login attempts and temporarily lock an account after repeated failures, mitigating the risk of password guessing attacks.
Technical Directives:
- The handler must validate the
emailandpasswordfields in the request body. - It must look up the user by email. To prevent timing-based user enumeration attacks, the password comparison logic must execute whether the user is found or not.
- Compare the provided password with the stored hash using bcrypt.
- If the password is correct and the account is not locked, reset
login_attempt_countto 0 and clear thelogin_locked_untiltimestamp. - If the password is incorrect, increment
login_attempt_count. If the count reaches 10, setlogin_locked_untilto 15 minutes in the future. - If a login attempt is made for an account where
login_locked_untilis in the future, reject the attempt immediately without checking the password. - In all failure cases (user not found, password incorrect, account locked), return the generic
AuthenticationErrorto avoid revealing account status. - On successful authentication, issue and return a new set of access and refresh tokens, identical to the registration flow.
Scope Boundaries:
- Auth Lambda (
functions/auth/)
Acceptance Criteria:
- A
POSTrequest to/auth/loginwith correct credentials for an existing user returns a new access token and refresh token. - The
login_attempt_countis reset to 0 in the database upon successful login. - A
POSTrequest with an incorrect password returns a 401 Unauthorized response. - The
login_attempt_countfor the user increments after each failed login attempt. - After the 10th consecutive failed login attempt,
login_locked_untilis set to a future timestamp, and subsequent login attempts fail until it expires. - A
POSTrequest with a non-existent email address returns a 401 Unauthorized response.
Dependencies: Ticket 2.2
Ticket 2.4: Implement Token Refresh Handler
Objective: This ticket creates the POST /auth/refresh endpoint to allow clients to exchange a valid refresh token for a new set of access and refresh tokens.
Context: This handler supports the long-lived session management described in the ADD. By allowing clients to use a long-lived refresh token to obtain a new short-lived access token, it improves security by minimizing the exposure of the access token while providing a seamless user experience.
Technical Directives:
- The handler must accept a
refreshTokenin the request body. - Hash the received token to find a matching, non-revoked, and unexpired token in the
refresh_tokenstable. - If no valid token is found, throw the
AuthenticationErroras specified in the phase contract. - Upon finding a valid token, mark it as revoked by setting
revoked = truein the database. - Issue a new access token (15-minute expiry) and a new refresh token (30-day expiry).
- Store the hash of the new refresh token in the
refresh_tokenstable. - Return the new
accessTokenandrefreshToken.
Scope Boundaries:
- Auth Lambda (
functions/auth/)
Acceptance Criteria:
- A
POSTrequest to/auth/refreshwith a valid, unrevoked refresh token returns a new access token and a new refresh token. - The old refresh token's row in the
refresh_tokenstable has itsrevokedcolumn set totrue. - A new row for the new refresh token is created in the
refresh_tokenstable. - A
POSTrequest with an invalid, expired, or already-revoked refresh token returns a 401 Unauthorized response.
Dependencies: Ticket 2.2
Ticket 2.5: Implement Lambda Authorizer
Objective: This ticket creates the Lambda Authorizer function responsible for validating JWTs and controlling access to protected API Gateway routes.
Context: As the central security gate for the API, the Lambda Authorizer enforces authentication for all protected endpoints as defined in the ADD. It decodes valid tokens and injects user identity information into the Lambda event context for downstream handlers to use for authorization checks.
Technical Directives:
- The authorizer must extract the bearer token from the
Authorizationheader of the incoming request. - Use the JWT verification utility to validate the token's signature and expiry.
- If the token is valid, generate and return an
AllowIAM policy for the requested API endpoint ARN. - The
Allowpolicy's context object must contain thesub,email, androlesclaims from the decoded JWT payload. Therolesarray must be stringified usingJSON.stringify. - If the token is missing, malformed, or invalid, generate and return a
DenyIAM policy. - The authorizer must not connect to the database.
Scope Boundaries:
- Lambda Authorizer (
functions/authorizer/)
Acceptance Criteria:
- An API Gateway request with a valid
Authorization: Bearer <token>header results in the authorizer returning anAllowpolicy. - The
Allowpolicy's context object contains the correctsub,email, and a stringifiedrolesarray. - A request with a missing
Authorizationheader results in aDenypolicy. - A request with an invalid or expired token results in a
Denypolicy.
Dependencies: Ticket 2.1
Ticket 2.6: Implement Auth Lambda Router and Logout Handler
Objective: This ticket creates the internal router for the Auth Lambda and implements the protected POST /auth/logout endpoint.
Context: This ticket completes the Auth Lambda by adding the routing logic to direct API Gateway requests to the correct handler (register, login, etc.) and implementing the logout functionality. The logout handler is the first endpoint in the system to be protected by the new Lambda Authorizer, serving as a key integration test.
Technical Directives:
- Create a router that maps incoming API Gateway events to the correct handler function based on the HTTP method and resource path (
POST /auth/register,POST /auth/login, etc.). - The router must be the single entry point for the Auth Lambda.
- The
logouthandler must be a protected route. - The
logouthandler must accept arefreshTokenin the request body. - It must hash the received token and find the matching record in the
refresh_tokenstable. - It must set the
revokedcolumn totruefor the matching refresh token, invalidating it for future use.
Scope Boundaries:
- Auth Lambda (
functions/auth/)
Acceptance Criteria:
- The Auth Lambda's main handler correctly routes a
POSTrequest to/auth/registerto the registration logic. - The Auth Lambda's main handler correctly routes a
POSTrequest to/auth/loginto the login logic. - A
POSTrequest to/auth/logoutwithout a valid access token is rejected by the authorizer with a 401/403 status code. - A
POSTrequest to/auth/logoutwith a valid access token and a valid refresh token finds the corresponding row inrefresh_tokensand setsrevokedtotrue. - The API returns
{ success: true }upon successful logout. - Attempting to use a revoked refresh token at the
/auth/refreshendpoint results in a 401 Unauthorized error.
Dependencies: Ticket 2.3, Ticket 2.4, Ticket 2.5
Ticket 2.7: Update SAM Template and API Gateway Configuration
Objective: This ticket updates the template.yaml file to define the Auth Lambda and Lambda Authorizer as new AWS resources and configures the API Gateway routes.
Context: This final integration step makes the authentication system deployable and operational. It translates the logical components built in previous tickets into concrete cloud resources using Infrastructure as Code, ensuring the API Gateway is correctly configured to use the new authorizer for protected routes.
Technical Directives:
- Add a new
AWS::Serverless::Functionresource totemplate.yamlfor the Lambda Authorizer. Configure its handler path and set the cache TTL to 300 seconds. - Add a new
AWS::Serverless::Functionresource for the Auth Lambda, defining its handler path and API events. - Define the API Gateway routes for
/auth/register,/auth/login, and/auth/refreshas public endpoints that integrate with the Auth Lambda. - Define the API Gateway route for
/auth/logout, and configure it to use the new Lambda Authorizer. - Ensure both Lambda functions reference the
SharedLambdaLayercreated in Phase 1. - Ensure the execution roles for both functions have the necessary permissions (e.g., CloudWatch Logs, SSM Parameter access, and RDS Proxy access for the Auth Lambda).
Scope Boundaries:
- Infrastructure as Code (
template.yaml)
Acceptance Criteria:
- After running
sam deploy, the newAuthLambdaandLambdaAuthorizerfunctions are visible in the AWS Lambda console. - In the API Gateway console, the
/auth/logoutroute shows that it is protected by the new authorizer. - The
/auth/registerand/auth/loginroutes are public and successfully invoke the Auth Lambda. - A deployment to the
devenvironment is successful and end-to-end tests for all authentication flows pass.
Dependencies: Ticket 2.6
Phase 3: Listing Management
Ticket 3.1: Database Schema for Listing Management
Objective: This ticket creates the PostgreSQL tables required to store all data related to listings, including photos, amenities, availability rules, and pricing overrides.
Context: This schema is the foundation for the entire listing management domain. It implements the data models described in the Architecture Design Document, enabling the Listings Lambda to store and retrieve host-provided information. These tables will be written to by this phase and read from by the Search and Bookings lambdas in subsequent phases.
Technical Directives:
- Create a
listingstable to store core listing attributes, including astatuscolumn to support the soft-deletion requirement from the ADD. - Create a
listing_photostable with a foreign key tolistings, including columns for the S3 object key and display order. - Create
listing_amenities,availability_rules, andpricing_overridestables, each with a foreign key to thelistingstable. - All table and column names must align with the data contracts specified for the
Listing,ListingPhoto,AvailabilityRule, andPricingOverridetypes.
Scope Boundaries:
- Database schema (migrations)
Acceptance Criteria:
- A database migration script is created that, when run, successfully creates the
listings,listing_photos,listing_amenities,availability_rules, andpricing_overridestables. - The created tables contain all necessary columns with appropriate data types to store the data defined in the
Listingresponse shape and its nested types. - Foreign key constraints are established between the
listingstable and all related tables.
Dependencies: None
Ticket 3.2: Implement Shared Pricing and Availability Logic Libraries
Objective: This ticket implements and unit tests the shared evaluateAvailabilityRules and calculateStayPrice business logic functions.
Context: These two functions contain complex, critical business logic that must be consistent across the application. Per the Architecture Design Document, they are built as shared library modules within the Listings service but will be imported and used by the Bookings Lambda in a later phase to validate and price a potential stay.
Technical Directives:
- Implement
evaluateAvailabilityRulesto check a given date range against all of a listing's availability rules in the correct order: blocked dates, minimum stay, and allowed check-in days. - Implement
calculateStayPriceto calculate the total cost of a stay, correctly applying pricing overrides based on their priority (seasonal > weekend > base). - The function signatures, input parameters, and return shapes must exactly match the
evaluateAvailabilityRulesandcalculateStayPricecontracts. - Thrown errors for availability violations must be of the specified
ConflictErrortype.
Scope Boundaries:
functions/listings/src/lib/availability.tsmodulefunctions/listings/src/lib/pricing.tsmodule
Acceptance Criteria:
- Unit tests for
evaluateAvailabilityRulesconfirm that it correctly throws aConflictErrorwhen a requested stay overlaps ablocked_range. - Unit tests for
evaluateAvailabilityRulesconfirm that it correctly throws aConflictErrorwhen a stay duration is less than amin_stayrule. - Unit tests for
evaluateAvailabilityRulesconfirm that it correctly throws aConflictErrorwhen the check-in day is not allowed by acheckin_daysrule. - Unit tests for
calculateStayPriceconfirm that the returnedPriceBreakdowncorrectly calculates subtotal, service fee, and total, applying seasonal and weekend overrides with the correct priority. - The modules are self-contained and can be imported without pulling in handler-specific dependencies.
Dependencies: Ticket 3.1
Ticket 3.3: Implement Core Listing CRUD Endpoints
Objective: This ticket implements the API endpoints for creating, reading, updating, and soft-deleting a listing.
Context: These endpoints provide the fundamental host-side functionality for managing a listing's lifecycle. This work implements the core of the Listings Lambda as described in the ADD, building upon the database schema from Ticket 3.1 and adhering to the authentication contracts from the preceding phase.
Technical Directives:
- The
createListing,updateListing, anddeleteListinghandlers must be protected routes that verify the caller has thehostrole using the JWT context provided by the Lambda Authorizer. - The
updateListinganddeleteListinghandlers must validate that the authenticated user is the owner of the listing record being modified. - Deleting a listing must be a soft delete, achieved by setting the
statuscolumn toinactive, not by removing the row. - The
getListinghandler must be a public, unauthenticated route. - The response for
getListingmust conform to theListingresponse shape, including all related photos, amenities, rules, and overrides.
Scope Boundaries:
functions/listings/src/handlers/listings.tsmodule
Acceptance Criteria:
- A
POST /listingsrequest from an authenticated host creates a listing row withstatus = 'draft'and returns the new listing object. - A
GET /listings/:idrequest returns the full listing details and returns a not found error for an invalid ID. - An
PUT /listings/:idrequest from the listing's owner successfully updates the specified fields. - A
DELETE /listings/:idrequest from the listing's owner changes the listing's status toinactive. - Any attempt to create, update, or delete a listing by a user without the
hostrole, or who is not the owner, results in an authorization error.
Dependencies: Ticket 3.1
Ticket 3.4: Implement Stripe Connect Onboarding Endpoints
Objective: This ticket implements the endpoints to generate a Stripe Connect onboarding URL for a user and to handle the subsequent redirect callback from Stripe.
Context: To become a host, a user must complete Stripe's onboarding process. This ticket provides the necessary API endpoints to initiate that flow, as outlined in the Integration Architecture section of the ADD. This is a critical step for enabling the platform's two-sided marketplace functionality.
Technical Directives:
- Implement the
getStripeOnboardingUrlhandler, which must be a protected route. - This handler must call the Stripe API to create a Connect account for the user if one does not already exist, and then generate an account link for onboarding.
- Implement the
handleStripeCallbackhandler, which must be a public route. - The callback handler must perform a 302 redirect to a static frontend URL, as its only purpose is to return the user to the application after they complete the flow on Stripe's website.
Scope Boundaries:
functions/listings/src/handlers/connect.tsmodule
Acceptance Criteria:
- An authenticated user can make a request to
GET /connect/onboarding-urland receive a valid Stripe Connect onboarding URL in the response. - A request to
GET /connect/callbackresults in an HTTP 302 redirect response to the configured frontend page.
Dependencies: None
Ticket 3.5: Implement Listing Photo Management Endpoints
Objective: This ticket implements all endpoints for managing listing photos, including generating an upload URL, confirming the upload, deleting a photo, and reordering photos.
Context: As specified in the ADD, listing photos are uploaded directly from the client to S3 using a pre-signed URL to improve performance and reduce Lambda execution time. This ticket implements the backend orchestration for that flow, from generating the secure URL to finalizing the photo record in the database.
Technical Directives:
- The
getPhotoUploadUrlhandler must generate a time-limited S3 pre-signed PUT URL for a key in apending/path and create an associatedlisting_photosdatabase row marked as unconfirmed. - The
confirmPhotoUploadhandler must verify the object exists in S3, copy it to a permanentphotos/path, delete the originalpending/object, and update the database row to be confirmed. - The
deletePhotohandler must delete both the S3 object and the correspondinglisting_photosdatabase row. - The
updatePhotoOrderhandler must update thedisplay_orderfor a set of photos within a single database transaction. - All handlers must be protected and must validate that the caller owns the parent listing.
Scope Boundaries:
functions/listings/src/handlers/photos.tsmodule
Acceptance Criteria:
- A call to
POST /listings/:id/photos/upload-urlreturns a valid S3 pre-signed URL and a photo ID. - After a file is uploaded to the URL, a call to
POST /listings/:id/photos/confirmwith the photo ID results in the S3 object being moved and the database record being marked as confirmed. - A call to
DELETE /listings/:id/photos/:photoIdremoves the record from the database and the object from S3. - A call to
PUT /listings/:id/photos/orderwith an array of photo IDs correctly updates thedisplay_ordercolumn for each photo in the database.
Dependencies: Ticket 3.3
Ticket 3.6: Implement Listing Attributes Management Endpoints
Objective: This ticket implements the API endpoints for setting a listing's amenities, availability rules, and pricing overrides.
Context: These endpoints allow hosts to manage the detailed configuration of their listings. Per the phase contract, all three handlers use "replace" semantics (deleting all existing records and inserting the new set) to simplify client-side state management and ensure data consistency.
Technical Directives:
- Implement
setAmenities,setAvailabilityRules, andsetPricingOverrideshandlers. - Each handler must perform a full replacement of the associated records for the given listing within a single database transaction.
- Input must be validated to ensure required fields for each rule and override type are present (e.g.,
startDatefor seasonal overrides). - All handlers must be protected and must validate that the caller is the owner of the listing.
Scope Boundaries:
functions/listings/src/handlers/attributes.tsmodule
Acceptance Criteria:
- A
POST /listings/:id/amenitiesrequest with a list of amenities replaces all existing amenities for that listing with the new set. - A
POST /listings/:id/availability-rulesrequest with a list of rules replaces all existing availability rules for that listing with the new set. - A
POST /listings/:id/pricing-overridesrequest with a list of overrides replaces all existing pricing overrides for that listing with the new set. - Subsequent calls with different or empty lists correctly reflect the "full replace" behavior.
Dependencies: Ticket 3.3
Ticket 3.7: Create and Configure Listings Lambda Router
Objective: This ticket creates the main Lambda entry point that routes API Gateway requests to the appropriate handler functions for the listing management domain.
Context: In accordance with ADR-1 ("Single Lambda per logical domain"), this ticket assembles all the previously implemented handlers into a single, deployable Lambda function. The router acts as the front controller for the entire Listings service, mapping incoming requests to the correct business logic.
Technical Directives:
- Create a router that maps HTTP methods and path patterns to the handler functions implemented in the previous tickets.
- The route table must exactly match the one specified in the
Phase 3: Listing Managementcontract. - The module must export a single
handlerfunction that serves as the entry point for the Lambda function. - A
NotFoundErrormust be returned for any route that is not explicitly defined. - Ensure the TypeScript types for
ListingandListingSummaryare exported from a shared types file so they can be imported by the Search Lambda in the next phase.
Scope Boundaries:
functions/listings/src/router.tsmodule- Shared types definition file
Acceptance Criteria:
- Requests to
POST /listings,GET /listings/:id, and all other defined listing-related routes are correctly dispatched to their respective handler functions. - A request to an undefined route, such as
GET /listings/invalid/path, returns a not found error response. - The
ListingSummarytype is successfully exported and available for import by other services. This fulfills the handoff to theFollowing Phase: Phase 4: Search.
Dependencies: Ticket 3.3, Ticket 3.4, Ticket 3.5, Ticket 3.6