LP#1879983: Curbside application: open-ils.curbside
authorMike Rylander <mrylander@gmail.com>
Tue, 19 May 2020 19:11:54 +0000 (15:11 -0400)
committerGalen Charlton <gmc@equinoxinitiative.org>
Tue, 15 Sep 2020 20:20:38 +0000 (16:20 -0400)
This commit adds the Curbside OpenSRF application, open-ils.curbside,
which provides all the business logic and data retrieval APIs. The
open-ils.curbside service must be registered with the public routeri
in order for the feature to function.

The methods in this service are:

* open-ils.curbside.fetch_mine: retrieve the active appointments
  for the current login session; this is meant for OPAC use.
* open-ils.curbside.open_user_appointments_at_lib: retrieve
  appointments for the specified user at a given library.
* open-ils.curbside.patron.ready_holds_at_lib.count: count
  available holds for a patron at a specified library; this is
  needed because there is no other single method that provides
  this.

* open-ils.curbside.fetch_to_be_staged
* open-ils.curbside.fetch_staged
* open-ils.curbside.fetch_arrived
* open-ils.curbside.fetch_delivered

Retrieve appointments in various states. These methods are streaming
and also have .atomic variants.

* open-ils.circ.curbside.claim_staging
* open-ils.circ.curbside.unclaim_staging

Allow a staff member to claim responsibility for staging items
for an appointment or to release a claim.

* open-ils.curbside.fetch_to_be_staged.latest
* open-ils.curbside.fetch_staged.latest
* open-ils.curbside.fetch_arrived.latest
* open-ils.curbside.fetch_delivered.latest

Retrieve a hash of apopintments in various states; used to determine
if the UI should be updated.

* open-ils.curbside.times_for_date

Retrieve available times for curbside appointments at the specified
date.

* open-ils.curbside.update_appointment
* open-ils.curbside.create_appointment
* open-ils.curbside.delete_appointment

CUD.

* open-ils.curbside.mark_staged
* open-ils.curbside.mark_unstaged
* open-ils.curbside.mark_arrived

Update the state of appointments.

* open-ils.curbside.mark_delivered

Update the state of an appointment to mark it delivered AND check
out all of the available holds.

In addition to Mike Rylander, significant contributions to this
patch were made by Galen Charlton.

Sponsored-by: PaILS

Signed-off-by: Mike Rylander <mrylander@gmail.com>
Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>
Signed-off-by: Michele Morgan <mmorgan@noblenet.org>

Open-ILS/examples/opensrf.xml.example
Open-ILS/examples/opensrf_core.xml.example
Open-ILS/src/perlmods/lib/OpenILS/Application/Curbside.pm [new file with mode: 0644]

index 0549643..1e6c981 100644 (file)
@@ -1282,6 +1282,19 @@ vim:et:ts=4:sw=4:
                     <unix_pid>courses_unix.pid</unix_pid>
                     <unix_log>courses_unix.log</unix_log>
                     <max_requests>100</max_requests>
+            </open-ils.courses>
+
+            <open-ils.curbside>
+                <keepalive>5</keepalive>
+                <stateless>1</stateless>
+                <language>perl</language>
+                <implementation>OpenILS::Application::Curbside</implementation>
+                <max_requests>1000</max_requests>
+                <unix_config>
+                    <unix_sock>curbside_unix.sock</unix_sock>
+                    <unix_pid>curbside_unix.pid</unix_pid>
+                    <unix_log>curbside_unix.log</unix_log>
+                    <max_requests>1000</max_requests>
                     <min_children>1</min_children>
                     <max_children>15</max_children>
                     <min_spare_children>1</min_spare_children>
@@ -1289,7 +1302,7 @@ vim:et:ts=4:sw=4:
                 </unix_config>
                 <app_settings>
                 </app_settings>
-            </open-ils.courses>
+            </open-ils.curbside>
         </apps>
     </default>
 
@@ -1338,6 +1351,7 @@ vim:et:ts=4:sw=4:
                 <appname>open-ils.hold-targeter</appname>  
                 <appname>open-ils.ebook_api</appname>
                 <appname>open-ils.courses</appname>
+                <appname>open-ils.curbside</appname>
             </activeapps>
         </localhost>
     </hosts>
index e801db9..db89f56 100644 (file)
@@ -28,6 +28,7 @@ Example OpenSRF bootstrap configuration file for Evergreen
           <service>open-ils.circ</service>
           <service>open-ils.collections</service>
           <service>open-ils.courses</service>
+          <service>open-ils.curbside</service>
           <service>open-ils.fielder</service>
           <service>open-ils.pcrud</service>
           <service>open-ils.permacrud</service>
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Curbside.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Curbside.pm
new file mode 100644 (file)
index 0000000..a98a66f
--- /dev/null
@@ -0,0 +1,1002 @@
+package OpenILS::Application::Curbside;
+
+use strict;
+use warnings;
+
+use POSIX qw/strftime/;
+use OpenSRF::AppSession;
+use OpenILS::Application;
+use base qw/OpenILS::Application/;
+
+use OpenILS::Utils::DateTime qw/:datetime/;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use OpenILS::Utils::Fieldmapper;
+use OpenILS::Application::AppUtils;
+my $U = "OpenILS::Application::AppUtils";
+
+use Digest::MD5 qw(md5_hex);
+
+use DateTime;
+use DateTime::Format::ISO8601;
+
+my $date_parser = DateTime::Format::ISO8601->new;
+
+use OpenSRF::Utils::Logger qw/$logger/;
+
+sub fetch_mine { # returns appointments owned by $authtoken user, optional $org filter
+    my ($self, $conn, $authtoken, $org, $limit, $offset) = @_;
+
+    my $e = new_editor(xact => 1, authtoken => $authtoken);
+    return $e->die_event unless $e->checkauth;
+
+    # NOTE: not checking if curbside is enabled here
+    # because the pickup lib might be anything
+
+    my $slots = $e->search_action_curbside([{
+        patron    => $e->requestor->id,
+        delivered => { '=' => undef },
+        ( $org ? (org => $org) : () )
+    },{
+        ($limit  ? (limit  => $limit) : ()),
+        ($offset ? (offset => $offset) : ()),
+        flesh => 2, flesh_fields => {acsp => ['patron'], au => ['card']},
+        order_by => { acsp => {slot => {direction => 'asc'}} }
+    }]);
+
+    $conn->respond($_) for @$slots;
+    return undef;
+}
+__PACKAGE__->register_method(
+    method   => "fetch_mine",
+    api_name => "open-ils.curbside.fetch_mine",
+    stream   => 1,
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Optional Library ID to filter further'},
+            {type => 'number', desc => 'Fetch limit'},
+            {type => 'number', desc => 'Fetch offset'},
+        ],
+        return => { desc => 'A stream of appointments that the authenticated user owns'}
+    }
+);
+
+sub fetch_appointments { # returns appointment for user at location
+    my ($self, $conn, $authtoken, $usr, $org) = @_;
+
+    my $e = new_editor(xact => 1, authtoken => $authtoken);
+    return $e->die_event unless $e->checkauth;
+
+    $org ||= $e->requestor->ws_ou;
+
+    return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
+        $U->ou_ancestor_setting_value($org, 'circ.curbside')
+    ));
+
+    return new OpenILS::Event("BAD_PARAMS", "desc" => "No user ID supplied") unless $usr;
+
+    unless ($usr == $e->requestor->id) {
+        return $e->die_event unless $e->allowed("STAFF_LOGIN");
+    }
+
+    my $slots = $e->search_action_curbside([{
+        patron    => $usr,
+        delivered => { '=' => undef },
+        org       => $org,
+    },{
+        order_by => { acsp => {slot => {direction => 'asc'}} }
+    }]);
+
+    $conn->respond($_) for @$slots;
+    return undef;
+}
+__PACKAGE__->register_method(
+    method   => "fetch_appointments",
+    api_name => "open-ils.curbside.open_user_appointments_at_lib",
+    stream   => 1,
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Patron ID'},
+            {type => 'number', desc => 'Optional pickup library. If not supplied, taken from WS of session.'},
+        ],
+        return => { desc => 'A stream of appointments that the authenticated user owns'}
+    }
+);
+
+sub fetch_holds_for_patron_at_pickup_lib {
+    my ($self, $conn, $authtoken, $usr, $org) = @_;
+
+    my $e = new_editor(xact => 1, authtoken => $authtoken);
+    return $e->die_event unless $e->checkauth;
+
+    $org ||= $e->requestor->ws_ou;
+
+    return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
+        $U->ou_ancestor_setting_value($org, 'circ.curbside')
+    ));
+
+    return new OpenILS::Event("BAD_PARAMS", "desc" => "No user ID supplied") unless $usr;
+
+    my $holds = $e->search_action_hold_request({
+        usr => $usr,
+        current_shelf_lib => $org,
+        pickup_lib => $org,
+        shelf_time => {'!=' => undef},
+        cancel_time => undef,
+        fulfillment_time => undef
+    }, { idlist => 1 });
+
+    return scalar(@$holds);
+
+}
+__PACKAGE__->register_method(
+    method   => "fetch_holds_for_patron_at_pickup_lib",
+    api_name => "open-ils.curbside.patron.ready_holds_at_lib.count",
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Patron ID'},
+            {type => 'number', desc => 'Optional pickup library. If not supplied, taken from WS of session.'},
+        ],
+        return => { desc => 'Number of holds on the shelf for the patron at the specified library'}
+    }
+);
+
+sub _flesh_and_emit_slots {
+    my ($conn, $e, $slots) = @_;
+
+    for my $s (@$slots) {
+        my $start_time;
+        my $end_time;
+        if ($s->delivered) {
+            $start_time = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($s->delivered));
+            $end_time = $start_time->clone->add(seconds => 90); # 90 seconds is arbitrary
+        }
+        my $holds = $e->search_action_hold_request([{
+            usr => $s->patron->id,
+            current_shelf_lib => $s->org,
+            pickup_lib => $s->org,
+            shelf_time => {'!=' => undef},
+            cancel_time => undef,
+            ($s->delivered) ?
+                (
+                    '-and' => [ { fulfillment_time => {'>=' => $start_time->strftime('%FT%T%z') } },
+                                { fulfillment_time => {'<=' => $end_time->strftime('%FT%T%z') } } ],
+                ) :
+                (fulfillment_time => undef),
+        },{
+            flesh => 1, flesh_fields => {ahr => ['current_copy']},
+        }]);
+
+        my $rhrr_list = $e->search_reporter_hold_request_record(
+            {id => [ map { $_->id } @$holds ]}
+        );
+
+        my %bib_data = map {
+            ($_->id => $e->retrieve_metabib_wide_display_entry( $_->bib_record))
+        } @$rhrr_list;
+
+        $conn->respond({slot_id => $s->id, slot => $s, holds => $holds, bib_data_by_hold => \%bib_data});
+    }
+}
+
+sub fetch_delivered { # returns appointments delivered TODAY
+    my ($self, $conn, $authtoken, $org, $limit, $offset) = @_;
+
+    my $e = new_editor(xact => 1, authtoken => $authtoken);
+    return $e->die_event unless $e->checkauth;
+
+    $org ||= $e->requestor->ws_ou;
+
+    return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
+        $U->ou_ancestor_setting_value($org, 'circ.curbside')
+    ));
+
+    my $slots = $e->search_action_curbside([{
+        org => $org,
+        arrival => { '!=' => undef},
+        delivered => { '>' => 'today'},
+    },{
+        ($limit  ? (limit  => $limit) : ()),
+        ($offset ? (offset => $offset) : ()),
+        flesh => 2, flesh_fields => {acsp => ['patron'], au => ['card']},
+        order_by => { acsp => {delivered => {direction => 'desc'}} }
+    }]);
+
+    _flesh_and_emit_slots($conn, $e, $slots);
+
+    return undef;
+}
+__PACKAGE__->register_method(
+    method   => "fetch_delivered",
+    api_name => "open-ils.curbside.fetch_delivered",
+    stream   => 1,
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Library ID'},
+            {type => 'number', desc => 'Fetch limit'},
+            {type => 'number', desc => 'Fetch offset'},
+        ],
+        return => { desc => 'A stream of appointments that were delivered today'}
+    }
+);
+
+sub fetch_latest_delivered { # returns appointments delivered TODAY
+    my ($self, $conn, $authtoken, $org) = @_;
+
+    my $e = new_editor(xact => 1, authtoken => $authtoken);
+    return $e->die_event unless $e->checkauth;
+
+    $org ||= $e->requestor->ws_ou;
+
+    return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
+        $U->ou_ancestor_setting_value($org, 'circ.curbside')
+    ));
+
+    my $slots = $e->search_action_curbside([{
+        org => $org,
+        arrival => { '!=' => undef},
+        delivered => { '>' => 'today'},
+    },{
+        order_by => { acsp => {delivered => {direction => 'desc'}} }
+    }],{ idlist => 1 });
+
+    return md5_hex( join(',', @$slots) );
+}
+__PACKAGE__->register_method(
+    method   => "fetch_latest_delivered",
+    api_name => "open-ils.curbside.fetch_delivered.latest",
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Library ID'},
+        ],
+        return => { desc => 'Hash of appointment IDs delivered today, or error event'}
+    }
+);
+
+sub fetch_arrived {
+    my ($self, $conn, $authtoken, $org, $limit, $offset) = @_;
+
+    my $e = new_editor(xact => 1, authtoken => $authtoken);
+    return $e->die_event unless $e->checkauth;
+
+    $org ||= $e->requestor->ws_ou;
+
+    return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
+        $U->ou_ancestor_setting_value($org, 'circ.curbside')
+    ));
+
+    my $slots = $e->search_action_curbside([{
+        org => $org,
+        arrival => { '!=' => undef},
+        delivered => undef,
+    },{
+        ($limit  ? (limit  => $limit) : ()),
+        ($offset ? (offset => $offset) : ()),
+        flesh => 3, flesh_fields => {acsp => ['patron'], au => ['card','standing_penalties'], ausp => ['standing_penalty']},
+        order_by => { acsp => 'arrival' }
+    }]);
+
+
+    _flesh_and_emit_slots($conn, $e, $slots);
+
+    return undef;
+}
+__PACKAGE__->register_method(
+    method   => "fetch_arrived",
+    api_name => "open-ils.curbside.fetch_arrived",
+    stream   => 1,
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Library ID'},
+            {type => 'number', desc => 'Fetch limit'},
+            {type => 'number', desc => 'Fetch offset'},
+        ],
+        return => { desc => 'A stream of appointments for patrons that have arrived but are not delivered'}
+    }
+);
+
+sub fetch_latest_arrived {
+    my ($self, $conn, $authtoken, $org) = @_;
+
+    my $e = new_editor(xact => 1, authtoken => $authtoken);
+    return $e->die_event unless $e->checkauth;
+
+    $org ||= $e->requestor->ws_ou;
+
+    return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
+        $U->ou_ancestor_setting_value($org, 'circ.curbside')
+    ));
+
+    my $slots = $e->search_action_curbside([{
+        org => $org,
+        arrival => { '!=' => undef},
+        delivered => undef,
+    },{
+        order_by => { acsp => { arrival => { direction => 'desc' } } }
+    }],{ idlist => 1 });
+
+    return md5_hex( join(',', @$slots) );
+}
+__PACKAGE__->register_method(
+    method   => "fetch_latest_arrived",
+    api_name => "open-ils.curbside.fetch_arrived.latest",
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Library ID'},
+        ],
+        return => { desc => 'Hash of appointment IDs for undelivered appointments'}
+    }
+);
+
+sub fetch_staged {
+    my ($self, $conn, $authtoken, $org, $limit, $offset) = @_;
+
+    my $e = new_editor(xact => 1, authtoken => $authtoken);
+    return $e->die_event unless $e->checkauth;
+
+    $org ||= $e->requestor->ws_ou;
+
+    return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
+        $U->ou_ancestor_setting_value($org, 'circ.curbside')
+    ));
+
+    my $slots = $e->search_action_curbside([{
+        org => $org,
+        staged => { '!=' => undef},
+        arrival => undef
+    },{
+        ($limit  ? (limit  => $limit) : ()),
+        ($offset ? (offset => $offset) : ()),
+        flesh => 3, flesh_fields => {acsp => ['patron'], au => ['card','standing_penalties'], ausp => ['standing_penalty']},
+        order_by => { acsp => 'slot' }
+    }]);
+
+    _flesh_and_emit_slots($conn, $e, $slots);
+
+    return undef;
+}
+__PACKAGE__->register_method(
+    method   => "fetch_staged",
+    api_name => "open-ils.curbside.fetch_staged",
+    stream   => 1,
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Library ID'},
+            {type => 'number', desc => 'Fetch limit'},
+            {type => 'number', desc => 'Fetch offset'},
+        ],
+        return => { desc => 'A stream of appointments that are staged but patrons have not yet arrived'}
+    }
+);
+
+sub fetch_latest_staged {
+    my ($self, $conn, $authtoken, $org) = @_;
+
+    my $e = new_editor(xact => 1, authtoken => $authtoken);
+    return $e->die_event unless $e->checkauth;
+
+    $org ||= $e->requestor->ws_ou;
+
+    return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
+        $U->ou_ancestor_setting_value($org, 'circ.curbside')
+    ));
+
+    my $slots = $e->search_action_curbside([{
+        org => $org,
+        staged => { '!=' => undef},
+        arrival => undef
+    },{
+        order_by => [
+            { class => acsp => field => slot => direction => 'desc' },
+            { class => acsp => field => id   => direction => 'desc' }
+        ]
+    }],{ idlist => 1 });
+
+    return md5_hex( join(',', @$slots) );
+}
+__PACKAGE__->register_method(
+    method   => "fetch_latest_staged",
+    api_name => "open-ils.curbside.fetch_staged.latest",
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Library ID'},
+        ],
+        return => { desc => 'Hash of appointment IDs for staged appointment'}
+    }
+);
+
+sub fetch_to_be_staged {
+    my ($self, $conn, $authtoken, $org, $limit, $offset) = @_;
+
+    my $e = new_editor(xact => 1, authtoken => $authtoken);
+    return $e->die_event unless $e->checkauth;
+
+    $org ||= $e->requestor->ws_ou;
+
+    return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
+        $U->ou_ancestor_setting_value($org, 'circ.curbside')
+    ));
+
+    my $gran = $U->ou_ancestor_setting_value($org, 'circ.curbside.granularity') || '15 minutes';
+    my $gran_seconds = interval_to_seconds($gran);
+    my $horizon = DateTime->now; # NOTE: does not need timezone set because it gets UTC, not floating, so we can math with it
+    $horizon->add(seconds => $gran_seconds * 2);
+
+    my $slots = $e->search_action_curbside([{
+        org => $org,
+        staged => undef,
+        slot => { '<=' => $horizon->strftime('%FT%T%z') },
+    },{
+        ($limit  ? (limit  => $limit) : ()),
+        ($offset ? (offset => $offset) : ()),
+        flesh => 3, flesh_fields => {acsp => ['patron','stage_staff'], au => ['card','standing_penalties'], ausp => ['standing_penalty']},
+        order_by => { acsp => 'slot' }
+    }]);
+
+    _flesh_and_emit_slots($conn, $e, $slots);
+
+    return undef;
+}
+__PACKAGE__->register_method(
+    method   => "fetch_to_be_staged",
+    api_name => "open-ils.curbside.fetch_to_be_staged",
+    stream   => 1,
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Library ID'},
+            {type => 'number', desc => 'Fetch limit'},
+            {type => 'number', desc => 'Fetch offset'},
+        ],
+        return => { desc => 'A stream of appointments that need to be staged'}
+    }
+);
+
+sub fetch_latest_to_be_staged {
+    my ($self, $conn, $authtoken, $org) = @_;
+
+    my $e = new_editor(xact => 1, authtoken => $authtoken);
+    return $e->die_event unless $e->checkauth;
+
+    $org ||= $e->requestor->ws_ou;
+
+    return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
+        $U->ou_ancestor_setting_value($org, 'circ.curbside')
+    ));
+
+    my $gran = $U->ou_ancestor_setting_value($org, 'circ.curbside.granularity') || '15 minutes';
+    my $gran_seconds = interval_to_seconds($gran);
+    my $horizon = DateTime->now; # NOTE: does not need timezone set because it gets UTC, not floating, so we can math with it
+    $horizon->add(seconds => $gran_seconds * 2);
+
+    my $slots = $e->search_action_curbside([{
+        org => $org,
+        staged => undef,
+        slot => { '<=' => $horizon->strftime('%FT%T%z') },
+    },{
+        order_by => [
+            { class => acsp => field => slot => direction => 'desc' },
+            { class => acsp => field => id   => direction => 'desc' }
+        ]
+    }]);
+
+    return md5_hex( join(',', map { join('-', $_->id(), $_->stage_staff() // '', $_->arrival() // '') } @$slots) );
+}
+__PACKAGE__->register_method(
+    method   => "fetch_latest_to_be_staged",
+    api_name => "open-ils.curbside.fetch_to_be_staged.latest",
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Library ID'},
+        ],
+        return => { desc => 'Hash of appointment IDs that needs to be staged'}
+    }
+);
+
+sub times_for_date {
+    my ($self, $conn, $authtoken, $date, $org) = @_;
+
+    my $e = new_editor(xact => 1, authtoken => $authtoken);
+    return $e->die_event unless $e->checkauth;
+
+    $org ||= $e->requestor->ws_ou;
+
+    return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
+        $U->ou_ancestor_setting_value($org, 'circ.curbside')
+    ));
+
+    my $start_obj = $date_parser->parse_datetime($date);
+    return $conn->respond_complete unless ($start_obj);
+
+    my $gran = $U->ou_ancestor_setting_value($org, 'circ.curbside.granularity') || '15 minutes';
+    my $gran_seconds = interval_to_seconds($gran);
+
+    my $max = $U->ou_ancestor_setting_value($org, 'circ.curbside.max_concurrent') || 10;
+
+    my $hoo = $e->retrieve_actor_org_unit_hours_of_operation($org);
+    return undef unless ($hoo);
+
+    my $dow = $start_obj->day_of_week_0;
+
+    my $open_method = "dow_${dow}_open";
+    my $close_method = "dow_${dow}_close";
+
+    my $open_time = $hoo->$open_method;
+    my $close_time = $hoo->$close_method;
+    return $conn->respond_complete if ($open_time eq $close_time); # location closed that day
+
+    my $tz = $U->ou_ancestor_setting_value($org, 'lib.timezone') || 'local';
+    $start_obj = $date_parser->parse_datetime($date.'T'.$open_time)->set_time_zone($tz); # reset this to opening time
+    my $end_obj = $date_parser->parse_datetime($date.'T'.$close_time)->set_time_zone($tz);
+
+    my $now_obj = DateTime->now; # NOTE: does not need timezone set because it gets UTC, not floating, so we can math with it
+    # Add two step intervals to avoid having an appointment be scheduled
+    # sooner than the library could stage the items. Setting the earliest
+    # available time to be no earlier than two intervals from now
+    # is arbitrary and could be made configurable in the future, though
+    # it does follow the hard-coding of the horizon in fetch_to_be_staged().
+    $now_obj->add(seconds => 2 * $gran_seconds);
+
+    my $step_obj = $start_obj->clone;
+    while (DateTime->compare($step_obj,$end_obj) < 0) { # inside HOO
+        if (DateTime->compare($step_obj,$now_obj) >= 0) { # only offer times in the future
+            my $step_ts = $step_obj->strftime('%FT%T%z');
+            my $other_slots = $e->search_action_curbside({org => $org, slot => $step_ts}, {idlist => 1});
+            my $available = $max - scalar(@$other_slots);
+            $available = $available < 0 ? 0 : $available; # so truthiness testing is always easy in the client
+
+            $conn->respond([$step_obj->strftime('%T'), $available]);
+        }
+        $step_obj->add(seconds => $gran_seconds);
+    }
+
+    $e->disconnect;
+    return undef;
+}
+__PACKAGE__->register_method(
+    method   => "times_for_date",
+    api_name => "open-ils.curbside.times_for_date",
+    stream   => 1,
+    argc     => 2,
+    signature=> {
+        params => [
+            {type => "string", desc => "Authentication token"},
+            {type => "string", desc => "Date to find times for"},
+            {type => "number", desc => "Library ID (default ws_ou)"},
+        ],
+        return => {desc => 'A stream of array refs, structure: ["hh:mm:ss",$available_count]; event on error.'}
+    },
+    notes   => 'Restricted to logged in users to avoid spamming induced load'
+);
+
+sub create_update_appointment {
+    my ($self, $conn, $authtoken, $patron, $date, $time, $org, $notes) = @_;
+    my $mode = 'create';
+    $mode = 'update' if ($self->api_name =~ /update/);
+
+    my $e = new_editor(xact => 1, authtoken => $authtoken);
+    return $e->die_event unless $e->checkauth;
+
+    $org ||= $e->requestor->ws_ou;
+
+    return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
+        $U->ou_ancestor_setting_value($org, 'circ.curbside')
+    ));
+
+    unless ($patron == $e->requestor->id) {
+        return $e->die_event unless $e->allowed("STAFF_LOGIN");
+    }
+
+    my $date_obj = $date_parser->parse_datetime($date); # no TZ necessary, just using it to test the input and get DOW
+    return undef unless ($date_obj);
+
+    if ($time =~ /^\d\d:\d\d$/) {
+        $time .= ":00"; # tack on seconds if needed to keep
+                        # interval_to_seconds happy
+    }
+
+    my $slot;
+
+    # do they already have an open slot?
+    # NOTE: once arrival is set, it's past the point of editing.
+    my $old_slot = $e->search_action_curbside({
+        patron  => $patron,
+        org     => $org,
+        slot    => { '!=' => undef },
+        arrival => undef
+    })->[0];
+    if ($old_slot) {
+        if ($mode eq 'create') {
+            my $ev = new OpenILS::Event("CURBSIDE_EXISTS");
+            $e->disconnect;
+            return $ev;
+        } else {
+            $slot = $old_slot;
+        }
+    }
+
+    my $gran = $U->ou_ancestor_setting_value($org, 'circ.curbside.granularity') || '15 minutes';
+    my $max = $U->ou_ancestor_setting_value($org, 'circ.curbside.max_concurrent') || 10;
+
+    # some sanity checking
+    my $hoo = $e->retrieve_actor_org_unit_hours_of_operation($org);
+    return undef unless ($hoo);
+
+    my $dow = $date_obj->day_of_week_0;
+
+    my $open_method = "dow_${dow}_open";
+    my $close_method = "dow_${dow}_close";
+
+    my $open_time = $hoo->$open_method;
+    my $close_time = $hoo->$close_method;
+    return undef if ($open_time eq $close_time); # location closed that day
+
+    my $open_seconds = interval_to_seconds($open_time);
+    my $close_seconds = interval_to_seconds($close_time);
+
+    my $time_seconds = interval_to_seconds($time);
+    my $gran_seconds = interval_to_seconds($gran);
+
+    return undef if ($time_seconds < $open_seconds); # too early
+    return undef if ($time_seconds > $close_seconds + 1); # too late (/at/ closing allowed)
+
+    my $time_into_open_second = $time_seconds - $open_seconds;
+    if (my $extra_time = $time_into_open_second % $gran) { # a remainder means we got a time we shouldn't have
+        $time_into_open_second -= $extra_time; # just back it off to have staff gather earlier
+    }
+
+    my $tz = $U->ou_ancestor_setting_value($org, 'lib.timezone') || 'local';
+    $date_obj = $date_parser->parse_datetime($date.'T'.$open_time)->set_time_zone($tz);
+
+    my $slot_ts = $date_obj->add(seconds => $time_into_open_second)->strftime('%FT%T%z');
+
+    # finally, confirm that there aren't too many already
+    my $other_slots = $e->search_action_curbside(
+        { org => $org,
+          slot => $slot_ts,
+          ( $slot ? (id => { '<>' => $slot->id }) : () ) # exclude our own slot from the count
+        },
+        {idlist => 1}
+    );
+    if (scalar(@$other_slots) >= $max) { # oops... return error
+        my $ev = new OpenILS::Event("CURBSIDE_MAX_FOR_TIME");
+        $e->disconnect;
+        return $ev;
+    }
+
+    my $method = 'update_action_curbside';
+    if ($mode eq 'create' or !$slot) {
+        $slot = $e->search_action_curbside({
+            patron  => $patron,
+            org     => $org,
+            slot    => undef,
+            arrival => undef,
+        })->[0];
+    }
+
+    if (!$slot) { # just in case the hold-ready reactor isn't in place
+        $slot = Fieldmapper::action::curbside->new;
+        $slot->isnew(1);
+        $slot->patron($patron);
+        $slot->org($org);
+        $slot->notes($notes) if ($notes);
+        $method = 'create_action_curbside';
+    } else {
+        $slot->notes($notes) if ($notes);
+        $slot->ischanged(1);
+        $method = 'update_action_curbside';
+    }
+
+    $slot->slot($slot_ts);
+    $e->$method($slot) or return $e->die_event;
+
+    $e->commit;
+    $conn->respond_complete($e->retrieve_action_curbside($slot->id));
+
+    OpenSRF::AppSession
+        ->create('open-ils.trigger')
+        ->request(
+            'open-ils.trigger.event.autocreate',
+            'hold.confirm_curbside',
+            $slot, $slot->org);
+
+    return undef;
+}
+__PACKAGE__->register_method(
+    method   => "create_update_appointment",
+    api_name => "open-ils.curbside.update_appointment",
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Patron ID'},
+            {type => 'string', desc => 'New Date'},
+            {type => 'string', desc => 'New Time'},
+            {type => 'number', desc => 'Library ID (default ws_ou)'},
+        ],
+        return => { desc => 'An action::curbside record on success, '.
+                            'an ILS Event on config, permission, or '.
+                            'recoverable errors, or nothing on bad '.
+                            'or silly data'}
+    }
+);
+
+__PACKAGE__->register_method(
+    method   => "create_update_appointment",
+    api_name => "open-ils.curbside.create_appointment",
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Patron ID'},
+            {type => 'string', desc => 'Date'},
+            {type => 'string', desc => 'Time'},
+            {type => 'number', desc => 'Library ID (default ws_ou)'},
+        ],
+        return => { desc => 'An action::curbside record on success, '.
+                            'an ILS Event on config, permission, or '.
+                            'recoverable errors, or nothing on bad '.
+                            'or silly data'}
+    }
+);
+
+sub delete_appointment {
+    my ($self, $conn, $authtoken, $appointment) = @_;
+    my $e = new_editor(xact => 1, authtoken => $authtoken);
+    return $e->die_event unless $e->checkauth;
+
+    my $slot = $e->retrieve_action_curbside($appointment);
+    return undef unless ($slot);
+
+    unless ($slot->patron == $e->requestor->id) {
+        return $e->die_event unless $e->allowed("STAFF_LOGIN");
+    }
+
+    $e->delete_action_curbside($slot) or return $e->die_event;
+    $e->commit;
+
+    return -1;
+}
+__PACKAGE__->register_method(
+    method   => "delete_appointment",
+    api_name => "open-ils.curbside.delete_appointment",
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Appointment ID'},
+        ],
+        return => { desc => '-1 on success, nothing when no appointment found, '.
+                            'or an ILS Event on permission error'}
+    }
+);
+
+sub manage_staging_claim {
+    my ($self, $conn, $authtoken, $appointment) = @_;
+    my $e = new_editor(xact => 1, authtoken => $authtoken);
+    return $e->die_event unless $e->checkauth;
+    return $e->die_event unless $e->allowed("STAFF_LOGIN");
+
+    my $slot = $e->retrieve_action_curbside($appointment);
+    return undef unless ($slot);
+
+    if ($self->api_name =~ /unclaim/) {
+        $slot->clear_stage_staff();
+    } else {
+        $slot->stage_staff($e->requestor->id);
+    }
+
+    $e->update_action_curbside($slot) or return $e->die_event;
+    $e->commit;
+
+    return $e->retrieve_action_curbside([
+        $slot->id, {
+            flesh => 3,
+            flesh_fields => {acsp => ['patron','stage_staff'], au => ['card','standing_penalties'], ausp => ['standing_penalty']},
+        }
+    ]);
+}
+__PACKAGE__->register_method(
+    method   => "manage_staging_claim",
+    api_name => "open-ils.curbside.claim_staging",
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Appointment ID'},
+        ],
+        return => { desc => 'Appointment on success, nothing when no appointment found, '.
+                            'an ILS Event on permission error'}
+    }
+);
+__PACKAGE__->register_method(
+    method   => "manage_staging_claim",
+    api_name => "open-ils.curbside.unclaim_staging",
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Appointment ID'},
+        ],
+        return => { desc => 'Appointment on success, nothing when no appointment found, '.
+                            'an ILS Event on permission error'}
+    }
+);
+
+sub mark_staged {
+    my ($self, $conn, $authtoken, $appointment) = @_;
+    my $e = new_editor(xact => 1, authtoken => $authtoken);
+    return $e->die_event unless $e->checkauth;
+    return $e->die_event unless $e->allowed("STAFF_LOGIN");
+
+    my $slot = $e->retrieve_action_curbside($appointment);
+    return undef unless ($slot);
+
+    $slot->staged('now');
+    $slot->stage_staff($e->requestor->id);
+    $e->update_action_curbside($slot) or return $e->die_event;
+    $e->commit;
+
+    return $e->retrieve_action_curbside($slot->id);
+}
+__PACKAGE__->register_method(
+    method   => "mark_staged",
+    api_name => "open-ils.curbside.mark_staged",
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Appointment ID'},
+        ],
+        return => { desc => 'Appointment on success, nothing when no appointment found, '.
+                            'an ILS Event on permission error'}
+    }
+);
+
+sub mark_unstaged {
+    my ($self, $conn, $authtoken, $appointment) = @_;
+    my $e = new_editor(xact => 1, authtoken => $authtoken);
+    return $e->die_event unless $e->checkauth;
+    return $e->die_event unless $e->allowed("STAFF_LOGIN");
+
+    my $slot = $e->retrieve_action_curbside($appointment);
+    return undef unless ($slot);
+
+    $slot->clear_staged();
+    $slot->clear_stage_staff();
+    $e->update_action_curbside($slot) or return $e->die_event;
+    $e->commit;
+
+    return $e->retrieve_action_curbside($slot->id);
+}
+__PACKAGE__->register_method(
+    method   => "mark_unstaged",
+    api_name => "open-ils.curbside.mark_unstaged",
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Appointment ID'},
+        ],
+        return => { desc => 'Appointment on success, nothing when no appointment found, '.
+                            'an ILS Event on permission error'}
+    }
+);
+
+sub mark_arrived {
+    my ($self, $conn, $authtoken, $appointment) = @_;
+    my $e = new_editor(xact => 1, authtoken => $authtoken);
+    return $e->die_event unless $e->checkauth;
+
+    my $slot = $e->retrieve_action_curbside($appointment);
+    return undef unless ($slot);
+
+    unless ($slot->patron == $e->requestor->id) {
+        return $e->die_event unless $e->allowed("STAFF_LOGIN");
+    }
+
+    $slot->arrival('now');
+
+    $e->update_action_curbside($slot) or return $e->die_event;
+    $e->commit;
+
+    return $e->retrieve_action_curbside($slot->id);
+}
+__PACKAGE__->register_method(
+    method   => "mark_arrived",
+    api_name => "open-ils.curbside.mark_arrived",
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Appointment ID'},
+        ],
+        return => { desc => 'Appointment on success, nothing when no appointment found, '.
+                            'or an ILS Event on permission error'}
+    }
+);
+
+sub mark_delivered {
+    my ($self, $conn, $authtoken, $appointment) = @_;
+    my $e = new_editor(xact => 1, authtoken => $authtoken);
+    return $e->die_event unless $e->checkauth;
+    return $e->die_event unless $e->allowed("STAFF_LOGIN");
+
+    my $slot = $e->retrieve_action_curbside($appointment);
+    return undef unless ($slot);
+
+    if (!$slot->staged) {
+        $slot->staged('now');
+        $slot->stage_staff($e->requestor->id);
+    }
+
+    if (!$slot->arrival) {
+        $slot->arrival('now');
+    }
+
+    $slot->delivered('now');
+    $slot->delivery_staff($e->requestor->id);
+
+    $e->update_action_curbside($slot) or return $e->die_event;
+    $e->commit;
+
+    my $holds = $e->search_action_hold_request({
+        usr => $slot->patron,
+        current_shelf_lib => $slot->org,
+        pickup_lib => $slot->org,
+        shelf_time => {'!=' => undef},
+        cancel_time => undef,
+        fulfillment_time => undef
+    });
+
+    my $circ_sess = OpenSRF::AppSession->connect('open-ils.circ');
+    my @requests = map {
+        $circ_sess->request( # Just try as hard as possible to check out everything
+            'open-ils.circ.checkout.full.override',
+            $authtoken, { patron => $slot->patron, copyid => $_->current_copy }
+        )
+    } @$holds;
+
+    my @successful_checkouts;
+    my $successful_patron;
+    for my $r (@requests) {
+        my $co_res = $r->gather(1);
+        $conn->respond($co_res);
+        next if (ref($co_res) eq 'ARRAY'); # success is always singular
+
+        if ($co_res->{textcode} eq 'SUCCESS') { # that's great news...
+            push @successful_checkouts, $co_res->{payload}->{circ}->id;
+            $successful_patron = $co_res->{payload}->{circ}->usr;
+        }
+    }
+
+    $conn->respond_complete($e->retrieve_action_curbside($slot->id));
+
+    $circ_sess->request(
+        'open-ils.circ.checkout.batch_notify.session.atomic',
+        $authtoken,
+        $successful_patron,
+        \@successful_checkouts
+    ) if (@successful_checkouts);
+
+    $circ_sess->disconnect;
+    return undef;
+}
+__PACKAGE__->register_method(
+    method   => "mark_delivered",
+    api_name => "open-ils.curbside.mark_delivered",
+    stream   => 1,
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Appointment ID'},
+        ],
+        return => { desc => 'Nothing for no appointment found, '.
+                            'a stream of open-ils.circ.checkout.full.override '.
+                            'responses followed by the finalized slot, '.
+                            'or an ILS Event on permission error'}
+    }
+);
+
+1;