import { z } from "zod" import { countriesMap } from "../../constants/countries" import { imageSchema } from "../../routers/hotels/schemas/image" import { getFriendsMembership, scandicMembershipTypes } from "./helpers" const scandicFriendsTier = z.enum(["L1", "L2", "L3", "L4", "L5", "L6", "L7"]) const sasEurobonusTier = z.enum(["EBB", "EBS", "EBG", "EBD", "EBP"]) const commonMembershipSchema = z.object({ membershipNumber: z.string(), tierExpires: z.string().nullish().default(null), memberSince: z.string().nullish(), }) // This prevents validation errors if the API returns an unhandled membership type const otherMembershipSchema = z .object({ // This ensures that `type` won't widen into "string", losing the literal types, when used in a union type: z.string().refine((val): val is string & {} => true), }) .merge(commonMembershipSchema) export const sasMembershipSchema = z .object({ type: z.literal(scandicMembershipTypes.SAS_EB), tier: sasEurobonusTier, nextTier: sasEurobonusTier.nullish(), spendablePoints: z.number().nullish(), boostedByScandic: z.boolean().nullish(), boostedTier: sasEurobonusTier.nullish(), boostedTierExpires: z.string().nullish().default(null), }) .merge(commonMembershipSchema) .transform((response) => { return { ...response, tierExpires: // SAS API returns 1900-01-01 for non-expiring tiers response.tierExpires === "1900-01-01" ? null : response.tierExpires, } }) export const friendsMembershipSchema = z .object({ type: z.literal(scandicMembershipTypes.SCANDIC_NATIVE), tier: scandicFriendsTier, nextTier: scandicFriendsTier.nullish(), pointsToNextTier: z.number().nullish(), tierPoints: z.number().nullish(), nightsToTopTier: z.number().nullish(), }) .merge(commonMembershipSchema) export const membershipSchema = z.union([ friendsMembershipSchema, sasMembershipSchema, otherMembershipSchema, ]) const pointExpirationSchema = z.object({ points: z.number().int(), expires: z.string(), }) export const employmentDetailsSchema = z .object({ employeeId: z.string(), location: z.string(), country: z.string(), retired: z.boolean(), }) .optional() export const userLoyaltySchema = z.object({ memberships: z.array(membershipSchema), points: z.object({ spendable: z.number().int(), earned: z.number().int().optional(), spent: z.number().int().optional(), }), tier: scandicFriendsTier, tierExpires: z.string(), tierBoostedBy: z.string().nullish(), pointExpirations: z.array(pointExpirationSchema).optional(), }) export const getUserSchema = z .object({ data: z.object({ attributes: z.object({ dateOfBirth: z.string().optional().default("1900-01-01"), email: z.string().email(), firstName: z.string(), language: z .string() // Preserve Profile v1 formatting for now so it matches ApiLang enum .transform((s) => s.charAt(0).toUpperCase() + s.slice(1)) .optional(), lastName: z.string(), phoneNumber: z.string().optional(), profileId: z.string().optional(), membershipNumber: z.string(), address: z .object({ city: z.string().optional(), country: z.string().optional(), countryCode: z.nativeEnum(countriesMap).optional(), streetAddress: z.string().optional(), zipCode: z.string().optional(), }) .optional() .nullable(), loyalty: userLoyaltySchema.optional(), employmentDetails: employmentDetailsSchema, profilingConsent: z.boolean().optional(), profilingConsentUpdateDate: z.string().optional(), promotions: z.array(z.string()).nullish(), }), type: z.string(), }), }) .transform((apiResponse) => { return { ...apiResponse.data.attributes, membership: apiResponse.data.attributes.loyalty ? getFriendsMembership(apiResponse.data.attributes.loyalty) : null, name: `${apiResponse.data.attributes.firstName} ${apiResponse.data.attributes.lastName}`, } }) export const getBasicUserSchema = z.object({ dateOfBirth: z.string().optional().default("1900-01-01"), firstName: z.string(), language: z .string() .transform((s) => s.charAt(0).toUpperCase() + s.slice(1)) .optional(), lastName: z.string(), phoneNumber: z.string().optional(), profileId: z.string().optional(), address: z .object({ city: z.string().optional(), country: z.string().optional(), countryCode: z.nativeEnum(countriesMap).optional(), streetAddress: z.string().optional(), zipCode: z.string().optional(), }) .optional() .nullable(), loyalty: z .object({ tier: scandicFriendsTier, tierExpires: z.string(), memberships: z.array( z.object({ membershipType: z .nativeEnum(scandicMembershipTypes) .catch(scandicMembershipTypes.OTHER), membershipNumber: z.string(), }) ), }) .optional() .nullable(), }) export const creditCardSchema = z .object({ attribute: z.object({ cardName: z.string().optional(), alias: z.string(), truncatedNumber: z.string().transform((s) => s.slice(-4)), expirationDate: z.string(), cardType: z .string() .transform((s) => s.charAt(0).toLowerCase() + s.slice(1)), }), id: z.string(), type: z.string(), }) .transform((apiResponse) => { return { id: apiResponse.id, type: apiResponse.attribute.cardType, truncatedNumber: apiResponse.attribute.truncatedNumber, alias: apiResponse.attribute.alias, expirationDate: apiResponse.attribute.expirationDate, cardType: apiResponse.attribute.cardType, } }) export const creditCardsSchema = z.object({ data: z.array(creditCardSchema), }) // Schema is the same for upcoming and previous stays endpoints export const getStaysSchema = z.object({ data: z.array( z.object({ attributes: z.object({ hotelOperaId: z.string(), hotelInformation: z.object({ hotelContent: z.object({ images: imageSchema, }), hotelName: z.string(), cityName: z.string().nullable(), }), confirmationNumber: z.string(), checkinDate: z.string(), checkoutDate: z.string(), isWebAppOrigin: z.boolean(), bookingUrl: z.string().default(""), }), relationships: z.object({ hotel: z.object({ links: z.object({ related: z.string().nullable().optional(), }), data: z.object({ id: z.string(), type: z.string(), }), }), }), type: z.string(), id: z.string(), links: z.object({ self: z.object({ href: z.string(), meta: z.object({ method: z.string(), }), }), }), }) ), links: z .object({ self: z.string(), offset: z.number(), limit: z.number(), totalCount: z.number(), }) .optional() .nullable(), }) type GetStaysData = z.infer export type Stay = GetStaysData["data"][number] export const getFriendTransactionsSchema = z.object({ data: z.array( z.object({ attributes: z.object({ awardPoints: z.number().default(0), checkinDate: z.string().default(""), checkoutDate: z.string().default(""), confirmationNumber: z.string().default(""), hotelOperaId: z.string().default(""), nights: z.number().default(1), pointsCalculated: z.boolean().default(true), transactionDate: z.string().default(""), bookingUrl: z.string().default(""), hotelInformation: z .object({ city: z.string().default(""), name: z.string().default(""), hotelContent: z.object({ images: imageSchema, }), }) .optional(), }), relationships: z.object({ booking: z.object({ data: z.object({ id: z.string().default(""), type: z.string().default(""), }), links: z.object({ related: z.string().default(""), }), }), hotel: z .object({ data: z.object({ id: z.string().default(""), type: z.string().default(""), }), links: z.object({ related: z.string().default(""), }), }) .optional(), }), type: z.string().default(""), }) ), links: z .object({ self: z.string(), }) .nullable(), }) type GetFriendTransactionsData = z.infer export type FriendTransaction = GetFriendTransactionsData["data"][number] export const initiateSaveCardSchema = z.object({ data: z.object({ attribute: z.object({ transactionId: z.string(), link: z.string(), mobileToken: z.string().optional(), }), type: z.string(), }), }) export const subscriberIdSchema = z.object({ subscriberId: z.string(), })