Skip to content

fix(hotel): hotel-detail links pointed at a route that matches nothing

Hermes Agent requested to merge fix/hotel-detail-link-wrong-url into main

Report

Every hotel search result's "View" link was broken. Example from the user: 4 of 5 links were shortened (s.safrat.me/...) and looked fine at a glance, but one fell through to a raw, unshortened link: https://safrat.me/en/hotels/hotel/5000247 — which does not show a hotel. The user confirmed a real, working link looks like:

https://safrat.me/en/hotel/Mashhad/5000027/homa--mashhad?adt[0]=1&chd[0]=0&chdAges[0]=&checkIn=2026-9-22&checkOut=2026-9-25&countryCode=NL&hotelId=5000027&hotelLabel=Homa%201

Root cause

HotelSearchLogic._hotel_base (ai/core/logics/hotel/hotel_search_logic.py) hardcoded {site}/{locale}/hotels/hotel/{id} — a bare path with nothing after the numeric id. Checked against the actual site repo (/srv/safrat-src/travel-platform/front-end/site):

  • The site's CURRENT hotel-detail page lives at pages/hotel/[hotelCity]/[hotelId]/[hotelName].tsx — i.e. /hotel/{city}/{id}/{slug} (singular "hotel", 3 dynamic segments).
  • There's also an OLD route tree at pages/hotels/hotel/[hotelId]/[hotelName]/details-hotel.tsx (plural "hotels", different segment count).
  • The broken /hotels/hotel/{id} (one segment after "hotel") matches neither tree. It's not almost-right — it's structurally incompatible with both, which is why it never showed a hotel at all.

This affected every hotel-listing link, not just the one that happened to skip the shortener in the report: shorten_many() just shortens whatever URL it's given, so the 4 "working-looking" short links resolved to the exact same broken destination — they only looked fine in the chat because the brokenness was hidden behind s.safrat.me/....

Why the fix is safe despite not replicating every query param

Read the site's own getServerSideProps for the current hotel-detail page:

export async function getServerSideProps(context: any) {
    const hotelData = await apiGetHotelInfo({ hotelId: context.query.hotelId.toString(), locale: context.locale });
    return { props: { hotelData: hotelData.data } };
}

It reads only hotelId. hotelCity and hotelName (the slug) are never read, validated, or checked against the loaded data anywhere — client-side they're only echoed into breadcrumb/canonical-URL text. So a placeholder city/slug loads the identical page, as long as hotelId is correct AND a third path segment exists at all (Next's dynamic route needs the segment count to match, even if the content doesn't matter). Query params (checkIn/checkOut/adt[]/chd[]/chdAges[]/countryCode/hotelLabel) are all optional prefill conveniences — the page runs searchHotelBasedOnDefaultData() (today/tomorrow, 1 adult, IP-geolocated or config-default nationality) when they're absent, so it works correctly either way.

Fix

  • _hotel_url now builds /{locale}/hotel/{city}/{id}/{slug} — city from state["city_label"] (already available at both call sites), slug from a new _name_to_url helper (a direct port of the site's own front-end/site/src/utils/name-to-url.ts slugifier, for consistency — not required for correctness, but no reason to diverge now that the real algorithm is known), with a "hotel" placeholder for either when unknown so the route always has all 3 segments.
  • Also passes through checkIn/checkOut (reformatted from the stored ...T14:00:00.000Z shape via a new _date_only helper) and hotelLabel as query params when known — a real, if minor, UX improvement (the link opens pre-filled with the dates the user actually asked for instead of defaulting to today/tomorrow).
  • _hotel_base (the old /hotels/hotel/{id} builder) is unchanged, still used by _room_url only.

Deliberately NOT touched: _room_url (room/offer booking link)

_room_url builds {_hotel_base}/offer/{offerId}/passengers-information = /hotels/hotel/{id}/offer/{offerId}/passengers-information. This matches an EXISTING page file exactly: pages/hotels/hotel/[hotelId]/offer/[offerId]/passengers-information.tsx — under the OLD route tree, which has no equivalent under the new /hotel/{city}/{id}/{name} tree (no offer/booking subfolder exists there). So unlike the plain hotel-detail link, this one still matches a real route today. The user didn't report it as broken, and I found no evidence it is. Given this touches an actual booking continuation flow in production, I deliberately left it alone rather than guess — recommend a dedicated check (click through hotel -> select room -> confirm on the live site) before ever touching it.

Test

ai/evals/tests/test_hotel_url.py (new, offline, no LLM/network/DB) — asserts: the new 3-segment path shape, the old broken path never reappears, checkIn/checkOut/hotelLabel populate as query params when known, city/slug fall back to a placeholder (never empty) when unknown, the Arabic no-locale-prefix case, hotelId-less returns None, and the ported slugifier's exact behavior (including the digit-stripping quirk that produces homa--mashhad-style double dashes, matched on purpose for consistency with the site).

Verification

  • Manually re-derived the exact URL-building logic standalone (outside the real module, which needs openai/langgraph/etc. not installed in this sandbox) and confirmed the output shape matches the intended /en/hotel/Mashhad/5000027/... structure with query params populated correctly.
  • Syntax-checked (py_compile) both changed files.
  • Could not execute the actual test suite in this session (same constraint as the other two open MRs — repo needs Python 3.14/uv, this sandbox only has system Python 3.11, and hotel_search_logic.py imports openai which isn't installed here). Please let CI run test_hotel_url.py before merging.

Not done (by design)

  • Not merged — opened for review only.
  • _room_url/the room-booking link intentionally untouched (see above).
  • No production deploy, service restart, or live database/system touched.

Merge request reports

Loading