diff options
Diffstat (limited to 'lib/Travelynx.pm')
-rwxr-xr-x | lib/Travelynx.pm | 4128 |
1 files changed, 1621 insertions, 2507 deletions
diff --git a/lib/Travelynx.pm b/lib/Travelynx.pm index 3cfc675..4749d65 100755 --- a/lib/Travelynx.pm +++ b/lib/Travelynx.pm @@ -1,4 +1,9 @@ package Travelynx; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later + use Mojo::Base 'Mojolicious'; use Mojo::Pg; @@ -8,21 +13,29 @@ use Cache::File; use Crypt::Eksblowfish::Bcrypt qw(bcrypt en_base64); use DateTime; use DateTime::Format::Strptime; -use Encode qw(decode encode); -use Geo::Distance; +use Encode qw(decode encode); +use File::Slurp qw(read_file); use JSON; use List::Util; -use List::MoreUtils qw(after_incl before_incl); +use List::UtilsBy qw(uniq_by); +use List::MoreUtils qw(first_index); use Travel::Status::DE::DBWagenreihung; -use Travel::Status::DE::IRIS; -use Travel::Status::DE::IRIS::Stations; +use Travelynx::Helper::DBDB; +use Travelynx::Helper::HAFAS; +use Travelynx::Helper::IRIS; use Travelynx::Helper::Sendmail; -use XML::LibXML; +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( $password, $hash ) eq $hash ) { + if ( bcrypt( substr( $password, 0, 10000 ), $hash ) eq $hash ) { return 1; } return 0; @@ -37,22 +50,11 @@ sub epoch_to_dt { return DateTime->from_epoch( epoch => $epoch, - time_zone => 'Europe/Berlin' + time_zone => 'Europe/Berlin', + locale => 'de-DE', ); } -sub get_station { - my ($station_name) = @_; - - my @candidates - = Travel::Status::DE::IRIS::Stations::get_station($station_name); - - if ( @candidates == 1 ) { - return $candidates[0]; - } - return undef; -} - sub startup { my ($self) = @_; @@ -60,6 +62,7 @@ sub startup { $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'); @@ -68,6 +71,9 @@ sub startup { $self->secrets( $self->config->{secrets} ); } + chomp $self->config->{version}; + $self->defaults( version => $self->config->{version} // 'UNKNOWN' ); + $self->plugin( authentication => { autoload_user => 1, @@ -78,7 +84,8 @@ sub startup { }, validate_user => sub { my ( $self, $username, $password, $extradata ) = @_; - my $user_info = $self->get_user_password($username); + my $user_info + = $self->users->get_login_data( name => $username ); if ( not $user_info ) { return undef; } @@ -93,6 +100,23 @@ sub startup { }, } ); + + 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. @@ -100,7 +124,7 @@ sub startup { # 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, marudor.de providing a + # 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. # @@ -116,10 +140,10 @@ sub startup { 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. + # 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 ); @@ -154,76 +178,132 @@ sub startup { ); $self->attr( - token_type => sub { - return { - status => 1, - history => 2, - travel => 3, - import => 4, - }; - } - ); - $self->attr( - token_types => sub { - return [qw(status history travel import)]; + 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( - account_public_mask => sub { - return { - status_intern => 0x01, - status_extern => 0x02, - status_comment => 0x04, - }; + ice_name => sub { + my $id_to_name = JSON->new->utf8->decode( + scalar read_file('share/ice_names.json') ); + return $id_to_name; } ); $self->attr( - journey_edit_mask => sub { - return { - sched_departure => 0x0001, - real_departure => 0x0002, - route => 0x0010, - is_cancelled => 0x0020, - sched_arrival => 0x0100, - real_arrival => 0x0200, - }; + renamed_station => sub { + my $legacy_to_new = JSON->new->utf8->decode( + scalar read_file('share/old_station_names.json') ); + return $legacy_to_new; } ); - $self->attr( - coordinates_by_station => sub { - my %location; - for - my $station ( Travel::Status::DE::IRIS::Stations::get_stations() ) + 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} ) { - if ( $station->[3] ) { - $location{ $station->[1] } - = [ $station->[4], $station->[3] ]; - } + return $url; } - return \%location; + return $self->url_for($path) + ->base( $self->app->config->{base_url} ); } ); - $self->attr( - station_by_eva => sub { - my %map; - for - my $station ( Travel::Status::DE::IRIS::Stations::get_stations() ) - { - $map{ $station->[2] } = $station; - } - return \%map; + $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( - sendmail => sub { - state $sendmail = Travelynx::Helper::Sendmail->new( - config => ( $self->config->{mail} // {} ), - log => $self->log + 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, ); } ); @@ -242,6 +322,52 @@ sub startup { 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}, + ); } ); @@ -268,248 +394,256 @@ sub startup { ); $self->helper( - 'get_departures' => sub { - my ( $self, $station, $lookbehind, $lookahead, $with_related ) = @_; - - $lookbehind //= 180; - $lookahead //= 30; - $with_related //= 0; - - my @station_matches - = Travel::Status::DE::IRIS::Stations::get_station($station); - - if ( @station_matches == 1 ) { - $station = $station_matches[0][0]; - my $status = Travel::Status::DE::IRIS->new( - station => $station, - main_cache => $self->app->cache_iris_main, - realtime_cache => $self->app->cache_iris_rt, - keep_transfers => 1, - lookbehind => 20, - datetime => DateTime->now( time_zone => 'Europe/Berlin' ) - ->subtract( minutes => $lookbehind ), - lookahead => $lookbehind + $lookahead, - lwp_options => { - timeout => 10, - agent => 'travelynx/' . $self->app->config->{version}, - }, - with_related => $with_related, - ); - return { - results => [ $status->results ], - errstr => $status->errstr, - station_ds100 => - ( $status->station ? $status->station->{ds100} : undef ), - station_eva => - ( $status->station ? $status->station->{uic} : undef ), - station_name => - ( $status->station ? $status->station->{name} : undef ), - related_stations => [ $status->related_stations ], - }; - } - elsif ( @station_matches > 1 ) { - return { - results => [], - errstr => 'Mehrdeutiger Stationsname. Mögliche Eingaben: ' - . join( q{, }, map { $_->[1] } @station_matches ), - }; + 'sprintf_km' => sub { + my ( $self, $km ) = @_; + + if ( $km < 1 ) { + return sprintf( '%.f m', $km * 1000 ); } - else { - return { - results => [], - errstr => 'Unbekannte Station', - }; + if ( $km < 10 ) { + return sprintf( '%.1f km', $km ); } + return sprintf( '%.f km', $km ); } ); $self->helper( - 'grep_unknown_stations' => sub { - my ( $self, @stations ) = @_; - - my @unknown_stations; - for my $station (@stations) { - my $station_info = get_station($station); - if ( not $station_info ) { - push( @unknown_stations, $station ); - } + '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'; } - return @unknown_stations; + if ( $visibility eq 'private' ) { + return 'lock'; + } + return 'help_outline'; } ); - # Returns (journey id, error) - # Must be called during a transaction. - # Must perform a rollback on error. $self->helper( - 'add_journey' => sub { + 'checkin_p' => sub { my ( $self, %opt ) = @_; - my $db = $opt{db}; - my $uid = $opt{uid} // $self->current_user->{id}; - my $now = DateTime->now( time_zone => 'Europe/Berlin' ); - my $dep_station = get_station( $opt{dep_station} ); - my $arr_station = get_station( $opt{arr_station} ); + 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; - if ( not $dep_station ) { - return ( undef, 'Unbekannter Startbahnhof' ); + 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 ( not $arr_station ) { - return ( undef, 'Unbekannter Zielbahnhof' ); + + if ( $train_id =~ m{[|]} ) { + return $self->_checkin_hafas_p(%opt); } - my @route = ( [ $dep_station->[1], {}, undef ] ); + my $promise = Mojo::Promise->new; - if ( $opt{route} ) { - my @unknown_stations; - for my $station ( @{ $opt{route} } ) { - my $station_info = get_station($station); - if ($station_info) { - push( @route, [ $station_info->[1], {}, undef ] ); - } - else { - push( @route, [ $station, {}, undef ] ); - push( @unknown_stations, $station ); - } - } + $self->iris->get_departures_p( + station => $station, + lookbehind => 140, + lookahead => 40 + )->then( + sub { + my ($status) = @_; - if ( not $opt{lax} ) { - if ( @unknown_stations == 1 ) { - return ( undef, - "Unbekannter Unterwegshalt: $unknown_stations[0]" ); - } - elsif (@unknown_stations) { - return ( undef, - 'Unbekannte Unterwegshalte: ' - . join( ', ', @unknown_stations ) ); + if ( $status->{errstr} ) { + $promise->reject( $status->{errstr} ); + return; } - } - } - - push( @route, [ $arr_station->[1], {}, undef ] ); - - if ( $route[0][0] eq $route[1][0] ) { - shift(@route); - } - if ( $route[-2][0] eq $route[-1][0] ) { - pop(@route); - } + my $eva = $status->{station_eva}; + my $train = List::Util::first { $_->train_id eq $train_id } + @{ $status->{results} }; - my $entry = { - user_id => $uid, - train_type => $opt{train_type}, - train_line => $opt{train_line}, - train_no => $opt{train_no}, - train_id => 'manual', - checkin_station_id => $dep_station->[2], - checkin_time => $now, - sched_departure => $opt{sched_departure}, - real_departure => $opt{rt_departure}, - checkout_station_id => $arr_station->[2], - sched_arrival => $opt{sched_arrival}, - real_arrival => $opt{rt_arrival}, - checkout_time => $now, - edited => 0x3fff, - cancelled => $opt{cancelled} ? 1 : 0, - route => JSON->new->encode( \@route ), - }; + if ( not defined $train ) { + $promise->reject("Train ${train_id} not found"); + return; + } - if ( $opt{comment} ) { - $entry->{user_data} - = JSON->new->encode( { comment => $opt{comment} } ); - } + 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; + } - my $journey_id = undef; - eval { - $journey_id - = $db->insert( 'journeys', $entry, { returning => 'id' } ) - ->hash->{id}; - $self->invalidate_stats_cache( $opt{rt_departure}, $db, $uid ); - }; + # mustn't be called during a transaction + if ( not $opt{in_transaction} ) { + $self->add_route_timestamps( $uid, $train, 1 ); + $self->run_hook( $uid, 'checkin' ); + } - if ($@) { - $self->app->log->error("add_journey($uid): $@"); - return ( undef, 'add_journey failed: ' . $@ ); - } + $promise->resolve($train); + return; + } + )->catch( + sub { + my ( $err, $status ) = @_; + $promise->reject( $status->{errstr} ); + return; + } + )->wait; - return ( $journey_id, undef ); + return $promise; } ); $self->helper( - 'checkin' => sub { - my ( $self, $station, $train_id, $uid ) = @_; + '_checkin_hafas_p' => sub { + my ( $self, %opt ) = @_; - $uid //= $self->current_user->{id}; + 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 $status = $self->get_departures( $station, 140, 40, 0 ); - if ( $status->{errstr} ) { - return ( undef, $status->{errstr} ); - } - else { - my ($train) = List::Util::first { $_->train_id eq $train_id } - @{ $status->{results} }; - if ( not defined $train ) { - return ( undef, "Train ${train_id} not found" ); - } - else { - - my $user = $self->get_user_status($uid); - if ( $user->{checked_in} or $user->{cancelled} ) { + my $promise = Mojo::Promise->new; - if ( $user->{train_id} eq $train_id - and $user->{dep_eva} eq $status->{station_eva} ) + $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 ) { - # checking in twice is harmless - return ( $train, undef ); + $found = $stop; + last; } - - # Otherwise, someone forgot to check out first - $self->checkout( $station, 1, $uid ); } - + 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 { - my $json = JSON->new; - $self->pg->db->insert( - 'in_transit', - { - user_id => $uid, - cancelled => $train->departure_is_cancelled - ? 1 - : 0, - checkin_station_id => $status->{station_eva}, - checkin_time => - DateTime->now( time_zone => 'Europe/Berlin' ), - dep_platform => $train->platform, - train_type => $train->type, - train_line => $train->line_no, - train_no => $train->train_no, - train_id => $train->train_id, - sched_departure => $train->sched_departure, - real_departure => $train->departure, - route => $json->encode( - [ $self->route_diff($train) ] - ), - messages => $json->encode( - [ - map { [ $_->[0]->epoch, $_->[1] ] } - $train->messages - ] - ) - } + $self->in_transit->add( + uid => $uid, + db => $db, + journey => $journey, + stop => $found, ); }; if ($@) { $self->app->log->error( "Checkin($uid): INSERT failed: $@"); - return ( undef, '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, + }; + } } - $self->add_route_timestamps( $uid, $train, 1 ); - $self->run_hook( $uid, 'checkin' ); - return ( $train, undef ); + + 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; } ); @@ -519,9 +653,7 @@ sub startup { $uid //= $self->current_user->{id}; if ( $journey_id eq 'in_transit' ) { - eval { - $self->pg->db->delete( 'in_transit', { user_id => $uid } ); - }; + eval { $self->in_transit->delete( uid => $uid ); }; if ($@) { $self->app->log->error("Undo($uid, $journey_id): $@"); return "Undo($journey_id): $@"; @@ -537,20 +669,10 @@ sub startup { my $db = $self->pg->db; my $tx = $db->begin; - my $journey = $db->select( - 'journeys', - '*', - { - user_id => $uid, - id => $journey_id - } - )->hash; - $db->delete( - 'journeys', - { - user_id => $uid, - id => $journey_id - } + my $journey = $self->journeys->pop( + uid => $uid, + db => $db, + journey_id => $journey_id ); if ( $journey->{edited} ) { @@ -562,7 +684,30 @@ sub startup { delete $journey->{edited}; delete $journey->{id}; - $db->insert( 'in_transit', $journey ); + # 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} @@ -574,7 +719,11 @@ sub startup { ); } - $self->invalidate_stats_cache( $cache_ts, $db, $uid ); + $self->journey_stats_cache->invalidate( + ts => $cache_ts, + db => $db, + uid => $uid + ); $tx->commit; }; @@ -587,628 +736,395 @@ sub startup { } ); - # Statistics are partitioned by real_departure, which must be provided - # when calling this function e.g. after journey deletion or editing. - # If a joureny's real_departure has been edited, this function must be - # called twice: once with the old and once with the new value. $self->helper( - 'invalidate_stats_cache' => sub { - my ( $self, $ts, $db, $uid ) = @_; - - $uid //= $self->current_user->{id}; - $db //= $self->pg->db; + 'checkout_p' => sub { + my ( $self, %opt ) = @_; - $self->pg->db->delete( - 'journey_stats', - { - user_id => $uid, - year => $ts->year, - month => $ts->month, - } - ); - $self->pg->db->delete( - 'journey_stats', - { - user_id => $uid, - year => $ts->year, - month => 0, - } - ); - } - ); + 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}; - $self->helper( - 'checkout' => sub { - my ( $self, $station, $force, $uid ) = @_; + my $promise = Mojo::Promise->new; - my $db = $self->pg->db; - my $status = $self->get_departures( $station, 120, 120, 0 ); - $uid //= $self->current_user->{id}; - my $user = $self->get_user_status($uid); - my $train_id = $user->{train_id}; + 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 ( 0, 'You are not checked into any train' ); - } - if ( $status->{errstr} and not $force ) { - return ( 1, $status->{errstr} ); + return $promise->resolve( 0, + 'You are not checked into any train' ); } - my $now = DateTime->now( time_zone => 'Europe/Berlin' ); - my $journey - = $db->select( 'in_transit', '*', { user_id => $uid } ) - ->expand->hash; - - # 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 + if ( $dep_eva and $dep_eva != $user->{dep_eva} ) { + return $promise->resolve( 0, 'race condition' ); } - @{ $status->{results} }; - - $train //= List::Util::first { $_->train_id eq $train_id } - @{ $status->{results} }; - - my $new_checkout_station_id = $status->{station_eva}; - - # When a checkout is triggered by a checkin, there is an edge case - # with related stations. - # Assume a user travels from A to B1, then from B2 to C. B1 and B2 are - # relatd stations (e.g. "Frankfurt Hbf" and "Frankfurt Hbf(tief)"). - # Now, if they check in for the journey from B2 to C, and have not yet - # checked out of the previous train, $train is undef as B2 is not B1. - # Redo the request with with_related => 1 to avoid this case. - # While at it, we increase the lookahead to handle long journeys as - # well. - if ( not $train ) { - $status = $self->get_departures( $station, 120, 180, 1 ); - ($train) = List::Util::first { $_->train_id eq $train_id } - @{ $status->{results} }; - if ( $train - and $self->app->station_by_eva->{ $train->station_uic } ) - { - $new_checkout_station_id = $train->station_uic; - } + if ( $arr_eva and $arr_eva != $user->{arr_eva} ) { + return $promise->resolve( 0, 'race condition' ); } - # Store the intended checkout station regardless of this operation's - # success. - $db->update( - 'in_transit', - { - checkout_station_id => $new_checkout_station_id, - }, - { user_id => $uid } - ); - - # 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 ) - { - $db->update( - 'in_transit', - { - checkout_time => undef, - arr_platform => undef, - sched_arrival => undef, - real_arrival => undef, - }, - { user_id => $uid } - ); + if ( $train_id =~ m{[|]} ) { + return $self->_checkout_hafas_p(%opt); } - if ( not defined $train ) { + my $now = DateTime->now( time_zone => 'Europe/Berlin' ); + my $journey = $self->in_transit->get( + uid => $uid, + with_data => 1 + ); - # 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->[1]; - if ( $station_data->{sched_arr} ) { - my $sched_arr - = epoch_to_dt( $station_data->{sched_arr} ); - my $rt_arr = $sched_arr->clone; - if ( $station_data->{adelay} - and $station_data->{adelay} =~ m{^\d+$} ) - { - $rt_arr->add( minutes => $station_data->{adelay} ); - } - $db->update( - 'in_transit', - { - sched_arrival => $sched_arr, - real_arrival => $rt_arr - }, - { user_id => $uid } + $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 ); } - } - if ( not $force ) { - $self->run_hook( $uid, 'update' ); - return ( 1, undef ); - } - } - - my $has_arrived = 0; - eval { - - my $tx = $db->begin; + # 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} }; - if ( defined $train ) { + $train //= List::Util::first { $_->train_id eq $train_id } + @{ $status->{results} }; - if ( not $train->arrival ) { - die("Train has no arrival timestamp\n"); - } + if ( not defined $train ) { - $has_arrived = $train->arrival->epoch < $now->epoch ? 1 : 0; - my $json = JSON->new; - $db->update( - 'in_transit', + # 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} } + ) { - checkout_time => $now, - arr_platform => $train->platform, - sched_arrival => $train->sched_arrival, - real_arrival => $train->arrival, - cancelled => $train->arrival_is_cancelled ? 1 : 0, - route => - $json->encode( [ $self->route_diff($train) ] ), - messages => $json->encode( - [ - map { [ $_->[0]->epoch, $_->[1] ] } - $train->messages - ] - ) - }, - { user_id => $uid } - ); - if ($has_arrived) { - my @unknown_stations - = $self->grep_unknown_stations( $train->route ); - if (@unknown_stations) { - $self->app->log->warn( - 'Encountered unknown stations: ' - . join( ', ', @unknown_stations ) ); + $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 ) { - $journey - = $db->select( 'in_transit', '*', { user_id => $uid } )->hash; - - if ( $has_arrived or $force ) { - delete $journey->{data}; - $journey->{edited} = 0; - $journey->{checkout_time} = $now; - $db->insert( 'journeys', $journey ); - $db->delete( 'in_transit', { user_id => $uid } ); - - my $cache_ts = $now->clone; - if ( $journey->{real_departure} - =~ m{ ^ (?<year> \d{4} ) - (?<month> \d{2} ) }x ) - { - $cache_ts->set( - year => $+{year}, - month => $+{month} - ); + # mustn't be called during a transaction + if ( not $opt{in_transaction} ) { + $self->run_hook( $uid, 'update' ); + } + $promise->resolve( 1, undef ); + return; + } } - $self->invalidate_stats_cache( $cache_ts, $db, $uid ); - } - - $tx->commit; - }; - - if ($@) { - $self->app->log->error("Checkout($uid): $@"); - return ( 1, 'Checkout error: ' . $@ ); - } + my $has_arrived = 0; - if ( $has_arrived or $force ) { - $self->run_hook( $uid, 'checkout' ); - return ( 0, undef ); - } - $self->run_hook( $uid, 'update' ); - $self->add_route_timestamps( $uid, $train, 0 ); - return ( 1, undef ); - } - ); + eval { - $self->helper( - 'mark_seen' => sub { - my ( $self, $uid ) = @_; + my $tx; + if ( not $opt{in_transaction} ) { + $tx = $db->begin; + } - $self->pg->db->update( - 'users', - { last_seen => DateTime->now( time_zone => 'Europe/Berlin' ) }, - { id => $uid } - ); - } - ); + 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) ] + ); - $self->helper( - 'update_in_transit_comment' => sub { - my ( $self, $comment, $uid ) = @_; - $uid //= $self->current_user->{id}; + $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 ) + ) + ); + } + } + } - my $status = $self->pg->db->select( 'in_transit', ['user_data'], - { user_id => $uid } )->expand->hash; - if ( not $status ) { - return; - } - $status->{user_data}{comment} = $comment; - $self->pg->db->update( - 'in_transit', - { user_data => JSON->new->encode( $status->{user_data} ) }, - { user_id => $uid } - ); - } - ); + $journey = $self->in_transit->get( + uid => $uid, + db => $db + ); - $self->helper( - 'update_journey_part' => sub { - my ( $self, $db, $journey_id, $key, $value ) = @_; - my $rows; - - my $journey = $self->get_journey( - db => $db, - journey_id => $journey_id, - with_datetime => 1, - ); + if ( $has_arrived or $force ) { + $self->journeys->add_from_in_transit( + db => $db, + journey => $journey + ); + $self->in_transit->delete( + uid => $uid, + db => $db + ); - eval { - if ( $key eq 'sched_departure' ) { - $rows = $db->update( - 'journeys', - { - sched_departure => $value, - edited => $journey->{edited} | 0x0001, - }, - { - id => $journey_id, + 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 + ); } - )->rows; - } - elsif ( $key eq 'rt_departure' ) { - $rows = $db->update( - 'journeys', - { - real_departure => $value, - edited => $journey->{edited} | 0x0002, - }, + elsif ( defined $train + and $train->arrival_is_cancelled ) { - id => $journey_id, - } - )->rows; - # stats are partitioned by rt_departure -> both the cache for - # the old value (see bottom of this function) and the new value - # (here) must be invalidated. - $self->invalidate_stats_cache( $value, $db ); - } - elsif ( $key eq 'sched_arrival' ) { - $rows = $db->update( - 'journeys', - { - sched_arrival => $value, - edited => $journey->{edited} | 0x0100, - }, - { - id => $journey_id, - } - )->rows; - } - elsif ( $key eq 'rt_arrival' ) { - $rows = $db->update( - 'journeys', - { - real_arrival => $value, - edited => $journey->{edited} | 0x0200, - }, - { - id => $journey_id, - } - )->rows; - } - elsif ( $key eq 'route' ) { - my @new_route = map { [ $_, {}, undef ] } @{$value}; - $rows = $db->update( - 'journeys', - { - route => JSON->new->encode( \@new_route ), - edited => $journey->{edited} | 0x0010, - }, - { - id => $journey_id, - } - )->rows; - } - elsif ( $key eq 'cancelled' ) { - $rows = $db->update( - 'journeys', - { - cancelled => $value, - edited => $journey->{edited} | 0x0020, - }, - { - id => $journey_id, + # 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, + ); } - )->rows; - } - elsif ( $key eq 'comment' ) { - $journey->{user_data}{comment} = $value; - $rows = $db->update( - 'journeys', - { - user_data => - JSON->new->encode( $journey->{user_data} ), - }, - { - id => $journey_id, + + if ( not $opt{in_transaction} ) { + $tx->commit; } - )->rows; - } - else { - die("Invalid key $key\n"); - } - }; + }; - if ($@) { - $self->app->log->error( - "update_journey_part($journey_id, $key): $@"); - return "update_journey_part($key): $@"; - } - if ( $rows == 1 ) { - $self->invalidate_stats_cache( $journey->{rt_departure}, $db ); - return undef; - } - return 'UPDATE failed: did not match any journey part'; - } - ); + if ($@) { + $self->app->log->error("Checkout($uid): $@"); + $promise->resolve( 1, 'Checkout error: ' . $@ ); + return; + } - $self->helper( - 'journey_sanity_check' => sub { - my ( $self, $journey, $lax ) = @_; + 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; - if ( $journey->{sched_duration} and $journey->{sched_duration} < 0 ) - { - return -'Die geplante Dauer dieser Zugfahrt ist negativ. Zeitreisen werden aktuell nicht unterstützt.'; - } - if ( $journey->{rt_duration} and $journey->{rt_duration} < 0 ) { - return -'Die Dauer dieser Zugfahrt ist negativ. Zeitreisen werden aktuell nicht unterstützt.'; - } - if ( $journey->{sched_duration} - and $journey->{sched_duration} > 60 * 60 * 24 ) - { - return 'Die Zugfahrt ist länger als 24 Stunden.'; - } - if ( $journey->{rt_duration} - and $journey->{rt_duration} > 60 * 60 * 24 ) - { - return 'Die Zugfahrt ist länger als 24 Stunden.'; - } - if ( $journey->{kmh_route} > 500 or $journey->{kmh_beeline} > 500 ) - { - return 'Zugfahrten mit über 500 km/h? Schön wär\'s.'; - } - if ( $journey->{route} and @{ $journey->{route} } > 99 ) { - my $stop_count = @{ $journey->{route} }; - return -"Die Zugfahrt hat $stop_count Unterwegshalte. Also ich weiß ja nicht so recht."; - } - if ( $journey->{edited} & 0x0010 and not $lax ) { - my @unknown_stations - = $self->grep_unknown_stations( map { $_->[0] } - @{ $journey->{route} } ); - if (@unknown_stations) { - return 'Unbekannte Station(en): ' - . join( ', ', @unknown_stations ); } - } - - return undef; - } - ); - - $self->helper( - 'verify_registration_token' => sub { - my ( $self, $uid, $token ) = @_; - - my $db = $self->pg->db; - my $tx = $db->begin; - - my $res = $db->select( - 'pending_registrations', - 'count(*) as count', - { - user_id => $uid, - token => $token + )->catch( + sub { + my ($err) = @_; + $promise->resolve( 1, $err ); + return; } - ); + )->wait; - if ( $res->hash->{count} ) { - $db->update( 'users', { status => 1 }, { id => $uid } ); - $db->delete( 'pending_registrations', { user_id => $uid } ); - $tx->commit; - return 1; - } - return; + return $promise; } ); $self->helper( - 'get_uid_by_name_and_mail' => sub { - my ( $self, $name, $email ) = @_; + '_checkout_hafas_p' => sub { + my ( $self, %opt ) = @_; - my $res = $self->pg->db->select( - 'users', - ['id'], - { - name => $name, - email => $email, - status => 1 - } - ); + my $station = $opt{station}; + my $force = $opt{force}; + my $uid = $opt{uid} // $self->current_user->{id}; + my $db = $opt{db} // $self->pg->db; - if ( my $user = $res->hash ) { - return $user->{id}; - } - return; - } - ); - - $self->helper( - 'get_privacy_by_name' => sub { - my ( $self, $name ) = @_; + my $promise = Mojo::Promise->new; - my $res = $self->pg->db->select( - 'users', - [ 'id', 'public_level' ], - { - name => $name, - status => 1 - } + 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, ); - if ( my $user = $res->hash ) { - return $user; + # 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; + } } - return; - } - ); - - $self->helper( - 'set_privacy' => sub { - my ( $self, $uid, $public_level ) = @_; - - $self->pg->db->update( - 'users', - { public_level => $public_level }, - { id => $uid } - ); - } - ); - - $self->helper( - 'mark_for_password_reset' => sub { - my ( $self, $db, $uid, $token ) = @_; - - my $res = $db->select( - 'pending_passwords', - 'count(*) as count', - { user_id => $uid } - ); - if ( $res->hash->{count} ) { - return 'in progress'; + if ( not $found ) { + return $promise->resolve( 1, 'station not found in route' ); } - $db->insert( - 'pending_passwords', - { - user_id => $uid, - token => $token, - requested_at => - DateTime->now( time_zone => 'Europe/Berlin' ) + eval { + my $tx; + if ( not $opt{in_transaction} ) { + $tx = $db->begin; } - ); - - return undef; - } - ); - $self->helper( - 'verify_password_token' => sub { - my ( $self, $uid, $token ) = @_; + 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 $res = $self->pg->db->select( - 'pending_passwords', - 'count(*) as count', - { - user_id => $uid, - token => $token + 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 ( $res->hash->{count} ) { - return 1; - } - return; - } - ); - - $self->helper( - 'mark_for_mail_change' => sub { - my ( $self, $db, $uid, $email, $token ) = @_; - - $db->insert( - 'pending_mails', - { - user_id => $uid, - email => $email, - token => $token, - requested_at => - DateTime->now( time_zone => 'Europe/Berlin' ) - }, - { - on_conflict => \ -'(user_id) do update set email = EXCLUDED.email, token = EXCLUDED.token, requested_at = EXCLUDED.requested_at' - }, - ); - } - ); - - $self->helper( - 'change_mail_with_token' => sub { - my ( $self, $uid, $token ) = @_; - - my $db = $self->pg->db; - my $tx = $db->begin; - - my $res_h = $db->select( - 'pending_mails', - ['email'], - { - user_id => $uid, - token => $token + if ($tx) { + $tx->commit; } - )->hash; + }; - if ($res_h) { - $db->update( - 'users', - { email => $res_h->{email} }, - { id => $uid } - ); - $db->delete( 'pending_mails', { user_id => $uid } ); - $tx->commit; - return 1; + if ($@) { + $self->app->log->error("Checkout($uid): $@"); + return $promise->resolve( 1, 'Checkout error: ' . $@ ); } - return; - } - ); - $self->helper( - 'remove_password_token' => sub { - my ( $self, $uid, $token ) = @_; - - $self->pg->db->delete( - 'pending_passwords', - { - user_id => $uid, - token => $token + 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 ); } ); @@ -1221,124 +1137,7 @@ sub startup { $uid //= $self->current_user->{id}; - my $user_data = $self->pg->db->select( - 'users', - 'id, name, status, public_level, email, ' - . 'extract(epoch from registered_at) as registered_at_ts, ' - . 'extract(epoch from last_seen) as last_seen_ts, ' - . 'extract(epoch from deletion_requested) as deletion_requested_ts', - { id => $uid } - )->hash; - - if ($user_data) { - return { - id => $user_data->{id}, - name => $user_data->{name}, - status => $user_data->{status}, - is_public => $user_data->{public_level}, - email => $user_data->{email}, - registered_at => DateTime->from_epoch( - epoch => $user_data->{registered_at_ts}, - time_zone => 'Europe/Berlin' - ), - last_seen => DateTime->from_epoch( - epoch => $user_data->{last_seen_ts}, - time_zone => 'Europe/Berlin' - ), - deletion_requested => $user_data->{deletion_requested_ts} - ? DateTime->from_epoch( - epoch => $user_data->{deletion_requested_ts}, - time_zone => 'Europe/Berlin' - ) - : undef, - }; - } - return undef; - } - ); - - $self->helper( - 'get_api_token' => sub { - my ( $self, $uid ) = @_; - $uid //= $self->current_user->{id}; - - my $token = {}; - my $res = $self->pg->db->select( - 'tokens', - [ 'type', 'token' ], - { user_id => $uid } - ); - - for my $entry ( $res->hashes->each ) { - $token->{ $self->app->token_types->[ $entry->{type} - 1 ] } - = $entry->{token}; - } - - return $token; - } - ); - - $self->helper( - 'get_webhook' => sub { - my ( $self, $uid ) = @_; - $uid //= $self->current_user->{id}; - - my $res_h - = $self->pg->db->select( 'webhooks_str', '*', - { user_id => $uid } )->hash; - - $res_h->{latest_run} = epoch_to_dt( $res_h->{latest_run_ts} ); - - return $res_h; - } - ); - - $self->helper( - 'set_webhook' => sub { - my ( $self, %opt ) = @_; - - $opt{uid} //= $self->current_user->{id}; - - if ( $opt{token} ) { - $opt{token} =~ tr{\r\n}{}d; - } - - my $res = $self->pg->db->insert( - 'webhooks', - { - user_id => $opt{uid}, - enabled => $opt{enabled}, - url => $opt{url}, - token => $opt{token} - }, - { - on_conflict => \ -'(user_id) do update set enabled = EXCLUDED.enabled, url = EXCLUDED.url, token = EXCLUDED.token, errored = null, latest_run = null, output = null' - } - ); - } - ); - - $self->helper( - 'mark_hook_status' => sub { - my ( $self, $uid, $url, $success, $text ) = @_; - - if ( length($text) > 1000 ) { - $text = substr( $text, 0, 1000 ) . '…'; - } - - $self->pg->db->update( - 'webhooks', - { - errored => $success ? 0 : 1, - latest_run => DateTime->now( time_zone => 'Europe/Berlin' ), - output => $text, - }, - { - user_id => $uid, - url => $url - } - ); + return $self->users->get( uid => $uid ); } ); @@ -1346,7 +1145,7 @@ sub startup { 'run_hook' => sub { my ( $self, $uid, $reason, $callback ) = @_; - my $hook = $self->get_webhook($uid); + my $hook = $self->users->get_webhook( uid => $uid ); if ( not $hook->{enabled} or not $hook->{url} =~ m{^ https?:// }x ) { @@ -1356,7 +1155,7 @@ sub startup { return; } - my $status = $self->get_user_status_json_v1($uid); + my $status = $self->get_user_status_json_v1( uid => $uid ); my $header = {}; my $hook_body = { reason => $reason, @@ -1365,6 +1164,8 @@ sub startup { if ( $hook->{token} ) { $header->{Authorization} = "Bearer $hook->{token}"; + $header->{'User-Agent'} + = 'travelynx/' . $self->app->config->{version}; } my $ua = $self->ua; @@ -1379,804 +1180,194 @@ sub startup { sub { my ($tx) = @_; if ( my $err = $tx->error ) { - $self->mark_hook_status( $uid, $hook->{url}, 0, - "HTTP $err->{code} $err->{message}" ); + $self->users->update_webhook_status( + uid => $uid, + url => $hook->{url}, + success => 0, + text => "HTTP $err->{code} $err->{message}" + ); } else { - $self->mark_hook_status( $uid, $hook->{url}, 1, - $tx->result->body ); + $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->mark_hook_status( $uid, $hook->{url}, 0, $err ); + $self->users->update_webhook_status( + uid => $uid, + url => $hook->{url}, + success => 0, + text => $err + ); if ($callback) { &$callback(); } + return; } )->wait; } ); - $self->helper( - 'get_user_password' => sub { - my ( $self, $name ) = @_; - - my $res_h = $self->pg->db->select( - 'users', - 'id, name, status, password as password_hash', - { name => $name } - )->hash; - - return $res_h; - } - ); - - $self->helper( - 'add_user' => sub { - my ( $self, $db, $user_name, $email, $token, $password ) = @_; - - # This helper must be called during a transaction, as user creation - # may fail even after the database entry has been generated, e.g. if - # the registration mail cannot be sent. We therefore use $db (the - # database handle performing the transaction) instead of $self->pg->db - # (which may be a new handle not belonging to the transaction). - - my $now = DateTime->now( time_zone => 'Europe/Berlin' ); - - my $res = $db->insert( - 'users', - { - name => $user_name, - status => 0, - public_level => 0, - email => $email, - password => $password, - registered_at => $now, - last_seen => $now, - }, - { returning => 'id' } - ); - my $uid = $res->hash->{id}; - - $db->insert( - 'pending_registrations', - { - user_id => $uid, - token => $token - } - ); - - return $uid; - } - ); - - $self->helper( - 'flag_user_deletion' => sub { - my ( $self, $uid ) = @_; - - my $now = DateTime->now( time_zone => 'Europe/Berlin' ); - - $self->pg->db->update( - 'users', - { deletion_requested => $now }, - { - id => $uid, - } - ); - } - ); - - $self->helper( - 'unflag_user_deletion' => sub { - my ( $self, $uid ) = @_; - - $self->pg->db->update( - 'users', - { - deletion_requested => undef, - }, - { - id => $uid, - } - ); - } - ); - - $self->helper( - 'set_user_password' => sub { - my ( $self, $uid, $password ) = @_; - - $self->pg->db->update( - 'users', - { password => $password }, - { id => $uid } - ); - } - ); - - $self->helper( - 'check_if_user_name_exists' => sub { - my ( $self, $user_name ) = @_; - - my $count = $self->pg->db->select( - 'users', - 'count(*) as count', - { name => $user_name } - )->hash->{count}; - - if ($count) { - return 1; - } - return 0; - } - ); - - $self->helper( - 'check_if_mail_is_blacklisted' => sub { - my ( $self, $mail ) = @_; - - my $count = $self->pg->db->select( - 'users', - 'count(*) as count', - { - email => $mail, - status => 0, - } - )->hash->{count}; - - if ($count) { - return 1; - } - - $count = $self->pg->db->select( - 'mail_blacklist', - 'count(*) as count', - { - email => $mail, - num_tries => { '>', 1 }, - } - )->hash->{count}; - - if ($count) { - return 1; - } - return 0; - } - ); - - $self->helper( - 'delete_journey' => sub { - my ( $self, $journey_id, $checkin_epoch, $checkout_epoch ) = @_; - my $uid = $self->current_user->{id}; - - my @journeys = $self->get_user_travels( - uid => $uid, - journey_id => $journey_id - ); - if ( @journeys == 0 ) { - return 'Journey not found'; - } - my $journey = $journeys[0]; - - # Double-check (comparing both ID and action epoch) to make sure we - # are really deleting the right journey and the user isn't just - # playing around with POST requests. - if ( $journey->{id} != $journey_id - or $journey->{checkin_ts} != $checkin_epoch - or $journey->{checkout_ts} != $checkout_epoch ) - { - return 'Invalid journey data'; - } - - my $rows; - eval { - $rows = $self->pg->db->delete( - 'journeys', - { - user_id => $uid, - id => $journey_id, - } - )->rows; - }; - - if ($@) { - $self->app->log->error("Delete($uid, $journey_id): $@"); - return 'DELETE failed: ' . $@; - } - - if ( $rows == 1 ) { - $self->invalidate_stats_cache( - epoch_to_dt( $journey->{rt_dep_ts} ) ); - return undef; - } - return sprintf( 'Deleted %d rows, expected 1', $rows ); - } - ); - - $self->helper( - 'get_journey_stats' => sub { - my ( $self, %opt ) = @_; - - if ( $opt{cancelled} ) { - $self->app->log->warning( -'get_journey_stats called with illegal option cancelled => 1' - ); - return {}; - } - - my $uid = $opt{uid} // $self->current_user->{id}; - my $year = $opt{year} // 0; - my $month = $opt{month} // 0; - - # Assumption: If the stats cache contains an entry it is up-to-date. - # -> Cache entries must be explicitly invalidated whenever the user - # checks out of a train or manually edits/adds a journey. - - my $res = $self->pg->db->select( - 'journey_stats', - ['data'], - { - user_id => $uid, - year => $year, - month => $month - } - ); - - my $res_h = $res->expand->hash; - - if ($res_h) { - $res->finish; - return $res_h->{data}; - } - - my $interval_start = DateTime->new( - time_zone => 'Europe/Berlin', - year => 2000, - month => 1, - day => 1, - hour => 0, - minute => 0, - second => 0, - ); - - # I wonder if people will still be traveling by train in the year 3000 - my $interval_end = $interval_start->clone->add( years => 1000 ); - - if ( $opt{year} and $opt{month} ) { - $interval_start->set( - year => $opt{year}, - month => $opt{month} - ); - $interval_end = $interval_start->clone->add( months => 1 ); - } - elsif ( $opt{year} ) { - $interval_start->set( year => $opt{year} ); - $interval_end = $interval_start->clone->add( years => 1 ); - } - - my @journeys = $self->get_user_travels( - uid => $uid, - cancelled => $opt{cancelled} ? 1 : 0, - verbose => 1, - after => $interval_start, - before => $interval_end - ); - my $stats = $self->compute_journey_stats(@journeys); - - eval { - $self->pg->db->insert( - 'journey_stats', - { - user_id => $uid, - year => $year, - month => $month, - data => JSON->new->encode($stats), - } - ); - }; - if ( my $err = $@ ) { - if ( $err =~ m{duplicate key value violates unique constraint} ) - { - # When a user opens the same history page several times in - # short succession, there is a race condition where several - # Mojolicious workers execute this helper, notice that there is - # no up-to-date history, compute it, and insert it using the - # statement above. This will lead to a uniqueness violation - # in each successive insert. However, this is harmless, and - # thus ignored. - } - else { - # Otherwise we probably have a problem. - die($@); - } - } - - return $stats; - } - ); - - $self->helper( - 'history_years' => sub { - my ( $self, $uid ) = @_; - $uid //= $self->current_user->{id}, - - my $res = $self->pg->db->select( - 'journeys', - 'distinct extract(year from real_departure) as year', - { user_id => $uid }, - { order_by => { -asc => 'year' } } - ); - - my @ret; - for my $row ( $res->hashes->each ) { - push( @ret, [ $row->{year}, $row->{year} ] ); - } - return @ret; - } - ); - - $self->helper( - 'history_months' => sub { - my ( $self, $uid ) = @_; - $uid //= $self->current_user->{id}, - - my $res = $self->pg->db->select( - 'journeys', - "distinct to_char(real_departure, 'YYYY.MM') as yearmonth", - { user_id => $uid }, - { order_by => { -asc => 'yearmonth' } } - ); - - my @ret; - for my $row ( $res->hashes->each ) { - my ( $year, $month ) = split( qr{[.]}, $row->{yearmonth} ); - push( @ret, [ "${year}/${month}", "${month}.${year}" ] ); - } - return @ret; - } - ); - - $self->helper( - 'route_diff' => sub { - my ( $self, $train ) = @_; - my @json_route; - my @route = $train->route; - my @sched_route = $train->sched_route; - - my $route_idx = 0; - my $sched_idx = 0; - - while ( $route_idx <= $#route and $sched_idx <= $#sched_route ) { - if ( $route[$route_idx] eq $sched_route[$sched_idx] ) { - push( @json_route, [ $route[$route_idx], {}, undef ] ); - $route_idx++; - $sched_idx++; - } - - # this branch is inefficient, but won't be taken frequently - elsif ( not( grep { $_ eq $route[$route_idx] } @sched_route ) ) - { - push( @json_route, - [ $route[$route_idx], {}, 'additional' ], - ); - $route_idx++; - } - else { - push( @json_route, - [ $sched_route[$sched_idx], {}, 'cancelled' ], - ); - $sched_idx++; - } - } - while ( $route_idx <= $#route ) { - push( @json_route, [ $route[$route_idx], {}, 'additional' ], ); - $route_idx++; - } - while ( $sched_idx <= $#sched_route ) { - push( @json_route, - [ $sched_route[$sched_idx], {}, 'cancelled' ], - ); - $sched_idx++; - } - return @json_route; - } - ); - - $self->helper( - 'get_dbdb_station_p' => sub { - my ( $self, $eva ) = @_; - - my $url = "https://lib.finalrewind.org/dbdb/s/${eva}.json"; - - my $cache = $self->app->cache_iris_main; - my $promise = Mojo::Promise->new; - - if ( my $content = $cache->thaw($url) ) { - $promise->resolve($content); - return $promise; - } - - $self->ua->request_timeout(5)->get_p($url)->then( - sub { - my ($tx) = @_; - my $body = decode( 'utf-8', $tx->res->body ); - - my $json = JSON->new->decode($body); - $cache->freeze( $url, $json ); - $promise->resolve($json); - } - )->catch( - sub { - my ($err) = @_; - $promise->reject($err); - } - )->wait; - return $promise; - } - ); - - $self->helper( - 'has_wagonorder_p' => sub { - my ( $self, $ts, $train_no ) = @_; - my $api_ts = $ts->strftime('%Y%m%d%H%M'); - my $url - = "https://lib.finalrewind.org/dbdb/has_wagonorder/${train_no}/${api_ts}"; - my $cache = $self->app->cache_iris_main; - my $promise = Mojo::Promise->new; - - if ( my $content = $cache->get($url) ) { - if ( $content eq 'y' ) { - $promise->resolve; - return $promise; - } - elsif ( $content eq 'n' ) { - $promise->reject; - return $promise; - } - } - - $self->ua->request_timeout(5)->head_p($url)->then( - sub { - my ($tx) = @_; - if ( $tx->result->is_success ) { - $cache->set( $url, 'y' ); - $promise->resolve; - } - else { - $cache->set( $url, 'n' ); - $promise->resolve; - } - } - )->catch( - sub { - $cache->set( $url, 'n' ); - $promise->reject; - } - )->wait; - return $promise; - } - ); - - $self->helper( - 'get_wagonorder_p' => sub { - my ( $self, $ts, $train_no ) = @_; - my $api_ts = $ts->strftime('%Y%m%d%H%M'); - my $url - = "https://www.apps-bahn.de/wr/wagenreihung/1.0/${train_no}/${api_ts}"; - - my $cache = $self->app->cache_iris_main; - my $promise = Mojo::Promise->new; - - if ( my $content = $cache->thaw($url) ) { - $promise->resolve($content); - return $promise; - } - - $self->ua->request_timeout(5)->get_p($url)->then( - sub { - my ($tx) = @_; - my $body = decode( 'utf-8', $tx->res->body ); - - my $json = JSON->new->decode($body); - $cache->freeze( $url, $json ); - $promise->resolve($json); - } - )->catch( - sub { - my ($err) = @_; - $promise->reject($err); - } - )->wait; - return $promise; - } - ); - - $self->helper( - 'get_hafas_json_p' => sub { - my ( $self, $url ) = @_; - - my $cache = $self->app->cache_iris_main; - my $promise = Mojo::Promise->new; - - if ( my $content = $cache->thaw($url) ) { - $promise->resolve($content); - return $promise; - } - - $self->ua->request_timeout(5)->get_p($url)->then( - sub { - my ($tx) = @_; - my $body = decode( 'ISO-8859-15', $tx->res->body ); - - $body =~ s{^TSLs[.]sls = }{}; - $body =~ s{;$}{}; - $body =~ s{(}{(}g; - $body =~ s{)}{)}g; - my $json = JSON->new->decode($body); - $cache->freeze( $url, $json ); - $promise->resolve($json); - } - )->catch( - sub { - my ($err) = @_; - $self->app->log->warning("get($url): $err"); - $promise->reject($err); - } - )->wait; - return $promise; - } - ); - - $self->helper( - 'get_hafas_xml_p' => sub { - my ( $self, $url ) = @_; - - my $cache = $self->app->cache_iris_rt; - my $promise = Mojo::Promise->new; - - if ( my $content = $cache->thaw($url) ) { - $promise->resolve($content); - return $promise; - } - - $self->ua->request_timeout(5)->get_p($url)->then( - sub { - my ($tx) = @_; - my $body = decode( 'ISO-8859-15', $tx->res->body ); - my $tree; - - my $traininfo = { - station => {}, - messages => [], - }; - - # <SDay text="... > ..."> is invalid HTML, but present in - # regardless. As it is the last tag, we just throw it away. - $body =~ s{<SDay [^>]*/>}{}s; - eval { $tree = XML::LibXML->load_xml( string => $body ) }; - if ($@) { - $self->app->log->warning("load_xml($url): $@"); - $cache->freeze( $url, $traininfo ); - $promise->resolve($traininfo); - return; - } - - for my $station ( $tree->findnodes('/Journey/St') ) { - my $name = $station->getAttribute('name'); - my $adelay = $station->getAttribute('adelay'); - my $ddelay = $station->getAttribute('ddelay'); - $traininfo->{station}{$name} = { - adelay => $adelay, - ddelay => $ddelay, - }; - } - - for my $message ( $tree->findnodes('/Journey/HIMMessage') ) - { - my $header = $message->getAttribute('header'); - my $lead = $message->getAttribute('lead'); - my $display = $message->getAttribute('display'); - push( - @{ $traininfo->{messages} }, - { - header => $header, - lead => $lead, - display => $display - } - ); - } - - $cache->freeze( $url, $traininfo ); - $promise->resolve($traininfo); - } - )->catch( - sub { - my ($err) = @_; - $self->app->log->warning("get($url): $err"); - $promise->reject($err); - } - )->wait; - return $promise; - } - ); - + # 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 ) = @_; + my ( $self, $uid, $train, $is_departure, $update_polyline ) = @_; $uid //= $self->current_user->{id}; my $db = $self->pg->db; - my $journey = $db->select( - 'in_transit_str', - [ 'arr_eva', 'dep_eva', 'route' ], - { user_id => $uid } - )->expand->hash; + # 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 $journey ) { + if ( not $in_transit ) { return; } - my ($platform) = ( ( $train->platform // 0 ) =~ m{(\d+)} ); - - my $route = $journey->{route}; - - my $base - = 'https://reiseauskunft.bahn.de/bin/trainsearch.exe/dn?L=vs_json.vs_hap&start=yes&rt=1'; - my $date_yy = $train->start->strftime('%d.%m.%y'); - my $date_yyyy = $train->start->strftime('%d.%m.%Y'); - my $train_no = $train->type . ' ' . $train->train_no; - - my ( $trainlink, $route_data ); - - $self->get_hafas_json_p( - "${base}&date=${date_yy}&trainname=${train_no}")->then( - sub { - my ($trainsearch) = @_; - - # Fallback: Take first result - $trainlink = $trainsearch->{suggestions}[0]{trainLink}; + my $route = $in_transit->{route}; - # Try finding a result for the current date - for - my $suggestion ( @{ $trainsearch->{suggestions} // [] } ) - { - - # Drunken API, sail with care. Both date formats are used interchangeably - if ( $suggestion->{depDate} eq $date_yy - or $suggestion->{depDate} eq $date_yyyy ) - { - # Train numbers are not unique, e.g. IC 149 refers both to the - # InterCity service Amsterdam -> Berlin and to the InterCity service - # Koebenhavns Lufthavn st -> Aarhus. One workaround is making - # requests with the stationFilter=80 parameter. Checking the origin - # station seems to be the more generic solution, so we do that - # instead. - if ( $suggestion->{dep} eq $train->origin ) { - $trainlink = $suggestion->{trainLink}; - last; - } - } - } - - if ( not $trainlink ) { - $self->app->log->debug("trainlink not found"); - return Mojo::Promise->reject("trainlink not found"); - } - my $base2 - = 'https://reiseauskunft.bahn.de/bin/traininfo.exe/dn'; - return $self->get_hafas_json_p( -"${base2}/${trainlink}?rt=1&date=${date_yy}&L=vs_json.vs_hap" - ); - } - )->then( + $self->hafas->get_tripid_p( train => $train )->then( sub { - my ($traininfo) = @_; - if ( not $traininfo or $traininfo->{error} ) { - $self->app->log->debug("traininfo error"); - return Mojo::Promise->reject("traininfo error"); - } - my $routeinfo - = $traininfo->{suggestions}[0]{locations}; + my ($trip_id) = @_; - my $strp = DateTime::Format::Strptime->new( - pattern => '%d.%m.%y %H:%M', - time_zone => 'Europe/Berlin', + $self->in_transit->update_data( + uid => $uid, + db => $db, + data => { trip_id => $trip_id } ); - $route_data = {}; - - for my $station ( @{$routeinfo} ) { - my $arr - = $strp->parse_datetime( - $station->{arrDate} . ' ' . $station->{arrTime} ); - my $dep - = $strp->parse_datetime( - $station->{depDate} . ' ' . $station->{depTime} ); - $route_data->{ $station->{name} } = { - sched_arr => $arr ? $arr->epoch : 0, - sched_dep => $dep ? $dep->epoch : 0, - }; - } - - my $base2 - = 'https://reiseauskunft.bahn.de/bin/traininfo.exe/dn'; - return $self->get_hafas_xml_p( - "${base2}/${trainlink}?rt=1&date=${date_yy}&L=vs_java3" + return $self->hafas->get_route_timestamps_p( + train => $train, + trip_id => $trip_id, + with_polyline => ( + $update_polyline + or not $in_transit->{polyline} + ) ? 1 : 0, ); } )->then( sub { - my ($traininfo2) = @_; + my ( $route_data, $journey, $polyline ) = @_; - for my $station ( keys %{$route_data} ) { - for my $key ( - keys %{ $traininfo2->{station}{$station} // {} } ) + for my $station ( @{$route} ) { + if ( $station->[0] + =~ m{^Betriebsstelle nicht bekannt (\d+)$} ) { - $route_data->{$station}{$key} - = $traininfo2->{station}{$station}{$key}; + my $eva = $1; + if ( $route_data->{$eva} ) { + $station->[0] = $route_data->{$eva}{name}; + $station->[1] = $route_data->{$eva}{eva}; + } } - } + if ( my $sd = $route_data->{ $station->[0] } ) { + $station->[1] = $sd->{eva}; + if ( $station->[2]{isAdditional} ) { + $sd->{isAdditional} = 1; + } + if ( $station->[2]{isCancelled} ) { + $sd->{isCancelled} = 1; + } - for my $station ( @{$route} ) { - $station->[1] - = $route_data->{ $station->[0] }; + # keep rt_dep / rt_arr if they are no longer present + my %old; + for my $k (qw(rt_arr rt_dep arr_delay dep_delay)) { + $old{$k} = $station->[2]{$k}; + } + $station->[2] = $sd; + if ( not $station->[2]{rt_arr} ) { + $station->[2]{rt_arr} = $old{rt_arr}; + $station->[2]{arr_delay} = $old{arr_delay}; + } + if ( not $station->[2]{rt_dep} ) { + $station->[2]{rt_dep} = $old{rt_dep}; + $station->[2]{dep_delay} = $old{dep_delay}; + } + } } - my $res = $db->select( 'in_transit', ['data'], - { user_id => $uid } ); - my $res_h = $res->expand->hash; - my $data = $res_h->{data} // {}; + my @messages; + for my $m ( $journey->messages ) { + if ( not $m->code ) { + push( + @messages, + { + header => $m->short, + lead => $m->text, + } + ); + } + } - $data->{delay_msg} = [ map { [ $_->[0]->epoch, $_->[1] ] } - $train->delay_messages ]; - $data->{qos_msg} = [ map { [ $_->[0]->epoch, $_->[1] ] } - $train->qos_messages ]; + $self->in_transit->set_route_data( + uid => $uid, + db => $db, + route => $route, + delay_messages => [ + map { [ $_->[0]->epoch, $_->[1] ] } + $train->delay_messages + ], + qos_messages => [ + map { [ $_->[0]->epoch, $_->[1] ] } + $train->qos_messages + ], + him_messages => \@messages, + ); - $data->{him_msg} = $traininfo2->{messages}; + if ($polyline) { + $self->in_transit->set_polyline( + uid => $uid, + db => $db, + polyline => $polyline, + old_id => $in_transit->{polyline_id}, + ); + } - $db->update( - 'in_transit', - { - route => JSON->new->encode($route), - data => JSON->new->encode($data) - }, - { user_id => $uid } - ); + return; + } + )->catch( + sub { + my ($err) = @_; + $self->app->log->debug("add_route_timestamps: $err"); + return; } )->wait; if ( $train->sched_departure ) { - $self->has_wagonorder_p( $train->sched_departure, + $self->dbdb->has_wagonorder_p( $train->sched_departure, $train->train_no )->then( sub { - return $self->get_wagonorder_p( $train->sched_departure, - $train->train_no ); + my ($api) = @_; + return $self->dbdb->get_wagonorder_p( $api, + $train->sched_departure, $train->train_no ); } )->then( sub { my ($wagonorder) = @_; - my $res = $db->select( - 'in_transit', - [ 'data', 'user_data' ], - { user_id => $uid } - ); - my $res_h = $res->expand->hash; - my $data = $res_h->{data} // {}; - my $user_data = $res_h->{user_data} // {}; - - if ($is_departure) { - $data->{wagonorder_dep} = $wagonorder; - if ( exists $user_data->{wagongroups} ) { - $user_data->{wagongroups} = []; - } + 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} @@ -2191,7 +1382,7 @@ sub startup { push( @wagons, { - id => $wagon->{fahrzeugnummer}, + id => $wagon->{fahrzeugnummer}, number => $wagon->{wagenordnungsnummer}, type => $wagon->{fahrzeugtyp}, @@ -2210,449 +1401,99 @@ sub startup { wagons => [@wagons], } ); - } - $db->update( - 'in_transit', + if ( $group->{fahrzeuggruppebezeichnung} + and $group->{fahrzeuggruppebezeichnung} eq + 'ICE0304' ) { - data => JSON->new->encode($data), - user_data => JSON->new->encode($user_data) - }, - { user_id => $uid } + $data->{wagonorder_pride} = 1; + } + } + $self->in_transit->update_data( + uid => $uid, + db => $db, + data => $data + ); + $self->in_transit->update_user_data( + uid => $uid, + db => $db, + user_data => $user_data ); } - else { + elsif ( not $is_departure + and not exists $wagonorder->{error} ) + { $data->{wagonorder_arr} = $wagonorder; - $db->update( - 'in_transit', - { data => JSON->new->encode($data) }, - { user_id => $uid } + $self->in_transit->update_data( + uid => $uid, + db => $db, + data => $data ); } + return; + } + )->catch( + sub { + # no wagonorder? no problem. + return; } )->wait; } if ($is_departure) { - $self->get_dbdb_station_p( $journey->{dep_eva} )->then( + $self->dbdb->get_stationinfo_p( $in_transit->{dep_eva} )->then( sub { my ($station_info) = @_; + my $data = { stationinfo_dep => $station_info }; - my $res = $db->select( 'in_transit', ['data'], - { user_id => $uid } ); - my $res_h = $res->expand->hash; - my $data = $res_h->{data} // {}; - - $data->{stationinfo_dep} = $station_info; - - $db->update( - 'in_transit', - { data => JSON->new->encode($data) }, - { user_id => $uid } + $self->in_transit->update_data( + uid => $uid, + db => $db, + data => $data ); + return; + } + )->catch( + sub { + # no stationinfo? no problem. + return; } )->wait; } - if ( $journey->{arr_eva} and not $is_departure ) { - $self->get_dbdb_station_p( $journey->{arr_eva} )->then( + 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 }; - my $res = $db->select( 'in_transit', ['data'], - { user_id => $uid } ); - my $res_h = $res->expand->hash; - my $data = $res_h->{data} // {}; - - $data->{stationinfo_arr} = $station_info; - - $db->update( - 'in_transit', - { data => JSON->new->encode($data) }, - { user_id => $uid } + $self->in_transit->update_data( + uid => $uid, + db => $db, + data => $data ); + return; } - )->wait; - } - } - ); - - $self->helper( - 'get_oldest_journey_ts' => sub { - my ($self) = @_; - - my $res_h = $self->pg->db->select( - 'journeys_str', - ['sched_dep_ts'], - { - user_id => $self->current_user->{id}, - }, - { - limit => 1, - order_by => { - -asc => 'real_dep_ts', - }, - } - )->hash; - - if ($res_h) { - return epoch_to_dt( $res_h->{sched_dep_ts} ); - } - return undef; - } - ); - - $self->helper( - 'get_latest_dest_id' => sub { - my ( $self, %opt ) = @_; - - my $uid = $opt{uid} // $self->current_user->{id}; - my $db = $opt{db} // $self->pg->db; - - my $journey = $db->select( 'in_transit', ['checkout_station_id'], - { user_id => $uid } )->hash; - if ( not $journey ) { - $journey = $db->select( - 'journeys', - ['checkout_station_id'], - { - user_id => $uid, - cancelled => 0 - }, - { - limit => 1, - order_by => { -desc => 'real_departure' } - } - )->hash; - } - - if ( not $journey ) { - return; - } - - return $journey->{checkout_station_id}; - } - ); - - $self->helper( - 'get_connection_targets' => sub { - my ( $self, %opt ) = @_; - - my $uid = $opt{uid} //= $self->current_user->{id}; - my $threshold = $opt{threshold} - // DateTime->now( time_zone => 'Europe/Berlin' ) - ->subtract( months => 4 ); - my $db = $opt{db} //= $self->pg->db; - my $min_count = $opt{min_count} // 3; - - my $dest_id = $opt{eva} // $self->get_latest_dest_id(%opt); - - if ( not $dest_id ) { - return; - } - - my $res = $db->query( - qq{ - select - count(checkout_station_id) as count, - checkout_station_id as dest - from journeys - where user_id = ? - and checkin_station_id = ? - and real_departure > ? - group by checkout_station_id - order by count desc; - }, - $uid, - $dest_id, - $threshold - ); - my @destinations - = $res->hashes->grep( sub { shift->{count} >= $min_count } ) - ->map( sub { shift->{dest} } )->each; - @destinations - = grep { $self->app->station_by_eva->{$_} } @destinations; - @destinations - = map { $self->app->station_by_eva->{$_}->[1] } @destinations; - return @destinations; - } - ); - - $self->helper( - 'get_connecting_trains' => sub { - my ( $self, %opt ) = @_; - - my $uid = $opt{uid} //= $self->current_user->{id}; - my $use_history = $self->account_use_history($uid); - - my ( $eva, $exclude_via, $exclude_train_id, $exclude_before ); - - if ( $opt{eva} ) { - if ( $use_history & 0x01 ) { - $eva = $opt{eva}; - } - } - else { - if ( $use_history & 0x02 ) { - my $status = $self->get_user_status; - $eva = $status->{arr_eva}; - $exclude_via = $status->{dep_name}; - $exclude_train_id = $status->{train_id}; - $exclude_before = $status->{real_arrival}->epoch; - } - } - - if ( not $eva ) { - return; - } - - my @destinations = $self->get_connection_targets(%opt); - - if ($exclude_via) { - @destinations = grep { $_ ne $exclude_via } @destinations; - } - - if ( not @destinations ) { - return; - } - - my $stationboard = $self->get_departures( $eva, 0, 40, 1 ); - if ( $stationboard->{errstr} ) { - return; - } - @{ $stationboard->{results} } = map { $_->[0] } - sort { $a->[1] <=> $b->[1] } - map { [ $_, $_->departure ? $_->departure->epoch : 0 ] } - @{ $stationboard->{results} }; - my @results; - my @cancellations; - my %via_count = map { $_ => 0 } @destinations; - for my $train ( @{ $stationboard->{results} } ) { - if ( not $train->departure ) { - next; - } - if ( $exclude_before - and $train->departure->epoch < $exclude_before ) - { - next; - } - if ( $exclude_train_id - and $train->train_id eq $exclude_train_id ) - { - next; - } - - # In general, this function is meant to return feasible - # connections. However, cancelled connections may also be of - # interest and are also useful for logging cancellations. - # To satisfy both demands with (hopefully) little confusion and - # UI clutter, this function returns two concatenated arrays: - # actual connections (ordered by actual departure time) followed - # by cancelled connections (ordered by scheduled departure time). - # This is easiest to achieve in two separate loops. - # - # Note that a cancelled train may still have a matching destination - # in its route_post, e.g. if it leaves out $eva due to - # unscheduled route changes but continues on schedule afterwards - # -- so it is only cancelled at $eva, not on the remainder of - # the route. Also note that this specific case is not yet handled - # properly by the cancellation logic etc. - - if ( $train->departure_is_cancelled ) { - my @via - = ( $train->sched_route_post, $train->sched_route_end ); - for my $dest (@destinations) { - if ( List::Util::any { $_ eq $dest } @via ) { - push( @cancellations, [ $train, $dest ] ); - next; - } - } - } - else { - my @via = ( $train->route_post, $train->route_end ); - for my $dest (@destinations) { - if ( $via_count{$dest} < 2 - and List::Util::any { $_ eq $dest } @via ) - { - push( @results, [ $train, $dest ] ); - $via_count{$dest}++; - next; - } - } - } - } - - @results = map { $_->[0] } - sort { $a->[1] <=> $b->[1] } - map { - [ - $_, - $_->[0]->departure->epoch // $_->[0]->sched_departure->epoch - ] - } @results; - @cancellations = map { $_->[0] } - sort { $a->[1] <=> $b->[1] } - map { [ $_, $_->[0]->sched_departure->epoch ] } @cancellations; - - return ( @results, @cancellations ); - } - ); - - $self->helper( - 'account_use_history' => sub { - my ( $self, $uid, $value ) = @_; - - if ($value) { - $self->pg->db->update( - 'users', - { use_history => $value }, - { id => $uid } - ); - } - else { - return $self->pg->db->select( 'users', ['use_history'], - { id => $uid } )->hash->{use_history}; - } - } - ); - - $self->helper( - 'get_user_travels' => sub { - my ( $self, %opt ) = @_; - - my $uid = $opt{uid} || $self->current_user->{id}; - - # If get_user_travels is called from inside a transaction, db - # specifies the database handle performing the transaction. - # Otherwise, we grab a fresh one. - my $db = $opt{db} // $self->pg->db; - - my %where = ( - user_id => $uid, - cancelled => 0 - ); - my %order = ( - order_by => { - -desc => 'real_dep_ts', - } - ); - - if ( $opt{cancelled} ) { - $where{cancelled} = 1; - } - - if ( $opt{limit} ) { - $order{limit} = $opt{limit}; - } - - if ( $opt{journey_id} ) { - $where{journey_id} = $opt{journey_id}; - delete $where{cancelled}; - } - elsif ( $opt{after} and $opt{before} ) { - $where{real_dep_ts} = { - -between => [ $opt{after}->epoch, $opt{before}->epoch, ] }; - } - - my @travels; - - my $res = $db->select( 'journeys_str', '*', \%where, \%order ); - - for my $entry ( $res->expand->hashes->each ) { - - my $ref = { - id => $entry->{journey_id}, - type => $entry->{train_type}, - line => $entry->{train_line}, - no => $entry->{train_no}, - from_eva => $entry->{dep_eva}, - checkin_ts => $entry->{checkin_ts}, - sched_dep_ts => $entry->{sched_dep_ts}, - rt_dep_ts => $entry->{real_dep_ts}, - to_eva => $entry->{arr_eva}, - checkout_ts => $entry->{checkout_ts}, - sched_arr_ts => $entry->{sched_arr_ts}, - rt_arr_ts => $entry->{real_arr_ts}, - messages => $entry->{messages}, - route => $entry->{route}, - edited => $entry->{edited}, - user_data => $entry->{user_data}, - }; - - if ( my $station - = $self->app->station_by_eva->{ $ref->{from_eva} } ) - { - $ref->{from_ds100} = $station->[0]; - $ref->{from_name} = $station->[1]; - } - if ( my $station - = $self->app->station_by_eva->{ $ref->{to_eva} } ) - { - $ref->{to_ds100} = $station->[0]; - $ref->{to_name} = $station->[1]; - } - - if ( $opt{with_datetime} ) { - $ref->{checkin} = epoch_to_dt( $ref->{checkin_ts} ); - $ref->{sched_departure} - = epoch_to_dt( $ref->{sched_dep_ts} ); - $ref->{rt_departure} = epoch_to_dt( $ref->{rt_dep_ts} ); - $ref->{checkout} = epoch_to_dt( $ref->{checkout_ts} ); - $ref->{sched_arrival} = epoch_to_dt( $ref->{sched_arr_ts} ); - $ref->{rt_arrival} = epoch_to_dt( $ref->{rt_arr_ts} ); - } - - if ( $opt{verbose} ) { - $ref->{cancelled} = $entry->{cancelled}; - my @parsed_messages; - for my $message ( @{ $ref->{messages} // [] } ) { - my ( $ts, $msg ) = @{$message}; - push( @parsed_messages, [ epoch_to_dt($ts), $msg ] ); + )->catch( + sub { + # no stationinfo? no problem. + return; } - $ref->{messages} = [ reverse @parsed_messages ]; - $ref->{sched_duration} - = $ref->{sched_arr_ts} - ? $ref->{sched_arr_ts} - $ref->{sched_dep_ts} - : undef; - $ref->{rt_duration} - = $ref->{rt_arr_ts} - ? $ref->{rt_arr_ts} - $ref->{rt_dep_ts} - : undef; - my ( $km_route, $km_beeline, $skip ) - = $self->get_travel_distance( $ref->{from_name}, - $ref->{to_name}, $ref->{route} ); - $ref->{km_route} = $km_route; - $ref->{skip_route} = $skip; - $ref->{km_beeline} = $km_beeline; - $ref->{skip_beeline} = $skip; - my $kmh_divisor - = ( $ref->{rt_duration} // $ref->{sched_duration} - // 999999 ) / 3600; - $ref->{kmh_route} - = $kmh_divisor ? $ref->{km_route} / $kmh_divisor : -1; - $ref->{kmh_beeline} - = $kmh_divisor - ? $ref->{km_beeline} / $kmh_divisor - : -1; - } - - push( @travels, $ref ); + )->wait; } - - return @travels; } ); $self->helper( - 'get_journey' => sub { - my ( $self, %opt ) = @_; - - $opt{cancelled} = 'any'; - my @journeys = $self->get_user_travels(%opt); - if ( @journeys == 0 ) { - return undef; - } - - return $journeys[0]; + '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; } ); @@ -2720,21 +1561,17 @@ sub startup { for my $station ( @{ $journey->{route_after} } ) { my $station_desc = $station->[0]; - if ( $station->[1]{rt_arr} ) { - $station_desc .= $station->[1]{sched_arr}->strftime(';%s'); - $station_desc .= $station->[1]{rt_arr}->strftime(';%s'); - if ( $station->[1]{rt_dep} ) { - $station_desc - .= $station->[1]{sched_dep}->strftime(';%s'); - $station_desc .= $station->[1]{rt_dep}->strftime(';%s'); - } - else { - $station_desc .= ';0;0'; - } - } - else { - $station_desc .= ';0;0;0;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 ); } @@ -2744,166 +1581,87 @@ sub startup { $self->helper( 'get_user_status' => sub { - my ( $self, $uid ) = @_; + my ( $self, $uid, $db ) = @_; $uid //= $self->current_user->{id}; + $db //= $self->pg->db; - my $db = $self->pg->db; my $now = DateTime->now( time_zone => 'Europe/Berlin' ); my $epoch = $now->epoch; - my $in_transit - = $db->select( 'in_transit_str', '*', { user_id => $uid } ) - ->expand->hash; + 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; - if ( my $station - = $self->app->station_by_eva->{ $in_transit->{dep_eva} } ) - { - $in_transit->{dep_ds100} = $station->[0]; - $in_transit->{dep_name} = $station->[1]; - } - if ( $in_transit->{arr_eva} - and my $station - = $self->app->station_by_eva->{ $in_transit->{arr_eva} } ) + my $traewelling = $self->traewelling->get( + uid => $uid, + db => $db + ); + if ( $traewelling->{latest_run} + >= epoch_to_dt( $in_transit->{checkin_ts} ) ) { - $in_transit->{arr_ds100} = $station->[0]; - $in_transit->{arr_name} = $station->[1]; - } - - my @route = @{ $in_transit->{route} // [] }; - my @route_after; - my $dep_info; - my $stop_before_dest; - my $is_after = 0; - for my $station (@route) { - - if ( $in_transit->{arr_name} - and @route_after - and $station->[0] eq $in_transit->{arr_name} ) + $ret->{traewelling} = $traewelling; + if ( @{ $traewelling->{data}{log} // [] } + and ( my $log_entry = $traewelling->{data}{log}[0] ) ) { - $stop_before_dest = $route_after[-1][0]; - } - if ($is_after) { - push( @route_after, $station ); - } - if ( $in_transit->{dep_name} - and $station->[0] eq $in_transit->{dep_name} ) - { - $is_after = 1; - if ( @{$station} > 1 ) { - $dep_info = $station->[1]; + 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 = @route_after ? $route_after[0][0] : undef; - - my $ts = $in_transit->{checkout_ts} - // $in_transit->{checkin_ts}; - my $action_time = epoch_to_dt($ts); - - my $ret = { - checked_in => !$in_transit->{cancelled}, - cancelled => $in_transit->{cancelled}, - timestamp => $action_time, - timestamp_delta => $now->epoch - $action_time->epoch, - train_type => $in_transit->{train_type}, - train_line => $in_transit->{train_line}, - train_no => $in_transit->{train_no}, - train_id => $in_transit->{train_id}, - boarding_countdown => -1, - sched_departure => - epoch_to_dt( $in_transit->{sched_dep_ts} ), - real_departure => epoch_to_dt( $in_transit->{real_dep_ts} ), - dep_ds100 => $in_transit->{dep_ds100}, - dep_eva => $in_transit->{dep_eva}, - dep_name => $in_transit->{dep_name}, - dep_platform => $in_transit->{dep_platform}, - sched_arrival => epoch_to_dt( $in_transit->{sched_arr_ts} ), - real_arrival => epoch_to_dt( $in_transit->{real_arr_ts} ), - arr_ds100 => $in_transit->{arr_ds100}, - arr_eva => $in_transit->{arr_eva}, - arr_name => $in_transit->{arr_name}, - arr_platform => $in_transit->{arr_platform}, - route_after => \@route_after, - messages => $in_transit->{messages}, - extra_data => $in_transit->{data}, - comment => $in_transit->{user_data}{comment}, - }; - - my @parsed_messages; - for my $message ( @{ $ret->{messages} // [] } ) { - my ( $ts, $msg ) = @{$message}; - push( @parsed_messages, [ epoch_to_dt($ts), $msg ] ); - } - $ret->{messages} = [ reverse @parsed_messages ]; - @parsed_messages = (); - for my $message ( @{ $ret->{extra_data}{qos_msg} // [] } ) { - my ( $ts, $msg ) = @{$message}; - push( @parsed_messages, [ epoch_to_dt($ts), $msg ] ); - } - $ret->{extra_data}{qos_msg} = [@parsed_messages]; - - if ( $dep_info and $dep_info->{sched_arr} ) { - $dep_info->{sched_arr} - = epoch_to_dt( $dep_info->{sched_arr} ); - $dep_info->{rt_arr} = $dep_info->{sched_arr}->clone; - if ( $dep_info->{adelay} - and $dep_info->{adelay} =~ m{^\d+$} ) + 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} ) { - $dep_info->{rt_arr} - ->add( minutes => $dep_info->{adelay} ); + $stop_before_dest = $ret->{route_after}[ $i - 1 ][0]; + last; } - $dep_info->{rt_arr_countdown} = $ret->{boarding_countdown} - = $dep_info->{rt_arr}->epoch - $epoch; } - for my $station (@route_after) { - if ( @{$station} > 1 ) { - - # Note: $station->[1]{sched_arr} may already have been - # converted to a DateTime object in $station->[1] is - # $dep_info. This can happen when a station is present - # several times in a train's route, e.g. for Frankfurt - # Flughafen in some nightly connections. - my $times = $station->[1]; - if ( $times->{sched_arr} - and ref( $times->{sched_arr} ) ne 'DateTime' ) - { - $times->{sched_arr} - = epoch_to_dt( $times->{sched_arr} ); - $times->{rt_arr} = $times->{sched_arr}->clone; - if ( $times->{adelay} - and $times->{adelay} =~ m{^\d+$} ) - { - $times->{rt_arr} - ->add( minutes => $times->{adelay} ); - } - $times->{rt_arr_countdown} - = $times->{rt_arr}->epoch - $epoch; - } - if ( $times->{sched_dep} - and ref( $times->{sched_dep} ) ne 'DateTime' ) - { - $times->{sched_dep} - = epoch_to_dt( $times->{sched_dep} ); - $times->{rt_dep} = $times->{sched_dep}->clone; - if ( $times->{ddelay} - and $times->{ddelay} =~ m{^\d+$} ) - { - $times->{rt_dep} - ->add( minutes => $times->{ddelay} ); - } - $times->{rt_dep_countdown} - = $times->{rt_dep}->epoch - $epoch; - } - } + 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 + ); } - $ret->{departure_countdown} - = $ret->{real_departure}->epoch - $now->epoch; + 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} ) @@ -2915,7 +1673,6 @@ sub startup { from_json => $in_transit->{data}{wagonorder_dep} ); }; if ( $wr - and $wr->sections and $wr->wagons and defined $wr->direction ) { @@ -2923,97 +1680,57 @@ sub startup { } } - if ( $in_transit->{real_arr_ts} ) { - $ret->{arrival_countdown} - = $ret->{real_arrival}->epoch - $now->epoch; - $ret->{journey_duration} - = $ret->{real_arrival}->epoch - - $ret->{real_departure}->epoch; - $ret->{journey_completion} - = $ret->{journey_duration} - ? 1 - - ( $ret->{arrival_countdown} / $ret->{journey_duration} ) - : 1; - if ( $ret->{journey_completion} > 1 ) { - $ret->{journey_completion} = 1; - } - elsif ( $ret->{journey_completion} < 0 ) { - $ret->{journey_completion} = 0; - } - - my ($dep_platform_number) - = ( ( $ret->{dep_platform} // 0 ) =~ m{(\d+)} ); - if ( $dep_platform_number - and exists $in_transit->{data}{stationinfo_dep} - {$dep_platform_number} ) - { - $ret->{dep_direction} - = $self->stationinfo_to_direction( - $in_transit->{data}{stationinfo_dep} - {$dep_platform_number}, - $in_transit->{data}{wagonorder_dep}, - undef, - $stop_after_dep - ); - } - - my ($arr_platform_number) - = ( ( $ret->{arr_platform} // 0 ) =~ m{(\d+)} ); - if ( $arr_platform_number - and exists $in_transit->{data}{stationinfo_arr} - {$arr_platform_number} ) - { - $ret->{arr_direction} - = $self->stationinfo_to_direction( - $in_transit->{data}{stationinfo_arr} - {$arr_platform_number}, - $in_transit->{data}{wagonorder_arr}, - $stop_before_dest, - undef - ); - } - - } - else { - $ret->{arrival_countdown} = undef; - $ret->{journey_duration} = undef; - $ret->{journey_completion} = undef; - } - return $ret; } - my $latest = $db->select( - 'journeys_str', - '*', + 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} + ) + ) { - user_id => $uid, - cancelled => 0 - }, + $latest_cancellation->{dep_ds100} = $station->{ds100}; + $latest_cancellation->{dep_name} = $station->{name}; + } + if ( + my $station = $self->stations->get_by_eva( + $latest_cancellation->{arr_eva} + ) + ) { - order_by => { -desc => 'journey_id' }, - limit => 1 + $latest_cancellation->{arr_ds100} = $station->{ds100}; + $latest_cancellation->{arr_name} = $station->{name}; } - )->expand->hash; + } + else { + $latest_cancellation = undef; + } if ($latest) { my $ts = $latest->{checkout_ts}; my $action_time = epoch_to_dt($ts); if ( my $station - = $self->app->station_by_eva->{ $latest->{dep_eva} } ) + = $self->stations->get_by_eva( $latest->{dep_eva} ) ) { - $latest->{dep_ds100} = $station->[0]; - $latest->{dep_name} = $station->[1]; + $latest->{dep_ds100} = $station->{ds100}; + $latest->{dep_name} = $station->{name}; } if ( my $station - = $self->app->station_by_eva->{ $latest->{arr_eva} } ) + = $self->stations->get_by_eva( $latest->{arr_eva} ) ) { - $latest->{arr_ds100} = $station->[0]; - $latest->{arr_name} = $station->[1]; + $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, @@ -3026,20 +1743,30 @@ sub startup { 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, @@ -3049,10 +1776,11 @@ sub startup { $self->helper( 'get_user_status_json_v1' => sub { - my ( $self, $uid ) = @_; - my $status = $self->get_user_status($uid); - - # TODO simplify lon/lat (can be returned from get_user_status) + 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, @@ -3060,57 +1788,82 @@ sub startup { $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 => undef, - latitude => undef, - scheduledTime => $status->{sched_departure}->epoch || undef, - realTime => $status->{real_departure}->epoch || undef, + 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 => undef, - latitude => undef, - scheduledTime => $status->{sched_arrival}->epoch || undef, - realTime => $status->{real_arrival}->epoch || undef, + 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}, + type => $status->{train_type}, + line => $status->{train_line}, + no => $status->{train_no}, + id => $status->{train_id}, + hafasId => $status->{extra_data}{trip_id}, }, - actionTime => $status->{timestamp}->epoch, + intermediateStops => [], + visibility => { + level => $status->{effective_visibility}, + desc => $status->{effective_visibility_str}, + } }; - if ( $status->{dep_eva} ) { - my @station_descriptions - = Travel::Status::DE::IRIS::Stations::get_station( - $status->{dep_eva} ); - if ( @station_descriptions == 1 ) { - ( - undef, undef, undef, - $ret->{fromStation}{longitude}, - $ret->{fromStation}{latitude} - ) = @{ $station_descriptions[0] }; + if ( $opt{public} ) { + if ( not $privacy->{comments_visible} ) { + delete $ret->{comment}; } } + else { + $ret->{actionTime} + = $status->{timestamp} + ? $status->{timestamp}->epoch + : undef; + } - if ( $status->{arr_ds100} ) { - my @station_descriptions - = Travel::Status::DE::IRIS::Stations::get_station( - $status->{arr_ds100} ); - if ( @station_descriptions == 1 ) { - ( - undef, undef, undef, - $ret->{toStation}{longitude}, - $ret->{toStation}{latitude} - ) = @{ $station_descriptions[0] }; + 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; @@ -3118,140 +1871,464 @@ sub startup { ); $self->helper( - 'get_travel_distance' => sub { - my ( $self, $from, $to, $route_ref ) = @_; - - my $distance_intermediate = 0; - my $distance_beeline = 0; - my $skipped = 0; - my $geo = Geo::Distance->new(); - my @stations = map { $_->[0] } @{$route_ref}; - my @route = after_incl { $_ eq $from } @stations; - @route = before_incl { $_ eq $to } @route; + 'traewelling_to_travelynx_p' => sub { + my ( $self, %opt ) = @_; + my $traewelling = $opt{traewelling}; + my $user_data = $opt{user_data}; + my $uid = $user_data->{user_id}; - if ( @route < 2 ) { + my $promise = Mojo::Promise->new; - # I AM ERROR - return ( 0, 0 ); + if ( not $traewelling->{checkin} + or $self->now->epoch - $traewelling->{checkin}->epoch > 900 ) + { + $self->log->debug("... not checked in"); + return $promise->resolve; } - - my $prev_station = get_station( shift @route ); - if ( not $prev_station ) { - return ( 0, 0 ); + 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; } - # Geo-coordinates for stations outside Germany are not available - # at the moment. When calculating distance with intermediate stops, - # these are simply left out (as if they were not part of the route). - # For beeline distance calculation, we use the route's first and last - # station with known geo-coordinates. - my $from_station_beeline; - my $to_station_beeline; - - for my $station_name (@route) { - if ( my $station = get_station($station_name) ) { - if ( not $from_station_beeline and $#{$prev_station} >= 4 ) - { - $from_station_beeline = $prev_station; - } - if ( $#{$station} >= 4 ) { - $to_station_beeline = $station; + 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 + ); } - if ( $#{$prev_station} >= 4 and $#{$station} >= 4 ) { - $distance_intermediate - += $geo->distance( 'kilometer', $prev_station->[3], - $prev_station->[4], $station->[3], $station->[4] ); + )->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; } - else { - $skipped++; + )->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; } - $prev_station = $station; - } + )->wait; + return $promise; } - if ( $from_station_beeline and $to_station_beeline ) { - $distance_beeline = $geo->distance( - 'kilometer', $from_station_beeline->[3], - $from_station_beeline->[4], $to_station_beeline->[3], - $to_station_beeline->[4] - ); - } + $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; + } - return ( $distance_intermediate, $distance_beeline, $skipped ); + $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( - 'compute_journey_stats' => sub { - my ( $self, @journeys ) = @_; - my $km_route = 0; - my $km_beeline = 0; - my $min_travel_sched = 0; - my $min_travel_real = 0; - my $delay_dep = 0; - my $delay_arr = 0; - my $interchange_real = 0; - my $num_trains = 0; - my $num_journeys = 0; - my @inconsistencies; - - my $next_departure = 0; - - for my $journey (@journeys) { - $num_trains++; - $km_route += $journey->{km_route}; - $km_beeline += $journey->{km_beeline}; - if ( $journey->{sched_duration} - and $journey->{sched_duration} > 0 ) + '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 ) { - $min_travel_sched += $journey->{sched_duration} / 60; - } - if ( $journey->{rt_duration} and $journey->{rt_duration} > 0 ) { - $min_travel_real += $journey->{rt_duration} / 60; + # Fall back to route + delete $journey->{polyline}; + next; } - if ( $journey->{sched_dep_ts} and $journey->{rt_dep_ts} ) { - $delay_dep - += ( $journey->{rt_dep_ts} - $journey->{sched_dep_ts} ) - / 60; + + my $key + = $from_eva . '!' + . $to_eva . '!' + . ( $to_index - $from_index ); + + if ( $seen{$key} ) { + next; } - if ( $journey->{sched_arr_ts} and $journey->{rt_arr_ts} ) { - $delay_arr - += ( $journey->{rt_arr_ts} - $journey->{sched_arr_ts} ) - / 60; + + $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] ); + } - # Note that journeys are sorted from recent to older entries - if ( $journey->{rt_arr_ts} - and $next_departure - and $next_departure - $journey->{rt_arr_ts} < ( 60 * 60 ) ) - { - if ( $next_departure - $journey->{rt_arr_ts} < 0 ) { - push( @inconsistencies, - epoch_to_dt($next_departure) - ->strftime('%d.%m.%Y %H:%M') ); + 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} } - else { - $interchange_real - += ( $next_departure - $journey->{rt_arr_ts} ) / 60; + @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; } - else { - $num_journeys++; + + $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; } - $next_departure = $journey->{rt_dep_ts}; } - return { - km_route => $km_route, - km_beeline => $km_beeline, - num_trains => $num_trains, - num_journeys => $num_journeys, - min_travel_sched => $min_travel_sched, - min_travel_real => $min_travel_real, - min_interchange_real => $interchange_real, - delay_dep => $delay_dep, - delay_arr => $delay_arr, - inconsistencies => \@inconsistencies, + + @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; } ); @@ -3274,71 +2351,108 @@ sub startup { $r->get('/changelog')->to('static#changelog'); $r->get('/impressum')->to('static#imprint'); $r->get('/imprint')->to('static#imprint'); - $r->get('/offline')->to('static#offline'); + $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('/register')->to('account#registration_form'); $r->get('/reg/:id/:token')->to('account#verify'); - $r->get('/status/:name')->to('traveling#user_status'); - $r->get('/status/:name/:ts')->to('traveling#user_status'); - $r->get('/ajax/status/:name')->to('traveling#public_status_card'); - $r->get('/ajax/status/:name/:ts')->to('traveling#public_status_card'); + $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#log_action'); + $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('/register')->to('account#register'); $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 ); + $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('/history/map')->to('traveling#map_history'); + $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'); } |