中文版

DIY Rental Platform — Slice 2: Viewings, Messaging, and Reminders

Slice 2 of the Malaysian rental marketplace: tenants propose viewing times, owners confirm or counter inside a per-request message thread, contact details reveal only on confirmation, and a 24-hour reminder fires by email. A same-day grilling session resolved 17 design gaps — a terminology collision with the platform's multi-tenancy vocabulary, the fact that slice 1 is still unbuilt, state-machine reachability, a missing terminal status for lapsed viewings, and per-transition email and reveal rules — before implementation starts.

  • Predecessor Slice 1 — Listings and Owner Onboarding (2026-08-24-rental-platform-slice1-listings-design)
  • ADRs 0006 · 0054 · 0055
  • Status Approved

View source markdown ↗ generated by claude-sonnet-5 · diagrams mermaid

Slice 2 proves demand meets supply without an agent: a tenant proposes a viewing time, the owner confirms it inside the platform, contact details reveal only at that point, and a reminder reaches both sides 24 hours out. A same-day grilling session closed 17 design gaps — a terminology collision, an unbuilt slice 1 dependency, state-machine reachability, a missing terminal status, and per-transition email/reveal rules — before implementation starts.

  • StatusDesign approved, ready for implementation plan
  • Depends onSlice 1 — Listings and Owner Onboarding (unbuilt, 0/14 + 0/23 tasks done)
  • Related ADRsADR-0006 (Better Auth read-only consumer), ADR-0054 (standalone quiz auth, rejected pattern), ADR-0055 (rental shares platform DB/auth)
  • Grilled17 questions, all accepted 2026-08-24

1. What slice 2 proves

A tenant finds a listing, asks to view it, agrees a time with the owner inside the platform, and both show up — with the platform, not WhatsApp, holding the record.

Slice 1 proved supply: owners will list. Slice 2 proves demand meets supply without an agent. If tenants will not book through the platform, no later slice is worth building.

Success criteria

  • A tenant books a viewing without contacting the owner outside the platform
  • An owner confirms a time from the panel, and both sides get an email
  • Contact details and the exact address appear only after confirmation, never before
  • A reminder reaches both sides 24 hours before the viewing

2. What the repository does not have yet

Three gaps found while exploring. Each one shapes this design.

3. Scope

In scope

  • Tenant identity — any signed-in user can book. No tenant role, no Better Auth schema change. Signup reuses only slice 1's Better Auth sign-up screen and session wiring — never its owner ownership-declaration step, which does not apply to a tenant.
  • Viewing request — tenant proposes up to 3 times; owner confirms one, counters with different times, or declines with a reason
  • Message thread — the viewing request is the thread. Async, polled.
  • Contact reveal on confirm — before confirmation both sides see a first name only; on confirmation both see full name, phone, and the exact address_line that slice 1 deliberately hides from the public
  • Contact tablerental_contacts, keyed by user_id, holding a phone number. One table serves owners and tenants alike.
  • Reminder — 24 hours before a confirmed viewing, to both sides
  • Working email sender — replaces the stub as shared infrastructure, behind a channel interface so WhatsApp can be added later
  • Tenant panel arearoutes/tenant.tsx and /tenant/requests, mirroring slice 1's /owner/*

Out of scope, by decision

Offers, tenancy agreements, e-signature, payments, deposits, ratings, no-show tracking, tenant screening and credit checks, saved listings and favourites, WhatsApp delivery, real-time chat, calendars and availability slots, viewing outcome capture.

Deliberately not handled

  • No-shows. Nothing records whether the viewing happened. A real problem, but there is no data to design against yet.
  • Spam and abuse. A booking needs a signed-in account and a phone number on file, which is friction enough at this volume. Rate limiting and blocking arrive when someone actually abuses it.
  • Read receipts, typing indicators, per-message unread counts. One read_at column per message is the whole feature.
  • Timezones. Everything is Asia/Kuala_Lumpur, stored as UTC.
  • Early contact sharing in messages. Messages are free text with no phone/address scrubbing. Accepted, not mitigated: the reveal rule protects a safety property (both sides can reach each other once a viewing is real), and an early leak only strengthens that property, it never weakens it.
  • Admin visibility into requests and messages. No admin endpoint in slice 2. The rare early complaint is handled with direct database access, the same thin tooling slice 1 leans on elsewhere.

4. Decisions

How does a viewing get booked?

Request then confirm, not published slots — the tenant proposes 2–3 times and the owner accepts one.

Matches how Malaysians already arrange viewings over WhatsApp, and avoids asking owners to maintain a calendar they will not maintain.

Rejected: a published availability-slot model — needs three tables and a concurrency lock to deliver a convenience nobody has asked for yet.

Is there a general chat feature?

The viewing request is the thread — the tenant's proposal is message one, the owner's reply is message two.

One table, no separate "start a chat" flow, and every conversation is already attached to a real listing and a real intent.

Rejected: free-form listing enquiries — they create threads with no intent and immediately need their own spam and abuse handling.

Does a tenant need a new role?

No tenant role. Any signed-in user may book; authorization is purely row-level against tenant_user_id.

users.role is single-valued, and a landlord who also wants to rent a place is common here — a tenant token would lock landlords out of booking.

Making users.role multi-valued is the correct long-term fix, but it touches Better Auth's schema, buildRoles, and every RequireOrgRole call site — a cross-cutting change that dwarfs slice 2.

Where does the tenant book from?

Tenant UI lives in the panel (routes/tenant.tsx), not the public Astro site — mirroring slice 1's decision for owners.

The Astro public site stays fully anonymous and CDN-cacheable.

Rejected: authenticated booking directly in Astro (the quiz app pattern, ADR-0054) — it adds a second auth surface on Cloudflare Workers and costs the browse pages their full-page caching. Cost accepted: a tenant moves to a second domain to sign in.

Email now or WhatsApp now?

Email ships now behind a Sender interface, so WhatsApp lands later as a second adapter.

WhatsApp Business API needs Meta verification and approved templates — weeks of external process before a line of feature code ships.

The interface has one implementation today, which would normally fail the project's no-single-use-abstraction rule; it is bought deliberately because WhatsApp is a named next adapter, not speculation.

5. Architecture

PieceLocationNew or reused
BackendExtend slice 1's rental module — new domain concepts inside internal/modules/rental/Reused: module, profile entry, route group, AuthMiddleware. Nothing new to register.
EmailNew internal/shared/infrastructure/email/ — one Sender interface, one Resend HTTP adapterNew. Sits beside scheduler/, storage/, messaging/.
Reminder jobscheduler.RunPeriodicReused as-is.
MigrationsAppended to slice 1's migrator_rental.goReused.
Tenant UIPanel routes/tenant.tsx plus /tenant/requestsNew layout file, copying slice 1's owner.tsx.
Owner UI/owner/requests under slice 1's existing owner.tsxReused layout.
Public siteThe /property/{slug} "Arrange Viewing" button stops being disabled and links to the panelAstro stays anonymous and CDN-cached.

The existing notification_sender.go stub stays untouched. Pointing it at the new sender is a separate change, not this one. Rental still does not use the notifications module: it requires an organization, and a landlord or tenant has none — the constraint from slice 1 is unchanged.

6. Data model

Three tables, appended to migrator_rental.go.

rental_viewing_requests
id, listing_id, tenant_user_id, owner_user_id, status, proposed_times (jsonb array), confirmed_time, decline_reason, reminder_sent_at, created_at, updated_at. owner_user_id is denormalized from the property so authorization needs no join. status is one of pending, countered, confirmed, completed, declined, withdrawn, cancelled.
rental_messages
id, request_id, sender_user_id, body, read_at, created_at. Counter and re-propose transitions also insert a row here — body holds a server-formatted string describing the new proposed times, rendered as plain thread text like any other message. No kind column: the thread doesn't need to style a proposal differently from a chat message.
rental_contacts
user_id (primary key), phone, created_at, updated_at. One real column, because ADR-0006 forbids writing to the Better Auth users table. phone is normalized to E.164 (+60...) and validated at write time; malformed values are rejected at the API — WhatsApp is a named next adapter for the Sender interface, so the number is stored in the format it will eventually need.

7. Request state machine

Viewing request state machine — pending/countered negotiation loop, then confirmed branching to cancelled or completed
Viewing request state machine — pending/countered negotiation loop, then confirmed branching to cancelled or completed.

countered means the owner proposed different times and the ball is with the tenant. It is one enum value, not a second table. proposed_times is a single current-state field — whichever side just proposed overwrites it, and status tells the reader whose times are currently on the table. Both a counter and a re-proposal also insert a row into rental_messages carrying the new times, so the thread keeps a full negotiation history even though the field itself only ever holds the current offer.

declined and withdrawn are reachable from either pending or countered — either side can end the request at any point before it's confirmed. There is no cap on how many times a request can bounce between pending and countered; the one-non-terminal-request rule already bounds abuse, and real negotiations rarely exceed two or three rounds.

Terminal statuses are declined, withdrawn, cancelled, and completed. pending, countered, and confirmed are non-terminal, so a tenant with an open or confirmed request cannot open a second one for the same listing until that one closes.

8. Authorization

Endpoint groupCheck
Create requestAuthMiddleware only — any signed-in user, plus a phone on file
Read, message, or act on a requestAuthMiddleware plus session user is either tenant_user_id or owner_user_id
Owner-only actions (confirm, counter, decline)the above, and session user is owner_user_id
Owner confirms a requestthe above, and a phone on file in rental_contacts — mirrors the tenant phone gate at creation
Adminnone — no admin endpoint in slice 2; direct database access covers the rare early complaint
Public listing APIunchanged — slice 1's golden test still guards it

No new role token, no RequireOrgRole, no Better Auth change. This is the same row-level pattern slice 1 established, now with two orgless users on opposite sides of one row.

9. Flows

A — Tenant asks to view

  1. Sign in or sign up

    Tenant on the Astro listing page clicks Arrange Viewing, goes to the panel — reusing only slice 1's Better Auth sign-up screen, not its owner ownership-declaration step.

  2. Phone gate

    If no phone is on file, a one-field form writes rental_contacts, normalized to E.164. Blocked in the use case, not only in the form.

  3. Propose

    Tenant picks up to 3 times and writes an optional first message. The request is created as pending and message row one is written in the same transaction.

  4. Owner notified

    Owner sees it at /owner/requests. An email is sent — "someone wants to view your property" — carrying no tenant details in the body.

  5. Confirm

    Owner confirms one time — blocked if the owner has no phone on file. Status becomes confirmed, both sides see full name, phone, and the exact address, and both get an email carrying that reveal.

  6. Or counter

    Owner counters with different times. Status becomes countered, a message row records the new times. No email — counter is a mid-negotiation step.

  7. Or decline

    Owner declines with a reason. Terminal. An email is sent with the status and reason only — no phone, no address.

  8. Re-propose

    The tenant may re-propose from countered, returning the request to pending with a new message row. No cap on how many times this loops.

  9. Cancel

    Either side may cancel a confirmed viewing before it happens. An email goes to both sides and may reference the address — it was already revealed at confirmation.

  10. Completion

    If nobody cancels, the 15-minute sweep moves the request to completed once confirmed_time is more than 24 hours past. No email — a silent background transition.

B — The thread

GET /api/v1/rental/requests/{id} returns the request plus its messages. POST /api/v1/rental/requests/{id}/messages appends one. The page polls on an interval while it is open — no websockets, no server-sent events. Both endpoints run the same either-party row check. Counter and re-propose (Flow A) write into this same rental_messages table as free-text chat — body holds a server-formatted string, no separate kind column distinguishes them.

C — Reminder

scheduler.RunPeriodic every 15 minutes — deliberately not RunPeriodicNow, so a deploy does not make every replica sweep at once.

UPDATE rental_viewing_requests
   SET reminder_sent_at = now()
 WHERE status = 'confirmed'
   AND confirmed_time > now()
   AND confirmed_time <= now() + interval '24 hours'
   AND reminder_sent_at IS NULL
RETURNING id, ...

Claim first, then send. This is what makes the sweep safe across multiple replicas without a lock table or a job queue. The trade-off is real and accepted: if the email send then fails, that reminder is lost rather than retried. The same narrow window applies if either side cancels between the claim and the send — accepted for the same reason, a stray reminder for an already-cancelled viewing is harmless noise, not the duplicate-spam or lost-reminder failure mode this trade-off actually guards against.

D — Completion

Same scheduler.RunPeriodic 15-minute tick as Flow C, one more query:

UPDATE rental_viewing_requests
   SET status = 'completed'
 WHERE status = 'confirmed'
   AND confirmed_time < now() - interval '24 hours'
RETURNING id

Makes no attendance or no-show claim — it only closes out a lapsed booking so the one-non-terminal-request rule stops blocking the tenant from requesting the same listing again. No email; reveal stays open at completed since the request already passed through confirmed.

10. Error handling

CaseBehaviour
Tenant has no phoneRequest blocked in the use case until rental_contacts has one
Owner has no phone when confirmingConfirm blocked until rental_contacts has one — mirrors the tenant gate
Phone number malformedRejected at the API — normalized to E.164 first
Tenant books the same listing twiceBlocked — one non-terminal request per (listing, tenant)
Tenant books their own listingBlocked — tenant_user_id cannot equal owner_user_id
Listing suspended or withdrawn while a request is openExisting requests are left alone; new requests are refused
Owner confirms an already-confirmed requestIdempotent — no second email, no state churn
Proposed time in the pastRejected at the API
More than 3 proposed timesRejected at the API
Email send failsLogged; request state unaffected. Delivery is never in the same transaction as the state change.

Deliberately not handled: no-show recording, message editing and deletion, and attachments in messages.

11. Testing

  • DomainTable-driven over every transition, including the pending↔countered loop (with its message rows), decline/withdraw from either state, and cancel-after-confirm
  • AuthorizationUser C cannot read or post to A↔B's request — first test with two orgless users on opposite sides of one row
  • Reveal golden testEvery status: pending/countered/declined/withdrawn never carry phone or address_line; confirmed/completed/cancelled always do — slice 2's leak alarm
  • Reminder claim testTwo concurrent sweeps, exactly one wins
  • Completion sweep testA confirmed request older than 24h past confirmed_time becomes completed, unblocking a new request
  • Email adapterAgainst a stub HTTP server. No live sends in CI.
  • Playwright happy pathTenant requests, owner confirms, both see the phone number

12. Delivery order

Each step ends with something observable. Every step depends on slice 1 shipping first, except step 2, which has no rental dependency and can ship independently.

  1. Contacts

    rental_contacts and phone endpoints, normalized to E.164 → phone stored and read back via curl

  2. Email sender (decoupled)

    Email sender in shared infrastructure, plus adapter test → a real email arrives in a test inbox. No slice 1 dependency — can ship before slice 1 does.

  3. Request lifecycle

    Viewing request CRUD, state machine including the completed transition, authorization tests → full lifecycle via curl

  4. Reveal

    Reveal rule and golden test → nothing leaks before confirmation, and reveal holds through completed and cancelled

  5. Messaging

    Message endpoints, including counter/re-propose message rows → thread works via curl

  6. Reminder + completion

    Reminder sweep, claim test, and completion sweep → a reminder fires 24 hours out and a lapsed confirmed request completes

  7. Tenant panel

    Panel /tenant/* layout and request screens → a tenant books in a browser

  8. Owner panel

    Panel /owner/requests screens → an owner confirms in a browser

  9. Public site

    Astro "Arrange Viewing" goes live → the loop closes

  10. Deploy

    Playwright happy path, deploy

Backend through step 6 first, so frontend work is never blocked on API shape — the same discipline as slice 1.

13. What this unblocks

Slice 3 (offer → tenancy agreement → e-signature) needs three things that only exist after slice 2: a tenant identity attached to a specific property, a conversation record between the two parties, and a working outbound delivery channel to send a document for signing. Slice 2 delivers all three.