Merged in feat/LOY-232-DTMC-API-INTEGRATION (pull request #2454)
feat(LOY-232): DTMC API Integration * feat(LOY-232): DTMC API Integration * feat(LOY-232): use employment data in team member card * refactor(LOY-232): remove static data, return employment details in parsed response & fix tests * refactor(LOY-232): improve DTMC API Linking error control flow + make res type safe * fix(LOY-232): remove unused utils * fix(LOY-232): error vars Approved-by: Christian Andolf Approved-by: Erik Tiekstra
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from "next/server"
|
||||
|
||||
import { overview } from "@scandic-hotels/common/constants/routes/myPages"
|
||||
import * as api from "@scandic-hotels/trpc/api"
|
||||
import { isValidSession } from "@scandic-hotels/trpc/utils/session"
|
||||
|
||||
import { DTMC_SUCCESS_BANNER_KEY } from "@/constants/dtmc"
|
||||
@@ -12,15 +13,94 @@ import { auth } from "@/auth"
|
||||
import { auth as dtmcAuth } from "@/auth.dtmc"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
async function linkEmployeeToUser(employeeId: string) {
|
||||
interface LinkEmployeeSuccessResult {
|
||||
success: true
|
||||
}
|
||||
|
||||
interface LinkEmployeeErrorResult {
|
||||
success: false
|
||||
statusCode: number
|
||||
queryParam?: string | null
|
||||
}
|
||||
|
||||
type LinkEmployeeResult = LinkEmployeeSuccessResult | LinkEmployeeErrorResult
|
||||
|
||||
/**
|
||||
* Links an employee to a user account via API call.
|
||||
*/
|
||||
async function linkEmployeeToUser(
|
||||
employeeId: string,
|
||||
accessToken: string
|
||||
): Promise<LinkEmployeeResult> {
|
||||
console.log(`[dtmc] Linking employee ID ${employeeId}`)
|
||||
let response: Response
|
||||
try {
|
||||
console.log(`[dtmc] Linking employee ID ${employeeId}`)
|
||||
// TODO: Use the actual API once available. For now, return a mock success response.
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("[dtmc] Error linking employee to user:", error)
|
||||
throw error
|
||||
response = await api.post(
|
||||
api.endpoints.v2.Profile.teamMemberCard(employeeId),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
} catch (networkError) {
|
||||
console.error("[dtmc] Network error during API request:", networkError)
|
||||
return {
|
||||
success: false,
|
||||
statusCode: 0,
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[dtmc] API returned error status ${response.status}`)
|
||||
try {
|
||||
const errorResponse = await response.json()
|
||||
console.error(`[dtmc] API error response:`, errorResponse)
|
||||
} catch (parseError) {
|
||||
console.warn(`[dtmc] Could not parse API error response:`, parseError)
|
||||
try {
|
||||
const errorText = await response.text()
|
||||
console.error(`[dtmc] Raw error response:`, errorText)
|
||||
} catch {
|
||||
console.error(`[dtmc] Could not read error response body`)
|
||||
}
|
||||
}
|
||||
|
||||
let queryParam: string | null = null
|
||||
switch (response.status) {
|
||||
case 400:
|
||||
case 404:
|
||||
queryParam = "unable_to_verify_employee_id"
|
||||
break
|
||||
case 401:
|
||||
queryParam = "unauthorized"
|
||||
break
|
||||
case 403:
|
||||
queryParam = "forbidden"
|
||||
break
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
statusCode: response.status,
|
||||
queryParam,
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[dtmc] API call successful - Status: ${response.status}`)
|
||||
console.log(
|
||||
`[dtmc] Response headers:`,
|
||||
Object.fromEntries(response.headers.entries())
|
||||
)
|
||||
try {
|
||||
const responseBody = await response.json()
|
||||
console.log(`[dtmc] Response body:`, responseBody)
|
||||
} catch (parseError) {
|
||||
console.warn(`[dtmc] Could not parse success response body:`, parseError)
|
||||
}
|
||||
|
||||
console.log(`[dtmc] Successfully linked employee ID ${employeeId}`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +152,16 @@ export async function GET(request: NextRequest) {
|
||||
"[dtmc] DTMC Callback handler - Calling linkEmployeeToUser with ID:",
|
||||
employeeId
|
||||
)
|
||||
const result = await linkEmployeeToUser(employeeId)
|
||||
|
||||
const accessToken = session.token.access_token
|
||||
if (!accessToken) {
|
||||
console.error("[dtmc] DTMC Callback handler - No access token in session")
|
||||
const errorUrl = new URL(linkEmploymentError[lang], baseUrl)
|
||||
errorUrl.searchParams.set("error", "missing_access_token")
|
||||
return NextResponse.redirect(errorUrl)
|
||||
}
|
||||
|
||||
const result = await linkEmployeeToUser(employeeId, accessToken)
|
||||
console.log(
|
||||
"[dtmc] DTMC Callback handler - linkEmployeeToUser result:",
|
||||
result
|
||||
@@ -80,10 +169,16 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
"[dtmc] DTMC Callback handler - Failed to verify employment"
|
||||
"[dtmc] DTMC Callback handler - Failed to verify employment:",
|
||||
`Status: ${result.statusCode}, Error: ${result.queryParam}`
|
||||
)
|
||||
|
||||
const errorUrl = new URL(linkEmploymentError[lang], baseUrl)
|
||||
errorUrl.searchParams.set("error", "unable_to_verify_employee_id")
|
||||
|
||||
if (result.queryParam) {
|
||||
errorUrl.searchParams.set("error", result.queryParam)
|
||||
}
|
||||
// For 500 errors and network errors, no query param = default error message.
|
||||
return NextResponse.redirect(errorUrl)
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +165,7 @@ const authenticatedUser: SafeUser = {
|
||||
name: "",
|
||||
phoneNumber: undefined,
|
||||
profileId: "",
|
||||
employmentDetails: undefined,
|
||||
}
|
||||
|
||||
const badAuthenticatedUser: SafeUser = {
|
||||
@@ -196,6 +197,7 @@ const badAuthenticatedUser: SafeUser = {
|
||||
name: "",
|
||||
phoneNumber: undefined,
|
||||
profileId: "",
|
||||
employmentDetails: undefined,
|
||||
}
|
||||
|
||||
const loggedOutGuest: Guest = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useIntl } from "react-intl"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { debounce } from "@/utils/debounce"
|
||||
import { getEmployeeInfo } from "@/utils/user"
|
||||
|
||||
import styles from "./digitalTeamMemberCard.module.css"
|
||||
|
||||
@@ -20,6 +21,9 @@ export default function DigitalTeamMemberCardContent({
|
||||
}: DigitalTeamMemberCardCardProps) {
|
||||
const intl = useIntl()
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const employeeInfo = getEmployeeInfo(user)
|
||||
const notAvailableText = "N/A"
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
const [coords, setCoords] = useState({ x: 0, y: 0 })
|
||||
const shimmerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -117,17 +121,13 @@ export default function DigitalTeamMemberCardContent({
|
||||
<span>
|
||||
{intl.formatMessage({ defaultMessage: "Team Member" })}
|
||||
</span>
|
||||
{/* TODO: Should display country of employment */}
|
||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||
<span>SWE</span>
|
||||
<span>{employeeInfo?.country || notAvailableText}</span>
|
||||
</div>
|
||||
</Typography>
|
||||
<div className={styles.middle}>
|
||||
<div className={styles.employeeNumber}>
|
||||
<Typography variant="Title/sm">
|
||||
{/* TODO: Should display employee number */}
|
||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||
<div>123 456</div>
|
||||
<div>{employeeInfo?.employeeId || notAvailableText}</div>
|
||||
</Typography>
|
||||
<svg
|
||||
width="42"
|
||||
@@ -153,15 +153,12 @@ export default function DigitalTeamMemberCardContent({
|
||||
</div>
|
||||
<Typography variant="Tag/sm">
|
||||
<div className={styles.bottom}>
|
||||
<span>{employeeInfo?.location || notAvailableText}</span>
|
||||
<span>
|
||||
{/* TODO: Should display department of employment */}
|
||||
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
|
||||
Haymarket by Scandic
|
||||
{/* eslint-enable */}
|
||||
{employeeInfo?.retired
|
||||
? intl.formatMessage({ defaultMessage: "Retired" })
|
||||
: intl.formatMessage({ defaultMessage: "Employee" })}
|
||||
</span>
|
||||
{/* TODO: Should display current state of employment */}
|
||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||
<span>Employee</span>
|
||||
</div>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import { isEmployeeLinked } from "@/utils/user"
|
||||
|
||||
import DigitalTeamMemberCardClient from "./Client"
|
||||
|
||||
import type { User } from "@scandic-hotels/trpc/types/user"
|
||||
@@ -15,7 +17,9 @@ export default async function DigitalTeamMemberCard({
|
||||
return null
|
||||
}
|
||||
|
||||
// TODO: Make a check whether user is eligible for benefits or not
|
||||
|
||||
const hasEmploymentData = isEmployeeLinked(user)
|
||||
if (!hasEmploymentData) {
|
||||
return null
|
||||
}
|
||||
return <DigitalTeamMemberCardClient user={user} />
|
||||
}
|
||||
|
||||
@@ -32,3 +32,19 @@ export function getSteppedUpLevel(
|
||||
}
|
||||
return values[currentIndex + stepsUp]
|
||||
}
|
||||
|
||||
export function isEmployeeLinked(user: User): boolean {
|
||||
return !!user.employmentDetails?.employeeId
|
||||
}
|
||||
|
||||
export function getEmployeeInfo(user: User) {
|
||||
const employment = user.employmentDetails
|
||||
if (!employment) return null
|
||||
|
||||
return {
|
||||
employeeId: employment.employeeId,
|
||||
location: employment.location,
|
||||
country: employment.country,
|
||||
retired: employment.retired,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,10 @@ export namespace endpoints {
|
||||
*/
|
||||
export namespace Profile {
|
||||
export const profile = `${base.path.profile}/${version}/${base.enitity.Profile}`
|
||||
|
||||
export function teamMemberCard(employeeId: string) {
|
||||
return `${profile}/${employeeId}/TeamMemberCard`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,15 @@ const pointExpirationSchema = z.object({
|
||||
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({
|
||||
@@ -102,6 +111,7 @@ export const getUserSchema = z
|
||||
.optional()
|
||||
.nullable(),
|
||||
loyalty: userLoyaltySchema.optional(),
|
||||
employmentDetails: employmentDetailsSchema,
|
||||
}),
|
||||
type: z.string(),
|
||||
}),
|
||||
|
||||
@@ -223,12 +223,13 @@ export function parsedUser(data: User, isMFA: boolean) {
|
||||
},
|
||||
dateOfBirth: data.dateOfBirth,
|
||||
email: data.email,
|
||||
employmentDetails: data.employmentDetails,
|
||||
firstName: data.firstName,
|
||||
language: data.language,
|
||||
lastName: data.lastName,
|
||||
loyalty: data.loyalty,
|
||||
membershipNumber: data.membershipNumber,
|
||||
membership: data.loyalty ? getFriendsMembership(data.loyalty) : null,
|
||||
loyalty: data.loyalty,
|
||||
name: `${data.firstName} ${data.lastName}`,
|
||||
phoneNumber: data.phoneNumber,
|
||||
profileId: data.profileId,
|
||||
|
||||
Reference in New Issue
Block a user