Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.77% covered (warning)
71.77%
605 / 843
46.27% covered (danger)
46.27%
31 / 67
CRAP
0.00% covered (danger)
0.00%
0 / 1
Folio
71.77% covered (warning)
71.77%
605 / 843
46.27% covered (danger)
46.27%
31 / 67
1120.23
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setConfig
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getBibIdType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 debugRequest
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 getCacheKey
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 preRequest
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 renewTenantToken
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 checkTenantToken
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 init
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getInstanceById
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 getBibId
27.27% covered (danger)
27.27%
3 / 11
0.00% covered (danger)
0.00%
0 / 1
19.85
 escapeCql
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getInstanceByBibId
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 getStatus
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getStatuses
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getConfig
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isHoldable
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 getLocations
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 getLocationData
37.50% covered (danger)
37.50%
6 / 16
0.00% covered (danger)
0.00%
0 / 1
5.20
 chooseCallNumber
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 formatNote
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 getHoldingDetailsForItem
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
2
 formatHoldingItem
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
3
 sortHoldings
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getBoundWithRecords
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 getHolding
96.72% covered (success)
96.72%
59 / 61
0.00% covered (danger)
0.00%
0 / 1
12
 getDateTimeFromString
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getDueDate
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 useLegacyAuthentication
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 performOkapiUsernamePasswordAuthentication
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 extractTokenFromResponse
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 patronLoginWithOkapi
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
2.06
 getUserWithCql
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 fetchUserWithCql
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getPagedResults
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
4.03
 patronLogin
89.29% covered (warning)
89.29%
25 / 28
0.00% covered (danger)
0.00%
0 / 1
8.08
 getUserById
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getMyProfile
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 getMyTransactions
22.86% covered (danger)
22.86%
8 / 35
0.00% covered (danger)
0.00%
0 / 1
11.35
 getRenewDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 renewMyItems
68.18% covered (warning)
68.18%
30 / 44
0.00% covered (danger)
0.00%
0 / 1
4.52
 getPickupLocations
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
2.19
 getMyHolds
95.00% covered (success)
95.00%
57 / 60
0.00% covered (danger)
0.00%
0 / 1
8
 getModuleMajorVersion
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
3.00
 getRequestTypeList
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 performHoldRequest
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
4.02
 placeHold
91.67% covered (success)
91.67%
33 / 36
0.00% covered (danger)
0.00%
0 / 1
11.07
 getCancelHoldDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 cancelHolds
97.06% covered (success)
97.06%
33 / 34
0.00% covered (danger)
0.00%
0 / 1
7
 getCourseResourceList
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getDepartments
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getInstructors
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getCourses
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 getCourseDetails
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getInstructorIds
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 findReserves
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
306
 getMyFines
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 userObjectToNameString
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 formatUserNameForProxyList
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadProxyUserData
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 getProxiedUsers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getProxyingUsers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRequestBlocks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPurchaseHistory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFunds
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMyTransactionHistory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNewItems
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * FOLIO REST API driver
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2018-2023.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  ILS_Drivers
25 * @author   Chris Hallberg <challber@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
28 */
29
30namespace VuFind\ILS\Driver;
31
32use DateTime;
33use DateTimeZone;
34use Exception;
35use Laminas\Http\Response;
36use VuFind\Exception\ILS as ILSException;
37use VuFind\I18n\Translator\TranslatorAwareInterface;
38use VuFindHttp\HttpServiceAwareInterface as HttpServiceAwareInterface;
39
40use function array_key_exists;
41use function count;
42use function in_array;
43use function is_int;
44use function is_object;
45use function is_string;
46
47/**
48 * FOLIO REST API driver
49 *
50 * @category VuFind
51 * @package  ILS_Drivers
52 * @author   Chris Hallberg <challber@villanova.edu>
53 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
54 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
55 */
56class Folio extends AbstractAPI implements
57    HttpServiceAwareInterface,
58    TranslatorAwareInterface
59{
60    use \VuFindHttp\HttpServiceAwareTrait;
61    use \VuFind\I18n\Translator\TranslatorAwareTrait;
62    use \VuFind\Log\LoggerAwareTrait {
63        logWarning as warning;
64        logError as error;
65    }
66
67    use \VuFind\Cache\CacheTrait {
68        getCacheKey as protected getBaseCacheKey;
69    }
70
71    /**
72     * Authentication tenant (X-Okapi-Tenant)
73     *
74     * @var string
75     */
76    protected $tenant = null;
77
78    /**
79     * Authentication token (X-Okapi-Token)
80     *
81     * @var string
82     */
83    protected $token = null;
84
85    /**
86     * Factory function for constructing the SessionContainer.
87     *
88     * @var callable
89     */
90    protected $sessionFactory;
91
92    /**
93     * Session cache
94     *
95     * @var \Laminas\Session\Container
96     */
97    protected $sessionCache;
98
99    /**
100     * Date converter
101     *
102     * @var \VuFind\Date\Converter
103     */
104    protected $dateConverter;
105
106    /**
107     * Default availability messages, in case they are not defined in Folio.ini
108     *
109     * @var string[]
110     */
111    protected $defaultAvailabilityStatuses = ['Open - Awaiting pickup'];
112
113    /**
114     * Default in_transit messages, in case they are not defined in Folio.ini
115     *
116     * @var string[]
117     */
118    protected $defaultInTransitStatuses = [
119        'Open - In transit',
120        'Open - Awaiting delivery',
121    ];
122
123    /**
124     * Constructor
125     *
126     * @param \VuFind\Date\Converter $dateConverter  Date converter object
127     * @param callable               $sessionFactory Factory function returning
128     * SessionContainer object
129     */
130    public function __construct(
131        \VuFind\Date\Converter $dateConverter,
132        $sessionFactory
133    ) {
134        $this->dateConverter = $dateConverter;
135        $this->sessionFactory = $sessionFactory;
136    }
137
138    /**
139     * Set the configuration for the driver.
140     *
141     * @param array $config Configuration array (usually loaded from a VuFind .ini
142     * file whose name corresponds with the driver class name).
143     *
144     * @throws ILSException if base url excluded
145     * @return void
146     */
147    public function setConfig($config)
148    {
149        parent::setConfig($config);
150        $this->tenant = $this->config['API']['tenant'];
151    }
152
153    /**
154     * Get the type of FOLIO ID used to match up with VuFind's bib IDs.
155     *
156     * @return string
157     */
158    protected function getBibIdType()
159    {
160        // Normalize string to tolerate minor variations in config file:
161        return trim(strtolower($this->config['IDs']['type'] ?? 'instance'));
162    }
163
164    /**
165     * Function that obscures and logs debug data
166     *
167     * @param string                $method      Request method
168     * (GET/POST/PUT/DELETE/etc.)
169     * @param string                $path        Request URL
170     * @param array                 $params      Request parameters
171     * @param \Laminas\Http\Headers $req_headers Headers object
172     *
173     * @return void
174     */
175    protected function debugRequest($method, $path, $params, $req_headers)
176    {
177        // Only log non-GET requests, unless configured otherwise
178        if (
179            $method == 'GET'
180            && !($this->config['API']['debug_get_requests'] ?? false)
181        ) {
182            return;
183        }
184        // remove passwords
185        $logParams = $params;
186        if (isset($logParams['password'])) {
187            unset($logParams['password']);
188        }
189        // truncate headers for token obscuring
190        $logHeaders = $req_headers->toArray();
191        if (isset($logHeaders['X-Okapi-Token'])) {
192            $logHeaders['X-Okapi-Token'] = substr(
193                $logHeaders['X-Okapi-Token'],
194                0,
195                30
196            ) . '...';
197        }
198
199        $this->debug(
200            $method . ' request.' .
201            ' URL: ' . $path . '.' .
202            ' Params: ' . $this->varDump($logParams) . '.' .
203            ' Headers: ' . $this->varDump($logHeaders)
204        );
205    }
206
207    /**
208     * Add instance-specific context to a cache key suffix (to ensure that
209     * multiple drivers don't accidentally share values in the cache.
210     *
211     * @param string $key Cache key suffix
212     *
213     * @return string
214     */
215    protected function getCacheKey($key = null)
216    {
217        // Override the base class formatting with FOLIO-specific details
218        // to ensure proper caching in a MultiBackend environment.
219        return 'FOLIO-'
220            . md5("{$this->tenant}|$key");
221    }
222
223    /**
224     * (From AbstractAPI) Allow default corrections to all requests
225     *
226     * Add X-Okapi headers and Content-Type to every request
227     *
228     * @param \Laminas\Http\Headers $headers the request headers
229     * @param object                $params  the parameters object
230     *
231     * @return array
232     */
233    public function preRequest(\Laminas\Http\Headers $headers, $params)
234    {
235        $headers->addHeaderLine('Accept', 'application/json');
236        if (!$headers->has('Content-Type')) {
237            $headers->addHeaderLine('Content-Type', 'application/json');
238        }
239        $headers->addHeaderLine('X-Okapi-Tenant', $this->tenant);
240        if ($this->token != null) {
241            $headers->addHeaderLine('X-Okapi-Token', $this->token);
242        }
243        return [$headers, $params];
244    }
245
246    /**
247     * Login and receive a new token
248     *
249     * @return void
250     */
251    protected function renewTenantToken()
252    {
253        $this->token = null;
254        $response = $this->performOkapiUsernamePasswordAuthentication(
255            $this->config['API']['username'],
256            $this->config['API']['password']
257        );
258        $this->token = $this->extractTokenFromResponse($response);
259        $this->sessionCache->folio_token = $this->token;
260        $this->debug(
261            'Token renewed. Username: ' . $this->config['API']['username'] .
262            ' Token: ' . substr($this->token, 0, 30) . '...'
263        );
264    }
265
266    /**
267     * Check if our token is still valid
268     *
269     * Method taken from Stripes JS (loginServices.js:validateUser)
270     *
271     * @return void
272     */
273    protected function checkTenantToken()
274    {
275        $response = $this->makeRequest('GET', '/users', [], [], [401, 403]);
276        if ($response->getStatusCode() >= 400) {
277            $this->token = null;
278            $this->renewTenantToken();
279        }
280    }
281
282    /**
283     * Initialize the driver.
284     *
285     * Check or renew our auth token
286     *
287     * @return void
288     */
289    public function init()
290    {
291        $factory = $this->sessionFactory;
292        $this->sessionCache = $factory($this->tenant);
293        if ($this->sessionCache->folio_token ?? false) {
294            $this->token = $this->sessionCache->folio_token;
295            $this->debug(
296                'Token taken from cache: ' . substr($this->token, 0, 30) . '...'
297            );
298        }
299        if ($this->token == null) {
300            $this->renewTenantToken();
301        } else {
302            $this->checkTenantToken();
303        }
304    }
305
306    /**
307     * Given some kind of identifier (instance, holding or item), retrieve the
308     * associated instance object from FOLIO.
309     *
310     * @param string $instanceId Instance ID, if available.
311     * @param string $holdingId  Holding ID, if available.
312     * @param string $itemId     Item ID, if available.
313     *
314     * @return object
315     */
316    protected function getInstanceById(
317        $instanceId = null,
318        $holdingId = null,
319        $itemId = null
320    ) {
321        if ($instanceId == null) {
322            if ($holdingId == null) {
323                if ($itemId == null) {
324                    throw new \Exception('No IDs provided to getInstanceObject.');
325                }
326                $response = $this->makeRequest(
327                    'GET',
328                    '/item-storage/items/' . $itemId
329                );
330                $item = json_decode($response->getBody());
331                $holdingId = $item->holdingsRecordId;
332            }
333            $response = $this->makeRequest(
334                'GET',
335                '/holdings-storage/holdings/' . $holdingId
336            );
337            $holding = json_decode($response->getBody());
338            $instanceId = $holding->instanceId;
339        }
340        $response = $this->makeRequest(
341            'GET',
342            '/inventory/instances/' . $instanceId
343        );
344        return json_decode($response->getBody());
345    }
346
347    /**
348     * Given an instance object or identifer, or a holding or item identifier,
349     * determine an appropriate value to use as VuFind's bibliographic ID.
350     *
351     * @param string $instanceOrInstanceId Instance object or ID (will be looked up
352     * using holding or item ID if not provided)
353     * @param string $holdingId            Holding-level id (optional)
354     * @param string $itemId               Item-level id (optional)
355     *
356     * @return string Appropriate bib id retrieved from FOLIO identifiers
357     */
358    protected function getBibId(
359        $instanceOrInstanceId = null,
360        $holdingId = null,
361        $itemId = null
362    ) {
363        $idType = $this->getBibIdType();
364
365        // Special case: if we're using instance IDs and we already have one,
366        // short-circuit the lookup process:
367        if ($idType === 'instance' && is_string($instanceOrInstanceId)) {
368            return $instanceOrInstanceId;
369        }
370
371        $instance = is_object($instanceOrInstanceId)
372            ? $instanceOrInstanceId
373            : $this->getInstanceById($instanceOrInstanceId, $holdingId, $itemId);
374
375        switch ($idType) {
376            case 'hrid':
377                return $instance->hrid;
378            case 'instance':
379                return $instance->id;
380        }
381
382        throw new \Exception('Unsupported ID type: ' . $idType);
383    }
384
385    /**
386     * Escape a string for use in a CQL query.
387     *
388     * @param string $in Input string
389     *
390     * @return string
391     */
392    protected function escapeCql($in)
393    {
394        return str_replace('"', '\"', str_replace('&', '%26', $in));
395    }
396
397    /**
398     * Retrieve FOLIO instance using VuFind's chosen bibliographic identifier.
399     *
400     * @param string $bibId Bib-level id
401     *
402     * @return object
403     */
404    protected function getInstanceByBibId($bibId)
405    {
406        // Figure out which ID type to use in the CQL query; if the user configured
407        // instance IDs, use the 'id' field, otherwise pass the setting through
408        // directly:
409        $idType = $this->getBibIdType();
410        $idField = $idType === 'instance' ? 'id' : $idType;
411
412        $query = [
413            'query' => '(' . $idField . '=="' . $this->escapeCql($bibId) . '")',
414        ];
415        $response = $this->makeRequest('GET', '/instance-storage/instances', $query);
416        $instances = json_decode($response->getBody());
417        if (count($instances->instances ?? []) == 0) {
418            throw new ILSException('Item Not Found');
419        }
420        return $instances->instances[0];
421    }
422
423    /**
424     * Get raw object of item from inventory/items/
425     *
426     * @param string $itemId Item-level id
427     *
428     * @return array
429     */
430    public function getStatus($itemId)
431    {
432        $holding = $this->getHolding($itemId);
433        return $holding['holdings'] ?? [];
434    }
435
436    /**
437     * This method calls getStatus for an array of records or implement a bulk method
438     *
439     * @param array $idList Item-level ids
440     *
441     * @return array values from getStatus
442     */
443    public function getStatuses($idList)
444    {
445        $status = [];
446        foreach ($idList as $id) {
447            $status[] = $this->getStatus($id);
448        }
449        return $status;
450    }
451
452    /**
453     * Retrieves renew, hold and cancel settings from the driver ini file.
454     *
455     * @param string $function The name of the feature to be checked
456     * @param array  $params   Optional feature-specific parameters (array)
457     *
458     * @return array An array with key-value pairs.
459     *
460     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
461     */
462    public function getConfig($function, $params = [])
463    {
464        return $this->config[$function] ?? false;
465    }
466
467    /**
468     * Check item location against list of configured locations
469     * where holds should be offered
470     *
471     * @param string $locationName locationName from getHolding
472     *
473     * @return bool
474     */
475    protected function isHoldable($locationName)
476    {
477        $mode = $this->config['Holds']['excludeHoldLocationsCompareMode'] ?? 'exact';
478        $excludeLocs = (array)($this->config['Holds']['excludeHoldLocations'] ?? []);
479
480        // Exclude checking by regex match
481        if (trim(strtolower($mode)) == 'regex') {
482            foreach ($excludeLocs as $pattern) {
483                $match = @preg_match($pattern, $locationName);
484                // Invalid regex, skip this pattern
485                if ($match === false) {
486                    $this->logWarning(
487                        'Invalid regex found in excludeHoldLocations: ' .
488                        $pattern
489                    );
490                    continue;
491                }
492                if ($match === 1) {
493                    return false;
494                }
495            }
496            return true;
497        }
498        // Otherwise exclude checking by exact match
499        return !in_array($locationName, $excludeLocs);
500    }
501
502    /**
503     * Gets locations from the /locations endpoint and sets
504     * an array of location IDs to display names.
505     * Display names are set from discoveryDisplayName, or name
506     * if discoveryDisplayName is not available.
507     *
508     * @return array
509     */
510    protected function getLocations()
511    {
512        $cacheKey = 'locationMap';
513        $locationMap = $this->getCachedData($cacheKey);
514        if (null === $locationMap) {
515            $locationMap = [];
516            foreach (
517                $this->getPagedResults(
518                    'locations',
519                    '/locations'
520                ) as $location
521            ) {
522                $name = $location->discoveryDisplayName ?? $location->name;
523                $code = $location->code;
524                $isActive = $location->isActive ?? true;
525                $locationMap[$location->id] = compact('name', 'code', 'isActive');
526            }
527            $this->putCachedData($cacheKey, $locationMap);
528        }
529        return $locationMap;
530    }
531
532    /**
533     * Get Inventory Location Name
534     *
535     * @param string $locationId UUID of item location
536     *
537     * @return array with the display name and code of location
538     */
539    protected function getLocationData($locationId)
540    {
541        $locationMap = $this->getLocations();
542        $name = '';
543        $code = '';
544        $isActive = true;
545        if (array_key_exists($locationId, $locationMap)) {
546            return $locationMap[$locationId];
547        } else {
548            // if key is not found in cache, the location could have
549            // been added before the cache expired so check again
550            $locationResponse = $this->makeRequest(
551                'GET',
552                '/locations/' . $locationId
553            );
554            if ($locationResponse->isSuccess()) {
555                $location = json_decode($locationResponse->getBody());
556                $name = $location->discoveryDisplayName ?? $location->name;
557                $code = $location->code;
558                $isActive = $location->isActive ?? $isActive;
559            }
560        }
561
562        return compact('name', 'code', 'isActive');
563    }
564
565    /**
566     * Choose a call number and callnumber prefix.
567     *
568     * @param string $hCallNumP Holding-level call number prefix
569     * @param string $hCallNum  Holding-level call number
570     * @param string $iCallNumP Item-level call number prefix
571     * @param string $iCallNum  Item-level call number
572     *
573     * @return array with call number and call number prefix.
574     */
575    protected function chooseCallNumber($hCallNumP, $hCallNum, $iCallNumP, $iCallNum)
576    {
577        if (empty($iCallNum)) {
578            return ['callnumber_prefix' => $hCallNumP, 'callnumber' => $hCallNum];
579        }
580        return ['callnumber_prefix' => $iCallNumP, 'callnumber' => $iCallNum];
581    }
582
583    /**
584     * Support method: format a note for display
585     *
586     * @param object $note Note object decoded from FOLIO JSON.
587     *
588     * @return string
589     */
590    protected function formatNote($note): string
591    {
592        return !($note->staffOnly ?? false) && !empty($note->note)
593            ? $note->note : '';
594    }
595
596    /**
597     * Support method for getHolding(): extract details from the holding record that
598     * will be needed by formatHoldingItem() below.
599     *
600     * @param object $holding FOLIO holding record (decoded from JSON)
601     *
602     * @return array
603     */
604    protected function getHoldingDetailsForItem($holding): array
605    {
606        $textFormatter = function ($supplement) {
607            $format = '%s %s';
608            $supStat = $supplement->statement ?? '';
609            $supNote = $supplement->note ?? '';
610            $statement = trim(
611                // Avoid duplicate display if note and statement are identical:
612                $supStat === $supNote ? $supStat : sprintf($format, $supStat, $supNote)
613            );
614            return $statement;
615        };
616        $id = $holding->id;
617        $holdingNotes = array_filter(
618            array_map([$this, 'formatNote'], $holding->notes ?? [])
619        );
620        $hasHoldingNotes = !empty(implode($holdingNotes));
621        $holdingsStatements = array_values(array_filter(array_map(
622            $textFormatter,
623            $holding->holdingsStatements ?? []
624        )));
625        $holdingsSupplements = array_values(array_filter(array_map(
626            $textFormatter,
627            $holding->holdingsStatementsForSupplements ?? []
628        )));
629        $holdingsIndexes = array_values(array_filter(array_map(
630            $textFormatter,
631            $holding->holdingsStatementsForIndexes ?? []
632        )));
633        $holdingCallNumber = $holding->callNumber ?? '';
634        $holdingCallNumberPrefix = $holding->callNumberPrefix ?? '';
635        return compact(
636            'id',
637            'holdingNotes',
638            'hasHoldingNotes',
639            'holdingsStatements',
640            'holdingsSupplements',
641            'holdingsIndexes',
642            'holdingCallNumber',
643            'holdingCallNumberPrefix'
644        );
645    }
646
647    /**
648     * Support method for getHolding() -- given a few key details, format an item
649     * for inclusion in the return value.
650     *
651     * @param string $bibId            Current bibliographic ID
652     * @param array  $holdingDetails   Holding details produced by
653     *                                 getHoldingDetailsForItem()
654     * @param object $item             FOLIO item record (decoded from JSON)
655     * @param int    $number           The current item number (position within
656     *                                 current holdings record)
657     * @param string $dueDateValue     The due date to display to the user
658     * @param array  $boundWithRecords Any bib records this holding is bound with
659     *
660     * @return array
661     */
662    protected function formatHoldingItem(
663        string $bibId,
664        array $holdingDetails,
665        $item,
666        $number,
667        string $dueDateValue,
668        $boundWithRecords,
669    ): array {
670        $itemNotes = array_filter(
671            array_map([$this, 'formatNote'], $item->notes ?? [])
672        );
673        $locationId = $item->effectiveLocation->id;
674        $locationData = $this->getLocationData($locationId);
675        $locationName = $locationData['name'];
676        $locationCode = $locationData['code'];
677        $locationIsActive = $locationData['isActive'];
678        // concatenate enumeration fields if present
679        $enum = implode(
680            ' ',
681            array_filter(
682                [
683                    $item->volume ?? null,
684                    $item->enumeration ?? null,
685                    $item->chronology ?? null,
686                ]
687            )
688        );
689        $callNumberData = $this->chooseCallNumber(
690            $holdingDetails['holdingCallNumberPrefix'],
691            $holdingDetails['holdingCallNumber'],
692            $item->effectiveCallNumberComponents->prefix
693                ?? $item->itemLevelCallNumberPrefix ?? '',
694            $item->effectiveCallNumberComponents->callNumber
695                ?? $item->itemLevelCallNumber ?? ''
696        );
697
698        return $callNumberData + [
699            'id' => $bibId,
700            'item_id' => $item->id,
701            'holdings_id' => $holdingDetails['id'],
702            'number' => $number,
703            'enumchron' => $enum,
704            'barcode' => $item->barcode ?? '',
705            'status' => $item->status->name,
706            'duedate' => $dueDateValue,
707            'availability' => $item->status->name == 'Available',
708            'is_holdable' => $this->isHoldable($locationName),
709            'holdings_notes' => $holdingDetails['hasHoldingNotes']
710                ? $holdingDetails['holdingNotes'] : null,
711            'item_notes' => !empty(implode($itemNotes)) ? $itemNotes : null,
712            'summary' => array_unique($holdingDetails['holdingsStatements']),
713            'supplements' => $holdingDetails['holdingsSupplements'],
714            'indexes' => $holdingDetails['holdingsIndexes'],
715            'location' => $locationName,
716            'location_code' => $locationCode,
717            'folio_location_is_active' => $locationIsActive,
718            'reserve' => 'TODO',
719            'addLink' => true,
720            'bound_with_records' => $boundWithRecords,
721        ];
722    }
723
724    /**
725     * Given a holdings array and a sort field, sort the array.
726     *
727     * @param array  $holdings  Holdings to sort
728     * @param string $sortField Sort field
729     *
730     * @return array
731     */
732    protected function sortHoldings(array $holdings, string $sortField): array
733    {
734        usort(
735            $holdings,
736            function ($a, $b) use ($sortField) {
737                return strnatcasecmp($a[$sortField], $b[$sortField]);
738            }
739        );
740        // Renumber the re-sorted batch:
741        $nbCount = count($holdings);
742        for ($nbIndex = 0; $nbIndex < $nbCount; $nbIndex++) {
743            $holdings[$nbIndex]['number'] = $nbIndex + 1;
744        }
745        return $holdings;
746    }
747
748    /**
749     * Get all bib records bound-with this item, including
750     * the directly-linked bib record.
751     *
752     * @param object $item The item record
753     *
754     * @return array An array of key metadata for each bib record
755     */
756    protected function getBoundWithRecords($item)
757    {
758        $boundWithRecords = [];
759        // Get the full item record, which includes the boundWithTitles data
760        $response = $this->makeRequest(
761            'GET',
762            '/inventory/items/' . $item->id
763        );
764        $item = json_decode($response->getBody());
765        foreach ($item->boundWithTitles ?? [] as $boundWithTitle) {
766            $boundWithRecords[] = [
767                'title' => $boundWithTitle->briefInstance?->title,
768                'bibId' => $this->getBibId($boundWithTitle->briefInstance->id),
769            ];
770        }
771        return $boundWithRecords;
772    }
773
774    /**
775     * This method queries the ILS for holding information.
776     *
777     * @param string $bibId   Bib-level id
778     * @param array  $patron  Patron login information from $this->patronLogin
779     * @param array  $options Extra options (not currently used)
780     *
781     * @return array An array of associative holding arrays
782     *
783     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
784     */
785    public function getHolding($bibId, array $patron = null, array $options = [])
786    {
787        $showDueDate = $this->config['Availability']['showDueDate'] ?? true;
788        $showTime = $this->config['Availability']['showTime'] ?? false;
789        $maxNumDueDateItems = $this->config['Availability']['maxNumberItems'] ?? 5;
790        $dueDateItemCount = 0;
791
792        $instance = $this->getInstanceByBibId($bibId);
793        $query = [
794            'query' => '(instanceId=="' . $instance->id
795                . '" NOT discoverySuppress==true)',
796        ];
797        $items = [];
798        $folioItemSort = $this->config['Holdings']['folio_sort'] ?? '';
799        $vufindItemSort = $this->config['Holdings']['vufind_sort'] ?? '';
800        foreach (
801            $this->getPagedResults(
802                'holdingsRecords',
803                '/holdings-storage/holdings',
804                $query
805            ) as $holding
806        ) {
807            $rawQuery = '(holdingsRecordId=="' . $holding->id . '")';
808            if (!empty($folioItemSort)) {
809                $rawQuery .= ' sortby ' . $folioItemSort;
810            }
811            $query = ['query' => $rawQuery];
812            $holdingDetails = $this->getHoldingDetailsForItem($holding);
813            $nextBatch = [];
814            $sortNeeded = false;
815            $number = 0;
816            foreach (
817                $this->getPagedResults(
818                    'items',
819                    '/inventory/items-by-holdings-id',
820                    $query
821                ) as $item
822            ) {
823                if ($item->discoverySuppress ?? false) {
824                    continue;
825                }
826                $number++;
827                $dueDateValue = '';
828                if (
829                    $item->status->name == 'Checked out'
830                    && $showDueDate
831                    && $dueDateItemCount < $maxNumDueDateItems
832                ) {
833                    $dueDateValue = $this->getDueDate($item->id, $showTime);
834                    $dueDateItemCount++;
835                }
836                if ($item->isBoundWith ?? false) {
837                    $boundWithRecords = $this->getBoundWithRecords($item);
838                }
839                $nextItem = $this->formatHoldingItem(
840                    $bibId,
841                    $holdingDetails,
842                    $item,
843                    $number,
844                    $dueDateValue,
845                    $boundWithRecords ?? []
846                );
847                if (!empty($vufindItemSort) && !empty($nextItem[$vufindItemSort])) {
848                    $sortNeeded = true;
849                }
850                $nextBatch[] = $nextItem;
851            }
852            $items = array_merge(
853                $items,
854                $sortNeeded
855                    ? $this->sortHoldings($nextBatch, $vufindItemSort) : $nextBatch
856            );
857        }
858        return [
859            'total' => count($items),
860            'holdings' => $items,
861            'electronic_holdings' => [],
862        ];
863    }
864
865    /**
866     * Convert a FOLIO date string to a DateTime object.
867     *
868     * @param string $str FOLIO date string
869     *
870     * @return DateTime
871     */
872    protected function getDateTimeFromString(string $str): DateTime
873    {
874        $dateTime = new DateTime($str, new DateTimeZone('UTC'));
875        $localTimezone = (new DateTime())->getTimezone();
876        $dateTime->setTimezone($localTimezone);
877        return $dateTime;
878    }
879
880    /**
881     * Support method for getHolding(): obtaining the Due Date from OKAPI
882     * by calling /circulation/loans with the item->id, adjusting the
883     * timezone and formatting in universal time with or without due time
884     *
885     * @param string $itemId   ID for the item to query
886     * @param bool   $showTime Determines if date or date & time is returned
887     *
888     * @return string
889     */
890    protected function getDueDate($itemId, $showTime)
891    {
892        $query = 'itemId==' . $itemId;
893        foreach (
894            $this->getPagedResults(
895                'loans',
896                '/circulation/loans',
897                compact('query')
898            ) as $loan
899        ) {
900            // many loans are returned for an item, the one we want
901            // is the one without a returnDate
902            if (!isset($loan->returnDate) && isset($loan->dueDate)) {
903                $dueDate = $this->getDateTimeFromString($loan->dueDate);
904                $method = $showTime
905                    ? 'convertToDisplayDateAndTime' : 'convertToDisplayDate';
906                return $this->dateConverter->$method('U', $dueDate->format('U'));
907            }
908        }
909        return '';
910    }
911
912    /**
913     * Should we use the legacy authentication mechanism?
914     *
915     * @return bool
916     */
917    protected function useLegacyAuthentication(): bool
918    {
919        return $this->config['API']['legacy_authentication'] ?? true;
920    }
921
922    /**
923     * Support method to perform a username/password login to Okapi.
924     *
925     * @param string $username The patron username
926     * @param string $password The patron password
927     *
928     * @return Response
929     */
930    protected function performOkapiUsernamePasswordAuthentication(string $username, string $password): Response
931    {
932        $tenant = $this->config['API']['tenant'];
933        $credentials = compact('tenant', 'username', 'password');
934        // Get token
935        return $this->makeRequest(
936            method: 'POST',
937            path: $this->useLegacyAuthentication() ? '/authn/login' : '/authn/login-with-expiry',
938            params: json_encode($credentials),
939            debugParams: '{"username":"...","password":"..."}'
940        );
941    }
942
943    /**
944     * Given a response from performOkapiUsernamePasswordAuthentication(),
945     * extract the token value.
946     *
947     * @param Response $response Response from performOkapiUsernamePasswordAuthentication().
948     *
949     * @return string
950     */
951    protected function extractTokenFromResponse(Response $response): string
952    {
953        if ($this->useLegacyAuthentication()) {
954            return $response->getHeaders()->get('X-Okapi-Token')->getFieldValue();
955        }
956        $folioUrl = $this->config['API']['base_url'];
957        $cookies = new \Laminas\Http\Cookies();
958        $cookies->addCookiesFromResponse($response, $folioUrl);
959        $results = $cookies->getAllCookies();
960        foreach ($results as $cookie) {
961            if ($cookie->getName() == 'folioAccessToken') {
962                return $cookie->getValue();
963            }
964        }
965        throw new \Exception('Could not find token in response');
966    }
967
968    /**
969     * Support method for patronLogin(): authenticate the patron with an Okapi
970     * login attempt. Returns a CQL query for retrieving more information about
971     * the authenticated user.
972     *
973     * @param string $username The patron username
974     * @param string $password The patron password
975     *
976     * @return string
977     */
978    protected function patronLoginWithOkapi($username, $password)
979    {
980        $response = $this->performOkapiUsernamePasswordAuthentication($username, $password);
981        $debugMsg = 'User logged in. User: ' . $username . '.';
982        // We've authenticated the user with Okapi, but we only have their
983        // username; set up a query to retrieve full info below.
984        $query = 'username == ' . $username;
985        // Replace admin with user as tenant if configured to do so:
986        if ($this->config['User']['use_user_token'] ?? false) {
987            $this->token = $this->extractTokenFromResponse($response);
988            $debugMsg .= ' Token: ' . substr($this->token, 0, 30) . '...';
989        }
990        $this->debug($debugMsg);
991        return $query;
992    }
993
994    /**
995     * Support method for patronLogin(): authenticate the patron with a CQL looup.
996     * Returns the CQL query for retrieving more information about the user.
997     *
998     * @param string $username The patron username
999     * @param string $password The patron password
1000     *
1001     * @return string
1002     */
1003    protected function getUserWithCql($username, $password)
1004    {
1005        // Construct user query using barcode, username, etc.
1006        $usernameField = $this->config['User']['username_field'] ?? 'username';
1007        $passwordField = $this->config['User']['password_field'] ?? false;
1008        $cql = $this->config['User']['cql']
1009            ?? '%%username_field%% == "%%username%%"'
1010            . ($passwordField ? ' and %%password_field%% == "%%password%%"' : '');
1011        $placeholders = [
1012            '%%username_field%%',
1013            '%%password_field%%',
1014            '%%username%%',
1015            '%%password%%',
1016        ];
1017        $values = [
1018            $usernameField,
1019            $passwordField,
1020            $this->escapeCql($username),
1021            $this->escapeCql($password),
1022        ];
1023        return str_replace($placeholders, $values, $cql);
1024    }
1025
1026    /**
1027     * Given a CQL query, fetch a single user; if we get an unexpected count, treat
1028     * that as an unsuccessful login by returning null.
1029     *
1030     * @param string $query CQL query
1031     *
1032     * @return object
1033     */
1034    protected function fetchUserWithCql($query)
1035    {
1036        $response = $this->makeRequest('GET', '/users', compact('query'));
1037        $json = json_decode($response->getBody());
1038        return count($json->users ?? []) === 1 ? $json->users[0] : null;
1039    }
1040
1041    /**
1042     * Helper function to retrieve paged results from FOLIO API
1043     *
1044     * @param string $responseKey Key containing values to collect in response
1045     * @param string $interface   FOLIO api interface to call
1046     * @param array  $query       CQL query
1047     * @param int    $limit       How many results to retrieve from FOLIO per call
1048     *
1049     * @return array
1050     */
1051    protected function getPagedResults($responseKey, $interface, $query = [], $limit = 1000)
1052    {
1053        $offset = 0;
1054
1055        do {
1056            $combinedQuery = array_merge($query, compact('offset', 'limit'));
1057            $response = $this->makeRequest(
1058                'GET',
1059                $interface,
1060                $combinedQuery
1061            );
1062            $json = json_decode($response->getBody());
1063            if (!$response->isSuccess() || !$json) {
1064                $msg = $json->errors[0]->message ?? json_last_error_msg();
1065                throw new ILSException("Error: '$msg' fetching '$responseKey'");
1066            }
1067            $totalEstimate = $json->totalRecords ?? 0;
1068            foreach ($json->$responseKey ?? [] as $item) {
1069                yield $item ?? '';
1070            }
1071            $offset += $limit;
1072
1073            // Continue until the current offset is greater than the totalRecords value returned
1074            // from the API (which could be an estimate if more than 1000 results are returned).
1075        } while ($offset <= $totalEstimate);
1076    }
1077
1078    /**
1079     * Patron Login
1080     *
1081     * This is responsible for authenticating a patron against the catalog.
1082     *
1083     * @param string $username The patron username
1084     * @param string $password The patron password
1085     *
1086     * @return mixed Associative array of patron info on successful login,
1087     * null on unsuccessful login.
1088     */
1089    public function patronLogin($username, $password)
1090    {
1091        $profile = null;
1092        $doOkapiLogin = $this->config['User']['okapi_login'] ?? false;
1093        $usernameField = $this->config['User']['username_field'] ?? 'username';
1094
1095        // If the username field is not the default 'username' we will need to
1096        // do a lookup to find the correct username value for Okapi login. We also
1097        // need to do this lookup if we're skipping Okapi login entirely.
1098        if (!$doOkapiLogin || $usernameField !== 'username') {
1099            $query = $this->getUserWithCql($username, $password);
1100            $profile = $this->fetchUserWithCql($query);
1101            if ($profile === null) {
1102                return null;
1103            }
1104        }
1105
1106        // If we need to do an Okapi login, we have the information we need to do
1107        // it at this point.
1108        if ($doOkapiLogin) {
1109            try {
1110                // If we fetched the profile earlier, we want to use the username
1111                // from there; otherwise, we'll use the passed-in version.
1112                $query = $this->patronLoginWithOkapi(
1113                    $profile->username ?? $username,
1114                    $password
1115                );
1116            } catch (Exception $e) {
1117                return null;
1118            }
1119            // If we didn't load a profile earlier, we should do so now:
1120            if (!isset($profile)) {
1121                $profile = $this->fetchUserWithCql($query);
1122                if ($profile === null) {
1123                    return null;
1124                }
1125            }
1126        }
1127
1128        return [
1129            'id' => $profile->id,
1130            'username' => $username,
1131            'cat_username' => $username,
1132            'cat_password' => $password,
1133            'firstname' => $profile->personal->firstName ?? null,
1134            'lastname' => $profile->personal->lastName ?? null,
1135            'email' => $profile->personal->email ?? null,
1136        ];
1137    }
1138
1139    /**
1140     * Given a user UUID, return the user's profile object (null if not found).
1141     *
1142     * @param string $id User UUID
1143     *
1144     * @return ?object
1145     */
1146    protected function getUserById(string $id): ?object
1147    {
1148        $query = ['query' => 'id == "' . $id . '"'];
1149        $response = $this->makeRequest('GET', '/users', $query);
1150        $users = json_decode($response->getBody());
1151        return $users->users[0] ?? null;
1152    }
1153
1154    /**
1155     * This method queries the ILS for a patron's current profile information
1156     *
1157     * @param array $patron Patron login information from $this->patronLogin
1158     *
1159     * @return array Profile data in associative array
1160     */
1161    public function getMyProfile($patron)
1162    {
1163        $profile = $this->getUserById($patron['id']);
1164        $expiration = isset($profile->expirationDate)
1165            ? $this->dateConverter->convertToDisplayDate(
1166                'Y-m-d H:i',
1167                $profile->expirationDate
1168            )
1169            : null;
1170        return [
1171            'id' => $profile->id,
1172            'firstname' => $profile->personal->firstName ?? null,
1173            'lastname' => $profile->personal->lastName ?? null,
1174            'address1' => $profile->personal->addresses[0]->addressLine1 ?? null,
1175            'city' => $profile->personal->addresses[0]->city ?? null,
1176            'country' => $profile->personal->addresses[0]->countryId ?? null,
1177            'zip' => $profile->personal->addresses[0]->postalCode ?? null,
1178            'phone' => $profile->personal->phone ?? null,
1179            'mobile_phone' => $profile->personal->mobilePhone ?? null,
1180            'expiration_date' => $expiration,
1181        ];
1182    }
1183
1184    /**
1185     * This method queries the ILS for a patron's current checked out items
1186     *
1187     * Input: Patron array returned by patronLogin method
1188     * Output: Returns an array of associative arrays.
1189     *         Each associative array contains these keys:
1190     *         duedate - The item's due date (a string).
1191     *         dueTime - The item's due time (a string, optional).
1192     *         dueStatus - A special status â€“ may be 'due' (for items due very soon)
1193     *                     or 'overdue' (for overdue items). (optional).
1194     *         id - The bibliographic ID of the checked out item.
1195     *         source - The search backend from which the record may be retrieved
1196     *                  (optional - defaults to Solr). Introduced in VuFind 2.4.
1197     *         barcode - The barcode of the item (optional).
1198     *         renew - The number of times the item has been renewed (optional).
1199     *         renewLimit - The maximum number of renewals allowed
1200     *                      (optional - introduced in VuFind 2.3).
1201     *         request - The number of pending requests for the item (optional).
1202     *         volume â€“ The volume number of the item (optional).
1203     *         publication_year â€“ The publication year of the item (optional).
1204     *         renewable â€“ Whether or not an item is renewable
1205     *                     (required for renewals).
1206     *         message â€“ A message regarding the item (optional).
1207     *         title - The title of the item (optional â€“ only used if the record
1208     *                                        cannot be found in VuFind's index).
1209     *         item_id - this is used to match up renew responses and must match
1210     *                   the item_id in the renew response.
1211     *         institution_name - Display name of the institution that owns the item.
1212     *         isbn - An ISBN for use in cover image loading
1213     *                (optional â€“ introduced in release 2.3)
1214     *         issn - An ISSN for use in cover image loading
1215     *                (optional â€“ introduced in release 2.3)
1216     *         oclc - An OCLC number for use in cover image loading
1217     *                (optional â€“ introduced in release 2.3)
1218     *         upc - A UPC for use in cover image loading
1219     *               (optional â€“ introduced in release 2.3)
1220     *         borrowingLocation - A string describing the location where the item
1221     *                         was checked out (optional â€“ introduced in release 2.4)
1222     *
1223     * @param array $patron Patron login information from $this->patronLogin
1224     *
1225     * @return array Transactions associative arrays
1226     */
1227    public function getMyTransactions($patron)
1228    {
1229        $query = ['query' => 'userId==' . $patron['id'] . ' and status.name==Open'];
1230        $transactions = [];
1231        foreach (
1232            $this->getPagedResults(
1233                'loans',
1234                '/circulation/loans',
1235                $query
1236            ) as $trans
1237        ) {
1238            $dueStatus = false;
1239            $date = $this->getDateTimeFromString($trans->dueDate);
1240            $dueDateTimestamp = $date->getTimestamp();
1241
1242            $now = time();
1243            if ($now > $dueDateTimestamp) {
1244                $dueStatus = 'overdue';
1245            } elseif ($now > $dueDateTimestamp - (1 * 24 * 60 * 60)) {
1246                $dueStatus = 'due';
1247            }
1248            $transactions[] = [
1249                'duedate' =>
1250                    $this->dateConverter->convertToDisplayDate(
1251                        'U',
1252                        $dueDateTimestamp
1253                    ),
1254                'dueTime' =>
1255                    $this->dateConverter->convertToDisplayTime(
1256                        'U',
1257                        $dueDateTimestamp
1258                    ),
1259                'dueStatus' => $dueStatus,
1260                'id' => $this->getBibId($trans->item->instanceId),
1261                'item_id' => $trans->item->id,
1262                'barcode' => $trans->item->barcode,
1263                'renew' => $trans->renewalCount ?? 0,
1264                'renewable' => true,
1265                'title' => $trans->item->title,
1266            ];
1267        }
1268        return $transactions;
1269    }
1270
1271    /**
1272     * Get FOLIO loan IDs for use in renewMyItems.
1273     *
1274     * @param array $transaction An single transaction
1275     * array from getMyTransactions
1276     *
1277     * @return string The FOLIO loan ID for this loan
1278     */
1279    public function getRenewDetails($transaction)
1280    {
1281        return $transaction['item_id'];
1282    }
1283
1284    /**
1285     * Attempt to renew a list of items for a given patron.
1286     *
1287     * @param array $renewDetails An associative array with
1288     * patron and details
1289     *
1290     * @return array $renewResult result of attempt to renew loans
1291     */
1292    public function renewMyItems($renewDetails)
1293    {
1294        $renewalResults = ['details' => []];
1295        foreach ($renewDetails['details'] ?? [] as $loanId) {
1296            $requestbody = [
1297                'itemId' => $loanId,
1298                'userId' => $renewDetails['patron']['id'],
1299            ];
1300            try {
1301                $response = $this->makeRequest(
1302                    'POST',
1303                    '/circulation/renew-by-id',
1304                    json_encode($requestbody),
1305                    [],
1306                    true
1307                );
1308                if ($response->isSuccess()) {
1309                    $json = json_decode($response->getBody());
1310                    $renewal = [
1311                        'success' => true,
1312                        'new_date' => $this->dateConverter->convertToDisplayDate(
1313                            'Y-m-d H:i',
1314                            $json->dueDate
1315                        ),
1316                        'new_time' => $this->dateConverter->convertToDisplayTime(
1317                            'Y-m-d H:i',
1318                            $json->dueDate
1319                        ),
1320                        'item_id' => $json->itemId,
1321                        'sysMessage' => $json->action,
1322                    ];
1323                } else {
1324                    $json = json_decode($response->getBody());
1325                    $sysMessage = $json->errors[0]->message;
1326                    $renewal = [
1327                        'success' => false,
1328                        'sysMessage' => $sysMessage,
1329                    ];
1330                }
1331            } catch (Exception $e) {
1332                $this->debug(
1333                    "Unexpected exception renewing $loanId" . $e->getMessage()
1334                );
1335                $renewal = [
1336                    'success' => false,
1337                    'sysMessage' => 'Renewal Failed',
1338                ];
1339            }
1340            $renewalResults['details'][$loanId] = $renewal;
1341        }
1342        return $renewalResults;
1343    }
1344
1345    /**
1346     * Get Pick Up Locations
1347     *
1348     * This is responsible get a list of valid locations for holds / recall
1349     * retrieval
1350     *
1351     * @param array $patron   Patron information returned by $this->patronLogin
1352     * @param array $holdInfo Optional array, only passed in when getting a list
1353     * in the context of placing or editing a hold. When placing a hold, it contains
1354     * most of the same values passed to placeHold, minus the patron data. When
1355     * editing a hold it contains all the hold information returned by getMyHolds.
1356     * May be used to limit the pickup options or may be ignored. The driver must
1357     * not add new options to the return array based on this data or other areas of
1358     * VuFind may behave incorrectly.
1359     *
1360     * @return array An array of associative arrays with locationID and
1361     * locationDisplay keys
1362     *
1363     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1364     */
1365    public function getPickupLocations($patron, $holdInfo = null)
1366    {
1367        $query = ['query' => 'pickupLocation=true'];
1368        $locations = [];
1369        foreach (
1370            $this->getPagedResults(
1371                'servicepoints',
1372                '/service-points',
1373                $query
1374            ) as $servicepoint
1375        ) {
1376            $locations[] = [
1377                'locationID' => $servicepoint->id,
1378                'locationDisplay' => $servicepoint->discoveryDisplayName,
1379            ];
1380        }
1381        return $locations;
1382    }
1383
1384    /**
1385     * This method queries the ILS for a patron's current holds
1386     *
1387     * Input: Patron array returned by patronLogin method
1388     * Output: Returns an array of associative arrays, one for each hold associated
1389     * with the specified account. Each associative array contains these keys:
1390     *     type - A string describing the type of hold â€“ i.e. hold vs. recall
1391     * (optional).
1392     *     id - The bibliographic record ID associated with the hold (optional).
1393     *     source - The search backend from which the record may be retrieved
1394     * (optional - defaults to Solr). Introduced in VuFind 2.4.
1395     *     location - A string describing the pickup location for the held item
1396     * (optional). In VuFind 1.2, this should correspond with a locationID value from
1397     * getPickUpLocations. In VuFind 1.3 and later, it may be either
1398     * a locationID value or a raw ready-to-display string.
1399     *     reqnum - A control number for the request (optional).
1400     *     expire - The expiration date of the hold (a string).
1401     *     create - The creation date of the hold (a string).
1402     *     position â€“ The position of the user in the holds queue (optional)
1403     *     available â€“ Whether or not the hold is available (true/false) (optional)
1404     *     item_id â€“ The item id the request item (optional).
1405     *     volume â€“ The volume number of the item (optional)
1406     *     publication_year â€“ The publication year of the item (optional)
1407     *     title - The title of the item
1408     * (optional â€“ only used if the record cannot be found in VuFind's index).
1409     *     isbn - An ISBN for use in cover image loading (optional)
1410     *     issn - An ISSN for use in cover image loading (optional)
1411     *     oclc - An OCLC number for use in cover image loading (optional)
1412     *     upc - A UPC for use in cover image loading (optional)
1413     *     cancel_details - The cancel token, or a blank string if cancel is illegal
1414     * for this hold; if omitted, this will be dynamically generated using
1415     * getCancelHoldDetails(). You should only fill this in if it is more efficient
1416     * to calculate the value up front; if it is an expensive calculation, you should
1417     * omit the value entirely and let getCancelHoldDetails() do its job on demand.
1418     * This optional feature was introduced in release 3.1.
1419     *
1420     * @param array $patron Patron login information from $this->patronLogin
1421     *
1422     * @return array Associative array of holds information
1423     */
1424    public function getMyHolds($patron)
1425    {
1426        $userQuery = '(requesterId == "' . $patron['id'] . '" '
1427            . 'or proxyUserId == "' . $patron['id'] . '")';
1428        $query = ['query' => '(' . $userQuery . ' and status == Open*)'];
1429        $holds = [];
1430        foreach (
1431            $this->getPagedResults(
1432                'requests',
1433                '/request-storage/requests',
1434                $query
1435            ) as $hold
1436        ) {
1437            $requestDate = $this->dateConverter->convertToDisplayDate(
1438                'Y-m-d H:i',
1439                $hold->requestDate
1440            );
1441            // Set expire date if it was included in the response
1442            $expireDate = isset($hold->requestExpirationDate)
1443                ? $this->dateConverter->convertToDisplayDate(
1444                    'Y-m-d H:i',
1445                    $hold->requestExpirationDate
1446                )
1447                : null;
1448            // Set lastPickup Date if provided, format to j M Y
1449            $lastPickup = isset($hold->holdShelfExpirationDate)
1450                ? $this->dateConverter->convertToDisplayDate(
1451                    'Y-m-d H:i',
1452                    $hold->holdShelfExpirationDate
1453                )
1454                : null;
1455            $currentHold = [
1456                'type' => $hold->requestType,
1457                'create' => $requestDate,
1458                'expire' => $expireDate ?? '',
1459                'id' => $this->getBibId(
1460                    $hold->instanceId,
1461                    $hold->holdingsRecordId ?? null,
1462                    $hold->itemId ?? null
1463                ),
1464                'item_id' => $hold->itemId ?? null,
1465                'reqnum' => $hold->id,
1466                // Title moved from item to instance in Lotus release:
1467                'title' => $hold->instance->title ?? $hold->item->title ?? '',
1468                'available' => in_array(
1469                    $hold->status,
1470                    $this->config['Holds']['available']
1471                    ?? $this->defaultAvailabilityStatuses
1472                ),
1473                'in_transit' => in_array(
1474                    $hold->status,
1475                    $this->config['Holds']['in_transit']
1476                    ?? $this->defaultInTransitStatuses
1477                ),
1478                'last_pickup_date' => $lastPickup,
1479                'position' => $hold->position ?? null,
1480            ];
1481            // If this request was created by a proxy user, and the proxy user
1482            // is not the current user, we need to indicate their name.
1483            if (
1484                ($hold->proxyUserId ?? $patron['id']) !== $patron['id']
1485                && isset($hold->proxy)
1486            ) {
1487                $currentHold['proxiedBy']
1488                    = $this->userObjectToNameString($hold->proxy);
1489            }
1490            // If this request was not created for the current user, it must be
1491            // a proxy request created by the current user. We should indicate this.
1492            if (
1493                ($hold->requesterId ?? $patron['id']) !== $patron['id']
1494                && isset($hold->requester)
1495            ) {
1496                $currentHold['proxiedFor']
1497                    = $this->userObjectToNameString($hold->requester);
1498            }
1499            $holds[] = $currentHold;
1500        }
1501        return $holds;
1502    }
1503
1504    /**
1505     * Get latest major version of a $moduleName enabled for a tenant.
1506     * Result is cached.
1507     *
1508     * @param string $moduleName module name
1509     *
1510     * @return int module version or 0 if no module found
1511     */
1512    protected function getModuleMajorVersion(string $moduleName): int
1513    {
1514        $cacheKey = 'module_version:' . $moduleName;
1515        $version = $this->getCachedData($cacheKey);
1516        if ($version === null) {
1517            // get latest version of a module enabled for a tenant
1518            $response = $this->makeRequest(
1519                'GET',
1520                '/_/proxy/tenants/' . $this->tenant . '/modules?filter=' . $moduleName . '&latest=1'
1521            );
1522
1523            // get version major from json result
1524            $versions = json_decode($response->getBody());
1525            $latest = $versions[0]->id ?? '0';
1526            preg_match_all('!\d+!', $latest, $matches);
1527            $version = (int)($matches[0][0] ?? 0);
1528            if ($version === 0) {
1529                $this->debug('Unable to find version in ' . $response->getBody());
1530            } else {
1531                // Only cache non-zero values, so we don't persist an error condition:
1532                $this->putCachedData($cacheKey, $version);
1533            }
1534        }
1535        return $version;
1536    }
1537
1538    /**
1539     * Support method for placeHold(): get a list of request types to try.
1540     *
1541     * @param string $preferred Method to try first.
1542     *
1543     * @return array
1544     */
1545    protected function getRequestTypeList(string $preferred): array
1546    {
1547        $backupMethods = (array)($this->config['Holds']['fallback_request_type'] ?? []);
1548        return array_merge(
1549            [$preferred],
1550            array_diff($backupMethods, [$preferred])
1551        );
1552    }
1553
1554    /**
1555     * Support method for placeHold(): send the request and process the response.
1556     *
1557     * @param array $requestBody Request body
1558     *
1559     * @return array
1560     * @throws ILSException
1561     */
1562    protected function performHoldRequest(array $requestBody): array
1563    {
1564        $response = $this->makeRequest(
1565            'POST',
1566            '/circulation/requests',
1567            json_encode($requestBody),
1568            [],
1569            true
1570        );
1571        try {
1572            $json = json_decode($response->getBody());
1573        } catch (Exception $e) {
1574            $this->throwAsIlsException($e, $response->getBody());
1575        }
1576        if ($response->isSuccess() && isset($json->status)) {
1577            return [
1578                'success' => true,
1579                'status' => $json->status,
1580            ];
1581        }
1582        return [
1583            'success' => false,
1584            'status' => $json->errors[0]->message ?? '',
1585        ];
1586    }
1587
1588    /**
1589     * Place Hold
1590     *
1591     * Attempts to place a hold or recall on a particular item and returns
1592     * an array with result details.
1593     *
1594     * @param array $holdDetails An array of item and patron data
1595     *
1596     * @return mixed An array of data on the request including
1597     * whether or not it was successful and a system message (if available)
1598     */
1599    public function placeHold($holdDetails)
1600    {
1601        $default_request = $this->config['Holds']['default_request'] ?? 'Hold';
1602        if (
1603            !empty($holdDetails['requiredByTS'])
1604            && !is_int($holdDetails['requiredByTS'])
1605        ) {
1606            throw new ILSException('hold_date_invalid');
1607        }
1608        $requiredBy = !empty($holdDetails['requiredByTS'])
1609            ? gmdate('Y-m-d', $holdDetails['requiredByTS']) : null;
1610
1611        $isTitleLevel = ($holdDetails['level'] ?? '') === 'title';
1612        if ($isTitleLevel) {
1613            $instance = $this->getInstanceByBibId($holdDetails['id']);
1614            $baseParams = [
1615                'instanceId' => $instance->id,
1616                'requestLevel' => 'Title',
1617            ];
1618            $preferredRequestType = $default_request;
1619        } else {
1620            // Note: early Lotus releases require instanceId and holdingsRecordId
1621            // to be set here as well, but the requirement was lifted in a hotfix
1622            // to allow backward compatibility. If you need compatibility with one
1623            // of those versions, you can add additional identifiers here, but
1624            // applying the latest hotfix is a better solution!
1625            $baseParams = ['itemId' => $holdDetails['item_id']];
1626            $preferredRequestType = ($holdDetails['status'] ?? '') == 'Available'
1627                ? 'Page' : $default_request;
1628        }
1629        // Account for an API spelling change introduced in mod-circulation v24:
1630        $fulfillmentKey = $this->getModuleMajorVersion('mod-circulation') >= 24
1631            ? 'fulfillmentPreference' : 'fulfilmentPreference';
1632        $requestBody = $baseParams + [
1633            'requesterId' => $holdDetails['patron']['id'],
1634            'requestDate' => date('c'),
1635            $fulfillmentKey => 'Hold Shelf',
1636            'requestExpirationDate' => $requiredBy,
1637            'pickupServicePointId' => $holdDetails['pickUpLocation'],
1638        ];
1639        if (!empty($holdDetails['proxiedUser'])) {
1640            $requestBody['requesterId'] = $holdDetails['proxiedUser'];
1641            $requestBody['proxyUserId'] = $holdDetails['patron']['id'];
1642        }
1643        if (!empty($holdDetails['comment'])) {
1644            $requestBody['patronComments'] = $holdDetails['comment'];
1645        }
1646        foreach ($this->getRequestTypeList($preferredRequestType) as $requestType) {
1647            $requestBody['requestType'] = $requestType;
1648            $result = $this->performHoldRequest($requestBody);
1649            if ($result['success']) {
1650                break;
1651            }
1652        }
1653        return $result ?? ['success' => false, 'status' => 'Unexpected failure'];
1654    }
1655
1656    /**
1657     * Get FOLIO hold IDs for use in cancelHolds.
1658     *
1659     * @param array $hold   A single hold array from getMyHolds
1660     * @param array $patron Patron information from patronLogin
1661     *
1662     * @return string request ID for this request
1663     *
1664     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1665     */
1666    public function getCancelHoldDetails($hold, $patron = [])
1667    {
1668        return $hold['reqnum'];
1669    }
1670
1671    /**
1672     * Cancel Holds
1673     *
1674     * Attempts to Cancel a hold or recall on a particular item. The
1675     * data in $cancelDetails['details'] is determined by getCancelHoldDetails().
1676     *
1677     * @param array $cancelDetails An array of item and patron data
1678     *
1679     * @return array               An array of data on each request including
1680     * whether or not it was successful and a system message (if available)
1681     */
1682    public function cancelHolds($cancelDetails)
1683    {
1684        $details = $cancelDetails['details'];
1685        $patron = $cancelDetails['patron'];
1686        $count = 0;
1687        $cancelResult = ['items' => []];
1688
1689        foreach ($details as $requestId) {
1690            $response = $this->makeRequest(
1691                'GET',
1692                '/circulation/requests/' . $requestId
1693            );
1694            $request_json = json_decode($response->getBody());
1695
1696            // confirm request belongs to signed in patron
1697            if (
1698                $request_json->requesterId != $patron['id']
1699                && ($request_json->proxyUserId ?? null) != $patron['id']
1700            ) {
1701                throw new ILSException('Invalid Request');
1702            }
1703            // Change status to Closed and add cancellationID
1704            $request_json->status = 'Closed - Cancelled';
1705            $request_json->cancellationReasonId
1706                = $this->config['Holds']['cancellation_reason']
1707                ?? '75187e8d-e25a-47a7-89ad-23ba612338de';
1708            $success = false;
1709            try {
1710                $cancel_response = $this->makeRequest(
1711                    'PUT',
1712                    '/circulation/requests/' . $requestId,
1713                    json_encode($request_json),
1714                    [],
1715                    true
1716                );
1717                $success = $cancel_response->getStatusCode() === 204;
1718            } catch (\Exception $e) {
1719                // Do nothing; the $success flag is already false by default.
1720            }
1721            $count += $success ? 1 : 0;
1722            $cancelResult['items'][$request_json->itemId] = [
1723                'success' => $success,
1724                'status' => $success ? 'hold_cancel_success' : 'hold_cancel_fail',
1725            ];
1726        }
1727        $cancelResult['count'] = $count;
1728        return $cancelResult;
1729    }
1730
1731    /**
1732     * Obtain a list of course resources, creating an id => value associative array.
1733     *
1734     * @param string       $type        Type of resource to retrieve from the API.
1735     * @param string       $responseKey Key containing useful values in response
1736     * (defaults to $type if unspecified)
1737     * @param string|array $valueKey    Key containing value(s) to extract from
1738     * response (defaults to 'name')
1739     * @param string       $formatStr   A sprintf format string for assembling the
1740     * parameters retrieved using $valueKey
1741     *
1742     * @return array
1743     */
1744    protected function getCourseResourceList(
1745        $type,
1746        $responseKey = null,
1747        $valueKey = 'name',
1748        $formatStr = '%s'
1749    ) {
1750        $retVal = [];
1751
1752        // Results can be paginated, so let's loop until we've gotten everything:
1753        foreach (
1754            $this->getPagedResults(
1755                $responseKey ?? $type,
1756                '/coursereserves/' . $type
1757            ) as $item
1758        ) {
1759            $callback = function ($key) use ($item) {
1760                return $item->$key ?? '';
1761            };
1762            $retVal[$item->id]
1763                = sprintf($formatStr, ...array_map($callback, (array)$valueKey));
1764        }
1765        return $retVal;
1766    }
1767
1768    /**
1769     * Get Departments
1770     *
1771     * Obtain a list of departments for use in limiting the reserves list.
1772     *
1773     * @return array An associative array with key = dept. ID, value = dept. name.
1774     */
1775    public function getDepartments()
1776    {
1777        return $this->getCourseResourceList('departments');
1778    }
1779
1780    /**
1781     * Get Instructors
1782     *
1783     * Obtain a list of instructors for use in limiting the reserves list.
1784     *
1785     * @return array An associative array with key = ID, value = name.
1786     */
1787    public function getInstructors()
1788    {
1789        $retVal = [];
1790        $ids = array_keys(
1791            $this->getCourseResourceList('courselistings', 'courseListings')
1792        );
1793        foreach ($ids as $id) {
1794            $retVal += $this->getCourseResourceList(
1795                'courselistings/' . $id . '/instructors',
1796                'instructors'
1797            );
1798        }
1799        return $retVal;
1800    }
1801
1802    /**
1803     * Get Courses
1804     *
1805     * Obtain a list of courses for use in limiting the reserves list.
1806     *
1807     * @return array An associative array with key = ID, value = name.
1808     */
1809    public function getCourses()
1810    {
1811        $showCodes = $this->config['CourseReserves']['displayCourseCodes'] ?? false;
1812        $courses = $this->getCourseResourceList(
1813            'courses',
1814            null,
1815            $showCodes ? ['courseNumber', 'name'] : ['name'],
1816            $showCodes ? '%s: %s' : '%s'
1817        );
1818        $callback = function ($course) {
1819            return trim(ltrim($course, ':'));
1820        };
1821        return array_map($callback, $courses);
1822    }
1823
1824    /**
1825     * Given a course listing ID, get an array of associated courses.
1826     *
1827     * @param string $courseListingId Course listing ID
1828     *
1829     * @return array
1830     */
1831    protected function getCourseDetails($courseListingId)
1832    {
1833        $values = empty($courseListingId)
1834            ? []
1835            : $this->getCourseResourceList(
1836                'courselistings/' . $courseListingId . '/courses',
1837                'courses',
1838                'departmentId'
1839            );
1840        // Return an array with empty values in it if we can't find any values,
1841        // because we want to loop at least once to build our reserves response.
1842        return empty($values) ? ['' => ''] : $values;
1843    }
1844
1845    /**
1846     * Given a course listing ID, get an array of associated instructors.
1847     *
1848     * @param string $courseListingId Course listing ID
1849     *
1850     * @return array
1851     */
1852    protected function getInstructorIds($courseListingId)
1853    {
1854        $values = empty($courseListingId)
1855            ? []
1856            : $this->getCourseResourceList(
1857                'courselistings/' . $courseListingId . '/instructors',
1858                'instructors'
1859            );
1860        // Return an array with null in it if we can't find any values, because
1861        // we want to loop at least once to build our course reserves response.
1862        return empty($values) ? [null] : array_keys($values);
1863    }
1864
1865    /**
1866     * Find Reserves
1867     *
1868     * Obtain information on course reserves.
1869     *
1870     * @param string $course ID from getCourses (empty string to match all)
1871     * @param string $inst   ID from getInstructors (empty string to match all)
1872     * @param string $dept   ID from getDepartments (empty string to match all)
1873     *
1874     * @return mixed An array of associative arrays representing reserve items.
1875     */
1876    public function findReserves($course, $inst, $dept)
1877    {
1878        $retVal = [];
1879        $query = [];
1880
1881        $includeSuppressed = $this->config['CourseReserves']['includeSuppressed'] ?? false;
1882
1883        if (!$includeSuppressed) {
1884            $query = [
1885                'query' => 'copiedItem.instanceDiscoverySuppress==false',
1886            ];
1887        }
1888
1889        // Results can be paginated, so let's loop until we've gotten everything:
1890        foreach (
1891            $this->getPagedResults(
1892                'reserves',
1893                '/coursereserves/reserves',
1894                $query
1895            ) as $item
1896        ) {
1897            $idProperty = $this->getBibIdType() === 'hrid'
1898                ? 'instanceHrid' : 'instanceId';
1899            $bibId = $item->copiedItem->$idProperty ?? null;
1900            if ($bibId !== null) {
1901                $courseData = $this->getCourseDetails(
1902                    $item->courseListingId ?? null
1903                );
1904                $instructorIds = $this->getInstructorIds(
1905                    $item->courseListingId ?? null
1906                );
1907                foreach ($courseData as $courseId => $departmentId) {
1908                    foreach ($instructorIds as $instructorId) {
1909                        $retVal[] = [
1910                            'BIB_ID' => $bibId,
1911                            'COURSE_ID' => $courseId == '' ? null : $courseId,
1912                            'DEPARTMENT_ID' => $departmentId == ''
1913                                ? null : $departmentId,
1914                            'INSTRUCTOR_ID' => $instructorId,
1915                        ];
1916                    }
1917                }
1918            }
1919        }
1920
1921        // If the user has requested a filter, apply it now:
1922        if (!empty($course) || !empty($inst) || !empty($dept)) {
1923            $filter = function ($value) use ($course, $inst, $dept) {
1924                return (empty($course) || $course == $value['COURSE_ID'])
1925                    && (empty($inst) || $inst == $value['INSTRUCTOR_ID'])
1926                    && (empty($dept) || $dept == $value['DEPARTMENT_ID']);
1927            };
1928            return array_filter($retVal, $filter);
1929        }
1930        return $retVal;
1931    }
1932
1933    /**
1934     * This method queries the ILS for a patron's current fines
1935     *
1936     * @param array $patron The patron array from patronLogin
1937     *
1938     * @return array
1939     */
1940    public function getMyFines($patron)
1941    {
1942        $query = ['query' => 'userId==' . $patron['id'] . ' and status.name==Open'];
1943        $fines = [];
1944        foreach (
1945            $this->getPagedResults(
1946                'accounts',
1947                '/accounts',
1948                $query
1949            ) as $fine
1950        ) {
1951            $date = date_create($fine->metadata->createdDate);
1952            $title = $fine->title ?? null;
1953            $bibId = isset($fine->instanceId)
1954                ? $this->getBibId($fine->instanceId)
1955                : null;
1956            $fines[] = [
1957                'id' => $bibId,
1958                'amount' => $fine->amount * 100,
1959                'balance' => $fine->remaining * 100,
1960                'status' => $fine->paymentStatus->name,
1961                'type' => $fine->feeFineType,
1962                'title' => $title,
1963                'createdate' => date_format($date, 'j M Y'),
1964            ];
1965        }
1966        return $fines;
1967    }
1968
1969    /**
1970     * Given a user object from the FOLIO API, return a name string.
1971     *
1972     * @param object $user User object
1973     *
1974     * @return string
1975     */
1976    protected function userObjectToNameString(object $user): string
1977    {
1978        $firstParts = ($user->firstName ?? '')
1979            . ' ' . ($user->middleName ?? '');
1980        $parts = [
1981            trim($user->lastName ?? ''),
1982            trim($firstParts),
1983        ];
1984        return implode(', ', array_filter($parts));
1985    }
1986
1987    /**
1988     * Given a user object returned by getUserById(), return a string representing
1989     * the user's name.
1990     *
1991     * @param object $proxy User object from FOLIO
1992     *
1993     * @return string
1994     */
1995    protected function formatUserNameForProxyList(object $proxy): string
1996    {
1997        return $this->userObjectToNameString($proxy->personal);
1998    }
1999
2000    /**
2001     * Support method for getProxiedUsers() and getProxyingUsers() to load proxy user data.
2002     *
2003     * This requires the FOLIO user configured in Folio.ini to have the permission:
2004     * proxiesfor.collection.get
2005     *
2006     * @param array  $patron       The patron array with username and password
2007     * @param string $lookupField  Field to use for looking up matching users
2008     * @param string $displayField Field in response to use for displaying user names
2009     *
2010     * @return array
2011     */
2012    protected function loadProxyUserData(array $patron, string $lookupField, string $displayField): array
2013    {
2014        $query = [
2015            'query' => '(' . $lookupField . '=="' . $patron['id'] . '")',
2016        ];
2017        $results = [];
2018        $proxies = $this->getPagedResults('proxiesFor', '/proxiesfor', $query);
2019        foreach ($proxies as $current) {
2020            if (
2021                $current->status ?? '' === 'Active'
2022                && $current->requestForSponsor ?? '' === 'Yes'
2023                && isset($current->$displayField)
2024            ) {
2025                if ($proxy = $this->getUserById($current->$displayField)) {
2026                    $results[$proxy->id] = $this->formatUserNameForProxyList($proxy);
2027                }
2028            }
2029        }
2030        return $results;
2031    }
2032
2033    /**
2034     * Get list of users for whom the provided patron is a proxy.
2035     *
2036     * @param array $patron The patron array with username and password
2037     *
2038     * @return array
2039     */
2040    public function getProxiedUsers(array $patron): array
2041    {
2042        return $this->loadProxyUserData($patron, 'proxyUserId', 'userId');
2043    }
2044
2045    /**
2046     * Get list of users who act as proxies for the provided patron.
2047     *
2048     * @param array $patron The patron array with username and password
2049     *
2050     * @return array
2051     *
2052     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
2053     */
2054    public function getProxyingUsers(array $patron): array
2055    {
2056        return $this->loadProxyUserData($patron, 'userId', 'proxyUserId');
2057    }
2058
2059    /**
2060     * NOT FINISHED BELOW THIS LINE
2061     **/
2062
2063    /**
2064     * Check for request blocks.
2065     *
2066     * @param array $patron The patron array with username and password
2067     *
2068     * @return array|bool An array of block messages or false if there are no blocks
2069     *
2070     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
2071     */
2072    public function getRequestBlocks($patron)
2073    {
2074        return false;
2075    }
2076
2077    /**
2078     * Get Purchase History Data
2079     *
2080     * This is responsible for retrieving the acquisitions history data for the
2081     * specific record (usually recently received issues of a serial). It is used
2082     * by getHoldings() and getPurchaseHistory() depending on whether the purchase
2083     * history is displayed by holdings or in a separate list.
2084     *
2085     * @param string $bibID The record id to retrieve the info for
2086     *
2087     * @return array An array with the acquisitions data on success.
2088     *
2089     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
2090     */
2091    public function getPurchaseHistory($bibID)
2092    {
2093        return [];
2094    }
2095
2096    /**
2097     * Get Funds
2098     *
2099     * Return a list of funds which may be used to limit the getNewItems list.
2100     *
2101     * @return array An associative array with key = fund ID, value = fund name.
2102     */
2103    public function getFunds()
2104    {
2105        return [];
2106    }
2107
2108    /**
2109     * Get Patron Loan History
2110     *
2111     * This is responsible for retrieving all historic loans (i.e. items previously
2112     * checked out and then returned), for a specific patron.
2113     *
2114     * @param array $patron The patron array from patronLogin
2115     * @param array $params Parameters
2116     *
2117     * @return array Array of the patron's transactions on success.
2118     *
2119     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
2120     */
2121    public function getMyTransactionHistory($patron, $params)
2122    {
2123        return[];
2124    }
2125
2126    /**
2127     * Get New Items
2128     *
2129     * Retrieve the IDs of items recently added to the catalog.
2130     *
2131     * @param int $page    Page number of results to retrieve (counting starts at 1)
2132     * @param int $limit   The size of each page of results to retrieve
2133     * @param int $daysOld The maximum age of records to retrieve in days (max. 30)
2134     * @param int $fundId  optional fund ID to use for limiting results (use a value
2135     * returned by getFunds, or exclude for no limit); note that "fund" may be a
2136     * misnomer - if funds are not an appropriate way to limit your new item
2137     * results, you can return a different set of values from getFunds. The
2138     * important thing is that this parameter supports an ID returned by getFunds,
2139     * whatever that may mean.
2140     *
2141     * @return array Associative array with 'count' and 'results' keys
2142     *
2143     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
2144     */
2145    public function getNewItems($page, $limit, $daysOld, $fundId = null)
2146    {
2147        return [];
2148    }
2149}