Merged in feature/SW-3572-hotel-data-endpoint (pull request #3051)

SW-3572 API route for listing hotels per city or country

* wip hotel data endpoint

* Correct route params type

* wip

* skip static paths call

* timeout when getting destinations take too long

* call noStore when we get a timeout

* add cache-control headers

* .

* .

* .

* wip

* wip

* wip

* wip

* add route for getting hotels per country

* include city when listing by country

* fix distance SI unit

* fix sorting

* Merge branch 'master' of bitbucket.org:scandic-swap/web into feature/SW-3572-hotel-data-endpoint

* packages/tracking passWithNoTests

* revalidate must be static value

* remove oxc reference

* cleanup

* cleanup hotel api route

* feat(SW-3572): cleanup error handling


Approved-by: Anton Gunnarsson
This commit is contained in:
Joakim Jäderberg
2025-11-03 12:10:22 +00:00
parent e8626d56af
commit 15a2da333d
25 changed files with 1227 additions and 249 deletions

View File

@@ -0,0 +1,139 @@
import * as Sentry from "@sentry/nextjs"
import { TRPCError } from "@trpc/server"
import { unstable_noStore as noStore } from "next/cache"
import { NextResponse } from "next/server"
import { Lang } from "@scandic-hotels/common/constants/language"
import { equalsIgnoreCaseAndAccents } from "@scandic-hotels/common/utils/stringEquals"
import {
gatewayTimeout,
httpStatusByErrorCode,
notFound,
} from "@scandic-hotels/trpc/errors"
import { Country } from "@scandic-hotels/trpc/types/country"
import {
isCityLocation,
isHotelLocation,
} from "@scandic-hotels/trpc/types/locations"
import { serverClient } from "@/lib/trpc/server"
import { timeout } from "@/utils/timeout"
import { createDataResponse } from "../createDataResponse"
export const revalidate = 28_800 // 8 hours
export async function GET(
_request: Request,
{ params }: { params: Promise<{ country: string; city: string }> }
) {
try {
const { country: countryParam, city: cityParam } = await params
const country = Object.values(Country).find((c) =>
equalsIgnoreCaseAndAccents(c, countryParam)
)
if (!country) {
throw notFound(`Country "${countryParam.toLowerCase()}" not found`)
}
const caller = await serverClient()
const locations = await Promise.any([
timeout(3_000).then(() => {
throw gatewayTimeout("Fetching locations timed out")
}),
caller.hotel.locations.get({ lang: Lang.en }),
])
const city = locations.filter(isCityLocation).find((c) => {
return (
equalsIgnoreCaseAndAccents(c.name, cityParam) &&
equalsIgnoreCaseAndAccents(c.countryName, countryParam)
)
})
if (!city) {
throw notFound(
`City "${cityParam.toLowerCase()}" not found in country "${countryParam.toLowerCase()}"`
)
}
const hotels = locations
.filter(isHotelLocation)
.filter(
(x) =>
x.isActive &&
x.isPublished &&
equalsIgnoreCaseAndAccents(x.relationships.city.name, cityParam)
)
if (hotels.length === 0) {
throw notFound(
`No hotels found in city "${cityParam.toLowerCase()}" and country "${countryParam.toLowerCase()}"`
)
}
return NextResponse.json(
createDataResponse({ countryParam, cityParam, hotels }),
{
status: 200,
headers: {
"Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
"Netlify-CDN-Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
},
}
)
} catch (error) {
noStore()
const noCacheHeader: HeadersInit = {
"Cache-Control": `no-store, max-age=0`,
}
if (error instanceof TRPCError) {
switch (error.code) {
case "GATEWAY_TIMEOUT":
case "INTERNAL_SERVER_ERROR": {
return NextResponse.json(
{
message: error.cause?.toString() || error.message,
},
{
status: httpStatusByErrorCode(error),
headers: {
...noCacheHeader,
},
}
)
}
case "NOT_FOUND": {
return NextResponse.json(
{
message: error.cause?.toString() || error.message,
},
{
status: 404,
headers: {
"Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
"Netlify-CDN-Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
},
}
)
}
}
}
Sentry.captureException(error)
return NextResponse.json(
{ message: "Internal Server Error" },
{
status: 500,
headers: {
...noCacheHeader,
},
}
)
}
}

View File

@@ -0,0 +1,59 @@
export function createDataResponse(
{
countryParam,
cityParam,
hotels,
}: {
countryParam: string
cityParam?: string
hotels: Array<{
name: string
distanceToCentre?: number | undefined
relationships: { city: { name: string } }
images?: { large?: string } | undefined
}>
},
options?: { includeCity: boolean }
) {
const { includeCity = false } = options || {}
return {
country: countryParam.toLowerCase(),
city: cityParam?.toLowerCase(),
hotels: hotels
.map((h) => ({
name: h.name,
city: includeCity ? h.relationships.city.name : undefined,
metersToCityCentre: h.distanceToCentre,
images: {
tiny: createImageUrl({ src: h.images?.large, width: 300 }),
small: createImageUrl({ src: h.images?.large, width: 500 }),
medium: createImageUrl({ src: h.images?.large, width: 1080 }),
large: createImageUrl({ src: h.images?.large, width: 1920 }),
},
}))
.toSorted((a, b) => a.name.localeCompare(b.name))
.toSorted((a, b) => {
return (
(a.metersToCityCentre ?? Infinity) -
(b.metersToCityCentre ?? Infinity)
)
})
.toSorted((a, b) => {
if (!includeCity) return 0
if (!a.city || !b.city) return 0
return a.city.localeCompare(b.city)
}),
}
}
function createImageUrl({
src,
width,
}: {
src: string | null | undefined
width: number
}) {
if (!src) return undefined
return `https://img.scandichotels.com/.netlify/images?url=${encodeURIComponent(src)}&w=${width}&q=90`
}

View File

@@ -0,0 +1,137 @@
import * as Sentry from "@sentry/nextjs"
import { TRPCError } from "@trpc/server"
import { unstable_noStore as noStore } from "next/cache"
import { NextResponse } from "next/server"
import { Lang } from "@scandic-hotels/common/constants/language"
import { equalsIgnoreCaseAndAccents } from "@scandic-hotels/common/utils/stringEquals"
import {
gatewayTimeout,
httpStatusByErrorCode,
notFound,
} from "@scandic-hotels/trpc/errors"
import { Country } from "@scandic-hotels/trpc/types/country"
import {
isCityLocation,
isHotelLocation,
} from "@scandic-hotels/trpc/types/locations"
import { serverClient } from "@/lib/trpc/server"
import { timeout } from "@/utils/timeout"
import { createDataResponse } from "./createDataResponse"
export const revalidate = 28_800 // 8 hours
export async function GET(
_request: Request,
{ params }: { params: Promise<{ country: string }> }
) {
try {
const { country: countryParam } = await params
const country = Object.values(Country).find((c) =>
equalsIgnoreCaseAndAccents(c, countryParam)
)
if (!country) {
throw notFound(`Country "${countryParam.toLowerCase()}" not found`)
}
const caller = await serverClient()
const locations = await Promise.any([
timeout(3_000).then(() => {
throw gatewayTimeout("Fetching locations timed out")
}),
caller.hotel.locations.get({ lang: Lang.en }),
])
const cities = locations.filter(isCityLocation).filter((c) => {
return equalsIgnoreCaseAndAccents(c.countryName, countryParam)
})
if (cities.length === 0) {
throw notFound(
`No cities found in country "${countryParam.toLowerCase()}"`
)
}
const hotels = locations
.filter(isHotelLocation)
.filter(
(x) =>
x.isActive &&
x.isPublished &&
cities.some((c) =>
equalsIgnoreCaseAndAccents(x.relationships.city.name, c.name)
)
)
if (hotels.length === 0) {
throw notFound(
`No hotels found in country "${countryParam.toLowerCase()}"`
)
}
return NextResponse.json(
createDataResponse({ countryParam, hotels }, { includeCity: true }),
{
status: 200,
headers: {
"Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
"Netlify-CDN-Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
},
}
)
} catch (error) {
noStore()
const noCacheHeader: HeadersInit = {
"Cache-Control": `no-store, max-age=0`,
}
if (error instanceof TRPCError) {
switch (error.code) {
case "GATEWAY_TIMEOUT":
case "INTERNAL_SERVER_ERROR": {
return NextResponse.json(
{
message: error.cause?.toString() || error.message,
},
{
status: httpStatusByErrorCode(error),
headers: {
...noCacheHeader,
},
}
)
}
case "NOT_FOUND": {
return NextResponse.json(
{
message: error.cause?.toString() || error.message,
},
{
status: 404,
headers: {
"Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
"Netlify-CDN-Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
},
}
)
}
}
}
Sentry.captureException(error)
return NextResponse.json(
{ message: "Internal Server Error" },
{
status: 500,
headers: {
...noCacheHeader,
},
}
)
}
}

View File

@@ -1,5 +1,4 @@
/* eslint-disable formatjs/no-literal-string-in-jsx */
import { getLang } from "@/i18n/serverContext"
import { texts } from "./Texts"