Loading repository dataβ¦
Loading repository dataβ¦
enzoftware / repository
Explore a sleek Flutter demo for a hotel booking app π© β€οΈ Witness the synergy of code and care, delivering a polished user interface.
hotelyn/
βββ pubspec.yaml β workspace root (workspace: [...], melos: [...])
βββ apps/
β βββ hotelyn_app/ β Flutter mobile app (Android, iOS)
β βββ hotelyn_dashboard/ β Flutter dashboard app (Android, iOS, Web)
βββ packages/
β βββ hotelyn_api_client/ β REST client (package:http) over the Dart Frog API
β βββ hotelyn_domain/ β domain entities (json_serializable) & interfaces
β βββ hotelyn_ui/ β shared widget library & design system
βββ backend/ β Dart Frog REST server (talks to Supabase)
βββ supabase/ β local Supabase stack config + migrations
Prerequisites: Flutter stable channel, Dart β₯ 3.5.0, Melos β₯ 7.
Follow these steps in order on a fresh clone:
flutter doctor
Resolve any issues it reports (e.g. missing Android/iOS toolchains) before continuing.cd into it:
git clone https://github.com/enzoftware/hotelyn.git
cd hotelyn
bin directory is on your PATH so the melos command is available in new shells:
dart pub global activate melos
export PATH="$PATH:$HOME/.pub-cache/bin"
melos bootstrap
melos run analyze
melos run test
flutter doctor β it should be clean with no unresolved issues:
flutter doctor
No other manual setup is required. Steps 1β7 are the complete bootstrap sequence for a new machine.
The app never talks to Supabase directly (see Architecture) β only the backend REST server does. To run that stack locally:
Prerequisites: Docker running, Supabase CLI β₯ 2.x.
supabase/migrations/ and loads
supabase/seed.sql:
supabase start
supabase status
supabase/seed.sql change, or whenever you want a clean, known dataset β it
drops the local database, re-runs all migrations (the first enables postgis),
then loads the seed:
supabase db reset
The seed is idempotent and deterministic (fixed IDs/coordinates), so a
reset always produces the same six hotels, their rooms, and the two test
accounts listed under Data model β safe to run as often as you
like.6). Copy DB_URL from supabase status:
psql "$DB_URL" -c 'select count(*) from hotels;'
No local psql? Browse the tables in Studio (step 6), or run the query inside
the database container:
docker exec -it supabase_db_hotelyn \
psql -U postgres -d postgres -c 'select count(*) from public.hotels;'
supabase status printed:
cp backend/.env.example backend/.env
cp apps/hotelyn_app/.env.example apps/hotelyn_app/.env
cp apps/hotelyn_dashboard/.env.example apps/hotelyn_dashboard/.env
STUDIO_URL (defaults to http://127.0.0.1:54323)
to browse the seeded schema and data.supabase stop
The schema (see supabase/migrations/) is four normalized tables plus enum
types and Row Level Security:
| Table | Notes |
|---|---|
profiles | 1:1 with auth.users; holds role (guest/hotel_staff/admin) + hotel_id. |
hotels | Includes a PostGIS location geometry(Point, 4326) with a GiST index. |
rooms | FK β hotels; is_available flags bookable rooms. |
reservations | FK β hotels/rooms/profiles; status enum; hold_expires_at + unique confirmation_code. Partial unique index on room_id WHERE status IN ('held','confirmed') makes double-booking impossible. |
RLS is enabled on every table: guests browse available rooms and manage only
their own reservations; hotel staff read/write only their own hotel's rooms and
reservations. The seed (supabase/seed.sql, loaded by supabase db reset) is
deterministic: six hotels across LatAm + North America launch cities, a mix of
available/unavailable rooms, and two test accounts (local password
password123):
| Account | Role |
|---|---|
guest@hotelyn.test | guest |
staff@hotelyn.test | hotel_staff (owns the Lima hotel) |
Metre-accurate proximity search casts location to geography (a distance in
metres), which is served by the dedicated functional GiST index rather than the
degree-based one:
-- hotels within 5 km of a point (lon, lat)
select name from hotels
where st_dwithin(location::geography,
st_setsrid(st_makepoint(-77.03, -12.11), 4326)::geography, 5000);
The RLS policies (and the geolocation functions below) are covered by automated pgTAP tests β the Hotel A / Hotel B isolation proof plus the search-function behaviour:
supabase test db
Three SECURITY DEFINER SQL functions (migration ..._geo_search.sql) power
search. They read every reservation to compute availability but only ever return
public catalogue rows:
| Function | Purpose |
|---|---|
nearby_hotels(lat, lng, radius_km) | Hotels within the radius (ST_DWithin), nearest-first with a distance_km (ST_Distance). Empty set when nothing is in range. |
rooms_with_availability(hotel_id?) | Each room with available_now = flagged available and no unexpired held/confirmed hold (hold_expires_at > now()). Expired holds don't block. |
recommended_hotels(lat, lng, radius_km) | In-radius hotels ranked by confirmed reservations in the trailing recommendation_window_days() (30), ties broken by proximity. Cold-start (all zero) falls back to nearby filtered to available-now. |
Nearby search is index-accelerated: p95 β 0.3 ms with 100 hotels (200 runs, local stack) β well under the 300 ms budget.
A hold is a short-lived, exclusive claim on a room (default 15 min, see
hold_duration()) so a guest can finish checkout without being double-booked.
Correctness is the database's job β the migration
..._reservation_holds.sql enforces it structurally:
| Piece | What it does |
|---|---|
Partial unique index reservations_active_room_uidx | At most one held/confirmed reservation per room. A concurrent second hold loses to the constraint β no application lock needed (BE-401). |
create_reservation_hold(room_id, guest_id, check_in, check_out) | INSERT β¦ ON CONFLICT DO NOTHING: returns the created reservation, or zero rows when the room is already held (the client maps this to a typed 409 RoomAlreadyHeldException). Generates the confirmation_code in the same transaction and reclaims any lapsed hold first (BE-402). |
| Query-time expiry | No paid scheduler: status may read held after real expiry, but every read path treats held AND hold_expires_at < now() as free, so no user ever sees a stale-blocked room (BE-403). |
The single-winner guarantee and expiry behaviour are covered by pgTAP
(supabase/tests/reservation_holds_test.sql).
Staff-facing operations over their own hotel (migration
..._hotel_inventory.sql). Because the backend holds the service-role key
and so bypasses RLS, each staff RPC takes the acting profile (p_actor) and
re-checks ownership itself (actor_manages_hotel) β exactly as the hold RPC
guards on auth.uid(). RLS (BE-203) remains the boundary for any direct client.
| Piece | What it does |
|---|---|
staff_room_list(actor, hotel_id?) | The actor's own hotel rooms with a derived status β available / unavailable / held / occupied β using the query-time expiry rule. Scope comes from the actor's profile, never a client-supplied hotel id (BE-501). |
set_room_availability(actor, room_id, is_available) | Toggle availability, ownership-checked. Refuses to set available while an active hold/confirmed reservation would double-allocate the room (room_has_active_reservation) (BE-502). |
confirm_reservation(actor, id) / reject_reservation(actor, id) | Owning staff confirm or reject a reservation. Confirming an expired hold fails (hold_expired) rather than resurrecting it; rejecting frees the room immediately (BE-503). |
supabase_realtime publication | reservations is published for Realtime; a staff session's subscription is scoped to its own hotel by the existing RLS select policy. The dashboard subscription is wired in the app layer (BE-504). |
Covered by pgTAP (supabase/tests/hotel_inventory_test.sql).
The MVP has no online payment integration: a guest pays in person and staff mark
the reservation paid (migration ..._manual_payment.sql). Like the other staff
RPCs it takes the acting profile and re-checks ownership via actor_manages_hotel.
| Piece | What it does |
|---|---|
confirmation_code (BE-701) | 6+ char Crockford-base32 handle (HZ-β¦, no I/L/O/U) generated inside create_reservation_hold and unique among reservations; the RPC retries on the astronomically rare collision. Already landed with the hold engine (EPIC-04). |
mark_reservation_paid(actor, id) | Owning staff mark a live hold paid: it transitions to confirmed, its expiry is cleared, and paid_by/paid_at are stamped for dispute resolution. Refuses (reservation_not_payable) anything that is not a live hold β already-confirmed, terminal, or an expired hold (BE-702). |
Covered by pgTAP (supabase/tests/manual_payment_test.sql).
Deferred (BE-703). ID/age verification data capture is a documented phase-2 item (decision #3) and is not built in the MVP. Shipping to real users in "hoteles de paso" jurisdictions without it is a compliance gap to close before launch, not a missing feature here.
Realtime & the REST-only rule. The apps have no direct Supabase dependency (see the architecture note at the top), so #174's dashboard subscription is a follow-up in the app layer; this change lands only the DB-side enabler.
Auth note. The staff RPCs are granted to
service_roleonly, so they are reachable only through the backend (a logged-in user cannot call them directly via PostgREST and pass someone else'sp_actor). The backend derives the ac