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.
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.
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
tenantrole, 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_linethat slice 1 deliberately hides from the public - Contact table —
rental_contacts, keyed byuser_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 area —
routes/tenant.tsxand/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_atcolumn 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
| Piece | Location | New or reused |
|---|---|---|
| Backend | Extend slice 1's rental module — new domain concepts inside internal/modules/rental/ | Reused: module, profile entry, route group, AuthMiddleware. Nothing new to register. |
New internal/shared/infrastructure/email/ — one Sender interface, one Resend HTTP adapter | New. Sits beside scheduler/, storage/, messaging/. | |
| Reminder job | scheduler.RunPeriodic | Reused as-is. |
| Migrations | Appended to slice 1's migrator_rental.go | Reused. |
| Tenant UI | Panel routes/tenant.tsx plus /tenant/requests | New layout file, copying slice 1's owner.tsx. |
| Owner UI | /owner/requests under slice 1's existing owner.tsx | Reused layout. |
| Public site | The /property/{slug} "Arrange Viewing" button stops being disabled and links to the panel | Astro 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_requestsid, 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_idis denormalized from the property so authorization needs no join.statusis one ofpending, countered, confirmed, completed, declined, withdrawn, cancelled.rental_messagesid, request_id, sender_user_id, body, read_at, created_at. Counter and re-propose transitions also insert a row here —bodyholds a server-formatted string describing the new proposed times, rendered as plain thread text like any other message. Nokindcolumn: the thread doesn't need to style a proposal differently from a chat message.rental_contactsuser_id(primary key),phone,created_at,updated_at. One real column, because ADR-0006 forbids writing to the Better Authuserstable.phoneis normalized to E.164 (+60...) and validated at write time; malformed values are rejected at the API — WhatsApp is a named next adapter for theSenderinterface, so the number is stored in the format it will eventually need.
7. Request state machine
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 group | Check |
|---|---|
| Create request | AuthMiddleware only — any signed-in user, plus a phone on file |
| Read, message, or act on a request | AuthMiddleware 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 request | the above, and a phone on file in rental_contacts — mirrors the tenant phone gate at creation |
| Admin | none — no admin endpoint in slice 2; direct database access covers the rare early complaint |
| Public listing API | unchanged — 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
- 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.
- 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. - Propose
Tenant picks up to 3 times and writes an optional first message. The request is created as
pendingand message row one is written in the same transaction. - 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. - 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. - 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. - Or decline
Owner declines with a reason. Terminal. An email is sent with the status and reason only — no phone, no address.
- Re-propose
The tenant may re-propose from
countered, returning the request topendingwith a new message row. No cap on how many times this loops. - 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.
- Completion
If nobody cancels, the 15-minute sweep moves the request to
completedonceconfirmed_timeis 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
| Case | Behaviour |
|---|---|
| Tenant has no phone | Request blocked in the use case until rental_contacts has one |
| Owner has no phone when confirming | Confirm blocked until rental_contacts has one — mirrors the tenant gate |
| Phone number malformed | Rejected at the API — normalized to E.164 first |
| Tenant books the same listing twice | Blocked — one non-terminal request per (listing, tenant) |
| Tenant books their own listing | Blocked — tenant_user_id cannot equal owner_user_id |
| Listing suspended or withdrawn while a request is open | Existing requests are left alone; new requests are refused |
| Owner confirms an already-confirmed request | Idempotent — no second email, no state churn |
| Proposed time in the past | Rejected at the API |
| More than 3 proposed times | Rejected at the API |
| Email send fails | Logged; 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.
- Contacts
rental_contactsand phone endpoints, normalized to E.164 → phone stored and read back via curl - 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.
- Request lifecycle
Viewing request CRUD, state machine including the
completedtransition, authorization tests → full lifecycle via curl - Reveal
Reveal rule and golden test → nothing leaks before confirmation, and reveal holds through
completedandcancelled - Messaging
Message endpoints, including counter/re-propose message rows → thread works via curl
- Reminder + completion
Reminder sweep, claim test, and completion sweep → a reminder fires 24 hours out and a lapsed confirmed request completes
- Tenant panel
Panel
/tenant/*layout and request screens → a tenant books in a browser - Owner panel
Panel
/owner/requestsscreens → an owner confirms in a browser - Public site
Astro "Arrange Viewing" goes live → the loop closes
- 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.