From aa3ca0f9f0f12435db0cf570a6f4e35a40d171cf Mon Sep 17 00:00:00 2001
From: Martin Renvoize <martin.renvoize@ptfs-europe.com>
Date: Fri, 5 May 2023 15:53:50 +0100
Subject: [PATCH] Bug 23336: Add checkout API's

This patch adds API's to allow for a checkout flow using the RESTful
API.

We add an availability endpoint to check an items current availability
status. The endpoint can be found at `/checkouts/availability` and is
a GET request that requires item_id and patron_id passed as parameters.

We return an availability object that includes blockers, confirms,
warnings and a confirmation token to be used for checkout.

We also add a corresponding checkout method to the `/checkouts` endpoint.
The method accepts a POST request with checkout details including item_id
, patron_id and the confirmation token in the body.

Future work: We should properly migrate CanBookBeIssued into Koha::* and
use that here instead of refering to C4::Circulation.

Signed-off-by: Helen Oliver <HOliver@tavi-port.ac.uk>
Signed-off-by: Kyle M Hall <kyle@bywatersolutions.com>
---
 C4/Circulation.pm                   |   4 +-
 Koha/Item.pm                        |   1 -
 Koha/REST/V1/Checkouts.pm           | 159 +++++++++++++++++++++++++++-
 api/v1/swagger/paths/checkouts.yaml |  97 +++++++++++++++++
 api/v1/swagger/swagger.yaml         |   7 ++
 5 files changed, 265 insertions(+), 3 deletions(-)

diff --git a/C4/Circulation.pm b/C4/Circulation.pm
index 6bba369114..9e86a2f57d 100644
--- a/C4/Circulation.pm
+++ b/C4/Circulation.pm
@@ -683,6 +683,7 @@ data is keyed in lower case!
 Available keys:
     override_high_holds - Ignore high holds
     onsite_checkout     - Checkout is an onsite checkout that will not leave the library
+    item                - Optionally pass the object for the item we are checking out to save a lookup
 
 =back
 
@@ -776,7 +777,8 @@ sub CanBookBeIssued {
     my $onsite_checkout     = $params->{onsite_checkout}     || 0;
     my $override_high_holds = $params->{override_high_holds} || 0;
 
-    my $item_object = Koha::Items->find({barcode => $barcode });
+    my $item_object = $params->{item}
+      // Koha::Items->find( { barcode => $barcode } );
 
     # MANDATORY CHECKS - unless item exists, nothing else matters
     unless ( $item_object ) {
diff --git a/Koha/Item.pm b/Koha/Item.pm
index 7251a8a094..c2a52b217b 100644
--- a/Koha/Item.pm
+++ b/Koha/Item.pm
@@ -336,7 +336,6 @@ sub move_to_deleted {
     return Koha::Database->new->schema->resultset('Deleteditem')->create($item_infos);
 }
 
-
 =head3 effective_itemtype
 
 Returns the itemtype for the item based on whether item level itemtypes are set or not.
diff --git a/Koha/REST/V1/Checkouts.pm b/Koha/REST/V1/Checkouts.pm
index e4a52b4cad..9c0ca662e0 100644
--- a/Koha/REST/V1/Checkouts.pm
+++ b/Koha/REST/V1/Checkouts.pm
@@ -19,10 +19,13 @@ use Modern::Perl;
 
 use Mojo::Base 'Mojolicious::Controller';
 use Mojo::JSON;
+use Mojo::JWT;
+use Digest::MD5 qw( md5_base64 );
+use Encode;
 
 use C4::Auth qw( haspermission );
 use C4::Context;
-use C4::Circulation qw( AddRenewal );
+use C4::Circulation qw( AddIssue AddRenewal );
 use Koha::Checkouts;
 use Koha::Old::Checkouts;
 
@@ -99,6 +102,160 @@ sub get {
     };
 }
 
+=head3 get_availablity
+
+Controller function that handles retrieval of Checkout availability
+
+=cut
+
+sub get_availability {
+    my $c = shift->openapi->valid_input or return;
+
+    my $patron = Koha::Patrons->find( $c->validation->param('patron_id') );
+    my $inprocess = 0; # What does this do?
+    my $ignore_reserves = 0; # Don't ignore reserves
+    my $item   = Koha::Items->find( $c->validation->param('item_id') );
+    my $params = {
+        item => $item
+    };
+
+    my ( $impossible, $confirmation, $alerts, $messages ) =
+      C4::Circulation::CanBookBeIssued( $patron, undef, undef, $inprocess, $ignore_reserves,
+        $params );
+
+    my $token;
+    if (keys %{$confirmation}) {
+        my $claims = { map { $_ => 1 } keys %{$confirmation} };
+        my $secret =
+          md5_base64( Encode::encode( 'UTF-8', C4::Context->config('pass') ) );
+        $token = Mojo::JWT->new( claims => $claims, secret => $secret )->encode;
+    }
+
+    my $response = {
+        blockers           => $impossible,
+        confirms           => $confirmation,
+        warnings           => { %{$alerts}, %{$messages} },
+        confirmation_token => $token
+    };
+
+    return $c->render( status => 200, openapi => $response );
+}
+
+=head3 add
+
+Add a new checkout
+
+=cut
+
+sub add {
+    my $c = shift->openapi->valid_input or return;
+
+    my $params = $c->validation->param('body');
+    my $item_id = $params->{item_id};
+    my $patron_id = $params->{patron_id};
+    my $onsite = $params->{onsite_checkout};
+
+    return try {
+        my $item = Koha::Items->find( $item_id );
+        unless ($item) {
+            return $c->render(
+                status  => 409,
+                openapi => {
+                    error      => 'Item not found',
+                    error_code => 'ITEM_NOT_FOUND',
+                }
+            );
+        }
+
+        my $patron = Koha::Patrons->find( $patron_id );
+        unless ($patron) {
+            return $c->render(
+                status  => 409,
+                openapi => {
+                    error      => 'Patron not found',
+                    error_code => 'PATRON_NOT_FOUND',
+                }
+            );
+        }
+
+        my $inprocess = 0; # What does this do?
+        my $ignore_reserves = 0; # Don't ignore reserves
+        my $params = {
+            item => $item
+        };
+
+        # Call 'CanBookBeIssued'
+        my ( $impossible, $confirmation, $alerts, $messages ) =
+          C4::Circulation::CanBookBeIssued( $patron, undef, undef, $inprocess, $ignore_reserves,
+            $params );
+
+        # * Fail for blockers - render 403
+        if (keys %{$impossible}) {
+            my @errors = keys %{$impossible};
+            return $c->render(
+                status => 403,
+                openapi => { error => "Checkout not authorized (@errors)" }
+            );
+        }
+
+        # * If confirmation required, check variable set above - render 412 if variable is false
+        if (keys %{$confirmation}) {
+            my $confirmed = 0;
+
+            # Check for existance of confirmation token and if exists check validity
+            if ( my $token = $c->validation->param('confirmation') ) {
+                my $secret =
+                  md5_base64(
+                    Encode::encode( 'UTF-8', C4::Context->config('pass') ) );
+                my $claims = try {
+                    Mojo::JWT->new( secret => $secret )->decode($token);
+                } catch {
+                    return $c->render(
+                        status  => 403,
+                        openapi =>
+                          { error => "Confirmation required" }
+                    );
+                };
+
+                # check claims match
+                my $token_claims = join( / /, sort keys %{$claims} );
+                my $confirm_keys = join( / /, sort keys %{$confirmation} );
+                $confirmed = 1 if ( $token_claims eq $confirm_keys );
+            }
+
+            unless ($confirmed) {
+                return $c->render(
+                    status  => 412,
+                    openapi => { error => "Confirmation error" }
+                );
+            }
+        }
+
+        # Call 'AddIssue'
+        my $checkout = AddIssue($patron->unblessed, $item->barcode);
+
+        # Check return of AddIssue
+        # Get checkout from AddIssue - render 201
+        # Get error from AddIssue - render 400?
+
+        if ( $checkout ) {
+            $c->res->headers->location( $c->req->url->to_string . '/' . $checkout->id );
+            return $c->render(
+                status  => 201,
+                openapi => $checkout->to_api
+            );
+        } else {
+            return $c->render(
+                status => 500,
+                openapi => { error => 'Unknown error during checkout' }
+            );
+        }
+    }
+    catch {
+        $c->unhandled_exception($_);
+    };
+}
+
 =head3 get_renewals
 
 List Koha::Checkout::Renewals
diff --git a/api/v1/swagger/paths/checkouts.yaml b/api/v1/swagger/paths/checkouts.yaml
index d20aaa1f7d..afe6e5660c 100644
--- a/api/v1/swagger/paths/checkouts.yaml
+++ b/api/v1/swagger/paths/checkouts.yaml
@@ -61,6 +61,68 @@
     x-koha-authorization:
       permissions:
         circulate: circulate_remaining_permissions
+  post:
+    x-mojo-to: Checkouts#add
+    operationId: addCheckout
+    tags:
+      - checkouts
+      - patrons
+    summary: Add a new checkout
+    parameters:
+      - name: body
+        in: body
+        description: A JSON object containing information about the new checkout
+        required: true
+        schema:
+          $ref: "../swagger.yaml#/definitions/checkout"
+      - name: confirmation
+        in: query
+        description: A JWT confirmation token
+        required: false
+        type: string
+    consumes:
+      - application/json
+    produces:
+      - application/json
+    responses:
+      "201":
+        description: Created checkout
+        schema:
+          $ref: "../swagger.yaml#/definitions/checkout"
+      "400":
+        description: Missing or wrong parameters
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "401":
+        description: Authentication required
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "403":
+        description: Cannot create checkout
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "409":
+        description: Conflict in creating checkout
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "412":
+        description: Precondition failed
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "500":
+        description: |
+          Internal server error. Possible `error_code` attribute values:
+
+          * `internal_server_error`
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "503":
+        description: Under maintenance
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+    x-koha-authorization:
+      permissions:
+        circulate: circulate_remaining_permissions
 "/checkouts/{checkout_id}":
   get:
     x-mojo-to: Checkouts#get
@@ -273,3 +335,38 @@
     x-koha-authorization:
       permissions:
         circulate: circulate_remaining_permissions
+"/checkouts/availability":
+  get:
+    x-mojo-to: Checkouts#get_availability
+    operationId: availabilityCheckouts
+    tags:
+      - checkouts
+    summary: Get checkout availability
+    parameters:
+      - $ref: "../swagger.yaml#/parameters/patron_id_qp"
+      - $ref: "../swagger.yaml#/parameters/item_id_qp"
+    produces:
+      - application/json
+    responses:
+      "200":
+        description: Availability
+        schema:
+          type: "object"
+      "403":
+        description: Access forbidden
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "500":
+        description: |
+          Internal server error. Possible `error_code` attribute values:
+
+          * `internal_server_error`
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "503":
+        description: Under maintenance
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+    x-koha-authorization:
+      permissions:
+        circulate: circulate_remaining_permissions
diff --git a/api/v1/swagger/swagger.yaml b/api/v1/swagger/swagger.yaml
index 23c59b96a1..52c04a21e6 100644
--- a/api/v1/swagger/swagger.yaml
+++ b/api/v1/swagger/swagger.yaml
@@ -197,6 +197,8 @@ paths:
     $ref: "./paths/checkouts.yaml#/~1checkouts~1{checkout_id}~1renewals"
   "/checkouts/{checkout_id}/renewal":
     $ref: "./paths/checkouts.yaml#/~1checkouts~1{checkout_id}~1renewal"
+  "/checkouts/availability":
+    $ref: "./paths/checkouts.yaml#/~1checkouts~1availability"
   /circulation-rules/kinds:
     $ref: ./paths/circulation-rules.yaml#/~1circulation-rules~1kinds
   /cities:
@@ -519,6 +521,11 @@ parameters:
     name: item_id
     required: true
     type: integer
+  item_id_qp:
+    description: Internal item identifier
+    in: query
+    name: item_id
+    type: integer
   job_id_pp:
     description: Job internal identifier
     in: path
-- 
2.40.1