Curbside application
authorMike Rylander <mrylander@gmail.com>
Tue, 19 May 2020 19:11:54 +0000 (15:11 -0400)
committerGalen Charlton <gmc@equinoxinitiative.org>
Thu, 21 May 2020 21:25:51 +0000 (17:25 -0400)
This commit adds the Curbside OpenSRF application, which provides all
the business logic and data retrieval APIs for the UIs.

Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>
Open-ILS/examples/opensrf.xml.example
Open-ILS/src/perlmods/lib/OpenILS/Application/Curbside.pm [new file with mode: 0644]

index eb875b1..f9736fd 100644 (file)
@@ -1270,6 +1270,26 @@ vim:et:ts=4:sw=4:
                   <request_timeout>60</request_timeout>
                 </app_settings>
             </open-ils.ebook_api>
+
+            <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>
+                    <max_spare_children>5</max_spare_children>
+                </unix_config>
+                <app_settings>
+                </app_settings>
+            </open-ils.curbside>
         </apps>
     </default>
 
@@ -1317,6 +1337,7 @@ vim:et:ts=4:sw=4:
                 <appname>open-ils.serial</appname>  
                 <appname>open-ils.hold-targeter</appname>  
                 <appname>open-ils.ebook_api</appname>
+                <appname>open-ils.curbside</appname>
             </activeapps>
         </localhost>
     </hosts>
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..3ae817b
--- /dev/null
@@ -0,0 +1,723 @@
+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 DateTime;
+use DateTime::Format::ISO8601;
+
+my $date_parser = DateTime::Format::ISO8601->new;
+
+use OpenSRF::Utils::Logger qw/$logger/;
+
+sub fetch_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'},
+    },{
+        flesh => 1, flesh_fields => {acsp => 'patron'},
+        order_by => { acsp => {delivered => {direction => 'desc'}} }
+    }]);
+
+    $conn->respond($_) for @$slots;
+    return undef;
+}
+__PACKAGE__->register_method(
+    method   => "fetch_delivered",
+    api_name => "open-ils.curbside.fetch_delivered",
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Library ID'},
+        ],
+        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'},
+    },{
+        limit => 1,
+        order_by => { acsp => {delivered => {direction => 'desc'}} }
+    }]);
+
+    return @$slots ? $$slots[0]->delivered : undef;
+}
+__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 => 'Most recent delivery timestamp from today, or error event'}
+    }
+);
+
+sub fetch_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,
+    },{
+        flesh => 1, flesh_fields => {acsp => 'patron'},
+        order_by => { acsp => 'arrival' }
+    }]);
+
+    for my $s (@$slots) {
+        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,
+            fulfillment_time => undef
+        });
+
+        $conn->respond({slot => $s, holds => $holds});
+    }
+
+    return undef;
+}
+__PACKAGE__->register_method(
+    method   => "fetch_arrived",
+    api_name => "open-ils.curbside.fetch_arrived",
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Library ID'},
+        ],
+        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,
+    },{
+        limit => 1, order_by => { acsp => { arrival => { direction => 'desc' } } }
+    }]);
+
+    return @$slots ? $$slots[0]->arrived : undef;
+}
+__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 => 'Most recent arrival time on undelivered appointments'}
+    }
+);
+
+sub fetch_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},
+        arrived => undef
+    },{
+        flesh => 1, flesh_fields => {acsp => 'patron'},
+        order_by => { acsp => 'slot' }
+    }]);
+
+    for my $s (@$slots) {
+        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,
+            fulfillment_time => undef
+        });
+
+        $conn->respond({slot => $s, holds => $holds});
+    }
+
+    return undef;
+}
+__PACKAGE__->register_method(
+    method   => "fetch_staged",
+    api_name => "open-ils.curbside.fetch_staged",
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Library ID'},
+        ],
+        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},
+        arrived => undef
+    },{
+        limit => 1, order_by => { acsp => { slot => { direction => 'desc' } } }
+    }]);
+
+    return @$slots ? $$slots[0]->staged : undef;
+}
+__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 => 'Time of most recently staged appointment'}
+    }
+);
+
+sub fetch_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 = new DateTime;
+    $horizon->add(seconds => $gran_seconds * 2);
+
+    my $slots = $e->search_action_curbside([{
+        org => $org,
+        staged => undef,
+        slot => { '<=' => $horizon->strftime('%FT%T%z') },
+    },{
+        flesh => 1, flesh_fields => {acsp => 'patron'},
+        order_by => { acsp => 'slot' }
+    }]);
+
+    for my $s (@$slots) {
+        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,
+            fulfillment_time => undef
+        });
+
+        $conn->respond({slot => $s, holds => $holds});
+    }
+
+    return undef;
+}
+__PACKAGE__->register_method(
+    method   => "fetch_to_be_staged",
+    api_name => "open-ils.curbside.fetch_to_be_staged",
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Library ID'},
+        ],
+        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 = new DateTime;
+    $horizon->add(seconds => $gran_seconds * 2);
+
+    my $slots = $e->search_action_curbside([{
+        org => $org,
+        staged => undef,
+        slot => { '<=' => $horizon->strftime('%FT%T%z') },
+    },{
+        limit => 1, order_by => { acsp => { slot => { direction => 'desc' } } }
+    }]);
+
+    return @$slots ? $$slots[0]->slot : undef;
+}
+__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 => 'Appointment time of the latest slot 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
+
+    $start_obj = $date_parser->parse_datetime($date.'T'.$open_time); # reset this to opening time
+    my $end_obj = $date_parser->parse_datetime($date.'T'.$close_time);
+
+    my $now_obj = DateTime->now;
+    my $step_obj = $start_obj->clone;
+    while (DateTime->compare($step_obj,$end_obj) <= 0) { # inside HOO
+        if (DateTime->compare($step_obj,$now_obj) >= 0) { # don't offer times in the past
+            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);
+            $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",
+    argc     => 2,
+    streaming=> 1,
+    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) = @_;
+    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);
+    return undef unless ($date_obj);
+
+    my $slot;
+
+    # do they already have an open slot?
+    # NOTE: once arrived is set, it's past the point of editing.
+    my $old_slot = $e->search_action_curbside({
+        patron => $patron,
+        org    => $org,
+        slot   => { '!=' => undef },
+        arrived=> 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)
+
+    $date_obj = $date_parser->parse_datetime($date.'T'.$open_time);
+
+    my $time_into_open_second = $time - $open_time;
+    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 $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}, {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,
+            arrived=> undef,
+        })->[0];
+    }
+
+    if (!$slot) { # just in case the hold-ready reactor isn't in place
+        $slot = Fieldmapper::action::curbside->new;
+        $slot->patron($patron);
+        $slot->org($org);
+        $method = 'create_action_curbside';
+    }
+
+    $slot->slot($slot_ts);
+    $slot = $e->$method($slot);
+
+    $e->commit;
+    $conn->respond_complete($slot);
+
+    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 ($appointment->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 undef;
+}
+__PACKAGE__->register_method(
+    method   => "delete_appointment",
+    api_name => "open-ils.curbside.delete_appointment",
+    signature => {
+        params => [
+            {type => 'string', desc => 'Authentication token'},
+            {type => 'number', desc => 'Patron'},
+            {type => 'string', desc => 'Date'},
+            {type => 'string', desc => 'Time'},
+        ],
+        return => { desc => 'Nothing on success or 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);
+    $slot = $e->update_action_curbside($slot) or return $e->die_event;
+    $e->commit;
+
+    return $slot;
+}
+__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_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 ($appointment->patron == $e->requestor->id) {
+        return $e->die_event unless $e->allowed("STAFF_LOGIN");
+    }
+
+    $slot->arrival('now');
+
+    $slot = $e->update_action_curbside($slot) or return $e->die_event;
+    $e->commit;
+
+    return $slot;
+}
+__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 => 'Nothing on success or no appointment found'.
+                            '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);
+
+    $slot = $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;
+
+    $conn->respond($_->gather(1)) for @requests;
+    $circ_sess->disconnect;
+
+    return $slot;
+}
+__PACKAGE__->register_method(
+    method   => "mark_delivered",
+    api_name => "open-ils.curbside.mark_delivered",
+    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;