Merge branch 'develop' into feat/SW-266-seo-loyalty-pages

This commit is contained in:
Pontus Dreij
2024-08-21 14:11:53 +02:00
87 changed files with 2056 additions and 10893 deletions

View File

@@ -44,6 +44,7 @@ export function createContext() {
const cookie = cookies()
const webviewTokenCookie = cookie.get("webviewToken")
const loginType = h.get("loginType")
return createContextInner({
auth: async () => {
@@ -53,7 +54,12 @@ export function createContext() {
return null
}
return session || ({ token: { access_token: webToken } } as Session)
return (
session ||
({
token: { access_token: webToken, loginType },
} as Session)
)
},
lang: h.get("x-lang") as Lang,
pathname: h.get("x-pathname")!,

View File

@@ -1,94 +1,98 @@
import { z } from "zod"
import { fromUppercaseToLangEnum } from "@/utils/languages"
import { toLang } from "@/server/utils"
const RatingsSchema = z.object({
tripAdvisor: z.object({
numberOfReviews: z.number(),
rating: z.number(),
ratingImageUrl: z.string(),
webUrl: z.string(),
awards: z.array(
z.object({
displayName: z.string(),
images: z.object({
small: z.string(),
medium: z.string(),
large: z.string(),
}),
})
),
reviews: z.object({
widgetHtmlTagId: z.string(),
widgetScriptEmbedUrlIframe: z.string(),
widgetScriptEmbedUrlJavaScript: z.string(),
const ratingsSchema = z
.object({
tripAdvisor: z.object({
numberOfReviews: z.number(),
rating: z.number(),
ratingImageUrl: z.string(),
webUrl: z.string(),
awards: z.array(
z.object({
displayName: z.string(),
images: z.object({
small: z.string(),
medium: z.string(),
large: z.string(),
}),
})
),
reviews: z
.object({
widgetHtmlTagId: z.string(),
widgetScriptEmbedUrlIframe: z.string(),
widgetScriptEmbedUrlJavaScript: z.string(),
})
.optional(),
}),
}),
})
})
.optional()
const AddressSchema = z.object({
const addressSchema = z.object({
streetAddress: z.string(),
city: z.string(),
zipCode: z.string(),
country: z.string(),
})
const ContactInformationSchema = z.object({
const contactInformationSchema = z.object({
phoneNumber: z.string(),
faxNumber: z.string(),
faxNumber: z.string().optional(),
email: z.string(),
websiteUrl: z.string(),
})
const CheckinSchema = z.object({
const checkinSchema = z.object({
checkInTime: z.string(),
checkOutTime: z.string(),
onlineCheckOutAvailableFrom: z.string().nullable().optional(),
onlineCheckout: z.boolean(),
})
const EcoLabelsSchema = z.object({
const ecoLabelsSchema = z.object({
euEcoLabel: z.boolean(),
greenGlobeLabel: z.boolean(),
nordicEcoLabel: z.boolean(),
svanenEcoLabelCertificateNumber: z.string().optional(),
})
const HotelFacilityDetailSchema = z.object({
const hotelFacilityDetailSchema = z.object({
heading: z.string(),
description: z.string(),
})
const HotelFacilitySchema = z.object({
breakfast: HotelFacilityDetailSchema,
checkout: HotelFacilityDetailSchema,
gym: HotelFacilityDetailSchema,
internet: HotelFacilityDetailSchema,
laundry: HotelFacilityDetailSchema,
luggage: HotelFacilityDetailSchema,
shop: HotelFacilityDetailSchema,
telephone: HotelFacilityDetailSchema,
const hotelFacilitySchema = z.object({
breakfast: hotelFacilityDetailSchema,
checkout: hotelFacilityDetailSchema,
gym: hotelFacilityDetailSchema,
internet: hotelFacilityDetailSchema,
laundry: hotelFacilityDetailSchema,
luggage: hotelFacilityDetailSchema,
shop: hotelFacilityDetailSchema,
telephone: hotelFacilityDetailSchema,
})
const HotelInformationDetailSchema = z.object({
const hotelInformationDetailSchema = z.object({
heading: z.string(),
description: z.string(),
link: z.string().optional(),
})
const HotelInformationSchema = z.object({
accessibility: HotelInformationDetailSchema,
safety: HotelInformationDetailSchema,
sustainability: HotelInformationDetailSchema,
const hotelInformationSchema = z.object({
accessibility: hotelInformationDetailSchema,
safety: hotelInformationDetailSchema,
sustainability: hotelInformationDetailSchema,
})
const InteriorSchema = z.object({
const interiorSchema = z.object({
numberOfBeds: z.number(),
numberOfCribs: z.number(),
numberOfFloors: z.number(),
numberOfRooms: z.object({
connected: z.number(),
forEllergics: z.number(),
forAllergics: z.number().optional(),
forDisabled: z.number(),
nonSmoking: z.number(),
pet: z.number(),
@@ -97,37 +101,37 @@ const InteriorSchema = z.object({
}),
})
const ReceptionHoursSchema = z.object({
const receptionHoursSchema = z.object({
alwaysOpen: z.boolean(),
isClosed: z.boolean(),
openingTime: z.string().optional(),
closingTime: z.string().optional(),
})
const LocationSchema = z.object({
const locationSchema = z.object({
distanceToCentre: z.number(),
latitude: z.number(),
longitude: z.number(),
})
const ImageMetaDataSchema = z.object({
const imageMetaDataSchema = z.object({
title: z.string(),
altText: z.string(),
altText_En: z.string(),
copyRight: z.string(),
})
const ImageSizesSchema = z.object({
const imageSizesSchema = z.object({
tiny: z.string(),
small: z.string(),
medium: z.string(),
large: z.string(),
})
const HotelContentSchema = z.object({
const hotelContentSchema = z.object({
images: z.object({
metaData: ImageMetaDataSchema,
imageSizes: ImageSizesSchema,
metaData: imageMetaDataSchema,
imageSizes: imageSizesSchema,
}),
texts: z.object({
facilityInformation: z.string(),
@@ -145,24 +149,24 @@ const HotelContentSchema = z.object({
}),
})
const DetailedFacilitySchema = z.object({
const detailedFacilitySchema = z.object({
id: z.number(),
name: z.string(),
code: z.string().optional(),
applyToAllHotels: z.boolean(),
public: z.boolean(),
icon: z.number(),
icon: z.string(),
iconName: z.string().optional(),
sortOrder: z.number(),
})
const HealthFacilitySchema = z.object({
const healthFacilitySchema = z.object({
type: z.string(),
content: z.object({
images: z.array(
z.object({
metaData: ImageMetaDataSchema,
imageSizes: ImageSizesSchema,
metaData: imageMetaDataSchema,
imageSizes: imageSizesSchema,
})
),
texts: z.object({
@@ -181,15 +185,15 @@ const HealthFacilitySchema = z.object({
ordinary: z.object({
alwaysOpen: z.boolean(),
isClosed: z.boolean(),
openingTime: z.string(),
closingTime: z.string(),
openingTime: z.string().optional(),
closingTime: z.string().optional(),
sortOrder: z.number().optional(),
}),
weekends: z.object({
alwaysOpen: z.boolean(),
isClosed: z.boolean(),
openingTime: z.string(),
closingTime: z.string(),
openingTime: z.string().optional(),
closingTime: z.string().optional(),
sortOrder: z.number().optional(),
}),
}),
@@ -203,7 +207,7 @@ const HealthFacilitySchema = z.object({
),
})
const RewardNightSchema = z.object({
const rewardNightSchema = z.object({
points: z.number(),
campaign: z.object({
start: z.string(),
@@ -212,30 +216,30 @@ const RewardNightSchema = z.object({
}),
})
const PointsOfInterestSchema = z.object({
const pointsOfInterestSchema = z.object({
name: z.string(),
distance: z.number(),
category: z.object({
name: z.string(),
group: z.string(),
}),
location: LocationSchema,
location: locationSchema,
isHighlighted: z.boolean(),
})
const ParkingPricingSchema = z.object({
const parkingPricingSchema = z.object({
freeParking: z.boolean(),
paymentType: z.string(),
localCurrency: z.object({
currency: z.string(),
range: z.object({
min: z.number(),
max: z.number(),
max: z.number().optional(),
}),
ordinary: z.array(
z.object({
period: z.string(),
amount: z.number(),
amount: z.number().optional(),
startTime: z.string(),
endTime: z.string(),
})
@@ -243,38 +247,40 @@ const ParkingPricingSchema = z.object({
weekend: z.array(
z.object({
period: z.string(),
amount: z.number(),
startTime: z.string(),
endTime: z.string(),
})
),
}),
requestedCurrency: z.object({
currency: z.string(),
range: z.object({
min: z.number(),
max: z.number(),
}),
ordinary: z.array(
z.object({
period: z.string(),
amount: z.number(),
startTime: z.string(),
endTime: z.string(),
})
),
weekend: z.array(
z.object({
period: z.string(),
amount: z.number(),
amount: z.number().optional(),
startTime: z.string(),
endTime: z.string(),
})
),
}),
requestedCurrency: z
.object({
currency: z.string(),
range: z.object({
min: z.number(),
max: z.number(),
}),
ordinary: z.array(
z.object({
period: z.string(),
amount: z.number(),
startTime: z.string(),
endTime: z.string(),
})
),
weekend: z.array(
z.object({
period: z.string(),
amount: z.number(),
startTime: z.string(),
endTime: z.string(),
})
),
})
.optional(),
})
const ParkingSchema = z.object({
const parkingSchema = z.object({
type: z.string(),
name: z.string(),
address: z.string(),
@@ -282,37 +288,37 @@ const ParkingSchema = z.object({
numberOfChargingSpaces: z.number(),
distanceToHotel: z.number(),
canMakeReservation: z.boolean(),
pricing: ParkingPricingSchema,
pricing: parkingPricingSchema,
})
const SpecialNeedSchema = z.object({
const specialNeedSchema = z.object({
name: z.string(),
details: z.string(),
})
const SpecialNeedGroupSchema = z.object({
const specialNeedGroupSchema = z.object({
name: z.string(),
specialNeeds: z.array(SpecialNeedSchema),
specialNeeds: z.array(specialNeedSchema),
})
const SocialMediaSchema = z.object({
const socialMediaSchema = z.object({
instagram: z.string().optional(),
facebook: z.string().optional(),
})
const MetaSpecialAlertSchema = z.object({
const metaSpecialAlertSchema = z.object({
type: z.string(),
description: z.string(),
description: z.string().optional(),
displayInBookingFlow: z.boolean(),
startDate: z.string(),
endDate: z.string(),
})
const MetaSchema = z.object({
specialAlerts: z.array(MetaSpecialAlertSchema),
const metaSchema = z.object({
specialAlerts: z.array(metaSpecialAlertSchema),
})
const RelationshipsSchema = z.object({
const relationshipsSchema = z.object({
restaurants: z.object({
links: z.object({
related: z.string(),
@@ -335,11 +341,11 @@ const RelationshipsSchema = z.object({
}),
})
const RoomContentSchema = z.object({
const roomContentSchema = z.object({
images: z.array(
z.object({
metaData: ImageMetaDataSchema,
imageSizes: ImageSizesSchema,
metaData: imageMetaDataSchema,
imageSizes: imageSizesSchema,
})
),
texts: z.object({
@@ -350,7 +356,7 @@ const RoomContentSchema = z.object({
}),
})
const RoomTypesSchema = z.object({
const roomTypesSchema = z.object({
name: z.string(),
description: z.string(),
code: z.string(),
@@ -384,20 +390,20 @@ const RoomTypesSchema = z.object({
isLackingExtraBeds: z.boolean(),
})
const RoomFacilitiesSchema = z.object({
const roomFacilitiesSchema = z.object({
availableInAllRooms: z.boolean(),
name: z.string(),
isUniqueSellingPoint: z.boolean(),
sortOrder: z.number(),
})
export const RoomSchema = z.object({
export const roomSchema = z.object({
attributes: z.object({
name: z.string(),
sortOrder: z.number(),
content: RoomContentSchema,
roomTypes: z.array(RoomTypesSchema),
roomFacilities: z.array(RoomFacilitiesSchema),
content: roomContentSchema,
roomTypes: z.array(roomTypesSchema),
roomFacilities: z.array(roomFacilitiesSchema),
occupancy: z.object({
total: z.number(),
adults: z.number(),
@@ -417,18 +423,13 @@ export const getHotelDataSchema = z.object({
data: z.object({
id: z.string(),
type: z.string(), // No enum here but the standard return appears to be "hotels".
language: z
.string()
.refine((val) => fromUppercaseToLangEnum(val) !== undefined, {
message: "Invalid language",
})
.transform((val) => {
const lang = fromUppercaseToLangEnum(val)
if (!lang) {
throw new Error("Invalid language")
}
return lang
}),
language: z.string().transform((val) => {
const lang = toLang(val)
if (!lang) {
throw new Error("Invalid language")
}
return lang
}),
attributes: z.object({
name: z.string(),
operaId: z.string(),
@@ -436,36 +437,35 @@ export const getHotelDataSchema = z.object({
isPublished: z.boolean(),
cityId: z.string(),
cityName: z.string(),
ratings: RatingsSchema,
address: AddressSchema,
contactInformation: ContactInformationSchema,
ratings: ratingsSchema,
address: addressSchema,
contactInformation: contactInformationSchema,
hotelFacts: z.object({
checkin: CheckinSchema,
ecoLabels: EcoLabelsSchema,
hotelFacilityDetail: HotelFacilitySchema,
hotelInformation: HotelInformationSchema,
interior: InteriorSchema,
receptionHours: ReceptionHoursSchema,
checkin: checkinSchema,
ecoLabels: ecoLabelsSchema,
hotelFacilityDetail: hotelFacilitySchema,
hotelInformation: hotelInformationSchema,
interior: interiorSchema,
receptionHours: receptionHoursSchema,
yearBuilt: z.string(),
}),
location: LocationSchema,
hotelContent: HotelContentSchema,
detailedFacilities: z.array(DetailedFacilitySchema),
healthFacilities: z.array(HealthFacilitySchema),
rewardNight: RewardNightSchema,
pointsOfInterest: z.array(PointsOfInterestSchema),
parking: z.array(ParkingSchema),
specialNeedGroups: z.array(SpecialNeedGroupSchema),
socialMedia: SocialMediaSchema,
meta: MetaSchema,
location: locationSchema,
hotelContent: hotelContentSchema,
detailedFacilities: z.array(detailedFacilitySchema),
healthFacilities: z.array(healthFacilitySchema),
rewardNight: rewardNightSchema,
pointsOfInterest: z.array(pointsOfInterestSchema),
parking: z.array(parkingSchema),
specialNeedGroups: z.array(specialNeedGroupSchema),
socialMedia: socialMediaSchema,
meta: metaSchema.optional(),
isActive: z.boolean(),
}),
relationships: RelationshipsSchema,
relationships: relationshipsSchema,
}),
// NOTE: We can pass an "include" param to the hotel API to retrieve
// additional data for an individual hotel.
// Example "included" data can be found in our tempHotelData file.
included: z.array(RoomSchema).optional(),
included: z.array(roomSchema).optional(),
})
const rate = z.object({

View File

@@ -1,6 +1,7 @@
import * as api from "@/lib/api"
import { badRequestError } from "@/server/errors/trpc"
import { publicProcedure, router } from "@/server/trpc"
import { publicProcedure, router, serviceProcedure } from "@/server/trpc"
import { toApiLang } from "@/server/utils"
import {
getFiltersInputSchema,
@@ -11,60 +12,58 @@ import {
getFiltersSchema,
getHotelDataSchema,
getRatesSchema,
RoomSchema,
roomSchema,
} from "./output"
import tempFilterData from "./tempFilterData.json"
import tempHotelData from "./tempHotelData.json"
import tempRatesData from "./tempRatesData.json"
import { toApiLang } from "./utils"
export const hotelQueryRouter = router({
getHotel: publicProcedure
getHotel: serviceProcedure
.input(getHotelInputSchema)
.query(async ({ input, ctx }) => {
const { hotelId, language, include } = input
const params = new URLSearchParams()
const apiLang = toApiLang(language)
params.set("hotelId", hotelId.toString())
params.set("language", apiLang)
if (include) {
params.set("include", include.join(","))
}
// TODO: Enable once we have authorized API access.
// const apiResponse = await api.get(
// api.endpoints.v1.hotel,
// {}, // Include token.
// params
// )
//
// if (!apiResponse.ok) {
// console.info(`API Response Failed - Getting Hotel`)
// console.error(apiResponse)
// return null
// }
// const apiJson = await apiResponse.json()
// NOTE: We can pass an "include" param to the hotel API to retrieve
// additional data for an individual hotel.
// Example "included" data can be found in our tempHotelData file.
const { included, ...apiJsonWithoutIncluded } = tempHotelData
const validatedHotelData = getHotelDataSchema.safeParse(
apiJsonWithoutIncluded
const apiResponse = await api.get(
`${api.endpoints.v1.hotels}/${hotelId}`,
{
cache: "no-store",
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
},
params
)
if (!apiResponse.ok) {
console.info(`API Response Failed - Getting Hotel`)
console.error(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const validatedHotelData = getHotelDataSchema.safeParse(apiJson)
if (!validatedHotelData.success) {
console.error(`Get Individual Hotel Data - Verified Data Error`)
console.error(validatedHotelData.error)
throw badRequestError()
}
const included = validatedHotelData.data.included || []
const roomCategories = included
? included
.filter((item) => item.type === "roomcategories")
.map((roomCategory) => {
const validatedRoom = RoomSchema.safeParse(roomCategory)
const validatedRoom = roomSchema.safeParse(roomCategory)
if (!validatedRoom.success) {
console.error(`Get Room Category Data - Verified Data Error`)
console.error(validatedRoom.error)
@@ -75,7 +74,7 @@ export const hotelQueryRouter = router({
: []
return {
attributes: validatedHotelData.data.data.attributes,
hotel: validatedHotelData.data.data.attributes,
roomCategories: roomCategories,
}
}),

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
import { Lang } from "@/constants/languages"
const langMap: { [key in Lang]: string } = {
[Lang.en]: "En",
[Lang.sv]: "Sv",
[Lang.no]: "No",
[Lang.fi]: "Fi",
[Lang.da]: "Da",
[Lang.de]: "De",
}
/**
* Helper function to convert Lang enum to uppercase
* Needed for the Hotel endpoint.
*/
export const toApiLang = (lang: Lang): string => {
const result = langMap[lang]
if (!result) {
throw new Error("Invalid language")
}
return result
}

View File

@@ -18,3 +18,20 @@ export const soonestUpcomingStaysInput = z
limit: z.number().int().positive(),
})
.default({ limit: 3 })
export const initiateSaveCardInput = z.object({
language: z.string(),
mobileToken: z.boolean(),
redirectUrl: z.string(),
})
export const saveCardInput = z.object({
transactionId: z.string(),
merchantId: z.string().optional(),
})
export const friendTransactionsInput = z
.object({
limit: z.number().int().positive(),
page: z.number().int().positive(),
})
.default({ limit: 5, page: 1 })

View File

@@ -180,6 +180,8 @@ export const getCreditCardsSchema = z.object({
expirationDate: z.string(),
cardType: z.string(),
}),
id: z.string(),
type: z.string(),
})
),
})
@@ -193,3 +195,14 @@ export const getMembershipCardsSchema = z.array(
membershipType: z.string(),
})
)
export const initiateSaveCardSchema = z.object({
data: z.object({
attribute: z.object({
transactionId: z.string(),
link: z.string(),
mobileToken: z.string().optional(),
}),
type: z.string(),
}),
})

View File

@@ -1,6 +1,12 @@
import { Lang } from "@/constants/languages"
import { env } from "@/env/server"
import * as api from "@/lib/api"
import { internalServerError } from "@/server/errors/next"
import {
badRequestError,
forbiddenError,
unauthorizedError,
} from "@/server/errors/trpc"
import {
protectedProcedure,
router,
@@ -12,13 +18,20 @@ import * as maskValue from "@/utils/maskValue"
import { getMembership, getMembershipCards } from "@/utils/user"
import encryptValue from "../utils/encryptValue"
import { getUserInputSchema, staysInput } from "./input"
import {
friendTransactionsInput,
getUserInputSchema,
initiateSaveCardInput,
saveCardInput,
staysInput,
} from "./input"
import {
getCreditCardsSchema,
getFriendTransactionsSchema,
getMembershipCardsSchema,
getStaysSchema,
getUserSchema,
initiateSaveCardSchema,
Stay,
} from "./output"
import { benefits, extendedUser, nextLevelPerks } from "./temp"
@@ -441,53 +454,68 @@ export const userQueryRouter = router({
}),
}),
transaction: router({
friendTransactions: protectedProcedure.query(async (opts) => {
const apiResponse = await api.get(api.endpoints.v1.friendTransactions, {
headers: {
Authorization: `Bearer ${opts.ctx.session.token.access_token}`,
},
})
friendTransactions: protectedProcedure
.input(friendTransactionsInput)
.query(async ({ ctx, input }) => {
const { limit, page } = input
const apiResponse = await api.get(api.endpoints.v1.friendTransactions, {
cache: undefined, // override defaultOptions
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
next: { revalidate: 30 * 60 * 1000 },
})
if (!apiResponse.ok) {
// switch (apiResponse.status) {
// case 400:
// throw badRequestError()
// case 401:
// throw unauthorizedError()
// case 403:
// throw forbiddenError()
// default:
// throw internalServerError()
// }
console.error(`API Response Failed - Getting Friend Transactions`)
console.error(`User: (${JSON.stringify(opts.ctx.session.user)})`)
console.error(apiResponse)
return null
}
if (!apiResponse.ok) {
// switch (apiResponse.status) {
// case 400:
// throw badRequestError()
// case 401:
// throw unauthorizedError()
// case 403:
// throw forbiddenError()
// default:
// throw internalServerError()
// }
console.error(`API Response Failed - Getting Friend Transactions`)
console.error(`User: (${JSON.stringify(ctx.session.user)})`)
console.error(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = getFriendTransactionsSchema.safeParse(apiJson)
if (!verifiedData.success) {
console.error(`Failed to validate Friend Transactions Data`)
console.error(`User: (${JSON.stringify(opts.ctx.session.user)})`)
console.error(verifiedData.error)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = getFriendTransactionsSchema.safeParse(apiJson)
if (!verifiedData.success) {
console.error(`Failed to validate Friend Transactions Data`)
console.error(`User: (${JSON.stringify(ctx.session.user)})`)
console.error(verifiedData.error)
return null
}
return {
data: verifiedData.data.data.map(({ attributes }) => {
return {
awardPoints: attributes.awardPoints,
checkinDate: attributes.checkinDate,
checkoutDate: attributes.checkoutDate,
city: attributes.hotelInformation?.city,
confirmationNumber: attributes.confirmationNumber,
hotelName: attributes.hotelInformation?.name,
nights: attributes.nights,
}
}),
}
}),
const pageData = verifiedData.data.data.slice(
limit * (page - 1),
limit * page
)
return {
data: {
transactions: pageData.map(({ attributes }) => {
return {
awardPoints: attributes.awardPoints,
checkinDate: attributes.checkinDate,
checkoutDate: attributes.checkoutDate,
city: attributes.hotelInformation?.city,
confirmationNumber: attributes.confirmationNumber,
hotelName: attributes.hotelInformation?.name,
nights: attributes.nights,
}
}),
},
meta: {
totalPages: Math.ceil(verifiedData.data.data.length / limit),
},
}
}),
}),
creditCards: protectedProcedure.query(async function ({ ctx }) {
@@ -517,6 +545,69 @@ export const userQueryRouter = router({
return verifiedData.data.data
}),
initiateSaveCard: protectedProcedure
.input(initiateSaveCardInput)
.mutation(async function ({ ctx, input }) {
const apiResponse = await api.post(api.endpoints.v1.initiateSaveCard, {
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
body: {
language: input.language,
mobileToken: input.mobileToken,
redirectUrl: input.redirectUrl,
},
})
if (!apiResponse.ok) {
switch (apiResponse.status) {
case 400:
throw badRequestError(apiResponse)
case 401:
throw unauthorizedError(apiResponse)
case 403:
throw forbiddenError(apiResponse)
default:
throw internalServerError(apiResponse)
}
}
const apiJson = await apiResponse.json()
const verifiedData = initiateSaveCardSchema.safeParse(apiJson)
if (!verifiedData.success) {
console.error(`Failed to initiate save card data`)
console.error(`User: (${JSON.stringify(ctx.session.user)})`)
console.error(verifiedData.error)
return null
}
return verifiedData.data.data
}),
saveCard: protectedProcedure.input(saveCardInput).mutation(async function ({
ctx,
input,
}) {
const apiResponse = await api.post(
`${api.endpoints.v1.creditCards}/${input.transactionId}`,
{
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
body: {},
}
)
if (!apiResponse.ok) {
console.error(`API Response Failed - Save card`)
console.error(`User: (${JSON.stringify(ctx.session.user)})`)
console.error(apiResponse)
return null
}
return true
}),
membershipCards: protectedProcedure.query(async function ({ ctx }) {
const apiResponse = await api.get(api.endpoints.v1.profile, {
cache: "no-store",

35
server/tokenManager.ts Normal file
View File

@@ -0,0 +1,35 @@
import { env } from "@/env/server"
import { ServiceTokenResponse } from "@/types/tokens"
const SERVICE_TOKEN_REVALIDATE_SECONDS = 3599 // 59 minutes and 59 seconds.
export async function fetchServiceToken(): Promise<ServiceTokenResponse> {
try {
const response = await fetch(`${env.CURITY_ISSUER_USER}/oauth/v2/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: env.CURITY_CLIENT_ID_SERVICE,
client_secret: env.CURITY_CLIENT_SECRET_SERVICE,
scope: ["hotel"].join(","),
}),
next: {
revalidate: SERVICE_TOKEN_REVALIDATE_SECONDS,
},
})
if (!response.ok) {
throw new Error("Failed to obtain service token")
}
return response.json()
} catch (error) {
console.error("Error fetching service token:", error)
throw error
}
}

View File

@@ -4,9 +4,11 @@ import { env } from "@/env/server"
import {
badRequestError,
internalServerError,
sessionExpiredError,
unauthorizedError,
} from "./errors/trpc"
import { fetchServiceToken } from "./tokenManager"
import { transformer } from "./transformer"
import { langInput } from "./utils"
@@ -99,3 +101,15 @@ export const safeProtectedProcedure = t.procedure.use(async function (opts) {
},
})
})
export const serviceProcedure = t.procedure.use(async (opts) => {
const { access_token } = await fetchServiceToken()
if (!access_token) {
throw internalServerError("Failed to obtain service token")
}
return opts.next({
ctx: {
serviceToken: access_token,
},
})
})

View File

@@ -5,3 +5,31 @@ import { Lang } from "@/constants/languages"
export const langInput = z.object({
lang: z.nativeEnum(Lang),
})
/**
* Helper function to convert Lang enum to API lang enum.
*/
export const toApiLang = (lang: Lang): string => {
const result = toApiLangMap[lang]
if (!result) {
throw new Error("Invalid language")
}
return result
}
const toApiLangMap: { [key in Lang]: string } = {
[Lang.en]: "En",
[Lang.sv]: "Sv",
[Lang.no]: "No",
[Lang.fi]: "Fi",
[Lang.da]: "Da",
[Lang.de]: "De",
}
/**
* Helper function to convert lang string to Lang enum.
*/
export function toLang(lang: string): Lang | undefined {
const lowerCaseLang = lang.toLowerCase()
return Object.values(Lang).find((l) => l === lowerCaseLang)
}