diff --git a/.env.example b/.env.example index ad3b49cdf..6b953d20a 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,6 @@ NEXT_PUBLIC_APP_ENV="development" PLATFORM_API_CLIENT_ID=[secret] PLATFORM_API_CLIENT_SECRET=[secret] NYPL_HEADER_URL=https://ds-header.nypl.org -CLOSED_LOCATIONS="" -RECAP_CLOSED_LOCATIONS="" -NON_RECAP_CLOSED_LOCATIONS="" ADOBE_EMBED_URL=https://assets.adobedtm.com/1a9376472d37/ddf1bedfe52e/launch-4eefcc91c90e.min.js SHEP_API=http://[fqdn]/api/v0.1 SIERRA_KEY=[secret] diff --git a/CHANGELOG b/CHANGELOG index 635dc2c4f..4d86ee017 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Prerelease + +## Updated + +- Better handling of bibs with no titles in SearchResultsBib model + ## 1.3.5 2024-10-23 ### Updated @@ -14,7 +20,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Call number search scope to search dropdown options [(SCC-4260)](https://newyorkpubliclibrary.atlassian.net/browse/SCC-4260) - Search tip per search scope [(SCC-4263)](https://newyorkpubliclibrary.atlassian.net/browse/SCC-4263) +### Added + +- Add server side delivery location fetch and location utils (SCC-3759) + +### Updated + +- Moved closedLocations from environment variables to appConfig (SCC-3759) + ### Fixed + ### [1.3.4] Hotfix 2024-10-17 - Fix 500 error caused by assumption in buildHoldingDetails that location field will be present in the bib holdings returned from discovery (SCC-4314) diff --git a/ENVIRONMENTVARS.md b/ENVIRONMENTVARS.md index 39a714c39..610f3a7ee 100644 --- a/ENVIRONMENTVARS.md +++ b/ENVIRONMENTVARS.md @@ -20,21 +20,18 @@ If an environment variable is updated, make sure to restart the server for the a These environment variables control how certain elements on the page render and where to fetch data. -| Variable | Type | Value Example | Description | -| ----------------------------------- | ------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `NEXT_PUBLIC_APP_ENV` | string | "development" | App environment key used to determine various environment-specific app settings. | -| `NYPL_HEADER_URL` | string | "https://ds-header.nypl.org" | The base URL of the NYPL environment-specific header and footer scripts. | -| `CLOSED_LOCATIONS` | string | "all;Library of the Performing Arts" | A semicolon-delimited list of strings. Include quotes around the string. All locations beginning with any string in this list will be removed from the list of request options in the `ElectronicDelivery`, `HoldRequest`, and `ItemTableRow` components. Currently used physical locations: `Schwarzman;Science;Library for the Performing Arts;Schomburg`. To close all locations, add `all`. This will also remove EDD as a request option, the 'Request' buttons, and also disable the hold request/edd forms. If `all` is not present, EDD and 'Request' buttons will still be available. | -| `RECAP_CLOSED_LOCATIONS` | string | "" | A semicolon-delimited list of closed locations that are recap. | -| `NON_RECAP_CLOSED_LOCATIONS` | string | "" | A semicolon-delimited list of closed locations that are not recap. | -| `ADOBE_EMBED_URL` | string | "" | Url endpoint used for Adobe Analytics event tracking. | -| `SHEP_API` | string | "" | SHEP API endpoint used for fetching Subject Heading data | -| `SEARCH_RESULTS_NOTIFICATION` | string | "Due to winter holiday closures, the delivery time for off-site requests will be delayed..." | A string that can include HTML that will be rendered as a notification on the Home and Search Results pages. | -| `LOGIN_BASE_URL` | string | "" | The base URL used to construct the environment-dependent login/logout link. | -| `NEXT_PUBLIC_REVERSE_PROXY_ENABLED` | boolean | true | Feature flag that disables Next router navigation on Searches to fix issues navigating between research-catalog and discovery-front-end | -| `SIERRA_BASE` | string | "" | Sierra base url | -| `SOURCE_EMAIL` | string | "" | Default source email used in feedback form submissions | -| `LIB_ANSWERS_EMAIL` | string | "" | Destination email for feedback form submissions | +| Variable | Type | Value Example | Description | +| ----------------------------------- | ------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `NEXT_PUBLIC_APP_ENV` | string | "development" | App environment key used to determine various environment-specific app settings. | +| `NYPL_HEADER_URL` | string | "https://ds-header.nypl.org" | The base URL of the NYPL environment-specific header and footer scripts. | +| `ADOBE_EMBED_URL` | string | "" | Url endpoint used for Adobe Analytics event tracking. | +| `SHEP_API` | string | "" | SHEP API endpoint used for fetching Subject Heading data | +| `SEARCH_RESULTS_NOTIFICATION` | string | "Due to winter holiday closures, the delivery time for off-site requests will be delayed..." | A string that can include HTML that will be rendered as a notification on the Home and Search Results pages. | +| `LOGIN_BASE_URL` | string | "" | The base URL used to construct the environment-dependent login/logout link. | +| `NEXT_PUBLIC_REVERSE_PROXY_ENABLED` | boolean | true | Feature flag that disables Next router navigation on Searches to fix issues navigating between research-catalog and discovery-front-end | +| `SIERRA_BASE` | string | "" | Sierra base url | +| `SOURCE_EMAIL` | string | "" | Default source email used in feedback form submissions | +| `LIB_ANSWERS_EMAIL` | string | "" | Destination email for feedback form submissions | ## AWS ECS Environment Variables diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index 06dc44e35..656320f8d 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -88,7 +88,7 @@ const Layout = ({ padding: "2em 2em .5em 2em", }} > - + {showSearch && } {showNotification && bannerNotification && ( { { "@id": "urn:biblevel:s", prefLabel: "serial" }, ]) }) + it("defaults to title display when no title", () => { + const bibWithNoTitle = new SearchResultsBib({ + ...bibWithItems.resource, + title: undefined, + titleDisplay: ["Display title"], + }) + expect(bibWithNoTitle.title).toBe("Display title") + }) + it("defaults to '[Untitled]' when no title or titleDisplay", () => { + const bibWithNoTitle = new SearchResultsBib({ + ...bibWithItems.resource, + title: undefined, + titleDisplay: undefined, + }) + expect(bibWithNoTitle.title).toBe("[Untitled]") + }) it("initializes the yearPublished based on dateStartYear and dateEndYear", () => { expect(searchResultsBib.yearPublished).toBe("1999-present") diff --git a/src/server/api/hold.ts b/src/server/api/hold.ts new file mode 100644 index 000000000..241104a06 --- /dev/null +++ b/src/server/api/hold.ts @@ -0,0 +1,61 @@ +import nyplApiClient from "../nyplApiClient" +import type { + DeliveryLocation, + DeliveryLocationsResponse, + DiscoveryLocationElement, +} from "../../types/locationTypes" +import { + mapLocationElementToDeliveryLocation, + locationIsClosed, +} from "../../utils/locationUtils" +import { appConfig } from "../../config/config" + +/** + * Getter function for hold delivery locations. + */ +export async function fetchDeliveryLocations( + barcode: string, + patronId: string +): Promise { + const deliveryEndpoint = `/request/deliveryLocationsByBarcode?barcodes[]=${barcode}&patronId=${patronId}` + + try { + const client = await nyplApiClient() + const discoveryLocationsResult = await client.get(deliveryEndpoint) + const discoveryLocationsItem = + discoveryLocationsResult?.itemListElement?.[0] + + // Malformed response + if (!discoveryLocationsItem) { + throw new Error("Malformed response from delivery locations API") + } + + const deliveryLocations = discoveryLocationsItem?.deliveryLocation.map( + (locationElement: DiscoveryLocationElement) => + mapLocationElementToDeliveryLocation(locationElement) + ) + const eddRequestable = discoveryLocationsItem.eddRequestable || false + + // Filter out closed locations + const openLocations = deliveryLocations.filter( + (location: DeliveryLocation) => + !locationIsClosed(location, appConfig.closedLocations) + ) + + /** + * Locations are returned with a status of 200. + * deliveryLocations can be an empty array if there are no open locations. + */ + return { + deliveryLocations: openLocations, + eddRequestable, + status: 200, + } + } catch (error) { + console.error(`Error fetching delivery locations ${error.message}`) + + return { + status: 500, + } + } +} diff --git a/src/types/locationTypes.ts b/src/types/locationTypes.ts new file mode 100644 index 000000000..a72876123 --- /dev/null +++ b/src/types/locationTypes.ts @@ -0,0 +1,33 @@ +import type { HTTPStatusCode } from "./appTypes" + +export interface DeliveryLocationsResponse { + deliveryLocations?: DeliveryLocation[] + eddRequestable?: boolean + status: HTTPStatusCode +} + +export interface DeliveryLocation { + key: NYPLocationKey + address: string + shortName: string + label: string +} + +export type NYPLocationKey = "lpa" | "schwarzman" | "schomburg" +export type RecapLocationKey = string + +export interface DiscoveryLocationsResult { + itemListElement?: DiscoveryLocationItem[] +} + +export interface DiscoveryLocationItem { + eddRequestable?: boolean + deliveryLocation?: DiscoveryLocationElement[] +} + +/* eslint-disable @typescript-eslint/naming-convention */ + +export interface DiscoveryLocationElement { + "@id"?: string + prefLabel?: string +} diff --git a/src/utils/itemUtils.ts b/src/utils/itemUtils.ts index b4ee69b23..7366c7436 100644 --- a/src/utils/itemUtils.ts +++ b/src/utils/itemUtils.ts @@ -26,10 +26,3 @@ export const locationEndpointsMap: Record = { export function locationLabelToKey(label: string): ItemLocationKey { return label.replace(/SASB/, "Schwarzman").split(" ")[0] as ItemLocationKey } - -/** - * parseLocations - * Takes a semicolon-separated list of locations set in an ENV variable and maps them to an array. - */ -export const parseLocations = (locations: string): string[] => - locations ? locations.split(";") : [] diff --git a/src/utils/locationUtils.ts b/src/utils/locationUtils.ts new file mode 100644 index 000000000..4e55d5fb1 --- /dev/null +++ b/src/utils/locationUtils.ts @@ -0,0 +1,74 @@ +import type { + DiscoveryLocationElement, + DeliveryLocation, + NYPLocationKey, +} from "../types/locationTypes" +import { NYPL_LOCATIONS } from "../config/constants" + +/** + * Maps a single location element from the discovery API response to a DeliveryLocation object. + */ +export const mapLocationElementToDeliveryLocation = ( + locationElement: DiscoveryLocationElement +): DeliveryLocation => { + const locationKey = getLocationKey(locationElement) + const details = NYPL_LOCATIONS[locationKey] + + if (!details) return null + + const shortName = details.shortName + + // LPA locations require label tranformation + const label = + locationKey === "lpa" + ? formatDeliveryLocationLabel(locationElement.prefLabel, shortName) + : locationElement.prefLabel + + return { + key: locationKey, + address: details.address, + shortName, + label, + } +} + +function formatDeliveryLocationLabel( + prefLabel: string, + shortName: string +): string { + const deliveryRoom = prefLabel?.split(" - ")[1] || "" + + return `${shortName}${deliveryRoom ? ` - ${deliveryRoom}` : ""}` +} + +function getLocationKey( + locationElement: DiscoveryLocationElement +): NYPLocationKey | null { + if (!locationElement?.["@id"]) return null + + const sierraId = getLocationSierraId(locationElement) + + switch (sierraId?.slice(0, 2)) { + case "pa": + return "lpa" + case "ma": + return "schwarzman" + case "sc": + return "schomburg" + default: + return null + } +} + +const getLocationSierraId = ( + locationElement: DiscoveryLocationElement +): string | null => + locationElement["@id"] ? locationElement["@id"].replace("loc:", "") : null + +export const locationIsClosed = ( + deliveryLocation: DeliveryLocation, + closedLocations: string[] +): boolean => + closedLocations.some( + (closedLocationKey) => deliveryLocation.key === closedLocationKey + ) || closedLocations.includes("all") diff --git a/src/utils/utilsTests/itemUtils.ts b/src/utils/utilsTests/itemUtils.ts index 359bc9a1e..db33aed56 100644 --- a/src/utils/utilsTests/itemUtils.ts +++ b/src/utils/utilsTests/itemUtils.ts @@ -1,4 +1,4 @@ -import { locationLabelToKey, parseLocations } from "../itemUtils" +import { locationLabelToKey } from "../itemUtils" describe("itemUtils", () => { describe("locationLabelToKey", () => { @@ -8,17 +8,4 @@ describe("itemUtils", () => { expect(locationLabelToKey("Schomburg")).toBe("schomburg") }) }) - describe("parseLocations", () => { - it("splits a semicolon separated list of locations into an array", () => { - expect(parseLocations("schwarzman;lpa")).toBe(["schwarzman", "lpa"]) - }) - it("returns a single value array when no semicolon delineator is present", () => { - expect(parseLocations("schomburg")).toBe(["schomburg"]) - }) - it("returns an empty array when locations is falsy", () => { - expect(parseLocations("")).toBe([]) - expect(parseLocations(null)).toBe([]) - expect(parseLocations(undefined)).toBe([]) - }) - }) }) diff --git a/src/utils/utilsTests/locationUtils.test.ts b/src/utils/utilsTests/locationUtils.test.ts new file mode 100644 index 000000000..e8cb1d77c --- /dev/null +++ b/src/utils/utilsTests/locationUtils.test.ts @@ -0,0 +1,202 @@ +import type { + DiscoveryLocationElement, + NYPLocationKey, +} from "../../types/locationTypes" +import { NYPL_LOCATIONS } from "../../config/constants" + +import { + mapLocationElementToDeliveryLocation, + locationIsClosed, +} from "../locationUtils" + +describe("itemUtils", () => { + describe("mapLocationElementToDeliveryLocation", () => { + it("maps single location response elements with different location slugs to the correctly formatted DeliveryLocation type", () => { + // mal17 + let locationElement: DiscoveryLocationElement = { + "@id": "loc:mal17", + prefLabel: "Schwarzman Building - Scholar Room 217", + } + expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({ + key: "schwarzman", + address: NYPL_LOCATIONS["schwarzman"].address, + label: `${NYPL_LOCATIONS["schwarzman"].shortName} - Scholar Room 217`, + shortName: NYPL_LOCATIONS["schwarzman"].shortName, + }) + + // mal + locationElement = { + "@id": "loc:mal", + prefLabel: "Schwarzman Building - Main Reading Room 315", + } + + expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({ + key: "schwarzman", + address: NYPL_LOCATIONS["schwarzman"].address, + label: `${NYPL_LOCATIONS["schwarzman"].shortName} - Main Reading Room 315`, + shortName: NYPL_LOCATIONS["schwarzman"].shortName, + }) + + // mab + locationElement = { + "@id": "loc:mab", + prefLabel: "Schwarzman Building - Art & Architecture Room 300", + } + + expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({ + key: "schwarzman", + address: NYPL_LOCATIONS["schwarzman"].address, + label: `${NYPL_LOCATIONS["schwarzman"].shortName} - Art & Architecture Room 300`, + shortName: NYPL_LOCATIONS["schwarzman"].shortName, + }) + + // maf + locationElement = { + "@id": "loc:maf", + prefLabel: "Schwarzman Building - Dorot Jewish Division Room 111", + } + + expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({ + key: "schwarzman", + address: NYPL_LOCATIONS["schwarzman"].address, + label: `${NYPL_LOCATIONS["schwarzman"].shortName} - Dorot Jewish Division Room 111`, + shortName: NYPL_LOCATIONS["schwarzman"].shortName, + }) + + // maf + locationElement = { + "@id": "loc:map", + prefLabel: "Schwarzman Building - Map Division Room 117", + } + + expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({ + key: "schwarzman", + address: NYPL_LOCATIONS["schwarzman"].address, + label: `${NYPL_LOCATIONS["schwarzman"].shortName} - Map Division Room 117`, + shortName: NYPL_LOCATIONS["schwarzman"].shortName, + }) + + // map + locationElement = { + "@id": "loc:map", + prefLabel: "Schwarzman Building - Map Division Room 117", + } + + expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({ + key: "schwarzman", + address: NYPL_LOCATIONS["schwarzman"].address, + label: `${NYPL_LOCATIONS["schwarzman"].shortName} - Map Division Room 117`, + shortName: NYPL_LOCATIONS["schwarzman"].shortName, + }) + + // mag + locationElement = { + "@id": "loc:mag", + prefLabel: "Schwarzman Building - Milstein Division Room 121", + } + + expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({ + key: "schwarzman", + address: NYPL_LOCATIONS["schwarzman"].address, + label: `${NYPL_LOCATIONS["schwarzman"].shortName} - Milstein Division Room 121`, + shortName: NYPL_LOCATIONS["schwarzman"].shortName, + }) + + // par + locationElement = { + "@id": "loc:par", + prefLabel: "Performing Arts Research Collections", + } + + expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({ + key: "lpa", + address: NYPL_LOCATIONS["lpa"].address, + label: NYPL_LOCATIONS["lpa"].shortName, + shortName: NYPL_LOCATIONS["lpa"].shortName, + }) + + // sc + locationElement = { + "@id": "loc:sc", + prefLabel: "Schomburg Center - Research and Reference Division", + } + + expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({ + key: "schomburg", + address: NYPL_LOCATIONS["schomburg"].address, + label: `${NYPL_LOCATIONS["schomburg"].shortName} - Research and Reference Division`, + shortName: NYPL_LOCATIONS["schomburg"].shortName, + }) + }) + it("returns null when the location is not found in the location details mapping found in this repo", () => { + const locationElement: DiscoveryLocationElement = { + "@id": "loc:spaghetti", + prefLabel: "Spaghetti Building - Scholar Room 1000", + } + + expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual( + null + ) + }) + it("returns null when the the id is absent", () => { + const locationElement: DiscoveryLocationElement = { + prefLabel: "Spaghetti Building - Scholar Room 217", + } + + expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual( + null + ) + }) + + it("doesn't add the delivery room when the prefLabel doesn't include a dash", () => { + const locationElement: DiscoveryLocationElement = { + "@id": "loc:sc", + prefLabel: NYPL_LOCATIONS["schomburg"].shortName, + } + + expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({ + key: "schomburg", + address: NYPL_LOCATIONS["schomburg"].address, + label: NYPL_LOCATIONS["schomburg"].shortName, + shortName: NYPL_LOCATIONS["schomburg"].shortName, + }) + }) + }) + + describe("locationIsClosed", () => { + it("determines if the location is closed based on the key and the closedLocations array set in config", () => { + let closedLocations = [] + const deliveryLocation = { + key: "schwarzman" as NYPLocationKey, + address: "476 Fifth Avenue (42nd St and Fifth Ave)", + label: "Schwarzman Building - Scholar Room 217", + shortName: "Schwarzman Building", + } + + expect(locationIsClosed(deliveryLocation, closedLocations)).toEqual(false) + + closedLocations = ["schwarzman"] + expect(locationIsClosed(deliveryLocation, closedLocations)).toEqual(true) + }) + it("always returns true if 'all' is in the closedLocations array", () => { + let closedLocations = ["all"] + let deliveryLocation = { + key: "schwarzman" as NYPLocationKey, + address: "476 Fifth Avenue (42nd St and Fifth Ave)", + label: "Schwarzman Building - Scholar Room 217", + shortName: "Schwarzman Building", + } + + expect(locationIsClosed(deliveryLocation, closedLocations)).toEqual(true) + + closedLocations = ["Schwarzman", "all"] + deliveryLocation = { + key: "lpa" as NYPLocationKey, + address: "40 Lincoln Center Plaza", + label: "Library for the Performing Arts", + shortName: "Library for the Performing Arts", + } + expect(locationIsClosed(deliveryLocation, closedLocations)).toEqual(true) + }) + }) +})