travelynx/lib/Travelynx.pm
Birte Kristina Friesel 5a2285bd59
check train/trip id when updating associated data
this avoid race conditions when a user changes their checkin between the time
of request (old trip id) and the time of data update (new trip id). These
could lead to funny effects such as buses suddenly having S-Bahn carriage
formations.
2024-05-24 21:39:09 +02:00

2480 lines
64 KiB
Perl
Executable file

package Travelynx;
# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious';
use Mojo::Pg;
use Mojo::Promise;
use Mojolicious::Plugin::Authentication;
use Cache::File;
use Crypt::Eksblowfish::Bcrypt qw(bcrypt en_base64);
use DateTime;
use DateTime::Format::Strptime;
use Encode qw(decode encode);
use File::Slurp qw(read_file);
use JSON;
use List::Util;
use List::UtilsBy qw(uniq_by);
use List::MoreUtils qw(first_index);
use Travel::Status::DE::DBWagenreihung;
use Travelynx::Helper::DBDB;
use Travelynx::Helper::HAFAS;
use Travelynx::Helper::IRIS;
use Travelynx::Helper::Sendmail;
use Travelynx::Helper::Traewelling;
use Travelynx::Model::InTransit;
use Travelynx::Model::Journeys;
use Travelynx::Model::JourneyStatsCache;
use Travelynx::Model::Stations;
use Travelynx::Model::Traewelling;
use Travelynx::Model::Users;
sub check_password {
my ( $password, $hash ) = @_;
if ( bcrypt( substr( $password, 0, 10000 ), $hash ) eq $hash ) {
return 1;
}
return 0;
}
sub epoch_to_dt {
my ($epoch) = @_;
# Bugs (and user errors) may lead to undefined timestamps. Set them to
# 1970-01-01 to avoid crashing and show obviously wrong data instead.
$epoch //= 0;
return DateTime->from_epoch(
epoch => $epoch,
time_zone => 'Europe/Berlin',
locale => 'de-DE',
);
}
sub startup {
my ($self) = @_;
push( @{ $self->commands->namespaces }, 'Travelynx::Command' );
$self->defaults( layout => 'default' );
$self->types->type( csv => 'text/csv; charset=utf-8' );
$self->types->type( json => 'application/json; charset=utf-8' );
$self->plugin('Config');
if ( $self->config->{secrets} ) {
$self->secrets( $self->config->{secrets} );
}
chomp $self->config->{version};
$self->defaults( version => $self->config->{version} // 'UNKNOWN' );
$self->plugin(
authentication => {
autoload_user => 1,
fail_render => { template => 'login' },
load_user => sub {
my ( $self, $uid ) = @_;
return $self->get_user_data($uid);
},
validate_user => sub {
my ( $self, $username, $password, $extradata ) = @_;
my $user_info
= $self->users->get_login_data( name => $username );
if ( not $user_info ) {
return undef;
}
if ( $user_info->{status} != 1 ) {
return undef;
}
if ( check_password( $password, $user_info->{password_hash} ) )
{
return $user_info->{id};
}
return undef;
},
}
);
if ( my $oa = $self->config->{traewelling}{oauth} ) {
$self->plugin(
OAuth2 => {
providers => {
traewelling => {
key => $oa->{id},
secret => $oa->{secret},
authorize_url =>
'https://traewelling.de/oauth/authorize?response_type=code',
token_url => 'https://traewelling.de/oauth/token',
}
}
}
);
}
$self->sessions->default_expiration( 60 * 60 * 24 * 180 );
# Starting with v8.11, Mojolicious sends SameSite=Lax Cookies by default.
# In theory, "The default lax value provides a reasonable balance between
# security and usability for websites that want to maintain user's logged-in
# session after the user arrives from an external link". In practice,
# Safari (both iOS and macOS) does not send a SameSite=lax cookie when
# following a link from an external site. So, bahn.expert providing a
# checkin link to travelynx.de/s/whatever does not work because the user
# is not logged in due to Safari not sending the cookie.
#
# This looks a lot like a Safari bug, but we can't do anything about it. So
# we don't set the SameSite flag at all for now.
#
# --derf, 2019-05-01
$self->sessions->samesite(undef);
$self->defaults( layout => 'default' );
$self->hook(
before_dispatch => sub {
my ($self) = @_;
# The "theme" cookie is set client-side if the theme we delivered was
# changed by dark mode detection or by using the theme switcher. It's
# not part of Mojolicious' session data (and can't be, due to
# signing and HTTPOnly), so we need to add it here.
for my $cookie ( @{ $self->req->cookies } ) {
if ( $cookie->name eq 'theme' ) {
$self->session( theme => $cookie->value );
return;
}
}
}
);
$self->attr(
cache_iris_main => sub {
my ($self) = @_;
return Cache::File->new(
cache_root => $self->app->config->{cache}->{schedule},
default_expires => '6 hours',
lock_level => Cache::File::LOCK_LOCAL(),
);
}
);
$self->attr(
cache_iris_rt => sub {
my ($self) = @_;
return Cache::File->new(
cache_root => $self->app->config->{cache}->{realtime},
default_expires => '70 seconds',
lock_level => Cache::File::LOCK_LOCAL(),
);
}
);
$self->attr(
coordinates_by_station => sub {
my $legacy_names = $self->app->renamed_station;
my $location = $self->stations->get_latlon_by_name;
while ( my ( $old_name, $new_name ) = each %{$legacy_names} ) {
$location->{$old_name} = $location->{$new_name};
}
return $location;
}
);
# https://de.wikipedia.org/wiki/Liste_nach_Gemeinden_und_Regionen_benannter_IC/ICE-Fahrzeuge#Namensgebung_ICE-Triebz%C3%BCge_nach_Gemeinden
# via https://github.com/marudor/bahn.expert/blob/main/src/server/coachSequence/TrainNames.ts
$self->attr(
ice_name => sub {
my $id_to_name = JSON->new->utf8->decode(
scalar read_file('share/ice_names.json') );
return $id_to_name;
}
);
$self->attr(
renamed_station => sub {
my $legacy_to_new = JSON->new->utf8->decode(
scalar read_file('share/old_station_names.json') );
return $legacy_to_new;
}
);
if ( not $self->app->config->{base_url} ) {
$self->app->log->error(
"travelynx.conf: 'base_url' is missing. Links in maintenance/work/worker-generated E-Mails will be incorrect. This variable was introduced in travelynx 1.22; see examples/travelynx.conf for documentation."
);
}
$self->helper(
base_url_for => sub {
my ( $self, $path ) = @_;
if ( ( my $url = $self->url_for($path) )->base ne q{}
or not $self->app->config->{base_url} )
{
return $url;
}
return $self->url_for($path)
->base( $self->app->config->{base_url} );
}
);
$self->helper(
hafas => sub {
my ($self) = @_;
state $hafas = Travelynx::Helper::HAFAS->new(
log => $self->app->log,
main_cache => $self->app->cache_iris_main,
realtime_cache => $self->app->cache_iris_rt,
root_url => $self->base_url_for('/')->to_abs,
user_agent => $self->ua,
version => $self->app->config->{version},
);
}
);
$self->helper(
iris => sub {
my ($self) = @_;
state $iris = Travelynx::Helper::IRIS->new(
log => $self->app->log,
main_cache => $self->app->cache_iris_main,
realtime_cache => $self->app->cache_iris_rt,
root_url => $self->base_url_for('/')->to_abs,
version => $self->app->config->{version},
);
}
);
$self->helper(
traewelling => sub {
my ($self) = @_;
state $trwl = Travelynx::Model::Traewelling->new( pg => $self->pg );
}
);
$self->helper(
traewelling_api => sub {
my ($self) = @_;
state $trwl_api = Travelynx::Helper::Traewelling->new(
log => $self->app->log,
model => $self->traewelling,
root_url => $self->base_url_for('/')->to_abs,
user_agent => $self->ua,
version => $self->app->config->{version},
);
}
);
$self->helper(
in_transit => sub {
my ($self) = @_;
state $in_transit = Travelynx::Model::InTransit->new(
log => $self->app->log,
pg => $self->pg,
);
}
);
$self->helper(
journey_stats_cache => sub {
my ($self) = @_;
state $journey_stats_cache
= Travelynx::Model::JourneyStatsCache->new(
log => $self->app->log,
pg => $self->pg,
);
}
);
$self->helper(
journeys => sub {
my ($self) = @_;
state $journeys = Travelynx::Model::Journeys->new(
log => $self->app->log,
pg => $self->pg,
in_transit => $self->in_transit,
stats_cache => $self->journey_stats_cache,
renamed_station => $self->app->renamed_station,
latlon_by_station => $self->app->coordinates_by_station,
stations => $self->stations,
);
}
);
$self->helper(
pg => sub {
my ($self) = @_;
my $config = $self->app->config;
my $dbname = $config->{db}->{database};
my $host = $config->{db}->{host} // 'localhost';
my $port = $config->{db}->{port} // 5432;
my $user = $config->{db}->{user};
my $pw = $config->{db}->{password};
state $pg
= Mojo::Pg->new("postgresql://${user}\@${host}:${port}/${dbname}")
->password($pw);
$pg->on(
connection => sub {
my ( $pg, $dbh ) = @_;
$dbh->do("set time zone 'Europe/Berlin'");
}
);
return $pg;
}
);
$self->helper(
sendmail => sub {
state $sendmail = Travelynx::Helper::Sendmail->new(
config => ( $self->config->{mail} // {} ),
log => $self->log
);
}
);
$self->helper(
stations => sub {
my ($self) = @_;
state $stations
= Travelynx::Model::Stations->new( pg => $self->pg );
}
);
$self->helper(
users => sub {
my ($self) = @_;
state $users = Travelynx::Model::Users->new( pg => $self->pg );
}
);
$self->helper(
dbdb => sub {
my ($self) = @_;
state $dbdb = Travelynx::Helper::DBDB->new(
log => $self->app->log,
cache => $self->app->cache_iris_main,
root_url => $self->base_url_for('/')->to_abs,
user_agent => $self->ua,
version => $self->app->config->{version},
);
}
);
$self->helper(
'now' => sub {
return DateTime->now( time_zone => 'Europe/Berlin' );
}
);
$self->helper(
'numify_skipped_stations' => sub {
my ( $self, $count ) = @_;
if ( $count == 0 ) {
return 'INTERNAL ERROR';
}
if ( $count == 1 ) {
return
'Eine Station ohne Geokoordinaten wurde nicht berücksichtigt.';
}
return
"${count} Stationen ohne Geookordinaten wurden nicht berücksichtigt.";
}
);
$self->helper(
'sprintf_km' => sub {
my ( $self, $km ) = @_;
if ( $km < 1 ) {
return sprintf( '%.f m', $km * 1000 );
}
if ( $km < 10 ) {
return sprintf( '%.1f km', $km );
}
return sprintf( '%.f km', $km );
}
);
$self->helper(
'load_icon' => sub {
my ( $self, $load ) = @_;
my $first = $load->{FIRST} // 0;
my $second = $load->{SECOND} // 0;
my @symbols
= (
qw(help_outline person_outline people priority_high not_interested)
);
return ( $symbols[$first], $symbols[$second] );
}
);
$self->helper(
'visibility_icon' => sub {
my ( $self, $visibility ) = @_;
if ( $visibility eq 'public' ) {
return 'language';
}
if ( $visibility eq 'travelynx' ) {
return 'lock_open';
}
if ( $visibility eq 'followers' ) {
return 'group';
}
if ( $visibility eq 'unlisted' ) {
return 'lock_outline';
}
if ( $visibility eq 'private' ) {
return 'lock';
}
return 'help_outline';
}
);
$self->helper(
'checkin_p' => sub {
my ( $self, %opt ) = @_;
my $station = $opt{station};
my $train_id = $opt{train_id};
my $uid = $opt{uid} // $self->current_user->{id};
my $db = $opt{db} // $self->pg->db;
my $hafas;
my $user = $self->get_user_status( $uid, $db );
if ( $user->{checked_in} or $user->{cancelled} ) {
return Mojo::Promise->reject('You are already checked in');
}
if ( $train_id =~ m{[|]} ) {
return $self->_checkin_hafas_p(%opt);
}
my $promise = Mojo::Promise->new;
$self->iris->get_departures_p(
station => $station,
lookbehind => 140,
lookahead => 40
)->then(
sub {
my ($status) = @_;
if ( $status->{errstr} ) {
$promise->reject( $status->{errstr} );
return;
}
my $eva = $status->{station_eva};
my $train = List::Util::first { $_->train_id eq $train_id }
@{ $status->{results} };
if ( not defined $train ) {
$promise->reject("Train ${train_id} not found");
return;
}
eval {
$self->in_transit->add(
uid => $uid,
db => $db,
departure_eva => $eva,
train => $train,
route => [ $self->iris->route_diff($train) ],
);
};
if ($@) {
$self->app->log->error(
"Checkin($uid): INSERT failed: $@");
$promise->reject( 'INSERT failed: ' . $@ );
return;
}
# mustn't be called during a transaction
if ( not $opt{in_transaction} ) {
$self->add_route_timestamps( $uid, $train, 1 );
$self->run_hook( $uid, 'checkin' );
}
$promise->resolve($train);
return;
}
)->catch(
sub {
my ( $err, $status ) = @_;
$promise->reject( $status->{errstr} );
return;
}
)->wait;
return $promise;
}
);
$self->helper(
'_checkin_hafas_p' => sub {
my ( $self, %opt ) = @_;
my $station = $opt{station};
my $train_id = $opt{train_id};
my $uid = $opt{uid} // $self->current_user->{id};
my $db = $opt{db} // $self->pg->db;
my $hafas;
my $promise = Mojo::Promise->new;
$self->hafas->get_journey_p(
trip_id => $train_id,
with_polyline => 1
)->then(
sub {
my ($journey) = @_;
my $found;
for my $stop ( $journey->route ) {
if ( $stop->loc->name eq $station
or $stop->loc->eva == $station )
{
$found = $stop;
last;
}
}
if ( not $found ) {
$promise->reject(
"Did not find journey $train_id at $station");
return;
}
for my $stop ( $journey->route ) {
$self->stations->add_or_update(
stop => $stop,
db => $db,
);
}
eval {
$self->in_transit->add(
uid => $uid,
db => $db,
journey => $journey,
stop => $found,
);
};
if ($@) {
$self->app->log->error(
"Checkin($uid): INSERT failed: $@");
$promise->reject( 'INSERT failed: ' . $@ );
return;
}
$self->in_transit->update_data(
uid => $uid,
db => $db,
data => { trip_id => $journey->id }
);
my $polyline;
if ( $journey->polyline ) {
my @station_list;
my @coordinate_list;
for my $coord ( $journey->polyline ) {
if ( $coord->{name} ) {
push(
@coordinate_list,
[
$coord->{lon}, $coord->{lat},
$coord->{eva}
]
);
push( @station_list, $coord->{name} );
}
else {
push( @coordinate_list,
[ $coord->{lon}, $coord->{lat} ] );
}
}
# equal length → polyline only consists of straight
# lines between stops. that's not helpful.
if ( @station_list == @coordinate_list ) {
$self->log->debug( 'Ignoring polyline for '
. $journey->line
. ' as it only consists of straight lines between stops.'
);
}
else {
$polyline = {
from_eva => ( $journey->route )[0]->loc->eva,
to_eva => ( $journey->route )[-1]->loc->eva,
coords => \@coordinate_list,
};
}
}
if ($polyline) {
$self->in_transit->set_polyline(
uid => $uid,
db => $db,
polyline => $polyline,
);
}
# mustn't be called during a transaction
if ( not $opt{in_transaction} ) {
$self->run_hook( $uid, 'checkin' );
}
$promise->resolve($journey);
}
)->catch(
sub {
my ($err) = @_;
$promise->reject($err);
return;
}
)->wait;
return $promise;
}
);
$self->helper(
'undo' => sub {
my ( $self, $journey_id, $uid ) = @_;
$uid //= $self->current_user->{id};
if ( $journey_id eq 'in_transit' ) {
eval { $self->in_transit->delete( uid => $uid ); };
if ($@) {
$self->app->log->error("Undo($uid, $journey_id): $@");
return "Undo($journey_id): $@";
}
$self->run_hook( $uid, 'undo' );
return undef;
}
if ( $journey_id !~ m{ ^ \d+ $ }x ) {
return 'Invalid Journey ID';
}
eval {
my $db = $self->pg->db;
my $tx = $db->begin;
my $journey = $self->journeys->pop(
uid => $uid,
db => $db,
journey_id => $journey_id
);
if ( $journey->{edited} ) {
die(
"Cannot undo a journey which has already been edited. Please delete manually.\n"
);
}
delete $journey->{edited};
delete $journey->{id};
# users may force checkouts at stations that are not part of
# the train's scheduled (or real-time) route. re-adding those
# to in-transit violates the assumption that each train has
# a valid destination. Remove the target in this case.
my $route = JSON->new->decode( $journey->{route} );
my $found_checkout_id;
for my $stop ( @{$route} ) {
if ( $stop->[1] == $journey->{checkout_station_id} ) {
$found_checkout_id = 1;
last;
}
}
if ( not $found_checkout_id ) {
$journey->{checkout_station_id} = undef;
$journey->{checkout_time} = undef;
$journey->{arr_platform} = undef;
$journey->{sched_arrival} = undef;
$journey->{real_arrival} = undef;
}
$self->in_transit->add_from_journey(
db => $db,
journey => $journey
);
my $cache_ts = DateTime->now( time_zone => 'Europe/Berlin' );
if ( $journey->{real_departure}
=~ m{ ^ (?<year> \d{4} ) - (?<month> \d{2} ) }x )
{
$cache_ts->set(
year => $+{year},
month => $+{month}
);
}
$self->journey_stats_cache->invalidate(
ts => $cache_ts,
db => $db,
uid => $uid
);
$tx->commit;
};
if ($@) {
$self->app->log->error("Undo($uid, $journey_id): $@");
return "Undo($journey_id): $@";
}
$self->run_hook( $uid, 'undo' );
return undef;
}
);
$self->helper(
'checkout_p' => sub {
my ( $self, %opt ) = @_;
my $station = $opt{station};
my $dep_eva = $opt{dep_eva};
my $arr_eva = $opt{arr_eva};
my $with_related = $opt{with_related} // 0;
my $force = $opt{force};
my $uid = $opt{uid} // $self->current_user->{id};
my $db = $opt{db} // $self->pg->db;
my $user = $self->get_user_status( $uid, $db );
my $train_id = $user->{train_id};
my $promise = Mojo::Promise->new;
if ( not $station ) {
$self->app->log->error("Checkout($uid): station is empty");
return $promise->resolve( 1,
'BUG: Checkout station is empty.' );
}
if ( not $user->{checked_in} and not $user->{cancelled} ) {
return $promise->resolve( 0,
'You are not checked into any train' );
}
if ( $dep_eva and $dep_eva != $user->{dep_eva} ) {
return $promise->resolve( 0, 'race condition' );
}
if ( $arr_eva and $arr_eva != $user->{arr_eva} ) {
return $promise->resolve( 0, 'race condition' );
}
if ( $train_id =~ m{[|]} ) {
return $self->_checkout_hafas_p(%opt);
}
my $now = DateTime->now( time_zone => 'Europe/Berlin' );
my $journey = $self->in_transit->get(
uid => $uid,
with_data => 1
);
$self->iris->get_departures_p(
station => $station,
lookbehind => 120,
lookahead => 180,
with_related => $with_related,
)->then(
sub {
my ($status) = @_;
my $new_checkout_station_id = $status->{station_eva};
# Store the intended checkout station regardless of this operation's
# success.
# TODO for with_related == 1, the correct EVA may be different
# and should be fetched from $train later on
$self->in_transit->set_arrival_eva(
uid => $uid,
db => $db,
arrival_eva => $new_checkout_station_id
);
# If in_transit already contains arrival data for another estimated
# destination, we must invalidate it.
if ( defined $journey->{checkout_station_id}
and $journey->{checkout_station_id}
!= $new_checkout_station_id )
{
$self->in_transit->unset_arrival_data(
uid => $uid,
db => $db
);
}
# Note that a train may pass the same station several times.
# Notable example: S41 / S42 ("Ringbahn") both starts and
# terminates at Berlin Südkreuz
my $train = List::Util::first {
$_->train_id eq $train_id
and $_->sched_arrival
and $_->sched_arrival->epoch
> $user->{sched_departure}->epoch
}
@{ $status->{results} };
$train //= List::Util::first { $_->train_id eq $train_id }
@{ $status->{results} };
if ( not defined $train ) {
# Arrival time via IRIS is unknown, so the train probably
# has not arrived yet. Fall back to HAFAS.
# TODO support cases where $station is EVA or DS100 code
if (
my $station_data
= List::Util::first { $_->[0] eq $station }
@{ $journey->{route} }
)
{
$station_data = $station_data->[2];
if ( $station_data->{sched_arr} ) {
my $sched_arr
= epoch_to_dt( $station_data->{sched_arr} );
my $rt_arr
= epoch_to_dt( $station_data->{rt_arr} );
if ( $rt_arr->epoch == 0 ) {
$rt_arr = $sched_arr->clone;
if ( $station_data->{arr_delay}
and $station_data->{arr_delay}
=~ m{^\d+$} )
{
$rt_arr->add( minutes =>
$station_data->{arr_delay} );
}
}
$self->in_transit->set_arrival_times(
uid => $uid,
db => $db,
sched_arrival => $sched_arr,
rt_arrival => $rt_arr
);
}
}
if ( not $force ) {
# mustn't be called during a transaction
if ( not $opt{in_transaction} ) {
$self->run_hook( $uid, 'update' );
}
$promise->resolve( 1, undef );
return;
}
}
my $has_arrived = 0;
eval {
my $tx;
if ( not $opt{in_transaction} ) {
$tx = $db->begin;
}
if ( defined $train
and not $train->arrival
and not $force )
{
my $train_no = $train->train_no;
die("Train ${train_no} has no arrival timestamp\n");
}
elsif ( defined $train and $train->arrival ) {
$self->in_transit->set_arrival(
uid => $uid,
db => $db,
train => $train,
route => [ $self->iris->route_diff($train) ]
);
$has_arrived
= $train->arrival->epoch < $now->epoch ? 1 : 0;
if ($has_arrived) {
my @unknown_stations
= $self->stations->grep_unknown(
$train->route );
if (@unknown_stations) {
$self->app->log->warn(
sprintf(
'Route of %s %s (%s -> %s) contains unknown stations: %s',
$train->type,
$train->train_no,
$train->origin,
$train->destination,
join( ', ', @unknown_stations )
)
);
}
}
}
$journey = $self->in_transit->get(
uid => $uid,
db => $db
);
if ( $has_arrived or $force ) {
$self->journeys->add_from_in_transit(
db => $db,
journey => $journey
);
$self->in_transit->delete(
uid => $uid,
db => $db
);
my $cache_ts = $now->clone;
if ( $journey->{real_departure}
=~ m{ ^ (?<year> \d{4} ) - (?<month> \d{2} ) }x
)
{
$cache_ts->set(
year => $+{year},
month => $+{month}
);
}
$self->journey_stats_cache->invalidate(
ts => $cache_ts,
db => $db,
uid => $uid
);
}
elsif ( defined $train
and $train->arrival_is_cancelled )
{
# This branch is only taken if the deparure was not cancelled,
# i.e., if the train was supposed to go here but got
# redirected or cancelled on the way and not from the start on.
# If the departure itself was cancelled, the user route is
# cancelled_from action -> 'cancelled journey' panel on main page
# -> cancelled_to action -> force checkout (causing the
# previous branch to be taken due to $force)
$journey->{cancelled} = 1;
$self->journeys->add_from_in_transit(
db => $db,
journey => $journey
);
$self->in_transit->set_cancelled_destination(
uid => $uid,
db => $db,
cancelled_destination => $train->station,
);
}
if ( not $opt{in_transaction} ) {
$tx->commit;
}
};
if ($@) {
$self->app->log->error("Checkout($uid): $@");
$promise->resolve( 1, 'Checkout error: ' . $@ );
return;
}
if ( $has_arrived or $force ) {
if ( not $opt{in_transaction} ) {
$self->run_hook( $uid, 'checkout' );
}
$promise->resolve( 0, undef );
return;
}
if ( not $opt{in_transaction} ) {
$self->run_hook( $uid, 'update' );
$self->add_route_timestamps( $uid, $train, 0, 1 );
}
$promise->resolve( 1, undef );
return;
}
)->catch(
sub {
my ($err) = @_;
$promise->resolve( 1, $err );
return;
}
)->wait;
return $promise;
}
);
$self->helper(
'_checkout_hafas_p' => sub {
my ( $self, %opt ) = @_;
my $station = $opt{station};
my $force = $opt{force};
my $uid = $opt{uid} // $self->current_user->{id};
my $db = $opt{db} // $self->pg->db;
my $promise = Mojo::Promise->new;
my $now = DateTime->now( time_zone => 'Europe/Berlin' );
my $journey = $self->in_transit->get(
uid => $uid,
db => $db,
with_data => 1,
with_timestamps => 1,
with_visibility => 1,
postprocess => 1,
);
# with_visibility needed due to postprocess
my $found;
my $has_arrived;
for my $stop ( @{ $journey->{route_after} } ) {
if ( $station eq $stop->[0] or $station eq $stop->[1] ) {
$found = 1;
$self->in_transit->set_arrival_eva(
uid => $uid,
db => $db,
arrival_eva => $stop->[1],
);
if ( defined $journey->{checkout_station_id}
and $journey->{checkout_station_id} != $stop->{eva} )
{
$self->in_transit->unset_arrival_data(
uid => $uid,
db => $db
);
}
$self->in_transit->set_arrival_times(
uid => $uid,
db => $db,
sched_arrival => $stop->[2]{sched_arr},
rt_arrival =>
( $stop->[2]{rt_arr} || $stop->[2]{sched_arr} )
);
if (
$now > ( $stop->[2]{rt_arr} || $stop->[2]{sched_arr} ) )
{
$has_arrived = 1;
}
last;
}
}
if ( not $found ) {
return $promise->resolve( 1, 'station not found in route' );
}
eval {
my $tx;
if ( not $opt{in_transaction} ) {
$tx = $db->begin;
}
if ( $has_arrived or $force ) {
$journey = $self->in_transit->get(
uid => $uid,
db => $db
);
$self->journeys->add_from_in_transit(
db => $db,
journey => $journey
);
$self->in_transit->delete(
uid => $uid,
db => $db
);
my $cache_ts = $now->clone;
if ( $journey->{real_departure}
=~ m{ ^ (?<year> \d{4} ) - (?<month> \d{2} ) }x )
{
$cache_ts->set(
year => $+{year},
month => $+{month}
);
}
$self->journey_stats_cache->invalidate(
ts => $cache_ts,
db => $db,
uid => $uid
);
}
if ($tx) {
$tx->commit;
}
};
if ($@) {
$self->app->log->error("Checkout($uid): $@");
return $promise->resolve( 1, 'Checkout error: ' . $@ );
}
if ( $has_arrived or $force ) {
if ( not $opt{in_transaction} ) {
$self->run_hook( $uid, 'checkout' );
}
return $promise->resolve( 0, undef );
}
if ( not $opt{in_transaction} ) {
$self->run_hook( $uid, 'update' );
}
return $promise->resolve( 1, undef );
}
);
# This helper should only be called directly when also providing a user ID.
# If you don't have one, use current_user() instead (get_user_data will
# delegate to it anyways).
$self->helper(
'get_user_data' => sub {
my ( $self, $uid ) = @_;
$uid //= $self->current_user->{id};
return $self->users->get( uid => $uid );
}
);
$self->helper(
'run_hook' => sub {
my ( $self, $uid, $reason, $callback ) = @_;
my $hook = $self->users->get_webhook( uid => $uid );
if ( not $hook->{enabled} or not $hook->{url} =~ m{^ https?:// }x )
{
if ($callback) {
&$callback();
}
return;
}
my $status = $self->get_user_status_json_v1( uid => $uid );
my $header = {};
my $hook_body = {
reason => $reason,
status => $status,
};
if ( $hook->{token} ) {
$header->{Authorization} = "Bearer $hook->{token}";
$header->{'User-Agent'}
= 'travelynx/' . $self->app->config->{version};
}
my $ua = $self->ua;
if ($callback) {
$ua->request_timeout(4);
}
else {
$ua->request_timeout(10);
}
$ua->post_p( $hook->{url} => $header => json => $hook_body )->then(
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
$self->users->update_webhook_status(
uid => $uid,
url => $hook->{url},
success => 0,
text => "HTTP $err->{code} $err->{message}"
);
}
else {
$self->users->update_webhook_status(
uid => $uid,
url => $hook->{url},
success => 1,
text => $tx->result->body
);
}
if ($callback) {
&$callback();
}
return;
}
)->catch(
sub {
my ($err) = @_;
$self->users->update_webhook_status(
uid => $uid,
url => $hook->{url},
success => 0,
text => $err
);
if ($callback) {
&$callback();
}
return;
}
)->wait;
}
);
# This helper is only ever called from an IRIS context.
# HAFAS already has all relevant information.
$self->helper(
'add_route_timestamps' => sub {
my ( $self, $uid, $train, $is_departure, $update_polyline ) = @_;
$uid //= $self->current_user->{id};
my $db = $self->pg->db;
# TODO "with_timestamps" is misleading, there are more differences between in_transit and in_transit_str
# Here it's only needed because of dep_eva / arr_eva names
my $in_transit = $self->in_transit->get(
db => $db,
uid => $uid,
with_data => 1,
with_timestamps => 1
);
if ( not $in_transit ) {
return;
}
my $route = $in_transit->{route};
my $train_id = $in_transit->{train_id};
# TODO get_tripid_p is only needed on the first call, afterwards the tripid is known.
$self->hafas->get_tripid_p( train => $train )->then(
sub {
my ($trip_id) = @_;
$self->in_transit->update_data(
uid => $uid,
db => $db,
data => { trip_id => $trip_id },
train_id => $train_id,
);
return $self->hafas->get_route_p(
train => $train,
trip_id => $trip_id,
with_polyline => (
$update_polyline
or not $in_transit->{polyline}
) ? 1 : 0,
);
}
)->then(
sub {
my ( $new_route, $journey, $polyline ) = @_;
my $db_route;
for my $i ( 0 .. $#{$new_route} ) {
my $old_name = $route->[$i][0];
my $old_eva = $route->[$i][1];
my $old_entry = $route->[$i][2];
my $new_name = $new_route->[$i]->{name};
my $new_eva = $new_route->[$i]->{eva};
my $new_entry = $new_route->[$i];
if ( defined $old_name and $old_name eq $new_name ) {
if ( $old_entry->{rt_arr}
and not $new_entry->{rt_arr} )
{
$new_entry->{rt_arr} = $old_entry->{rt_arr};
$new_entry->{arr_delay}
= $old_entry->{arr_delay};
}
if ( $old_entry->{rt_dep}
and not $new_entry->{rt_dep} )
{
$new_entry->{rt_dep} = $old_entry->{rt_dep};
$new_entry->{dep_delay}
= $old_entry->{dep_delay};
}
}
push(
@{$db_route},
[
$new_name,
$new_eva,
{
sched_arr => $new_entry->{sched_arr},
rt_arr => $new_entry->{rt_arr},
arr_delay => $new_entry->{arr_delay},
sched_dep => $new_entry->{sched_dep},
rt_dep => $new_entry->{rt_dep},
dep_delay => $new_entry->{dep_delay},
tz_offset => $new_entry->{tz_offset},
isAdditional => $new_entry->{isAdditional},
isCancelled => $new_entry->{isCancelled},
load => $new_entry->{load},
}
]
);
}
my @messages;
for my $m ( $journey->messages ) {
if ( not $m->code ) {
push(
@messages,
{
header => $m->short,
lead => $m->text,
}
);
}
}
$self->in_transit->set_route_data(
uid => $uid,
db => $db,
route => $db_route,
delay_messages => [
map { [ $_->[0]->epoch, $_->[1] ] }
$train->delay_messages
],
qos_messages => [
map { [ $_->[0]->epoch, $_->[1] ] }
$train->qos_messages
],
him_messages => \@messages,
train_id => $train_id,
);
if ($polyline) {
$self->in_transit->set_polyline(
uid => $uid,
db => $db,
polyline => $polyline,
old_id => $in_transit->{polyline_id},
train_id => $train_id,
);
}
return;
}
)->catch(
sub {
my ($err) = @_;
$self->app->log->debug("add_route_timestamps: $err");
return;
}
)->wait;
if ( $train->sched_departure ) {
$self->dbdb->has_wagonorder_p( $train->sched_departure,
$train->train_no )->then(
sub {
my ($api) = @_;
return $self->dbdb->get_wagonorder_p( $api,
$train->sched_departure, $train->train_no );
}
)->then(
sub {
my ($wagonorder) = @_;
my $data = {};
my $user_data = {};
if ( $is_departure and not exists $wagonorder->{error} )
{
$data->{wagonorder_dep} = $wagonorder;
$user_data->{wagongroups} = [];
for my $group (
@{
$wagonorder->{data}{istformation}
{allFahrzeuggruppe} // []
}
)
{
my @wagons;
for
my $wagon ( @{ $group->{allFahrzeug} // [] } )
{
push(
@wagons,
{
id => $wagon->{fahrzeugnummer},
number =>
$wagon->{wagenordnungsnummer},
type => $wagon->{fahrzeugtyp},
}
);
}
push(
@{ $user_data->{wagongroups} },
{
name =>
$group->{fahrzeuggruppebezeichnung},
from =>
$group->{startbetriebsstellename},
to => $group->{zielbetriebsstellename},
no => $group->{verkehrlichezugnummer},
wagons => [@wagons],
}
);
if ( $group->{fahrzeuggruppebezeichnung}
and $group->{fahrzeuggruppebezeichnung} eq
'ICE0304' )
{
$data->{wagonorder_pride} = 1;
}
}
$self->in_transit->update_data(
uid => $uid,
db => $db,
data => $data,
train_id => $train_id,
);
$self->in_transit->update_user_data(
uid => $uid,
db => $db,
user_data => $user_data,
train_id => $train_id,
);
}
elsif ( not $is_departure
and not exists $wagonorder->{error} )
{
$data->{wagonorder_arr} = $wagonorder;
$self->in_transit->update_data(
uid => $uid,
db => $db,
data => $data,
train_id => $train_id,
);
}
return;
}
)->catch(
sub {
# no wagonorder? no problem.
return;
}
)->wait;
}
if ($is_departure) {
$self->dbdb->get_stationinfo_p( $in_transit->{dep_eva} )->then(
sub {
my ($station_info) = @_;
my $data = { stationinfo_dep => $station_info };
$self->in_transit->update_data(
uid => $uid,
db => $db,
data => $data,
train_id => $train_id,
);
return;
}
)->catch(
sub {
# no stationinfo? no problem.
return;
}
)->wait;
}
if ( $in_transit->{arr_eva} and not $is_departure ) {
$self->dbdb->get_stationinfo_p( $in_transit->{arr_eva} )->then(
sub {
my ($station_info) = @_;
my $data = { stationinfo_arr => $station_info };
$self->in_transit->update_data(
uid => $uid,
db => $db,
data => $data,
train_id => $train_id,
);
return;
}
)->catch(
sub {
# no stationinfo? no problem.
return;
}
)->wait;
}
}
);
$self->helper(
'resolve_sb_template' => sub {
my ( $self, $template, %opt ) = @_;
my $ret = $template;
my $name = $opt{name} =~ s{/}{%2F}gr;
$ret =~ s{[{]eva[}]}{$opt{eva}}g;
$ret =~ s{[{]name[}]}{$name}g;
$ret =~ s{[{]tt[}]}{$opt{tt}}g;
$ret =~ s{[{]tn[}]}{$opt{tn}}g;
$ret =~ s{[{]id[}]}{$opt{id}}g;
return $ret;
}
);
$self->helper(
'stationinfo_to_direction' => sub {
my ( $self, $platform_info, $wagonorder, $prev_stop, $next_stop )
= @_;
if ( $platform_info->{kopfgleis} ) {
if ($next_stop) {
return $platform_info->{direction} eq 'r' ? 'l' : 'r';
}
return $platform_info->{direction};
}
elsif ( $prev_stop
and exists $platform_info->{direction_from}{$prev_stop} )
{
return $platform_info->{direction_from}{$prev_stop};
}
elsif ( $next_stop
and exists $platform_info->{direction_from}{$next_stop} )
{
return $platform_info->{direction_from}{$next_stop} eq 'r'
? 'l'
: 'r';
}
elsif ($wagonorder) {
my $wr;
eval {
$wr
= Travel::Status::DE::DBWagenreihung->new(
from_json => $wagonorder );
};
if ( $wr
and $wr->sections
and defined $wr->direction )
{
my $section_0 = ( $wr->sections )[0];
my $direction = $wr->direction;
if ( $section_0->name eq 'A'
and $direction == 0 )
{
return $platform_info->{direction};
}
elsif ( $section_0->name ne 'A'
and $direction == 100 )
{
return $platform_info->{direction};
}
elsif ( $platform_info->{direction} ) {
return $platform_info->{direction} eq 'r'
? 'l'
: 'r';
}
return;
}
}
}
);
$self->helper(
'journey_to_ajax_route' => sub {
my ( $self, $journey ) = @_;
my @route;
for my $station ( @{ $journey->{route_after} } ) {
my $station_desc = $station->[0];
my $sa = $station->[2]{sched_arr};
my $ra = $station->[2]{rt_arr} || $station->[2]{sched_arr};
my $sd = $station->[2]{sched_dep};
my $rd = $station->[2]{rt_dep} || $station->[2]{sched_dep};
$station_desc .= $sa ? $sa->strftime(';%s') : ';0';
$station_desc .= $ra ? $ra->strftime(';%s') : ';0';
$station_desc .= $sd ? $sd->strftime(';%s') : ';0';
$station_desc .= $rd ? $rd->strftime(';%s') : ';0';
push( @route, $station_desc );
}
return join( '|', @route );
}
);
$self->helper(
'get_user_status' => sub {
my ( $self, $uid, $db ) = @_;
$uid //= $self->current_user->{id};
$db //= $self->pg->db;
my $now = DateTime->now( time_zone => 'Europe/Berlin' );
my $epoch = $now->epoch;
my $in_transit = $self->in_transit->get(
uid => $uid,
db => $db,
with_data => 1,
with_timestamps => 1,
with_visibility => 1,
postprocess => 1,
);
if ($in_transit) {
my $ret = $in_transit;
my $traewelling = $self->traewelling->get(
uid => $uid,
db => $db
);
if ( $traewelling->{latest_run}
>= epoch_to_dt( $in_transit->{checkin_ts} ) )
{
$ret->{traewelling} = $traewelling;
if ( @{ $traewelling->{data}{log} // [] }
and ( my $log_entry = $traewelling->{data}{log}[0] ) )
{
if ( $log_entry->[2] ) {
$ret->{traewelling_status} = $log_entry->[2];
$ret->{traewelling_url}
= 'https://traewelling.de/status/'
. $log_entry->[2];
}
$ret->{traewelling_log_latest} = $log_entry->[1];
}
}
my $stop_after_dep
= scalar @{ $ret->{route_after} }
? $ret->{route_after}[0][0]
: undef;
my $stop_before_dest;
for my $i ( 1 .. $#{ $ret->{route_after} } ) {
if ( $ret->{arr_name}
and $ret->{route_after}[$i][0] eq $ret->{arr_name} )
{
$stop_before_dest = $ret->{route_after}[ $i - 1 ][0];
last;
}
}
my ($dep_platform_number)
= ( ( $ret->{dep_platform} // 0 ) =~ m{(\d+)} );
if ( $dep_platform_number
and
exists $ret->{data}{stationinfo_dep}{$dep_platform_number} )
{
$ret->{dep_direction} = $self->stationinfo_to_direction(
$ret->{data}{stationinfo_dep}{$dep_platform_number},
$ret->{data}{wagonorder_dep},
undef, $stop_after_dep
);
}
my ($arr_platform_number)
= ( ( $ret->{arr_platform} // 0 ) =~ m{(\d+)} );
if ( $arr_platform_number
and
exists $ret->{data}{stationinfo_arr}{$arr_platform_number} )
{
$ret->{arr_direction} = $self->stationinfo_to_direction(
$ret->{data}{stationinfo_arr}{$arr_platform_number},
$ret->{data}{wagonorder_arr},
$stop_before_dest,
undef
);
}
if ( $ret->{departure_countdown} > 0
and $in_transit->{data}{wagonorder_dep} )
{
my $wr;
eval {
$wr
= Travel::Status::DE::DBWagenreihung->new(
from_json => $in_transit->{data}{wagonorder_dep} );
};
if ( $wr
and $wr->wagons
and defined $wr->direction )
{
$ret->{wagonorder} = $wr;
}
}
return $ret;
}
my ( $latest, $latest_cancellation ) = $self->journeys->get_latest(
uid => $uid,
db => $db,
);
if ( $latest_cancellation and $latest_cancellation->{cancelled} ) {
if (
my $station = $self->stations->get_by_eva(
$latest_cancellation->{dep_eva}
)
)
{
$latest_cancellation->{dep_ds100} = $station->{ds100};
$latest_cancellation->{dep_name} = $station->{name};
}
if (
my $station = $self->stations->get_by_eva(
$latest_cancellation->{arr_eva}
)
)
{
$latest_cancellation->{arr_ds100} = $station->{ds100};
$latest_cancellation->{arr_name} = $station->{name};
}
}
else {
$latest_cancellation = undef;
}
if ($latest) {
my $ts = $latest->{checkout_ts};
my $action_time = epoch_to_dt($ts);
if ( my $station
= $self->stations->get_by_eva( $latest->{dep_eva} ) )
{
$latest->{dep_ds100} = $station->{ds100};
$latest->{dep_name} = $station->{name};
}
if ( my $station
= $self->stations->get_by_eva( $latest->{arr_eva} ) )
{
$latest->{arr_ds100} = $station->{ds100};
$latest->{arr_name} = $station->{name};
}
return {
checked_in => 0,
cancelled => 0,
cancellation => $latest_cancellation,
journey_id => $latest->{journey_id},
timestamp => $action_time,
timestamp_delta => $now->epoch - $action_time->epoch,
train_type => $latest->{train_type},
train_line => $latest->{train_line},
train_no => $latest->{train_no},
train_id => $latest->{train_id},
sched_departure => epoch_to_dt( $latest->{sched_dep_ts} ),
real_departure => epoch_to_dt( $latest->{real_dep_ts} ),
dep_ds100 => $latest->{dep_ds100},
dep_eva => $latest->{dep_eva},
dep_name => $latest->{dep_name},
dep_lat => $latest->{dep_lat},
dep_lon => $latest->{dep_lon},
dep_platform => $latest->{dep_platform},
sched_arrival => epoch_to_dt( $latest->{sched_arr_ts} ),
real_arrival => epoch_to_dt( $latest->{real_arr_ts} ),
arr_ds100 => $latest->{arr_ds100},
arr_eva => $latest->{arr_eva},
arr_name => $latest->{arr_name},
arr_lat => $latest->{arr_lat},
arr_lon => $latest->{arr_lon},
arr_platform => $latest->{arr_platform},
comment => $latest->{user_data}{comment},
visibility => $latest->{visibility},
visibility_str => $latest->{visibility_str},
effective_visibility => $latest->{effective_visibility},
effective_visibility_str =>
$latest->{effective_visibility_str},
};
}
return {
checked_in => 0,
cancelled => 0,
cancellation => $latest_cancellation,
no_journeys_yet => 1,
timestamp => epoch_to_dt(0),
timestamp_delta => $now->epoch,
};
}
);
$self->helper(
'get_user_status_json_v1' => sub {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
my $privacy = $opt{privacy}
// $self->users->get_privacy_by( uid => $uid );
my $status = $opt{status} // $self->get_user_status($uid);
my $ret = {
deprecated => \0,
checkedIn => (
$status->{checked_in}
or $status->{cancelled}
) ? \1 : \0,
comment => $status->{comment},
fromStation => {
ds100 => $status->{dep_ds100},
name => $status->{dep_name},
uic => $status->{dep_eva},
longitude => $status->{dep_lon},
latitude => $status->{dep_lat},
scheduledTime => $status->{sched_departure}
? $status->{sched_departure}->epoch
: undef,
realTime => $status->{real_departure}
? $status->{real_departure}->epoch
: undef,
},
toStation => {
ds100 => $status->{arr_ds100},
name => $status->{arr_name},
uic => $status->{arr_eva},
longitude => $status->{arr_lon},
latitude => $status->{arr_lat},
scheduledTime => $status->{sched_arrival}
? $status->{sched_arrival}->epoch
: undef,
realTime => $status->{real_arrival}
? $status->{real_arrival}->epoch
: undef,
},
train => {
type => $status->{train_type},
line => $status->{train_line},
no => $status->{train_no},
id => $status->{train_id},
hafasId => $status->{extra_data}{trip_id},
},
intermediateStops => [],
visibility => {
level => $status->{effective_visibility},
desc => $status->{effective_visibility_str},
}
};
if ( $opt{public} ) {
if ( not $privacy->{comments_visible} ) {
delete $ret->{comment};
}
}
else {
$ret->{actionTime}
= $status->{timestamp}
? $status->{timestamp}->epoch
: undef;
}
for my $stop ( @{ $status->{route_after} // [] } ) {
if ( $status->{arr_name} and $stop->[0] eq $status->{arr_name} )
{
last;
}
push(
@{ $ret->{intermediateStops} },
{
name => $stop->[0],
scheduledArrival => $stop->[2]{sched_arr}
? $stop->[2]{sched_arr}->epoch
: undef,
realArrival => $stop->[2]{rt_arr}
? $stop->[2]{rt_arr}->epoch
: undef,
scheduledDeparture => $stop->[2]{sched_dep}
? $stop->[2]{sched_dep}->epoch
: undef,
realDeparture => $stop->[2]{rt_dep}
? $stop->[2]{rt_dep}->epoch
: undef,
}
);
}
return $ret;
}
);
$self->helper(
'traewelling_to_travelynx_p' => sub {
my ( $self, %opt ) = @_;
my $traewelling = $opt{traewelling};
my $user_data = $opt{user_data};
my $uid = $user_data->{user_id};
my $promise = Mojo::Promise->new;
if ( not $traewelling->{checkin}
or $self->now->epoch - $traewelling->{checkin}->epoch > 900 )
{
$self->log->debug("... not checked in");
return $promise->resolve;
}
if ( $traewelling->{status_id}
and $user_data->{data}{latest_pull_status_id}
and $traewelling->{status_id}
== $user_data->{data}{latest_pull_status_id} )
{
$self->log->debug("... already handled");
return $promise->resolve;
}
$self->log->debug(
"... checked in : $traewelling->{dep_name} $traewelling->{dep_eva} -> $traewelling->{arr_name} $traewelling->{arr_eva}"
);
my $user_status = $self->get_user_status($uid);
if ( $user_status->{checked_in} ) {
$self->log->debug(
"... also checked in via travelynx. aborting.");
return $promise->resolve;
}
if ( $traewelling->{category}
!~ m{^ (?: national .* | regional .* | suburban ) $ }x )
{
my $db = $self->pg->db;
my $tx = $db->begin;
$self->checkin_p(
station => $traewelling->{dep_eva},
train_id => $traewelling->{trip_id},
uid => $uid,
in_transaction => 1,
db => $db
)->then(
sub {
$self->log->debug("... handled origin");
return $self->checkout_p(
station => $traewelling->{arr_eva},
train_id => $traewelling->{trip_id},
uid => $uid,
in_transaction => 1,
db => $db
);
}
)->then(
sub {
my ( undef, $err ) = @_;
if ($err) {
$self->log->debug("... error: $err");
return Mojo::Promise->reject($err);
}
$self->log->debug("... handled destination");
if ( $traewelling->{message} ) {
$self->in_transit->update_user_data(
uid => $uid,
db => $db,
user_data =>
{ comment => $traewelling->{message} }
);
}
$self->traewelling->log(
uid => $uid,
db => $db,
message =>
"Eingecheckt in $traewelling->{line} nach $traewelling->{arr_name}",
status_id => $traewelling->{status_id},
);
$self->traewelling->set_latest_pull_status_id(
uid => $uid,
status_id => $traewelling->{status_id},
db => $db
);
$tx->commit;
$promise->resolve;
return;
}
)->catch(
sub {
my ($err) = @_;
$self->log->debug("... error: $err");
$self->traewelling->log(
uid => $uid,
message =>
"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: $err",
status_id => $traewelling->{status_id},
is_error => 1
);
$promise->resolve;
return;
}
)->wait;
return $promise;
}
$self->iris->get_departures_p(
station => $traewelling->{dep_eva},
lookbehind => 60,
lookahead => 40
)->then(
sub {
my ($dep) = @_;
my ( $train_ref, $train_id );
if ( $dep->{errstr} ) {
$self->traewelling->log(
uid => $uid,
message =>
"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: $dep->{errstr}",
status_id => $traewelling->{status_id},
is_error => 1,
);
$promise->resolve;
return;
}
for my $train ( @{ $dep->{results} } ) {
if ( $train->line ne $traewelling->{line} ) {
next;
}
if ( not $train->sched_departure
or $train->sched_departure->epoch
!= $traewelling->{dep_dt}->epoch )
{
next;
}
if (
not
List::Util::first { $_ eq $traewelling->{arr_name} }
$train->route_post
)
{
next;
}
$train_id = $train->train_id;
$train_ref = $train;
last;
}
if ( not $train_id ) {
$self->log->debug(
"... train $traewelling->{line} not found");
$self->traewelling->log(
uid => $uid,
message =>
"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: Zug nicht gefunden",
status_id => $traewelling->{status_id},
is_error => 1
);
return $promise->resolve;
}
$self->log->debug("... found train: $train_id");
my $db = $self->pg->db;
my $tx = $db->begin;
$self->checkin_p(
station => $traewelling->{dep_eva},
train_id => $train_id,
uid => $uid,
in_transaction => 1,
db => $db
)->then(
sub {
$self->log->debug("... handled origin");
return $self->checkout_p(
station => $traewelling->{arr_eva},
train_id => 0,
uid => $uid,
in_transaction => 1,
db => $db
);
}
)->then(
sub {
my ( undef, $err ) = @_;
if ($err) {
$self->log->debug("... error: $err");
return Mojo::Promise->reject($err);
}
$self->log->debug("... handled destination");
if ( $traewelling->{message} ) {
$self->in_transit->update_user_data(
uid => $uid,
db => $db,
user_data =>
{ comment => $traewelling->{message} }
);
}
$self->traewelling->log(
uid => $uid,
db => $db,
message =>
"Eingecheckt in $traewelling->{line} nach $traewelling->{arr_name}",
status_id => $traewelling->{status_id},
);
$self->traewelling->set_latest_pull_status_id(
uid => $uid,
status_id => $traewelling->{status_id},
db => $db
);
$tx->commit;
$promise->resolve;
return;
}
)->catch(
sub {
my ($err) = @_;
$self->log->debug("... error: $err");
$self->traewelling->log(
uid => $uid,
message =>
"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: $err",
status_id => $traewelling->{status_id},
is_error => 1
);
$promise->resolve;
return;
}
)->wait;
}
)->catch(
sub {
my ( $err, $dep ) = @_;
$self->traewelling->log(
uid => $uid,
message =>
"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: $dep->{errstr}",
status_id => $traewelling->{status_id},
is_error => 1,
);
$promise->resolve;
return;
}
)->wait;
return $promise;
}
);
$self->helper(
'journeys_to_map_data' => sub {
my ( $self, %opt ) = @_;
my @journeys = @{ $opt{journeys} // [] };
my $route_type = $opt{route_type} // 'polybee';
my $include_manual = $opt{include_manual} ? 1 : 0;
my $location = $self->app->coordinates_by_station;
my $with_polyline = $route_type eq 'beeline' ? 0 : 1;
if ( not @journeys ) {
return {
skipped_journeys => [],
station_coordinates => [],
polyline_groups => [],
};
}
my $json = JSON->new->utf8;
my $first_departure = $journeys[-1]->{rt_departure};
my $last_departure = $journeys[0]->{rt_departure};
my @stations = List::Util::uniq map { $_->{to_name} } @journeys;
push( @stations,
List::Util::uniq map { $_->{from_name} } @journeys );
@stations = List::Util::uniq @stations;
my @station_coordinates = map { [ $location->{$_}, $_ ] }
grep { exists $location->{$_} } @stations;
my @station_pairs;
my @polylines;
my %seen;
my @skipped_journeys;
my @polyline_journeys = grep { $_->{polyline} } @journeys;
my @beeline_journeys = grep { not $_->{polyline} } @journeys;
if ( $route_type eq 'polyline' ) {
@beeline_journeys = ();
}
elsif ( $route_type eq 'beeline' ) {
push( @beeline_journeys, @polyline_journeys );
@polyline_journeys = ();
}
for my $journey (@polyline_journeys) {
my @polyline = @{ $journey->{polyline} };
my $from_eva = $journey->{from_eva};
my $to_eva = $journey->{to_eva};
my $from_index
= first_index { $_->[2] and $_->[2] == $from_eva } @polyline;
my $to_index
= first_index { $_->[2] and $_->[2] == $to_eva } @polyline;
if ( $from_index == -1
or $to_index == -1 )
{
# Fall back to route
delete $journey->{polyline};
next;
}
my $key
= $from_eva . '!'
. $to_eva . '!'
. ( $to_index - $from_index );
if ( $seen{$key} ) {
next;
}
$seen{$key} = 1;
# direction does not matter at the moment
$key
= $to_eva . '!'
. $from_eva . '!'
. ( $to_index - $from_index );
$seen{$key} = 1;
@polyline = @polyline[ $from_index .. $to_index ];
my @polyline_coords;
for my $coord (@polyline) {
push( @polyline_coords, [ $coord->[1], $coord->[0] ] );
}
push( @polylines, [@polyline_coords] );
}
for my $journey (@beeline_journeys) {
my @route = map { $_->[0] } @{ $journey->{route} };
my $from_index
= first_index { $_ eq $journey->{from_name} } @route;
my $to_index = first_index { $_ eq $journey->{to_name} } @route;
if ( $from_index == -1 ) {
my $rename = $self->app->renamed_station;
$from_index = first_index {
( $rename->{$_} // $_ ) eq $journey->{from_name}
}
@route;
}
if ( $to_index == -1 ) {
my $rename = $self->app->renamed_station;
$to_index = first_index {
( $rename->{$_} // $_ ) eq $journey->{to_name}
}
@route;
}
if ( $from_index == -1
or $to_index == -1 )
{
push( @skipped_journeys,
[ $journey, 'Start/Ziel nicht in Route gefunden' ] );
next;
}
# Manual journey entries are only included if one of the following
# conditions is satisfied:
# * their route has more than two elements (-> probably more than just
# start and stop station), or
# * $include_manual is true (-> user wants to see incomplete routes)
# This avoids messing up the map in case an A -> B connection has been
# tracked both with a regular checkin (-> detailed route shown on map)
# and entered manually (-> beeline also shown on map, typically
# significantly differs from detailed route) -- unless the user
# sets include_manual, of course.
if ( $journey->{edited} & 0x0010
and @route <= 2
and not $include_manual )
{
push( @skipped_journeys,
[ $journey, 'Manueller Eintrag ohne Unterwegshalte' ] );
next;
}
@route = @route[ $from_index .. $to_index ];
my $key = join( '|', @route );
if ( $seen{$key} ) {
next;
}
$seen{$key} = 1;
# direction does not matter at the moment
$seen{ join( '|', reverse @route ) } = 1;
my $prev_station = shift @route;
for my $station (@route) {
push( @station_pairs, [ $prev_station, $station ] );
$prev_station = $station;
}
}
@station_pairs = uniq_by { $_->[0] . '|' . $_->[1] } @station_pairs;
@station_pairs = grep {
exists $location->{ $_->[0] }
and exists $location->{ $_->[1] }
} @station_pairs;
@station_pairs
= map { [ $location->{ $_->[0] }, $location->{ $_->[1] } ] }
@station_pairs;
my $ret = {
skipped_journeys => \@skipped_journeys,
station_coordinates => \@station_coordinates,
polyline_groups => [
{
polylines => $json->encode( \@station_pairs ),
color => '#673ab7',
opacity => @polylines
? $with_polyline
? 0.4
: 0.6
: 0.8,
},
{
polylines => $json->encode( \@polylines ),
color => '#673ab7',
opacity => 0.8,
}
],
};
if (@station_coordinates) {
my @lats = map { $_->[0][0] } @station_coordinates;
my @lons = map { $_->[0][1] } @station_coordinates;
my $min_lat = List::Util::min @lats;
my $max_lat = List::Util::max @lats;
my $min_lon = List::Util::min @lons;
my $max_lon = List::Util::max @lons;
$ret->{bounds}
= [ [ $min_lat, $min_lon ], [ $max_lat, $max_lon ] ];
}
return $ret;
}
);
$self->helper(
'navbar_class' => sub {
my ( $self, $path ) = @_;
if ( $self->req->url eq $self->url_for($path) ) {
return 'active';
}
return q{};
}
);
my $r = $self->routes;
$r->get('/')->to('traveling#homepage');
$r->get('/about')->to('static#about');
$r->get('/api')->to('api#documentation');
$r->get('/changelog')->to('static#changelog');
$r->get('/impressum')->to('static#imprint');
$r->get('/imprint')->to('static#imprint');
$r->get('/legend')->to('static#legend');
$r->get('/offline.html')->to('static#offline');
$r->get('/api/v1/:user_action/:token')->to('api#get_v1');
$r->get('/login')->to('account#login_form');
$r->get('/recover')->to('account#request_password_reset');
$r->get('/recover/:id/:token')->to('account#recover_password');
$r->get('/reg/:id/:token')->to('account#verify');
$r->get( '/status/:name' => [ format => [ 'html', 'json' ] ] )
->to( 'profile#user_status', format => undef );
$r->get( '/status/:name/:ts' => [ format => [ 'html', 'json' ] ] )
->to( 'profile#user_status', format => undef );
$r->get('/ajax/status/#name')->to('profile#status_card');
$r->get('/ajax/status/:name/:ts')->to('profile#status_card');
$r->get('/p/:name')->to('profile#profile');
$r->get( '/p/:name/j/:id' => 'public_journey' )
->to('profile#journey_details');
$r->get('/.well-known/webfinger')->to('account#webfinger');
$r->get('/dyn/:av/autocomplete.js')->to('api#autocomplete');
$r->post('/api/v1/import')->to('api#import_v1');
$r->post('/api/v1/travel')->to('api#travel_v1');
$r->post('/action')->to('traveling#travel_action');
$r->post('/geolocation')->to('traveling#geolocation');
$r->post('/list_departures')->to('traveling#redirect_to_station');
$r->post('/login')->to('account#do_login');
$r->post('/recover')->to('account#request_password_reset');
if ( $self->config->{traewelling}{oauth} ) {
$r->get('/oauth/traewelling')->to('traewelling#oauth');
$r->post('/oauth/traewelling')->to('traewelling#oauth');
}
if ( not $self->config->{registration}{disabled} ) {
$r->get('/register')->to('account#registration_form');
$r->post('/register')->to('account#register');
}
my $authed_r = $r->under(
sub {
my ($self) = @_;
if ( $self->is_user_authenticated ) {
return 1;
}
$self->render(
'login',
redirect_to => $self->req->url,
from => 'auth_required'
);
return undef;
}
);
$authed_r->get('/account')->to('account#account');
$authed_r->get('/account/privacy')->to('account#privacy');
$authed_r->get('/account/social')->to('account#social');
$authed_r->get('/account/social/:kind')->to('account#social_list');
$authed_r->get('/account/profile')->to('account#profile');
$authed_r->get('/account/hooks')->to('account#webhook');
$authed_r->get('/account/traewelling')->to('traewelling#settings');
$authed_r->get('/account/insight')->to('account#insight');
$authed_r->get('/account/services')->to('account#services');
$authed_r->get('/ajax/status_card.html')->to('traveling#status_card');
$authed_r->get('/cancelled')->to('traveling#cancelled');
$authed_r->get('/fgr')->to('passengerrights#list_candidates');
$authed_r->get('/account/password')->to('account#password_form');
$authed_r->get('/account/mail')->to('account#change_mail');
$authed_r->get('/account/name')->to('account#change_name');
$authed_r->get('/export.json')->to('account#json_export');
$authed_r->get('/history.json')->to('traveling#json_history');
$authed_r->get('/history.csv')->to('traveling#csv_history');
$authed_r->get('/history')->to('traveling#history');
$authed_r->get('/history/commute')->to('traveling#commute');
$authed_r->get('/history/map')->to('traveling#map_history');
$authed_r->get('/history/:year')->to('traveling#yearly_history');
$authed_r->get('/history/:year/review')->to('traveling#year_in_review');
$authed_r->get('/history/:year/:month')->to('traveling#monthly_history');
$authed_r->get('/journey/add')->to('traveling#add_journey_form');
$authed_r->get('/journey/comment')->to('traveling#comment_form');
$authed_r->get('/journey/visibility')->to('traveling#visibility_form');
$authed_r->get('/journey/:id')->to('traveling#journey_details');
$authed_r->get('/s/*station')->to('traveling#station');
$authed_r->get('/confirm_mail/:token')->to('account#confirm_mail');
$authed_r->post('/account/privacy')->to('account#privacy');
$authed_r->post('/account/social')->to('account#social');
$authed_r->post('/account/profile')->to('account#profile');
$authed_r->post('/account/hooks')->to('account#webhook');
$authed_r->post('/account/traewelling')->to('traewelling#settings');
$authed_r->post('/account/insight')->to('account#insight');
$authed_r->post('/account/services')->to('account#services');
$authed_r->post('/journey/add')->to('traveling#add_journey_form');
$authed_r->post('/journey/comment')->to('traveling#comment_form');
$authed_r->post('/journey/visibility')->to('traveling#visibility_form');
$authed_r->post('/journey/edit')->to('traveling#edit_journey');
$authed_r->post('/journey/passenger_rights/*filename')
->to('passengerrights#generate');
$authed_r->post('/account/password')->to('account#change_password');
$authed_r->post('/account/mail')->to('account#change_mail');
$authed_r->post('/account/name')->to('account#change_name');
$authed_r->post('/social-action')->to('account#social_action');
$authed_r->post('/delete')->to('account#delete');
$authed_r->post('/logout')->to('account#do_logout');
$authed_r->post('/set_token')->to('api#set_token');
$authed_r->get('/timeline/in-transit')->to('profile#checked_in');
}
1;