fix(hotel): hotel-detail links pointed at a route that matches nothing
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_urlnow builds/{locale}/hotel/{city}/{id}/{slug}— city fromstate["city_label"](already available at both call sites), slug from a new_name_to_urlhelper (a direct port of the site's ownfront-end/site/src/utils/name-to-url.tsslugifier, 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.000Zshape via a new_date_onlyhelper) andhotelLabelas 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_urlonly.
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, andhotel_search_logic.pyimportsopenaiwhich isn't installed here). Please let CI runtest_hotel_url.pybefore 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.