summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rwxr-xr-xlib/Travelynx.pm4061
-rw-r--r--lib/Travelynx/Command/account.pm119
-rw-r--r--lib/Travelynx/Command/database.pm1423
-rw-r--r--lib/Travelynx/Command/dumpconfig.pm3
-rw-r--r--lib/Travelynx/Command/dumpstops.pm51
-rw-r--r--lib/Travelynx/Command/influxdb.pm209
-rw-r--r--lib/Travelynx/Command/integritycheck.pm112
-rw-r--r--lib/Travelynx/Command/maintenance.pm95
-rw-r--r--lib/Travelynx/Command/munin.pm50
-rw-r--r--lib/Travelynx/Command/traewelling.pm207
-rw-r--r--lib/Travelynx/Command/work.pm290
-rw-r--r--lib/Travelynx/Command/worker.pm27
-rw-r--r--lib/Travelynx/Controller/Account.pm1095
-rwxr-xr-xlib/Travelynx/Controller/Api.pm591
-rw-r--r--lib/Travelynx/Controller/Passengerrights.pm30
-rwxr-xr-xlib/Travelynx/Controller/Profile.pm603
-rw-r--r--lib/Travelynx/Controller/Static.pm20
-rw-r--r--lib/Travelynx/Controller/Traewelling.pm154
-rwxr-xr-xlib/Travelynx/Controller/Traveling.pm1998
-rw-r--r--lib/Travelynx/Helper/DBDB.pm150
-rw-r--r--lib/Travelynx/Helper/HAFAS.pm315
-rw-r--r--lib/Travelynx/Helper/IRIS.pm237
-rw-r--r--lib/Travelynx/Helper/Sendmail.pm52
-rw-r--r--lib/Travelynx/Helper/Traewelling.pm387
-rw-r--r--lib/Travelynx/Model/InTransit.pm900
-rwxr-xr-xlib/Travelynx/Model/JourneyStatsCache.pm122
-rwxr-xr-xlib/Travelynx/Model/Journeys.pm1785
-rw-r--r--lib/Travelynx/Model/Stations.pm199
-rw-r--r--lib/Travelynx/Model/Traewelling.pm243
-rw-r--r--lib/Travelynx/Model/Users.pm1160
30 files changed, 13549 insertions, 3139 deletions
diff --git a/lib/Travelynx.pm b/lib/Travelynx.pm
index 1c5b449..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 qw(first);
-use List::MoreUtils qw(after_incl before_incl);
+use List::Util;
+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,40 +178,132 @@ sub startup {
);
$self->attr(
- token_type => sub {
- return {
- status => 1,
- history => 2,
- action => 3,
- };
+ 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(
- token_types => sub {
- return [qw(status history action)];
+ ice_name => sub {
+ my $id_to_name = JSON->new->utf8->decode(
+ scalar read_file('share/ice_names.json') );
+ return $id_to_name;
}
);
$self->attr(
- coordinates_by_station => sub {
- my %location;
- for
- my $station ( Travel::Status::DE::IRIS::Stations::get_stations() )
+ renamed_station => sub {
+ my $legacy_to_new = JSON->new->utf8->decode(
+ scalar read_file('share/old_station_names.json') );
+ return $legacy_to_new;
+ }
+ );
+
+ if ( not $self->app->config->{base_url} ) {
+ $self->app->log->error(
+"travelynx.conf: 'base_url' is missing. Links in maintenance/work/worker-generated E-Mails will be incorrect. This variable was introduced in travelynx 1.22; see examples/travelynx.conf for documentation."
+ );
+ }
+
+ $self->helper(
+ base_url_for => sub {
+ my ( $self, $path ) = @_;
+ if ( ( my $url = $self->url_for($path) )->base ne q{}
+ or not $self->app->config->{base_url} )
{
- 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->helper(
- sendmail => sub {
- state $sendmail = Travelynx::Helper::Sendmail->new(
- config => ( $self->config->{mail} // {} ),
- log => $self->log
+ hafas => sub {
+ my ($self) = @_;
+ state $hafas = Travelynx::Helper::HAFAS->new(
+ log => $self->app->log,
+ main_cache => $self->app->cache_iris_main,
+ realtime_cache => $self->app->cache_iris_rt,
+ root_url => $self->base_url_for('/')->to_abs,
+ user_agent => $self->ua,
+ version => $self->app->config->{version},
+ );
+ }
+ );
+
+ $self->helper(
+ iris => sub {
+ my ($self) = @_;
+ state $iris = Travelynx::Helper::IRIS->new(
+ log => $self->app->log,
+ main_cache => $self->app->cache_iris_main,
+ realtime_cache => $self->app->cache_iris_rt,
+ root_url => $self->base_url_for('/')->to_abs,
+ version => $self->app->config->{version},
+ );
+ }
+ );
+
+ $self->helper(
+ traewelling => sub {
+ my ($self) = @_;
+ state $trwl = Travelynx::Model::Traewelling->new( pg => $self->pg );
+ }
+ );
+
+ $self->helper(
+ traewelling_api => sub {
+ my ($self) = @_;
+ state $trwl_api = Travelynx::Helper::Traewelling->new(
+ log => $self->app->log,
+ model => $self->traewelling,
+ root_url => $self->base_url_for('/')->to_abs,
+ user_agent => $self->ua,
+ version => $self->app->config->{version},
+ );
+ }
+ );
+
+ $self->helper(
+ in_transit => sub {
+ my ($self) = @_;
+ state $in_transit = Travelynx::Model::InTransit->new(
+ log => $self->app->log,
+ pg => $self->pg,
+ );
+ }
+ );
+
+ $self->helper(
+ journey_stats_cache => sub {
+ my ($self) = @_;
+ state $journey_stats_cache
+ = Travelynx::Model::JourneyStatsCache->new(
+ log => $self->app->log,
+ pg => $self->pg,
+ );
+ }
+ );
+
+ $self->helper(
+ journeys => sub {
+ my ($self) = @_;
+ state $journeys = Travelynx::Model::Journeys->new(
+ log => $self->app->log,
+ pg => $self->pg,
+ in_transit => $self->in_transit,
+ stats_cache => $self->journey_stats_cache,
+ renamed_station => $self->app->renamed_station,
+ latlon_by_station => $self->app->coordinates_by_station,
+ stations => $self->stations,
);
}
);
@@ -206,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},
+ );
}
);
@@ -232,261 +394,266 @@ 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,
- 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_name =>
- ( $status->station ? $status->station->{name} : 'undef' ),
- related_stations => [ $status->related_stations ],
- };
+ 'sprintf_km' => sub {
+ my ( $self, $km ) = @_;
+
+ if ( $km < 1 ) {
+ return sprintf( '%.f m', $km * 1000 );
}
- elsif ( @station_matches > 1 ) {
- return {
- results => [],
- errstr => 'Ambiguous station name',
- };
- }
- else {
- return {
- results => [],
- errstr => 'Unknown station name',
- };
+ 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';
+ }
+ if ( $visibility eq 'private' ) {
+ return 'lock';
}
- return @unknown_stations;
+ 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 = $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' );
- }
-
- my @route = ( [ $dep_station->[1], {}, undef ] );
-
- if ( $opt{route} ) {
- my @unknown_stations;
- for my $station ( @{ $opt{route} } ) {
- my $station_info = get_station($station);
- if ( not $station_info ) {
- push( @unknown_stations, $station );
- }
- push( @route, [ $station_info->[1], {}, undef ] );
- }
- if ( @unknown_stations == 1 ) {
- return ( undef,
- "Unbekannter Unterwegshalt: $unknown_stations[0]" );
- }
- elsif (@unknown_stations) {
- return ( undef,
- 'Unbekannte Unterwegshalte: '
- . join( ', ', @unknown_stations ) );
- }
+ if ( $train_id =~ m{[|]} ) {
+ return $self->_checkin_hafas_p(%opt);
}
- push( @route, [ $arr_station->[1], {}, undef ] );
+ my $promise = Mojo::Promise->new;
- if ( $route[0][0] eq $route[1][0] ) {
- shift(@route);
- }
+ $self->iris->get_departures_p(
+ station => $station,
+ lookbehind => 140,
+ lookahead => 40
+ )->then(
+ sub {
+ my ($status) = @_;
- if ( $route[-2][0] eq $route[-1][0] ) {
- pop(@route);
- }
+ if ( $status->{errstr} ) {
+ $promise->reject( $status->{errstr} );
+ return;
+ }
- 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 => $self->get_station_id(
- ds100 => $dep_station->[0],
- name => $dep_station->[1],
- ),
- checkin_time => $now,
- sched_departure => $opt{sched_departure},
- real_departure => $opt{rt_departure},
- checkout_station_id => $self->get_station_id(
- ds100 => $arr_station->[0],
- name => $arr_station->[1],
- ),
- 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 ),
- };
+ my $eva = $status->{station_eva};
+ my $train = List::Util::first { $_->train_id eq $train_id }
+ @{ $status->{results} };
- if ( $opt{comment} ) {
- $entry->{user_data}
- = JSON->new->encode( { comment => $opt{comment} } );
- }
+ if ( not defined $train ) {
+ $promise->reject("Train ${train_id} not found");
+ return;
+ }
- my $journey_id = undef;
- eval {
- $journey_id
- = $db->insert( 'journeys', $entry, { returning => 'id' } )
- ->hash->{id};
- $self->invalidate_stats_cache( $opt{rt_departure}, $db );
- };
+ 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;
+ }
- if ($@) {
- $self->app->log->error("add_journey($uid): $@");
- return ( undef, 'add_journey failed: ' . $@ );
- }
+ # mustn't be called during a transaction
+ if ( not $opt{in_transaction} ) {
+ $self->add_route_timestamps( $uid, $train, 1 );
+ $self->run_hook( $uid, 'checkin' );
+ }
- return ( $journey_id, undef );
+ $promise->resolve($train);
+ return;
+ }
+ )->catch(
+ sub {
+ my ( $err, $status ) = @_;
+ $promise->reject( $status->{errstr} );
+ return;
+ }
+ )->wait;
+
+ return $promise;
}
);
$self->helper(
- 'checkin' => sub {
- my ( $self, $station, $train_id ) = @_;
+ '_checkin_hafas_p' => sub {
+ my ( $self, %opt ) = @_;
- my $status = $self->get_departures( $station, 140, 40, 0 );
- if ( $status->{errstr} ) {
- return ( undef, $status->{errstr} );
- }
- else {
- my ($train)
- = first { $_->train_id eq $train_id } @{ $status->{results} };
- if ( not defined $train ) {
- return ( undef, "Train ${train_id} not found" );
- }
- else {
+ my $station = $opt{station};
+ my $train_id = $opt{train_id};
+ my $uid = $opt{uid} // $self->current_user->{id};
+ my $db = $opt{db} // $self->pg->db;
+ my $hafas;
- my $user = $self->get_user_status;
- if ( $user->{checked_in} or $user->{cancelled} ) {
+ my $promise = Mojo::Promise->new;
- if ( $user->{train_id} eq $train_id
- and $user->{dep_ds100} eq $status->{station_ds100} )
+ $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 );
}
-
+ 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 => $self->current_user->{id},
- cancelled => $train->departure_is_cancelled
- ? 1
- : 0,
- checkin_station_id => $self->get_station_id(
- ds100 => $status->{station_ds100},
- name => $status->{station_name}
- ),
- 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 ($@) {
- my $uid = $self->current_user->{id};
$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,
+ };
+ }
+ }
+
+ if ($polyline) {
+ $self->in_transit->set_polyline(
+ uid => $uid,
+ db => $db,
+ polyline => $polyline,
+ );
}
- $self->add_route_timestamps( $self->current_user->{id},
- $train, 1 );
- $self->run_hook( $self->current_user->{id}, 'checkin' );
- return ( $train, undef );
+
+ # mustn't be called during a transaction
+ if ( not $opt{in_transaction} ) {
+ $self->run_hook( $uid, 'checkin' );
+ }
+
+ $promise->resolve($journey);
}
- }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
}
);
$self->helper(
'undo' => sub {
- my ( $self, $journey_id ) = @_;
- my $uid = $self->current_user->{id};
+ my ( $self, $journey_id, $uid ) = @_;
+ $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): $@";
@@ -502,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} ) {
@@ -527,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}
@@ -539,7 +719,11 @@ sub startup {
);
}
- $self->invalidate_stats_cache( $cache_ts, $db );
+ $self->journey_stats_cache->invalidate(
+ ts => $cache_ts,
+ db => $db,
+ uid => $uid
+ );
$tx->commit;
};
@@ -552,601 +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 ) = @_;
+ 'checkout_p' => sub {
+ my ( $self, %opt ) = @_;
- $uid //= $self->current_user->{id};
- $db //= $self->pg->db;
+ 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->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,
- }
- );
- }
- );
-
- $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' );
+ return $promise->resolve( 0,
+ 'You are not checked into any train' );
+ }
+
+ if ( $dep_eva and $dep_eva != $user->{dep_eva} ) {
+ return $promise->resolve( 0, 'race condition' );
}
- if ( $status->{errstr} and not $force ) {
- return ( 1, $status->{errstr} );
+ if ( $arr_eva and $arr_eva != $user->{arr_eva} ) {
+ return $promise->resolve( 0, 'race condition' );
}
- my $now = DateTime->now( time_zone => 'Europe/Berlin' );
- my $journey
- = $db->select( 'in_transit', '*', { user_id => $uid } )
- ->expand->hash;
- my ($train)
- = first { $_->train_id eq $train_id } @{ $status->{results} };
-
- # 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)
- = first { $_->train_id eq $train_id } @{ $status->{results} };
+ if ( $train_id =~ m{[|]} ) {
+ return $self->_checkout_hafas_p(%opt);
}
- # Store the intended checkout station regardless of this operation's
- # success.
- my $new_checkout_station_id = $self->get_station_id(
- ds100 => $status->{station_ds100},
- name => $status->{station_name}
- );
- $db->update(
- 'in_transit',
- {
- checkout_station_id => $new_checkout_station_id,
- },
- { user_id => $uid }
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ my $journey = $self->in_transit->get(
+ uid => $uid,
+ with_data => 1
);
- # 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 ( not defined $train ) {
+ $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
+ );
- # Arrival time via IRIS is unknown, so the train probably has not
- # arrived yet. Fall back to HAFAS.
- if ( my $station_data
- = 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 }
+ # 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;
+ # 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} };
- eval {
+ $train //= List::Util::first { $_->train_id eq $train_id }
+ @{ $status->{results} };
- my $tx = $db->begin;
+ if ( not defined $train ) {
- if ( 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( $self->current_user->{id}, $train, 0 );
- return ( 1, undef );
- }
- );
-
- $self->helper(
- 'mark_seen' => sub {
- my ( $self, $uid ) = @_;
-
- $self->pg->db->update(
- 'users',
- { last_seen => DateTime->now( time_zone => 'Europe/Berlin' ) },
- { id => $uid }
- );
- }
- );
+ eval {
- $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,
- );
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
- eval {
- if ( $key eq 'sched_departure' ) {
- $rows = $db->update(
- 'journeys',
+ if ( defined $train
+ and not $train->arrival
+ and not $force )
{
- sched_departure => $value,
- edited => $journey->{edited} | 0x0001,
- },
- {
- id => $journey_id,
+ my $train_no = $train->train_no;
+ die("Train ${train_no} has no arrival timestamp\n");
}
- )->rows;
- }
- elsif ( $key eq 'rt_departure' ) {
- $rows = $db->update(
- 'journeys',
- {
- real_departure => $value,
- edited => $journey->{edited} | 0x0002,
- },
- {
- id => $journey_id,
+ elsif ( defined $train and $train->arrival ) {
+ $self->in_transit->set_arrival(
+ uid => $uid,
+ db => $db,
+ train => $train,
+ route => [ $self->iris->route_diff($train) ]
+ );
+
+ $has_arrived
+ = $train->arrival->epoch < $now->epoch ? 1 : 0;
+ if ($has_arrived) {
+ my @unknown_stations
+ = $self->stations->grep_unknown(
+ $train->route );
+ if (@unknown_stations) {
+ $self->app->log->warn(
+ sprintf(
+'Route of %s %s (%s -> %s) contains unknown stations: %s',
+ $train->type,
+ $train->train_no,
+ $train->origin,
+ $train->destination,
+ join( ', ', @unknown_stations )
+ )
+ );
+ }
+ }
}
- )->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,
+ $journey = $self->in_transit->get(
+ uid => $uid,
+ db => $db
+ );
+
+ if ( $has_arrived or $force ) {
+ $self->journeys->add_from_in_transit(
+ db => $db,
+ journey => $journey
+ );
+ $self->in_transit->delete(
+ uid => $uid,
+ db => $db
+ );
+
+ my $cache_ts = $now->clone;
+ if ( $journey->{real_departure}
+ =~ m{ ^ (?<year> \d{4} ) - (?<month> \d{2} ) }x
+ )
+ {
+ $cache_ts->set(
+ year => $+{year},
+ month => $+{month}
+ );
+ }
+ $self->journey_stats_cache->invalidate(
+ ts => $cache_ts,
+ db => $db,
+ uid => $uid
+ );
}
- )->rows;
- }
- elsif ( $key eq 'route' ) {
- my @new_route = map { [ $_, {}, undef ] } @{$value};
- $rows = $db->update(
- 'journeys',
+ elsif ( defined $train
+ and $train->arrival_is_cancelled )
{
- route => JSON->new->encode( \@new_route ),
- edited => $journey->{edited} | 0x0010,
- },
- {
- 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 ) = @_;
+ 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->{edited} & 0x0010 ) {
- my @unknown_stations
- = $self->grep_unknown_stations( map { $_->[0] }
- @{ $journey->{route} } );
- if (@unknown_stations) {
- return 'Unbekannte Station(en): '
- . join( ', ', @unknown_stations );
}
- }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->resolve( 1, $err );
+ return;
+ }
+ )->wait;
- return undef;
+ return $promise;
}
);
$self->helper(
- 'get_station_id' => sub {
+ '_checkout_hafas_p' => sub {
my ( $self, %opt ) = @_;
- my $res = $self->pg->db->select( 'stations', ['id'],
- { ds100 => $opt{ds100} } );
- my $res_h = $res->hash;
-
- if ($res_h) {
- $res->finish;
- return $res_h->{id};
- }
+ my $station = $opt{station};
+ my $force = $opt{force};
+ my $uid = $opt{uid} // $self->current_user->{id};
+ my $db = $opt{db} // $self->pg->db;
- if ( $opt{readonly} ) {
- return;
- }
+ my $promise = Mojo::Promise->new;
- $self->pg->db->insert(
- 'stations',
- {
- ds100 => $opt{ds100},
- name => $opt{name},
- }
+ 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,
);
- $res = $self->pg->db->select( 'stations', ['id'],
- { ds100 => $opt{ds100} } );
- my $id = $res->hash->{id};
- $res->finish;
- return $id;
- }
- );
-
- $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
+ # with_visibility needed due to postprocess
+
+ my $found;
+ my $has_arrived;
+ for my $stop ( @{ $journey->{route_after} } ) {
+ if ( $station eq $stop->[0] or $station eq $stop->[1] ) {
+ $found = 1;
+ $self->in_transit->set_arrival_eva(
+ uid => $uid,
+ db => $db,
+ arrival_eva => $stop->[1],
+ );
+ if ( defined $journey->{checkout_station_id}
+ and $journey->{checkout_station_id} != $stop->{eva} )
+ {
+ $self->in_transit->unset_arrival_data(
+ uid => $uid,
+ db => $db
+ );
+ }
+ $self->in_transit->set_arrival_times(
+ uid => $uid,
+ db => $db,
+ sched_arrival => $stop->[2]{sched_arr},
+ rt_arrival =>
+ ( $stop->[2]{rt_arr} || $stop->[2]{sched_arr} )
+ );
+ if (
+ $now > ( $stop->[2]{rt_arr} || $stop->[2]{sched_arr} ) )
+ {
+ $has_arrived = 1;
+ }
+ last;
}
- );
-
- if ( $res->hash->{count} ) {
- $db->update( 'users', { status => 1 }, { id => $uid } );
- $db->delete( 'pending_registrations', { user_id => $uid } );
- $tx->commit;
- return 1;
}
- return;
- }
- );
-
- $self->helper(
- 'get_uid_by_name_and_mail' => sub {
- my ( $self, $name, $email ) = @_;
-
- my $res = $self->pg->db->select(
- 'users',
- ['id'],
- {
- name => $name,
- email => $email,
- status => 1
- }
- );
-
- if ( my $user = $res->hash ) {
- return $user->{id};
+ if ( not $found ) {
+ return $promise->resolve( 1, 'station not found in route' );
}
- return;
- }
- );
- $self->helper(
- 'get_privacy_by_name' => sub {
- my ( $self, $name ) = @_;
-
- my $res = $self->pg->db->select(
- 'users',
- [ 'id', 'public_level' ],
- {
- name => $name,
- status => 1
+ eval {
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
}
- );
-
- if ( my $user = $res->hash ) {
- return $user;
- }
- 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 ( $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
+ );
- $db->insert(
- 'pending_passwords',
- {
- user_id => $uid,
- token => $token,
- requested_at =>
- DateTime->now( time_zone => 'Europe/Berlin' )
+ 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
+ );
}
- );
-
- return undef;
- }
- );
-
- $self->helper(
- 'verify_password_token' => sub {
- my ( $self, $uid, $token ) = @_;
- my $res = $self->pg->db->select(
- 'pending_passwords',
- 'count(*) as count',
- {
- user_id => $uid,
- token => $token
+ if ($tx) {
+ $tx->commit;
}
- );
+ };
- if ( $res->hash->{count} ) {
- return 1;
+ if ($@) {
+ $self->app->log->error("Checkout($uid): $@");
+ return $promise->resolve( 1, 'Checkout error: ' . $@ );
}
- 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 ( $has_arrived or $force ) {
+ if ( not $opt{in_transaction} ) {
+ $self->run_hook( $uid, 'checkout' );
}
- )->hash;
-
- if ($res_h) {
- $db->update(
- 'users',
- { email => $res_h->{email} },
- { id => $uid }
- );
- $db->delete( 'pending_mails', { user_id => $uid } );
- $tx->commit;
- return 1;
+ return $promise->resolve( 0, undef );
}
- return;
- }
- );
-
- $self->helper(
- 'remove_password_token' => sub {
- my ( $self, $uid, $token ) = @_;
-
- $self->pg->db->delete(
- 'pending_passwords',
- {
- user_id => $uid,
- token => $token
- }
- );
+ if ( not $opt{in_transaction} ) {
+ $self->run_hook( $uid, 'update' );
+ }
+ return $promise->resolve( 1, undef );
}
);
@@ -1159,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 );
}
);
@@ -1284,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 )
{
@@ -1294,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,
@@ -1303,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;
@@ -1317,1181 +1180,320 @@ 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, $ds100 ) = @_;
-
- my $url = "https://lib.finalrewind.org/dbdb/s/${ds100}.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(
- '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{&#x0028;}{(}g;
- $body =~ s{&#x0029;}{)}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="... &gt; ..."> 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_ds100', 'dep_ds100', '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;
-
- $self->app->log->debug("add_route_timestamps");
-
- 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};
-
- # Try finding a result for the current date
- for
- my $suggestion ( @{ $trainsearch->{suggestions} // [] } )
- {
+ my $route = $in_transit->{route};
- # 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->type =~ m{[EI]C} and $train->sched_departure ) {
- $self->get_wagonorder_p( $train->sched_departure,
+ if ( $train->sched_departure ) {
+ $self->dbdb->has_wagonorder_p( $train->sched_departure,
$train->train_no )->then(
sub {
+ my ($api) = @_;
+ return $self->dbdb->get_wagonorder_p( $api,
+ $train->sched_departure, $train->train_no );
+ }
+ )->then(
+ sub {
my ($wagonorder) = @_;
- my $res = $db->select( 'in_transit', ['data'],
- { user_id => $uid } );
- my $res_h = $res->expand->hash;
- my $data = $res_h->{data} // {};
+ my $data = {};
+ my $user_data = {};
- if ($is_departure) {
- $data->{wagonorder_dep} = $wagonorder;
+ if ( $is_departure and not exists $wagonorder->{error} )
+ {
+ $data->{wagonorder_dep} = $wagonorder;
+ $user_data->{wagongroups} = [];
+ for my $group (
+ @{
+ $wagonorder->{data}{istformation}
+ {allFahrzeuggruppe} // []
+ }
+ )
+ {
+ my @wagons;
+ for
+ my $wagon ( @{ $group->{allFahrzeug} // [] } )
+ {
+ push(
+ @wagons,
+ {
+ id => $wagon->{fahrzeugnummer},
+ number =>
+ $wagon->{wagenordnungsnummer},
+ type => $wagon->{fahrzeugtyp},
+ }
+ );
+ }
+ push(
+ @{ $user_data->{wagongroups} },
+ {
+ name =>
+ $group->{fahrzeuggruppebezeichnung},
+ from =>
+ $group->{startbetriebsstellename},
+ to => $group->{zielbetriebsstellename},
+ no => $group->{verkehrlichezugnummer},
+ wagons => [@wagons],
+ }
+ );
+ if ( $group->{fahrzeuggruppebezeichnung}
+ and $group->{fahrzeuggruppebezeichnung} eq
+ 'ICE0304' )
+ {
+ $data->{wagonorder_pride} = 1;
+ }
+ }
+ $self->in_transit->update_data(
+ uid => $uid,
+ db => $db,
+ data => $data
+ );
+ $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;
+ $self->in_transit->update_data(
+ uid => $uid,
+ db => $db,
+ data => $data
+ );
}
-
- $db->update(
- 'in_transit',
- { data => JSON->new->encode($data) },
- { user_id => $uid }
- );
+ return;
+ }
+ )->catch(
+ sub {
+ # no wagonorder? no problem.
+ return;
}
)->wait;
}
if ($is_departure) {
- $self->get_dbdb_station_p( $journey->{dep_ds100} )->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_ds100} and not $is_departure ) {
- $self->get_dbdb_station_p( $journey->{arr_ds100} )->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;
-
- if ( $opt{ds100} ) {
- $dest_id = $self->get_station_id(
- ds100 => $opt{ds100},
- readonly => 1
- );
- }
- else {
- $dest_id = $self->get_latest_dest_id(%opt);
- }
-
- if ( not $dest_id ) {
- return;
- }
-
- my $res = $db->query(
- qq{
- select
- count(stations.name) as count,
- stations.name as dest
- from journeys
- left outer join stations on checkout_station_id = stations.id
- where user_id = ?
- and checkin_station_id = ?
- and real_departure > ?
- group by stations.name
- order by count desc;
- },
- $uid,
- $dest_id,
- $threshold
- );
- my @destinations
- = $res->hashes->grep( sub { shift->{count} >= $min_count } )
- ->map( sub { shift->{dest} } )->each;
- 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 ( $ds100, $exclude_via, $exclude_train_id, $exclude_before );
-
- if ( $opt{ds100} ) {
- if ( $use_history & 0x01 ) {
- $ds100 = $opt{ds100};
- }
- }
- else {
- if ( $use_history & 0x02 ) {
- my $status = $self->get_user_status;
- $ds100 = $status->{arr_ds100};
- $exclude_via = $status->{dep_name};
- $exclude_train_id = $status->{train_id};
- $exclude_before = $status->{real_arrival}->epoch;
- }
- }
-
- if ( not $ds100 ) {
- 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( $ds100, 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 $ds100 due to
- # unscheduled route changes but continues on schedule afterwards
- # -- so it is only cancelled at $ds100, 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_name => $entry->{dep_name},
- checkin_ts => $entry->{checkin_ts},
- sched_dep_ts => $entry->{sched_dep_ts},
- rt_dep_ts => $entry->{real_dep_ts},
- to_name => $entry->{arr_name},
- 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 ( $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;
}
);
@@ -2559,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 );
}
@@ -2583,151 +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;
- 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} )
+ my $traewelling = $self->traewelling->get(
+ uid => $uid,
+ db => $db
+ );
+ if ( $traewelling->{latest_run}
+ >= epoch_to_dt( $in_transit->{checkin_ts} ) )
+ {
+ $ret->{traewelling} = $traewelling;
+ if ( @{ $traewelling->{data}{log} // [] }
+ and ( my $log_entry = $traewelling->{data}{log}[0] ) )
{
- $stop_before_dest = $route_after[-1][0];
- }
- if ($is_after) {
- push( @route_after, $station );
- }
- if ( $station->[0] eq $in_transit->{dep_name} ) {
- $is_after = 1;
- $self->app->log->debug("is_after");
- if ( @{$station} > 1 ) {
- $self->app->log->debug("set dep_info");
- $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[0][0];
-
- 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_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_name => $in_transit->{arr_name},
- arr_platform => $in_transit->{arr_platform},
- route_after => \@route_after,
- messages => $in_transit->{messages},
- extra_data => $in_transit->{data},
- };
- 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} ) {
- $self->app->log->debug(
- "dep_info has sched_arr. set countdown.");
- $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} )
@@ -2739,7 +1673,6 @@ sub startup {
from_json => $in_transit->{data}{wagonorder_dep} );
};
if ( $wr
- and $wr->sections
and $wr->wagons
and defined $wr->direction )
{
@@ -2747,85 +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};
}
- )->hash;
+ }
+ else {
+ $latest_cancellation = undef;
+ }
if ($latest) {
my $ts = $latest->{checkout_ts};
my $action_time = epoch_to_dt($ts);
+ if ( my $station
+ = $self->stations->get_by_eva( $latest->{dep_eva} ) )
+ {
+ $latest->{dep_ds100} = $station->{ds100};
+ $latest->{dep_name} = $station->{name};
+ }
+ if ( my $station
+ = $self->stations->get_by_eva( $latest->{arr_eva} ) )
+ {
+ $latest->{arr_ds100} = $station->{ds100};
+ $latest->{arr_name} = $station->{name};
+ }
return {
checked_in => 0,
cancelled => 0,
+ cancellation => $latest_cancellation,
journey_id => $latest->{journey_id},
timestamp => $action_time,
timestamp_delta => $now->epoch - $action_time->epoch,
@@ -2836,19 +1741,32 @@ sub startup {
sched_departure => epoch_to_dt( $latest->{sched_dep_ts} ),
real_departure => epoch_to_dt( $latest->{real_dep_ts} ),
dep_ds100 => $latest->{dep_ds100},
+ dep_eva => $latest->{dep_eva},
dep_name => $latest->{dep_name},
+ dep_lat => $latest->{dep_lat},
+ dep_lon => $latest->{dep_lon},
dep_platform => $latest->{dep_platform},
sched_arrival => epoch_to_dt( $latest->{sched_arr_ts} ),
real_arrival => epoch_to_dt( $latest->{real_arr_ts} ),
arr_ds100 => $latest->{arr_ds100},
+ arr_eva => $latest->{arr_eva},
arr_name => $latest->{arr_name},
+ arr_lat => $latest->{arr_lat},
+ arr_lon => $latest->{arr_lon},
arr_platform => $latest->{arr_platform},
+ comment => $latest->{user_data}{comment},
+ visibility => $latest->{visibility},
+ visibility_str => $latest->{visibility_str},
+ effective_visibility => $latest->{effective_visibility},
+ effective_visibility_str =>
+ $latest->{effective_visibility_str},
};
}
return {
checked_in => 0,
cancelled => 0,
+ cancellation => $latest_cancellation,
no_journeys_yet => 1,
timestamp => epoch_to_dt(0),
timestamp_delta => $now->epoch,
@@ -2858,8 +1776,11 @@ sub startup {
$self->helper(
'get_user_status_json_v1' => sub {
- my ( $self, $uid ) = @_;
- my $status = $self->get_user_status($uid);
+ 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,
@@ -2867,59 +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 => undef,
- longitude => undef,
- latitude => undef,
- scheduledTime => $status->{sched_departure}->epoch || undef,
- realTime => $status->{real_departure}->epoch || undef,
+ uic => $status->{dep_eva},
+ longitude => $status->{dep_lon},
+ latitude => $status->{dep_lat},
+ scheduledTime => $status->{sched_departure}
+ ? $status->{sched_departure}->epoch
+ : undef,
+ realTime => $status->{real_departure}
+ ? $status->{real_departure}->epoch
+ : undef,
},
toStation => {
ds100 => $status->{arr_ds100},
name => $status->{arr_name},
- uic => undef,
- longitude => undef,
- latitude => undef,
- scheduledTime => $status->{sched_arrival}->epoch || undef,
- realTime => $status->{real_arrival}->epoch || undef,
+ uic => $status->{arr_eva},
+ longitude => $status->{arr_lon},
+ latitude => $status->{arr_lat},
+ scheduledTime => $status->{sched_arrival}
+ ? $status->{sched_arrival}->epoch
+ : undef,
+ realTime => $status->{real_arrival}
+ ? $status->{real_arrival}->epoch
+ : undef,
},
train => {
- type => $status->{train_type},
- line => $status->{train_line},
- no => $status->{train_no},
- id => $status->{train_id},
+ 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_ds100} ) {
- my @station_descriptions
- = Travel::Status::DE::IRIS::Stations::get_station(
- $status->{dep_ds100} );
- if ( @station_descriptions == 1 ) {
- (
- undef, undef,
- $ret->{fromStation}{uic},
- $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,
- $ret->{toStation}{uic},
- $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;
@@ -2927,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;
+ }
- return ( $distance_intermediate, $distance_beeline, $skipped );
+ if ( not $train_id ) {
+ $self->log->debug(
+ "... train $traewelling->{line} not found");
+ $self->traewelling->log(
+ uid => $uid,
+ message =>
+"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: Zug nicht gefunden",
+ status_id => $traewelling->{status_id},
+ is_error => 1
+ );
+ return $promise->resolve;
+ }
+
+ $self->log->debug("... found train: $train_id");
+
+ my $db = $self->pg->db;
+ my $tx = $db->begin;
+
+ $self->checkin_p(
+ station => $traewelling->{dep_eva},
+ train_id => $train_id,
+ uid => $uid,
+ in_transaction => 1,
+ db => $db
+ )->then(
+ sub {
+ $self->log->debug("... handled origin");
+ return $self->checkout_p(
+ station => $traewelling->{arr_eva},
+ train_id => 0,
+ uid => $uid,
+ in_transaction => 1,
+ db => $db
+ );
+ }
+ )->then(
+ sub {
+ my ( undef, $err ) = @_;
+ if ($err) {
+ $self->log->debug("... error: $err");
+ return Mojo::Promise->reject($err);
+ }
+ $self->log->debug("... handled destination");
+ if ( $traewelling->{message} ) {
+ $self->in_transit->update_user_data(
+ uid => $uid,
+ db => $db,
+ user_data =>
+ { comment => $traewelling->{message} }
+ );
+ }
+ $self->traewelling->log(
+ uid => $uid,
+ db => $db,
+ message =>
+"Eingecheckt in $traewelling->{line} nach $traewelling->{arr_name}",
+ status_id => $traewelling->{status_id},
+ );
+ $self->traewelling->set_latest_pull_status_id(
+ uid => $uid,
+ status_id => $traewelling->{status_id},
+ db => $db
+ );
+
+ $tx->commit;
+ $promise->resolve;
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->log->debug("... error: $err");
+ $self->traewelling->log(
+ uid => $uid,
+ message =>
+"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: $err",
+ status_id => $traewelling->{status_id},
+ is_error => 1
+ );
+ $promise->resolve;
+ return;
+ }
+ )->wait;
+ }
+ )->catch(
+ sub {
+ my ( $err, $dep ) = @_;
+ $self->traewelling->log(
+ uid => $uid,
+ message =>
+"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: $dep->{errstr}",
+ status_id => $traewelling->{status_id},
+ is_error => 1,
+ );
+ $promise->resolve;
+ return;
+ }
+ )->wait;
+
+ return $promise;
}
);
$self->helper(
- '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;
}
- else {
- $num_journeys++;
+
+ # Manual journey entries are only included if one of the following
+ # conditions is satisfied:
+ # * their route has more than two elements (-> probably more than just
+ # start and stop station), or
+ # * $include_manual is true (-> user wants to see incomplete routes)
+ # This avoids messing up the map in case an A -> B connection has been
+ # tracked both with a regular checkin (-> detailed route shown on map)
+ # and entered manually (-> beeline also shown on map, typically
+ # significantly differs from detailed route) -- unless the user
+ # sets include_manual, of course.
+ if ( $journey->{edited} & 0x0010
+ and @route <= 2
+ and not $include_manual )
+ {
+ push( @skipped_journeys,
+ [ $journey, 'Manueller Eintrag ohne Unterwegshalte' ] );
+ next;
+ }
+
+ @route = @route[ $from_index .. $to_index ];
+
+ my $key = join( '|', @route );
+
+ if ( $seen{$key} ) {
+ next;
+ }
+
+ $seen{$key} = 1;
+
+ # direction does not matter at the moment
+ $seen{ join( '|', reverse @route ) } = 1;
+
+ my $prev_station = shift @route;
+ for my $station (@route) {
+ push( @station_pairs, [ $prev_station, $station ] );
+ $prev_station = $station;
}
- $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;
}
);
@@ -3083,67 +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('/api/v0/:user_action/:token')->to('api#get_v0');
+ $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->post('/action')->to('traveling#log_action');
+ $r->get( '/status/:name' => [ format => [ 'html', 'json' ] ] )
+ ->to( 'profile#user_status', format => undef );
+ $r->get( '/status/:name/:ts' => [ format => [ 'html', 'json' ] ] )
+ ->to( 'profile#user_status', format => undef );
+ $r->get('/ajax/status/#name')->to('profile#status_card');
+ $r->get('/ajax/status/:name/:ts')->to('profile#status_card');
+ $r->get('/p/:name')->to('profile#profile');
+ $r->get( '/p/:name/j/:id' => 'public_journey' )
+ ->to('profile#journey_details');
+ $r->get('/.well-known/webfinger')->to('account#webfinger');
+ $r->get('/dyn/:av/autocomplete.js')->to('api#autocomplete');
+ $r->post('/api/v1/import')->to('api#import_v1');
+ $r->post('/api/v1/travel')->to('api#travel_v1');
+ $r->post('/action')->to('traveling#travel_action');
$r->post('/geolocation')->to('traveling#geolocation');
$r->post('/list_departures')->to('traveling#redirect_to_station');
$r->post('/login')->to('account#do_login');
- $r->post('/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('/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');
}
diff --git a/lib/Travelynx/Command/account.pm b/lib/Travelynx/Command/account.pm
new file mode 100644
index 0000000..1d17400
--- /dev/null
+++ b/lib/Travelynx/Command/account.pm
@@ -0,0 +1,119 @@
+package Travelynx::Command::account;
+
+# Copyright (C) 2021 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+use Mojo::Base 'Mojolicious::Command';
+use UUID::Tiny qw(:std);
+
+has description => 'Add or remove user accounts';
+
+has usage => sub { shift->extract_usage };
+
+sub add_user {
+ my ( $self, $name, $email ) = @_;
+
+ my $db = $self->app->pg->db;
+
+ if ( my $error = $self->app->users->is_name_invalid( name => $name ) ) {
+ say "Cannot add account '$name': $error";
+ die;
+ }
+
+ my $token = "tmp";
+ my $password = substr( create_uuid_as_string(UUID_V4), 0, 18 );
+
+ my $tx = $db->begin;
+ my $user_id = $self->app->users->add(
+ db => $db,
+ name => $name,
+ email => $email,
+ token => $token,
+ password => $password,
+ );
+ my $success = $self->app->users->verify_registration_token(
+ db => $db,
+ uid => $user_id,
+ token => $token,
+ in_transaction => 1,
+ );
+
+ if ($success) {
+ $tx->commit;
+ say "Added user $name ($email) with UID $user_id";
+ say "Temporary password for login: $password";
+ }
+}
+
+sub delete_user {
+ my ( $self, $uid ) = @_;
+
+ my $user_data = $self->app->users->get( uid => $uid );
+
+ if ( not $user_data ) {
+ say "UID $uid does not exist.";
+ return;
+ }
+
+ $self->app->users->flag_deletion( uid => $uid );
+
+ say "User $user_data->{name} (UID $uid) has been flagged for deletion.";
+}
+
+sub really_delete_user {
+ my ( $self, $uid, $name ) = @_;
+
+ my $user_data = $self->app->users->get( uid => $uid );
+
+ if ( $user_data->{name} ne $name ) {
+ say
+ "User name $name does not match UID $uid. Account deletion aborted.";
+ return;
+ }
+
+ my $count = $self->app->users->delete( uid => $uid );
+
+ printf( "Deleted %s -- %d tokens, %d monthly stats, %d journeys\n",
+ $name, $count->{tokens}, $count->{stats}, $count->{journeys} );
+
+ return;
+}
+
+sub run {
+ my ( $self, $command, @args ) = @_;
+
+ if ( $command eq 'add' ) {
+ $self->add_user(@args);
+ }
+ elsif ( $command eq 'delete' ) {
+ $self->delete_user(@args);
+ }
+ elsif ( $command eq 'DELETE' ) {
+ $self->really_delete_user(@args);
+ }
+ else {
+ $self->help;
+ }
+}
+
+1;
+
+__END__
+
+=head1 SYNOPSIS
+
+ Usage: index.pl account add [name] [email]
+
+ Adds user [name] with a temporary password, which is shown on stdout.
+ Users can change the password once logged in.
+
+ Usage: index.pl account delete [uid]
+
+ Request deletion of user [uid]. This has the same effect as using the
+ account deletion button. The user account and all corresponding data will
+ be deleted by a maintenance run after three days.
+
+ Usage: index.pl account DELETE [uid] [name]
+
+ Immediately delete user [uid]/[name] and all associated data. Deletion is
+ irrevocable. Deletion is only performed if [name] matches the name of [uid].
diff --git a/lib/Travelynx/Command/database.pm b/lib/Travelynx/Command/database.pm
index 4b23692..d13b2a7 100644
--- a/lib/Travelynx/Command/database.pm
+++ b/lib/Travelynx/Command/database.pm
@@ -1,20 +1,26 @@
package Travelynx::Command::database;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Command';
use DateTime;
+use File::Slurp qw(read_file);
+use JSON;
+use Travel::Status::DE::IRIS::Stations;
has description => 'Initialize or upgrade database layout';
has usage => sub { shift->extract_usage };
sub get_schema_version {
- my ($db) = @_;
+ my ( $db, $key ) = @_;
my $version;
- eval {
- $version
- = $db->select( 'schema_version', ['version'] )->hash->{version};
- };
+ $key //= 'version';
+
+ eval { $version = $db->select( 'schema_version', [$key] )->hash->{$key}; };
if ($@) {
# If it failed, the version table does not exist -> run setup first.
@@ -809,8 +815,1327 @@ my @migrations = (
}
);
},
+
+ # v18 -> v19
+ sub {
+ my ($db) = @_;
+ say
+'Transitioning from travelynx station ID to EVA IDs, this may take a while ...';
+ $db->query(
+ qq{
+ alter table in_transit drop constraint in_transit_checkin_station_id_fkey;
+ alter table in_transit drop constraint in_transit_checkout_station_id_fkey;
+ alter table journeys drop constraint journeys_checkin_station_id_fkey;
+ alter table journeys drop constraint journeys_checkout_station_id_fkey;
+ }
+ );
+ for my $journey ( $db->select( 'in_transit_str', '*' )->hashes->each ) {
+ my ($s_dep)
+ = Travel::Status::DE::IRIS::Stations::get_station(
+ $journey->{dep_ds100} );
+ if ( $s_dep->[1] ne $journey->{dep_name} ) {
+ die(
+"$s_dep->[0] name mismatch: $s_dep->[1] vs. $journey->{dep_name}"
+ );
+ }
+ my $rows = $db->update(
+ 'in_transit',
+ { checkin_station_id => $s_dep->[2] },
+ { user_id => $journey->{user_id} }
+ )->rows;
+ if ( $rows != 1 ) {
+ die(
+"Update error at in_transit checkin_station_id UID $journey->{user_id}\n"
+ );
+ }
+ if ( $journey->{arr_ds100} ) {
+ my ($s_arr)
+ = Travel::Status::DE::IRIS::Stations::get_station(
+ $journey->{arr_ds100} );
+ if ( $s_arr->[1] ne $journey->{arr_name} ) {
+ die(
+"$s_arr->[0] name mismatch: $s_arr->[1] vs. $journey->{arr_name}"
+ );
+ }
+ my $rows = $db->update(
+ 'in_transit',
+ { checkout_station_id => $s_arr->[2] },
+ { user_id => $journey->{user_id} }
+ )->rows;
+ if ( $rows != 1 ) {
+ die(
+"Update error at in_transit checkout_station_id UID $journey->{user_id}\n"
+ );
+ }
+ }
+ }
+ for my $journey ( $db->select( 'journeys_str', '*' )->hashes->each ) {
+ my ($s_dep)
+ = Travel::Status::DE::IRIS::Stations::get_station(
+ $journey->{dep_ds100} );
+ my ($s_arr)
+ = Travel::Status::DE::IRIS::Stations::get_station(
+ $journey->{arr_ds100} );
+ if ( $s_dep->[1] ne $journey->{dep_name} ) {
+ die(
+"$s_dep->[0] name mismatch: $s_dep->[1] vs. $journey->{dep_name}"
+ );
+ }
+ my $rows = $db->update(
+ 'journeys',
+ { checkin_station_id => $s_dep->[2] },
+ { id => $journey->{journey_id} }
+ )->rows;
+ if ( $rows != 1 ) {
+ die(
+"While updating journeys#checkin_station_id for journey $journey->{id}: got $rows rows, expected 1\n"
+ );
+ }
+ if ( $s_arr->[1] ne $journey->{arr_name} ) {
+ die(
+"$s_arr->[0] name mismatch: $s_arr->[1] vs. $journey->{arr_name}"
+ );
+ }
+ $rows = $db->update(
+ 'journeys',
+ { checkout_station_id => $s_arr->[2] },
+ { id => $journey->{journey_id} }
+ )->rows;
+ if ( $rows != 1 ) {
+ die(
+"While updating journeys#checkout_station_id for journey $journey->{id}: got $rows rows, expected 1\n"
+ );
+ }
+ }
+ $db->query(
+ qq{
+ drop view journeys_str;
+ drop view in_transit_str;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ ;
+ create or replace view in_transit_str as select
+ user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ ;
+ drop table stations;
+ update schema_version set version = 19;
+ }
+ );
+ },
+
+ # v19 -> v20
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ create table polylines (
+ id serial not null primary key,
+ origin_eva integer not null,
+ destination_eva integer not null,
+ polyline jsonb not null
+ );
+ alter table journeys
+ add column polyline_id integer references polylines (id);
+ alter table in_transit
+ add column polyline_id integer references polylines (id);
+ drop view journeys_str;
+ drop view in_transit_str;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ polylines.polyline as polyline,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ ;
+ create or replace view in_transit_str as select
+ user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ polylines.polyline as polyline,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ ;
+ update schema_version set version = 20;
+ }
+ );
+ },
+
+ # v20 -> v21
+ # After introducing polyline support, journey distance calculation diverged:
+ # the detail view (individual train) used the polyline, whereas monthly and
+ # yearly statistics were still based on beeline between intermediate stops.
+ # Release 1.16.0 fixes this -> ensure all caches are rebuilt.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ truncate journey_stats;
+ update schema_version set version = 21;
+ }
+ );
+ },
+
+ # v21 -> v22
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ create table traewelling (
+ user_id integer not null references users (id) primary key,
+ email varchar(256) not null,
+ push_sync boolean not null,
+ pull_sync boolean not null,
+ errored boolean,
+ token text,
+ data jsonb,
+ latest_run timestamptz
+ );
+ comment on table traewelling is 'Token and Status for Traewelling';
+ create view traewelling_str as select
+ user_id, email, push_sync, pull_sync, errored, token, data,
+ extract(epoch from latest_run) as latest_run_ts
+ from traewelling
+ ;
+ update schema_version set version = 22;
+ }
+ );
+ },
+
+ # v22 -> v23
+ # 1.18.1 fixes handling of negative cumulative arrival/departure delays
+ # and introduces additional statistics entries with pre-formatted duration
+ # strings while at it. Old cache entries lack those.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ truncate journey_stats;
+ update schema_version set version = 23;
+ }
+ );
+ },
+
+ # v23 -> v24
+ # travelynx 1.22 warns about upcoming account deletion due to inactivity
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table users add column deletion_notified timestamptz;
+ comment on column users.deletion_notified is 'Time at which warning about upcoming account deletion due to inactivity was sent';
+ update schema_version set version = 24;
+ }
+ );
+ },
+
+ # v24 -> v25
+ # travelynx 1.23 adds optional links to external services, e.g.
+ # DBF or bahn.expert departure boards
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table users add column external_services smallint;
+ comment on column users.external_services is 'Which external service to use for stationboard or routing links';
+ update schema_version set version = 25;
+ }
+ );
+ },
+
+ # v25 -> v26
+ # travelynx 1.24 adds local transit connections and needs to know targets
+ # for that to work, as local transit does not support checkins yet.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ create table localtransit (
+ user_id integer not null references users (id) primary key,
+ data jsonb
+ );
+ create view user_transit as select
+ id,
+ use_history,
+ localtransit.data as data
+ from users
+ left join localtransit on localtransit.user_id = id
+ ;
+ update schema_version set version = 26;
+ }
+ );
+ },
+
+ # v26 -> v27
+ # add list of stations that are not (or no longer) present in T-S-DE-IRIS
+ # (in this case, stations that were removed up to 1.74)
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table schema_version
+ add column iris varchar(12);
+ create table stations (
+ eva int not null primary key,
+ ds100 varchar(16) not null,
+ name varchar(64) not null,
+ lat real not null,
+ lon real not null,
+ source smallint not null,
+ archived bool not null
+ );
+ update schema_version set version = 27;
+ update schema_version set iris = '0';
+ }
+ );
+ },
+
+ # v27 -> v28
+ # add ds100, name, and lat/lon from stations table to journeys_str / in_transit_str
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view journeys_str;
+ drop view in_transit_str;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ create view in_transit_str as select
+ user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ update schema_version set version = 28;
+ }
+ );
+ },
+
+ # v28 -> v29
+ # add pre-migration travelynx version. This way, a failed migration can
+ # print a helpful "git checkout" command.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table schema_version
+ add column travelynx varchar(64);
+ update schema_version set version = 29;
+ }
+ );
+ },
+
+ # v29 -> v30
+ # change layout of stops in in_transit and journeys "route" lists.
+ # Old layout: A mixture of [name, {data}, undef/"additional"/"cancelled"], [name, timestamp, timestamp], and [name]
+ # New layout: [name, eva, {data including isAdditional/isCancelled}]
+ # Combined with a maintenance task that adds eva IDs to past stops, this will allow for more resilience against station name changes.
+ # It will also help increase the performance of distance and map calculation
+ sub {
+ my ($db) = @_;
+ my $json = JSON->new;
+
+ say 'Adjusting route schema, this may take a while ...';
+
+ my $res = $db->select( 'in_transit_str', [ 'route', 'user_id' ] );
+ while ( my $row = $res->expand->hash ) {
+ my @new_route;
+ for my $stop ( @{ $row->{route} } ) {
+ push( @new_route, [ $stop->[0], undef, {} ] );
+ }
+ $db->update(
+ 'in_transit',
+ { route => $json->encode( \@new_route ) },
+ { user_id => $row->{user_id} }
+ );
+ }
+
+ my $total
+ = $db->select( 'journeys', 'count(*) as count' )->hash->{count};
+ my $count = 0;
+
+ $res = $db->select( 'journeys_str', [ 'route', 'journey_id' ] );
+ while ( my $row = $res->expand->hash ) {
+ my @new_route;
+
+ for my $stop ( @{ $row->{route} } ) {
+ if ( @{$stop} == 1 ) {
+ push( @new_route, [ $stop->[0], undef, {} ] );
+ }
+ elsif (
+ ( not defined $stop->[1] or $stop->[1] =~ m{ ^ \d+ $ }x )
+ and
+ ( not defined $stop->[2] or $stop->[2] =~ m{ ^ \d+ $ }x )
+ )
+ {
+ push( @new_route, [ $stop->[0], undef, {} ] );
+ }
+ else {
+ my $attr = $stop->[1] // {};
+ if ( $stop->[2] and $stop->[2] eq 'additional' ) {
+ $attr->{isAdditional} = 1;
+ }
+ elsif ( $stop->[2] and $stop->[2] eq 'cancelled' ) {
+ $attr->{isCancelled} = 1;
+ }
+ push( @new_route, [ $stop->[0], undef, $attr ] );
+ }
+ }
+
+ $db->update(
+ 'journeys',
+ { route => $json->encode( \@new_route ) },
+ { id => $row->{journey_id} }
+ );
+
+ if ( $count++ % 10000 == 0 ) {
+ printf( " %2.0f%% complete\n", $count * 100 / $total );
+ }
+ }
+ say ' done';
+ $db->query(
+ qq{
+ update schema_version set version = 30;
+ }
+ );
+ },
+
+ # v30 -> v31
+ # travelynx v1.29.17 introduces links to conflicting journeys.
+ # These require changes to statistics data.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ truncate journey_stats;
+ update schema_version set version = 31;
+ }
+ );
+ },
+
+ # v31 -> v32
+ # travelynx v1.29.18 improves above-mentioned conflict links.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ truncate journey_stats;
+ update schema_version set version = 32;
+ }
+ );
+ },
+
+ # v32 -> v33
+ # add optional per-status visibility that overrides global visibility
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table journeys add column visibility smallint;
+ alter table in_transit add column visibility smallint;
+ drop view journeys_str;
+ drop view in_transit_str;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ visibility,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ create view in_transit_str as select
+ user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ }
+ );
+ my $res = $db->select( 'users', [ 'id', 'public_level' ] );
+ while ( my $row = $res->hash ) {
+ my $old_level = $row->{public_level};
+
+ # status default: unlisted
+ my $new_level = 30;
+ if ( $old_level & 0x01 ) {
+
+ # status: account required
+ $new_level = 80;
+ }
+ if ( $old_level & 0x02 ) {
+
+ # status: public
+ $new_level = 100;
+ }
+ if ( $old_level & 0x04 ) {
+
+ # comment public
+ $new_level |= 0x80;
+ }
+ if ( $old_level & 0x10 ) {
+
+ # past: account required
+ $new_level |= 0x100;
+ }
+ if ( $old_level & 0x20 ) {
+
+ # past: public
+ $new_level |= 0x200;
+ }
+ if ( $old_level & 0x40 ) {
+
+ # past: infinite (default is 4 weeks)
+ $new_level |= 0x400;
+ }
+ my $r = $db->update(
+ 'users',
+ { public_level => $new_level },
+ { id => $row->{id} }
+ )->rows;
+ if ( $r != 1 ) {
+ die("oh no");
+ }
+ }
+ $db->update( 'schema_version', { version => 33 } );
+ },
+
+ # v33 -> v34
+ # add polyline_id to in_transit_str
+ # (https://github.com/derf/travelynx/issues/66)
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view in_transit_str;
+ create view in_transit_str as select
+ user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ update schema_version set version = 34;
+ }
+ );
+ },
+
+ # v34 -> v35
+ sub {
+ my ($db) = @_;
+
+ # 1 : follows
+ # 2 : follow requested
+ # 3 : is blocked by
+ $db->query(
+ qq{
+ create table relations (
+ subject_id integer not null references users (id),
+ predicate smallint not null,
+ object_id integer not null references users (id),
+ primary key (subject_id, object_id)
+ );
+ create view followers as select
+ relations.object_id as self_id,
+ users.id as id,
+ users.name as name
+ from relations
+ join users on relations.subject_id = users.id
+ where predicate = 1;
+ create view followees as select
+ relations.subject_id as self_id,
+ users.id as id,
+ users.name as name
+ from relations
+ join users on relations.object_id = users.id
+ where predicate = 1;
+ create view follow_requests as select
+ relations.object_id as self_id,
+ users.id as id,
+ users.name as name
+ from relations
+ join users on relations.subject_id = users.id
+ where predicate = 2;
+ create view blocked_users as select
+ relations.object_id as self_id,
+ users.id as id,
+ users.name as name
+ from relations
+ join users on relations.subject_id = users.id
+ where predicate = 3;
+ update schema_version set version = 35;
+ }
+ );
+ },
+
+ # v35 -> v36
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table relations
+ add column ts timestamptz not null;
+ alter table users
+ add column accept_follows smallint default 0;
+ update schema_version set version = 36;
+ }
+ );
+ },
+
+ # v36 -> v37
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table users
+ add column notifications smallint default 0,
+ add column profile jsonb;
+ update schema_version set version = 37;
+ }
+ );
+ },
+
+ # v37 -> v38
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view followers;
+ create view followers as select
+ relations.object_id as self_id,
+ users.id as id,
+ users.name as name,
+ users.accept_follows as accept_follows,
+ r2.predicate as inverse_predicate
+ from relations
+ join users on relations.subject_id = users.id
+ left join relations as r2 on relations.subject_id = r2.object_id
+ where relations.predicate = 1;
+ update schema_version set version = 38;
+ }
+ );
+ },
+
+ # v38 -> v39
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view followers;
+ create view followers as select
+ relations.object_id as self_id,
+ users.id as id,
+ users.name as name,
+ users.accept_follows as accept_follows,
+ r2.predicate as inverse_predicate
+ from relations
+ join users on relations.subject_id = users.id
+ left join relations as r2
+ on relations.subject_id = r2.object_id
+ and relations.object_id = r2.subject_id
+ where relations.predicate = 1;
+ update schema_version set version = 39;
+ }
+ );
+ },
+
+ # v39 -> v40
+ # distinguish between public / travelynx / followers / private visibility
+ # for the history page, just like status visibility.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table users alter public_level type integer;
+ }
+ );
+ my $res = $db->select( 'users', [ 'id', 'public_level' ] );
+ while ( my $row = $res->hash ) {
+ my $old_level = $row->{public_level};
+
+ # checkin and comment visibility remain unchanged
+ my $new_level = $old_level & 0x00ff;
+
+ # past: account required
+ if ( $old_level & 0x100 ) {
+ $new_level |= 80 << 8;
+ }
+
+ # past: public
+ elsif ( $old_level & 0x200 ) {
+ $new_level |= 100 << 8;
+ }
+
+ # past: private
+ else {
+ $new_level |= 10 << 8;
+ }
+
+ # past: infinite (default is 4 weeks)
+ if ( $old_level & 0x400 ) {
+ $new_level |= 0x10000;
+ }
+
+ # show past journey on status page
+ if ( $old_level & 0x800 ) {
+ $new_level |= 0x8000;
+ }
+
+ my $r = $db->update(
+ 'users',
+ { public_level => $new_level },
+ { id => $row->{id} }
+ )->rows;
+ if ( $r != 1 ) {
+ die("oh no");
+ }
+ }
+ $db->update( 'schema_version', { version => 40 } );
+ },
+
+ # v40 -> v41
+ # Compute effective visibility in in_transit_str and journeys_str.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view in_transit_str;
+ drop view journeys_str;
+ create view in_transit_str as select
+ user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ update schema_version set version = 41;
+ }
+ );
+ },
+
+ # v41 -> v42
+ # adds current followee checkins
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ create view follows_in_transit as select
+ r1.subject_id as follower_id, user_id as followee_id,
+ users.name as followee_name,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ update schema_version set version = 42;
+ }
+ );
+ },
+
+ # v42 -> v43
+ # list sent and received follow requests
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter view follow_requests rename to rx_follow_requests;
+ create view tx_follow_requests as select
+ relations.subject_id as self_id,
+ users.id as id,
+ users.name as name
+ from relations
+ join users on relations.object_id = users.id
+ where predicate = 2;
+ update schema_version set version = 43;
+ }
+ );
+ },
+
+ # v43 -> v44
+ # show inverse relation in followees as well
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view followees;
+ create view followees as select
+ relations.subject_id as self_id,
+ users.id as id,
+ users.name as name,
+ r2.predicate as inverse_predicate
+ from relations
+ join users on relations.object_id = users.id
+ left join relations as r2
+ on relations.subject_id = r2.object_id
+ and relations.object_id = r2.subject_id
+ where relations.predicate = 1;
+ update schema_version set version = 44;
+ }
+ );
+ },
+
+ # v44 -> v45
+ # prepare for HAFAS support: many HAFAS stations do not have DS100 codes
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table stations alter column ds100 drop not null;
+ update schema_version set version = 45;
+ }
+ );
+ },
+
+ # v45 -> v46
+ # Switch to Traewelling OAuth2 authentication.
+ # E-Mail is no longer needed.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view traewelling_str;
+ create view traewelling_str as select
+ user_id, push_sync, pull_sync, errored, token, data,
+ extract(epoch from latest_run) as latest_run_ts
+ from traewelling
+ ;
+ alter table traewelling drop column email;
+ update schema_version set version = 46;
+ }
+ );
+ },
+
+ # v46 -> v47
+ # sort followee checkins by checkin time
+ # (descending / most recent first, like a timeline)
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view follows_in_transit;
+ create view follows_in_transit as select
+ r1.subject_id as follower_id, user_id as followee_id,
+ users.name as followee_name,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ order by checkin_time desc
+ ;
+ update schema_version set version = 47;
+ }
+ );
+ },
+
+ # v47 -> v48
+ # Store Traewelling refresh tokens; store expiry as explicit column.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table traewelling
+ add column refresh_token text,
+ add column expiry timestamptz;
+ drop view traewelling_str;
+ create view traewelling_str as select
+ user_id, push_sync, pull_sync, errored,
+ token, refresh_token, data,
+ extract(epoch from latest_run) as latest_run_ts,
+ extract(epoch from expiry) as expiry_ts
+ from traewelling
+ ;
+ update schema_version set version = 48;
+ }
+ );
+ },
+
+ # v48 -> v49
+ # create indexes for common queries
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ create index uid_real_departure_idx on journeys (user_id, real_departure);
+ update schema_version set version = 49;
+ }
+ );
+ },
+
+ # v49 -> v50
+ # travelynx 2.0 introduced proper HAFAS support, so there is no need for
+ # the 'FYI, here is some hAFAS data' kludge anymore.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view user_transit;
+ drop table localtransit;
+ update schema_version set version = 50;
+ }
+ );
+ },
+
+ # v50 -> v51
+ # store related HAFAS stations
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ create table related_stations (
+ eva integer not null,
+ meta integer not null,
+ unique (eva, meta)
+ );
+ create index rel_eva on related_stations (eva);
+ update schema_version set version = 51;
+ }
+ );
+ },
);
+sub sync_stations {
+ my ( $db, $iris_version ) = @_;
+
+ $db->update( 'schema_version',
+ { iris => $Travel::Status::DE::IRIS::Stations::VERSION } );
+
+ say 'Updating stations table, this may take a while ...';
+ my $total = scalar Travel::Status::DE::IRIS::Stations::get_stations();
+ my $count = 0;
+ for my $s ( Travel::Status::DE::IRIS::Stations::get_stations() ) {
+ my ( $ds100, $name, $eva, $lon, $lat ) = @{$s};
+ if ( $ENV{__TRAVELYNX_TEST_MINI_IRIS}
+ and ( $eva < 8000000 or $eva > 8000100 ) )
+ {
+ next;
+ }
+ $db->insert(
+ 'stations',
+ {
+ eva => $eva,
+ ds100 => $ds100,
+ name => $name,
+ lat => $lat,
+ lon => $lon,
+ source => 0,
+ archived => 0
+ },
+ {
+ on_conflict => \
+'(eva) do update set archived = false, source = 0, ds100 = EXCLUDED.ds100, name=EXCLUDED.name, lat=EXCLUDED.lat, lon=EXCLUDED.lon'
+ }
+ );
+ if ( $count++ % 1000 == 0 ) {
+ printf( " %2.0f%% complete\n", $count * 100 / $total );
+ }
+ }
+ say ' done';
+
+ my $res1 = $db->query(
+ qq{
+ select checkin_station_id
+ from journeys
+ left join stations on journeys.checkin_station_id = stations.eva
+ where stations.eva is null
+ limit 1;
+ }
+ )->hash;
+
+ my $res2 = $db->query(
+ qq{
+ select checkout_station_id
+ from journeys
+ left join stations on journeys.checkout_station_id = stations.eva
+ where stations.eva is null
+ limit 1;
+ }
+ )->hash;
+
+ if ( $res1 or $res2 ) {
+ say 'Dropping stats cache for archived stations ...';
+ $db->query('truncate journey_stats;');
+ }
+
+ say 'Updating archived stations ...';
+ my $old_stations
+ = JSON->new->utf8->decode( scalar read_file('share/old_stations.json') );
+ if ( $ENV{__TRAVELYNX_TEST_MINI_IRIS} ) {
+ $old_stations = [];
+ }
+ for my $s ( @{$old_stations} ) {
+ $db->insert(
+ 'stations',
+ {
+ eva => $s->{eva},
+ ds100 => $s->{ds100},
+ name => $s->{name},
+ lat => $s->{latlong}[0],
+ lon => $s->{latlong}[1],
+ source => 0,
+ archived => 1
+ },
+ { on_conflict => undef }
+ );
+ }
+
+ if ( $iris_version == 0 ) {
+ say 'Applying EVA ID changes ...';
+ for my $change (
+ [ 721394, 301002, 'RKBP: Kronenplatz (U), Karlsruhe' ],
+ [
+ 721356, 901012,
+ 'RKME: Ettlinger Tor/Staatstheater (U), Karlsruhe'
+ ],
+ )
+ {
+ my ( $old, $new, $desc ) = @{$change};
+ my $rows = $db->update(
+ 'journeys',
+ { checkout_station_id => $new },
+ { checkout_station_id => $old }
+ )->rows;
+ $rows += $db->update(
+ 'journeys',
+ { checkin_station_id => $new },
+ { checkin_station_id => $old }
+ )->rows;
+ if ($rows) {
+ say "$desc ($old -> $new) : $rows rows";
+ }
+ }
+ }
+
+ say 'Checking for unknown EVA IDs ...';
+ my $found = 0;
+
+ $res1 = $db->query(
+ qq{
+ select checkin_station_id
+ from journeys
+ left join stations on journeys.checkin_station_id = stations.eva
+ where stations.eva is null;
+ }
+ );
+
+ $res2 = $db->query(
+ qq{
+ select checkout_station_id
+ from journeys
+ left join stations on journeys.checkout_station_id = stations.eva
+ where stations.eva is null;
+ }
+ );
+
+ my %notified;
+ while ( my $row = $res1->hash ) {
+ my $eva = $row->{checkin_station_id};
+ if ( not $found ) {
+ $found = 1;
+ say '';
+ say '------------8<----------';
+ say 'Travel::Status::DE::IRIS v'
+ . $Travel::Status::DE::IRIS::Stations::VERSION;
+ }
+ if ( not $notified{$eva} ) {
+ say $eva;
+ $notified{$eva} = 1;
+ }
+ }
+
+ while ( my $row = $res2->hash ) {
+ my $eva = $row->{checkout_station_id};
+ if ( not $found ) {
+ $found = 1;
+ say '';
+ say '------------8<----------';
+ say 'Travel::Status::DE::IRIS v'
+ . $Travel::Status::DE::IRIS::Stations::VERSION;
+ }
+ if ( not $notified{$eva} ) {
+ say $eva;
+ $notified{$eva} = 1;
+ }
+ }
+
+ if ($found) {
+ say '------------8<----------';
+ say '';
+ say
+'Due to a conceptual flaw in past travelynx releases, your database contains unknown EVA IDs.';
+ say
+'Please file a bug report titled "Missing EVA IDs after DB migration" at https://github.com/derf/travelynx/issues';
+ say 'and include the list shown above in the bug report.';
+ say
+'If you do not have a GitHub account, please send an E-Mail to derf+travelynx@finalrewind.org instead.';
+ say '';
+ say 'This issue does not affect usability or long-term data integrity,';
+ say 'and handling it is not time-critical.';
+ say
+'Past journeys referencing unknown EVA IDs may have inaccurate distance statistics,';
+ say
+'but this will be resolved once a future release handles those EVA IDs.';
+ say 'Note that this issue was already present in previous releases.';
+ }
+ else {
+ say 'None found.';
+ }
+}
+
sub setup_db {
my ($db) = @_;
my $tx = $db->begin;
@@ -824,31 +2149,86 @@ sub setup_db {
}
}
+sub failure_hints {
+ my ($old_version) = @_;
+ say STDERR 'This travelynx instance has reached an undefined state:';
+ say STDERR
+'The source code is expecting a different schema version than present in the database.';
+ say STDERR
+'Please file a detailed bug report at <https://github.com/derf/travelynx/issues>';
+ say STDERR 'or send an e-mail to derf+travelynx@finalrewind.org.';
+ if ($old_version) {
+ say STDERR '';
+ say STDERR
+ "The last migration was performed with travelynx v${old_version}.";
+ say STDERR
+'You may be able to return to a working state with the following command:';
+ say STDERR "git checkout ${old_version}";
+ say STDERR '';
+ say STDERR 'We apologize for any inconvenience.';
+ }
+}
+
sub migrate_db {
- my ($db) = @_;
+ my ( $self, $db ) = @_;
my $tx = $db->begin;
my $schema_version = get_schema_version($db);
say "Found travelynx schema v${schema_version}";
+ my $old_version;
+
+ if ( $schema_version >= 29 ) {
+ $old_version = get_schema_version( $db, 'travelynx' );
+ }
+
if ( $schema_version == @migrations ) {
- say "Database layout is up-to-date";
+ say 'Database layout is up-to-date';
+ }
+ else {
+ eval {
+ for my $i ( $schema_version .. $#migrations ) {
+ printf( "Updating to v%d ...\n", $i + 1 );
+ $migrations[$i]($db);
+ }
+ say 'Update complete.';
+ };
+ if ($@) {
+ say STDERR "Migration failed: $@";
+ say STDERR "Rolling back to v${schema_version}";
+ failure_hints($old_version);
+ exit(1);
+ }
}
- eval {
- for my $i ( $schema_version .. $#migrations ) {
- printf( "Updating to v%d ...\n", $i + 1 );
- $migrations[$i]($db);
+ my $iris_version = get_schema_version( $db, 'iris' );
+ say "Found IRIS station database v${iris_version}";
+ if ( $iris_version eq $Travel::Status::DE::IRIS::Stations::VERSION ) {
+ say 'Station database is up-to-date';
+ }
+ else {
+ eval {
+ say
+"Synchronizing with Travel::Status::DE::IRIS $Travel::Status::DE::IRIS::Stations::VERSION";
+ sync_stations( $db, $iris_version );
+ say 'Synchronization complete.';
+ };
+ if ($@) {
+ say STDERR "Synchronization failed: $@";
+ if ( $schema_version != @migrations ) {
+ say STDERR "Rolling back to v${schema_version}";
+ failure_hints($old_version);
+ }
+ exit(1);
}
- };
- if ($@) {
- say STDERR "Migration failed: $@";
- say STDERR "Rolling back to v${schema_version}";
- exit(1);
}
+ $db->update( 'schema_version',
+ { travelynx => $self->app->config->{version} } );
+
if ( get_schema_version($db) == @migrations ) {
$tx->commit;
+ say 'Changes committed to database. Have a nice day.';
}
else {
printf STDERR (
@@ -857,6 +2237,8 @@ sub migrate_db {
get_schema_version($db)
);
say STDERR "Rolling back to v${schema_version}";
+ say STDERR "";
+ failure_hints($old_version);
exit(1);
}
}
@@ -875,10 +2257,13 @@ sub run {
if ( not defined get_schema_version($db) ) {
setup_db($db);
}
- migrate_db($db);
+ $self->migrate_db($db);
}
elsif ( $command eq 'has-current-schema' ) {
- if ( get_schema_version($db) == @migrations ) {
+ if ( get_schema_version($db) == @migrations
+ and get_schema_version( $db, 'iris' ) eq
+ $Travel::Status::DE::IRIS::Stations::VERSION )
+ {
say "yes";
}
else {
@@ -903,5 +2288,5 @@ __END__
Recommended workflow:
> systemctl stop travelynx
- > perl index.pl migrate
+ > perl index.pl database migrate
> systemctl start travelynx
diff --git a/lib/Travelynx/Command/dumpconfig.pm b/lib/Travelynx/Command/dumpconfig.pm
index d2a6761..600ffb0 100644
--- a/lib/Travelynx/Command/dumpconfig.pm
+++ b/lib/Travelynx/Command/dumpconfig.pm
@@ -1,4 +1,7 @@
package Travelynx::Command::dumpconfig;
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Command';
use Data::Dumper;
diff --git a/lib/Travelynx/Command/dumpstops.pm b/lib/Travelynx/Command/dumpstops.pm
new file mode 100644
index 0000000..e6740ec
--- /dev/null
+++ b/lib/Travelynx/Command/dumpstops.pm
@@ -0,0 +1,51 @@
+package Travelynx::Command::dumpstops;
+
+# Copyright (C) 2024 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use Mojo::Base 'Mojolicious::Command';
+use List::Util qw();
+use Text::CSV;
+
+has description => 'Export HAFAS/IRIS stops to CSV';
+
+has usage => sub { shift->extract_usage };
+
+sub run {
+ my ( $self, $command, $filename ) = @_;
+ my $db = $self->app->pg->db;
+
+ if ( not $command or not $filename ) {
+ $self->help;
+ }
+ elsif ( $command eq 'csv' ) {
+ open( my $fh, '>:encoding(utf-8)', $filename )
+ or die("open($filename): $!\n");
+
+ my $csv = Text::CSV->new( { eol => "\r\n" } );
+ $csv->combine(qw(name eva lat lon source archived));
+ print $fh $csv->string;
+
+ my $iter = $self->app->stations->get_db_iterator;
+ while ( my $row = $iter->hash ) {
+ $csv->combine( @{$row}{qw{name eva lat lon source archived}} );
+ print $fh $csv->string;
+ }
+ close($fh);
+ }
+ else {
+ $self->help;
+ }
+}
+
+1;
+
+__END__
+
+=head1 SYNOPSIS
+
+ Usage: index.pl dumpstops <format> <filename>
+
+ Exports known stops to <filename>.
+ Right now, only the "csv" format is supported.
diff --git a/lib/Travelynx/Command/influxdb.pm b/lib/Travelynx/Command/influxdb.pm
new file mode 100644
index 0000000..f3fc3de
--- /dev/null
+++ b/lib/Travelynx/Command/influxdb.pm
@@ -0,0 +1,209 @@
+package Travelynx::Command::influxdb;
+
+# Copyright (C) 2022 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+use Mojo::Base 'Mojolicious::Command';
+
+use DateTime;
+
+has description => 'Generate statistics for InfluxDB';
+
+has usage => sub { shift->extract_usage };
+
+sub query_to_influx {
+ my ( $label, $value ) = @_;
+
+ if ( defined $value ) {
+ return sprintf( '%s=%f', $label, $value );
+ }
+ return;
+}
+
+sub run {
+ my ($self) = @_;
+
+ my $db = $self->app->pg->db;
+
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ my $active = $now->clone->subtract( months => 1 );
+
+ my @stats;
+ my @stations;
+ my @traewelling;
+
+ push(
+ @stats,
+ query_to_influx(
+ 'pending_user_count',
+ $db->select( 'users', 'count(*) as count', { status => 0 } )
+ ->hash->{count}
+ )
+ );
+ push(
+ @stats,
+ query_to_influx(
+ 'reg_user_count',
+ $db->select( 'users', 'count(*) as count', { status => 1 } )
+ ->hash->{count}
+ )
+ );
+ push(
+ @stats,
+ query_to_influx(
+ 'active_user_count',
+ $db->select(
+ 'users',
+ 'count(*) as count',
+ {
+ status => 1,
+ last_seen => { '>', $active }
+ }
+ )->hash->{count}
+ )
+ );
+
+ push(
+ @stats,
+ query_to_influx(
+ 'checked_in_count',
+ $db->select( 'in_transit', 'count(*) as count' )->hash->{count}
+ )
+ );
+ push(
+ @stats,
+ query_to_influx(
+ 'checkin_count',
+ $db->select( 'journeys', 'count(*) as count' )->hash->{count}
+ )
+ );
+ push(
+ @stats,
+ query_to_influx(
+ 'polyline_count',
+ $db->select( 'polylines', 'count(*) as count' )->hash->{count}
+ )
+ );
+
+ push(
+ @stations,
+ query_to_influx(
+ 'iris',
+ $db->select(
+ 'stations',
+ 'count(*) as count',
+ {
+ source => 0,
+ archived => 0
+ }
+ )->hash->{count}
+ )
+ );
+ push(
+ @stations,
+ query_to_influx(
+ 'hafas',
+ $db->select(
+ 'stations',
+ 'count(*) as count',
+ {
+ source => 1,
+ archived => 0
+ }
+ )->hash->{count}
+ )
+ );
+ push(
+ @stations,
+ query_to_influx(
+ 'archived',
+ $db->select( 'stations', 'count(*) as count', { archived => 1 } )
+ ->hash->{count}
+ )
+ );
+ push(
+ @stations,
+ query_to_influx(
+ 'meta',
+ $db->select( 'related_stations', 'count(*) as count' )
+ ->hash->{count}
+ )
+ );
+
+ push(
+ @traewelling,
+ query_to_influx(
+ 'pull_user_count',
+ $db->select(
+ 'traewelling',
+ 'count(*) as count',
+ { pull_sync => 1 }
+ )->hash->{count}
+ )
+ );
+ push(
+ @traewelling,
+ query_to_influx(
+ 'push_user_count',
+ $db->select(
+ 'traewelling',
+ 'count(*) as count',
+ { push_sync => 1 }
+ )->hash->{count}
+ )
+ );
+ push(
+ @stats,
+ query_to_influx(
+ 'polyline_ratio',
+ $db->query(
+'select (select count(polyline_id) from journeys)::float / (select count(*) from polylines) as ratio'
+ )->hash->{ratio}
+ )
+ );
+
+ if ( $self->app->mode eq 'development' ) {
+ $self->app->log->debug( 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . ' stats '
+ . join( ',', @stats ) );
+ $self->app->log->debug( 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . ' stations '
+ . join( ',', @stations ) );
+ $self->app->log->debug( 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . ' traewelling '
+ . join( ',', @traewelling ) );
+ }
+ elsif ( $self->app->config->{influxdb}->{url} ) {
+ $self->app->ua->post_p(
+ $self->app->config->{influxdb}->{url},
+ 'stats ' . join( ',', @stats )
+ )->wait;
+ $self->app->ua->post_p(
+ $self->app->config->{influxdb}->{url},
+ 'stations ' . join( ',', @stations )
+ )->wait;
+ $self->app->ua->post_p(
+ $self->app->config->{influxdb}->{url},
+ 'traewelling ' . join( ',', @traewelling )
+ )->wait;
+ }
+ else {
+ $self->app->log->warn(
+ "influxdb command called, but no influxdb url has been configured");
+ }
+
+ return;
+}
+
+1;
+
+__END__
+
+=head1 SYNOPSIS
+
+ Usage: index.pl influxdb
+
+ Write statistics to InfluxDB
diff --git a/lib/Travelynx/Command/integritycheck.pm b/lib/Travelynx/Command/integritycheck.pm
new file mode 100644
index 0000000..4894c3d
--- /dev/null
+++ b/lib/Travelynx/Command/integritycheck.pm
@@ -0,0 +1,112 @@
+package Travelynx::Command::integritycheck;
+
+# Copyright (C) 2022 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use Mojo::Base 'Mojolicious::Command';
+use List::Util qw();
+use Travel::Status::DE::IRIS::Stations;
+
+sub run {
+ my ($self) = @_;
+ my $found = 0;
+ my $db = $self->app->pg->db;
+
+ my $res1 = $db->query(
+ qq{
+ select checkin_station_id
+ from journeys
+ left join stations on journeys.checkin_station_id = stations.eva
+ where stations.eva is null;
+ }
+ );
+
+ my $res2 = $db->query(
+ qq{
+ select checkout_station_id
+ from journeys
+ left join stations on journeys.checkout_station_id = stations.eva
+ where stations.eva is null;
+ }
+ );
+
+ my %notified;
+ while ( my $row = $res1->hash ) {
+ my $eva = $row->{checkin_station_id};
+ if ( not $found ) {
+ $found = 1;
+ say
+'Journeys in the travelynx database contain the following unknown EVA IDs.';
+ say '------------8<----------';
+ say 'Travel::Status::DE::IRIS v'
+ . $Travel::Status::DE::IRIS::Stations::VERSION;
+ }
+ if ( not $notified{$eva} ) {
+ say $eva;
+ $notified{$eva} = 1;
+ }
+ }
+
+ while ( my $row = $res2->hash ) {
+ my $eva = $row->{checkout_station_id};
+ if ( not $found ) {
+ $found = 1;
+ say
+'Journeys in the travelynx database contain the following unknown EVA IDs.';
+ say '------------8<----------';
+ say 'Travel::Status::DE::IRIS v'
+ . $Travel::Status::DE::IRIS::Stations::VERSION;
+ }
+ if ( not $notified{$eva} ) {
+ say $eva;
+ $notified{$eva} = 1;
+ }
+ }
+
+ if ($found) {
+ say '------------8<----------';
+ say '';
+ $found = 0;
+ }
+
+ my $rename = $self->app->renamed_station;
+
+ my $res = $db->select( 'journeys', [ 'route', 'edited' ] )->expand;
+ while ( my $j = $res->hash ) {
+ if ( $j->{edited} & 0x0010 ) {
+ next;
+ }
+ my @stops = @{ $j->{route} // [] };
+ for my $stop (@stops) {
+ my $stop_name = $stop->[0];
+ if ( $rename->{ $stop->[0] } ) {
+ $stop->[0] = $rename->{ $stop->[0] };
+ }
+ }
+ my @unknown
+ = $self->app->stations->grep_unknown( map { $_->[0] } @stops );
+ for my $stop_name (@unknown) {
+ if ( not $notified{$stop_name} ) {
+ if ( not $found ) {
+ say
+'Journeys in the travelynx database contain the following unknown route entries.';
+ say 'Note that this check ignores manual route entries.';
+ say 'All reports refer to routes obtained via HAFAS/IRIS.';
+ say '------------8<----------';
+ say 'Travel::Status::DE::IRIS v'
+ . $Travel::Status::DE::IRIS::Stations::VERSION;
+ $found = 1;
+ }
+ say $stop_name;
+ $notified{$stop_name} = 1;
+ }
+ }
+ }
+ if ($found) {
+ say '------------8<----------';
+ say '';
+ }
+}
+
+1;
diff --git a/lib/Travelynx/Command/maintenance.pm b/lib/Travelynx/Command/maintenance.pm
index fc64884..c9c7ed6 100644
--- a/lib/Travelynx/Command/maintenance.pm
+++ b/lib/Travelynx/Command/maintenance.pm
@@ -1,9 +1,13 @@
package Travelynx::Command::maintenance;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Command';
use DateTime;
-has description => 'Prune unverified users etc';
+has description => 'Prune unverified users, incomplete checkins etc';
has usage => sub { shift->extract_usage };
@@ -11,9 +15,10 @@ sub run {
my ( $self, $filename ) = @_;
my $now = DateTime->now( time_zone => 'Europe/Berlin' );
- my $verification_deadline = $now->clone->subtract( hours => 48 );
- my $deletion_deadline = $now->clone->subtract( hours => 72 );
- my $old_deadline = $now->clone->subtract( years => 1 );
+ my $verification_deadline = $now->clone->subtract( hours => 48 );
+ my $deletion_deadline = $now->clone->subtract( hours => 72 );
+ my $old_deadline = $now->clone->subtract( years => 1 );
+ my $old_notification_deadline = $now->clone->subtract( weeks => 4 );
my $db = $self->app->pg->db;
my $tx = $db->begin;
@@ -78,12 +83,40 @@ sub run {
printf( "Pruned %d pending mail change(s)\n", $rows );
}
+ my $to_notify = $db->select(
+ 'users',
+ [ 'id', 'name', 'email', 'last_seen' ],
+ {
+ last_seen => { '<', $old_deadline },
+ deletion_notified => undef
+ }
+ );
+
+ for my $user ( $to_notify->hashes->each ) {
+ say "Sending account deletion notification to uid $user->{id}...";
+ $self->app->sendmail->age_deletion_notification(
+ name => $user->{name},
+ email => $user->{email},
+ last_seen => $user->{last_seen},
+ login_url => $self->app->base_url_for('login')->to_abs,
+ account_url => $self->app->base_url_for('account')->to_abs,
+ imprint_url => $self->app->base_url_for('impressum')->to_abs,
+ );
+ $self->app->users->mark_deletion_notified( uid => $user->{id} );
+ }
+
my $to_delete = $db->select( 'users', ['id'],
{ deletion_requested => { '<', $deletion_deadline } } );
my @uids_to_delete = $to_delete->arrays->map( sub { shift->[0] } )->each;
- $to_delete
- = $db->select( 'users', ['id'], { last_seen => { '<', $old_deadline } } );
+ $to_delete = $db->select(
+ 'users',
+ ['id'],
+ {
+ last_seen => { '<', $old_deadline },
+ deletion_notified => { '<', $old_notification_deadline }
+ }
+ );
push( @uids_to_delete,
$to_delete->arrays->map( sub { shift->[0] } )->each );
@@ -93,33 +126,49 @@ sub run {
"About to delete %d accounts, which is quite a lot.\n",
scalar @uids_to_delete
);
+ for my $uid (@uids_to_delete) {
+ my $journeys_res = $db->select(
+ 'journeys',
+ 'count(*) as count',
+ { user_id => $uid }
+ )->hash;
+ printf STDERR (
+ " - UID %5d (%4d journeys)\n",
+ $uid, $journeys_res->{count}
+ );
+ }
say STDERR 'Aborting maintenance. Please investigate.';
exit(1);
}
for my $uid (@uids_to_delete) {
say "Deleting uid ${uid}...";
- my $tokens_res = $db->delete( 'tokens', { user_id => $uid } );
- my $stats_res = $db->delete( 'journey_stats', { user_id => $uid } );
- my $journeys_res = $db->delete( 'journeys', { user_id => $uid } );
- my $transit_res = $db->delete( 'in_transit', { user_id => $uid } );
- my $password_res
- = $db->delete( 'pending_passwords', { user_id => $uid } );
- my $user_res = $db->delete( 'users', { id => $uid } );
-
+ my $count = $self->app->users->delete(
+ uid => $uid,
+ db => $db,
+ in_transaction => 1
+ );
printf( " %d tokens, %d monthly stats, %d journeys\n",
- $tokens_res->rows, $stats_res->rows, $journeys_res->rows );
-
- if ( $user_res->rows != 1 ) {
- printf STDERR (
- "Deleted %d rows from users, expected 1. Rollback and abort.\n",
- $user_res->rows
- );
- exit(1);
- }
+ $count->{tokens}, $count->{stats}, $count->{journeys} );
}
$tx->commit;
+
+ # Computing stats may take a while, but we've got all time in the
+ # world here. This means users won't have to wait when loading their
+ # own journey log.
+ say 'Generating missing stats ...';
+ for
+ my $user ( $db->select( 'users', ['id'], { status => 1 } )->hashes->each )
+ {
+ $tx = $db->begin;
+ $self->app->journeys->generate_missing_stats( uid => $user->{id} );
+ $self->app->journeys->get_stats(
+ uid => $user->{id},
+ year => $now->year
+ );
+ $tx->commit;
+ }
}
1;
diff --git a/lib/Travelynx/Command/munin.pm b/lib/Travelynx/Command/munin.pm
index 110e058..3b6e393 100644
--- a/lib/Travelynx/Command/munin.pm
+++ b/lib/Travelynx/Command/munin.pm
@@ -1,4 +1,8 @@
package Travelynx::Command::munin;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Command';
use DateTime;
@@ -11,7 +15,7 @@ sub query_to_munin {
my ( $label, $value ) = @_;
if ( defined $value ) {
- printf( "%s.value %d\n", $label, $value );
+ printf( "%s.value %f\n", $label, $value );
}
}
@@ -20,12 +24,25 @@ sub run {
my $db = $self->app->pg->db;
- my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
my $active = $now->clone->subtract( months => 1 );
my $checkin_window_query
= qq{select count(*) as count from journeys where checkin_time > to_timestamp(?);};
+ # DateTime's math does not like time zones: When subtracting 7 days from
+ # sun 2am and the previous sunday was the switch from CET to CEST (i.e.,
+ # the switch to daylight saving time), the resulting datetime is invalid.
+ # This is a fatal error. We avoid this edge case by performing date math
+ # on the epoch timestamp, which does not know or care about time zones and
+ # daylight saving time.
+ my $one_day = 24 * 60 * 60;
+ my $one_week = 7 * $one_day;
+ my $one_month = 30 * $one_day;
+
+ query_to_munin( 'pending_user_count',
+ $db->select( 'users', 'count(*) as count', { status => 0 } )
+ ->hash->{count} );
query_to_munin( 'reg_user_count',
$db->select( 'users', 'count(*) as count', { status => 1 } )
->hash->{count} );
@@ -40,19 +57,30 @@ sub run {
}
)->hash->{count}
);
- query_to_munin(
- 'checkins_24h',
- $db->query( $checkin_window_query,
- $now->subtract( hours => 24 )->epoch )->hash->{count}
- );
+ query_to_munin( 'checked_in',
+ $db->select( 'in_transit', 'count(*) as count' )->hash->{count} );
+ query_to_munin( 'checkins_24h',
+ $db->query( $checkin_window_query, $now->epoch - $one_day )
+ ->hash->{count} );
query_to_munin( 'checkins_7d',
- $db->query( $checkin_window_query, $now->subtract( days => 7 )->epoch )
+ $db->query( $checkin_window_query, $now->epoch - $one_week )
+ ->hash->{count} );
+ query_to_munin( 'checkins_30d',
+ $db->query( $checkin_window_query, $now->epoch - $one_month )
+ ->hash->{count} );
+ query_to_munin( 'polylines',
+ $db->select( 'polylines', 'count(*) as count' )->hash->{count} );
+ query_to_munin( 'traewelling_pull',
+ $db->select( 'traewelling', 'count(*) as count', { pull_sync => 1 } )
+ ->hash->{count} );
+ query_to_munin( 'traewelling_push',
+ $db->select( 'traewelling', 'count(*) as count', { push_sync => 1 } )
->hash->{count} );
query_to_munin(
- 'checkins_30d',
+ 'polyline_ratio',
$db->query(
- $checkin_window_query, $now->subtract( days => 30 )->epoch
- )->hash->{count}
+'select (select count(polyline_id) from journeys)::float / (select count(*) from polylines) as ratio'
+ )->hash->{ratio}
);
}
diff --git a/lib/Travelynx/Command/traewelling.pm b/lib/Travelynx/Command/traewelling.pm
new file mode 100644
index 0000000..4c47e84
--- /dev/null
+++ b/lib/Travelynx/Command/traewelling.pm
@@ -0,0 +1,207 @@
+package Travelynx::Command::traewelling;
+
+# Copyright (C) 2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+use Mojo::Base 'Mojolicious::Command';
+use Mojo::Promise;
+
+use DateTime;
+use JSON;
+use List::Util;
+
+has description => 'Synchronize with Traewelling';
+
+has usage => sub { shift->extract_usage };
+
+sub pull_sync {
+ my ($self) = @_;
+ my %pull_result;
+ my $request_count = 0;
+ for my $account_data ( $self->app->traewelling->get_pull_accounts ) {
+
+ my $in_transit = $self->app->in_transit->get(
+ uid => $account_data->{user_id},
+ );
+ if ($in_transit) {
+ $self->app->log->debug(
+"Skipping Traewelling status pull for UID $account_data->{user_id}: already checked in"
+ );
+ next;
+ }
+
+ # $account_data->{user_id} is the travelynx uid
+ # $account_data->{user_name} is the Träwelling username
+ $request_count += 1;
+ $self->app->log->debug(
+"Scheduling Traewelling status pull for UID $account_data->{user_id}"
+ );
+
+ # In 'work', the event loop is not running,
+ # so there's no need to multiply by $request_count at the moment
+ Mojo::Promise->timer(1)->then(
+ sub {
+ return $self->app->traewelling_api->get_status_p(
+ username => $account_data->{data}{user_name},
+ token => $account_data->{token}
+ );
+ }
+ )->then(
+ sub {
+ my ($traewelling) = @_;
+ $pull_result{ $traewelling->{http} } += 1;
+ return $self->app->traewelling_to_travelynx_p(
+ traewelling => $traewelling,
+ user_data => $account_data
+ );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $pull_result{ $err->{http} // 0 } += 1;
+ $self->app->traewelling->log(
+ uid => $account_data->{user_id},
+ message => "Fehler bei der Status-Abfrage: $err->{text}",
+ is_error => 1
+ );
+ $self->app->log->debug("Error $err->{text}");
+ }
+ )->wait;
+ }
+
+ return \%pull_result;
+}
+
+sub push_sync {
+ my ($self) = @_;
+ my %push_result;
+
+ for my $candidate ( $self->app->traewelling->get_pushable_accounts ) {
+ $self->app->log->debug(
+ "Pushing to Traewelling for UID $candidate->{uid}");
+ my $trip_id = $candidate->{journey_data}{trip_id};
+ if ( not $trip_id ) {
+ $self->app->log->debug("... trip_id is missing");
+ $self->app->traewelling->log(
+ uid => $candidate->{uid},
+ message =>
+"Konnte $candidate->{train_type} $candidate->{train_no} nicht übertragen: Keine trip_id vorhanden",
+ is_error => 1
+ );
+ next;
+ }
+ if ( $candidate->{data}{latest_push_ts}
+ and $candidate->{data}{latest_push_ts} == $candidate->{checkin_ts} )
+ {
+ $self->app->log->debug("... already handled");
+ next;
+ }
+ $self->app->traewelling_api->checkin_p( %{$candidate},
+ trip_id => $trip_id )->then(
+ sub {
+ my ($status) = @_;
+ $push_result{ $status->{http} } += 1;
+ }
+ )->catch(
+ sub {
+ my ($status) = @_;
+ $push_result{ $status->{http} // 0 } += 1;
+ }
+ )->wait;
+ }
+
+ return \%push_result;
+}
+
+sub run {
+ my ( $self, $direction ) = @_;
+
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ my $started_at = $now;
+ my $push_result;
+ my $pull_result;
+
+ if ( not $direction or $direction eq 'push' ) {
+ $push_result = $self->push_sync;
+ }
+
+ my $trwl_push_finished_at = DateTime->now( time_zone => 'Europe/Berlin' );
+
+ if ( not $direction or $direction eq 'pull' ) {
+ $pull_result = $self->pull_sync;
+ }
+
+ my $trwl_pull_finished_at = DateTime->now( time_zone => 'Europe/Berlin' );
+
+ my $trwl_push_duration = $trwl_push_finished_at->epoch - $started_at->epoch;
+ my $trwl_pull_duration
+ = $trwl_pull_finished_at->epoch - $trwl_push_finished_at->epoch;
+ my $trwl_duration = $trwl_pull_finished_at->epoch - $started_at->epoch;
+
+ if ( $self->app->config->{influxdb}->{url} ) {
+ my $report = "sync_runtime_seconds=${trwl_duration}";
+ if ( not $direction or $direction eq 'push' ) {
+ $report .= ",push_runtime_seconds=${trwl_push_duration}";
+ }
+ if ( not $direction or $direction eq 'pull' ) {
+ $report .= ",pull_runtime_seconds=${trwl_pull_duration}";
+ }
+ if ( $self->app->mode eq 'development' ) {
+ $self->app->log->debug( 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . " traewelling ${report}" );
+ }
+ else {
+ $self->app->ua->post_p( $self->app->config->{influxdb}->{url},
+ "traewelling ${report}" )->wait;
+ }
+
+ if ($push_result) {
+ for my $status ( keys %{$push_result} ) {
+ my $count = $push_result->{$status};
+ if ( $self->app->mode eq 'development' ) {
+ $self->app->log->debug( 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . " traewelling_push,http=$status count=$count" );
+ }
+ else {
+ $self->app->ua->post_p(
+ $self->app->config->{influxdb}->{url},
+ "traewelling_push,http=$status count=$count"
+ )->wait;
+ }
+ }
+ }
+
+ if ($pull_result) {
+ for my $status ( keys %{$pull_result} ) {
+ my $count = $pull_result->{$status};
+ if ( $self->app->mode eq 'development' ) {
+ $self->app->log->debug( 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . " traewelling_pull,http=$status count=$count" );
+ }
+ else {
+ $self->app->ua->post_p(
+ $self->app->config->{influxdb}->{url},
+ "traewelling_pull,http=$status count=$count"
+ )->wait;
+ }
+ }
+ }
+ }
+}
+
+1;
+
+__END__
+
+=head1 SYNOPSIS
+
+ Usage: index.pl traewelling [direction]
+
+ Performs both push and pull synchronization by default.
+ If "direction" is specified, only synchronizes in the specified direction
+ ("push" or "pull")
+
+ Should be called from a cronjob every three to ten minutes.
diff --git a/lib/Travelynx/Command/work.pm b/lib/Travelynx/Command/work.pm
index b9a8520..10b1b69 100644
--- a/lib/Travelynx/Command/work.pm
+++ b/lib/Travelynx/Command/work.pm
@@ -1,33 +1,113 @@
package Travelynx::Command::work;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Command';
+use Mojo::Promise;
use DateTime;
use JSON;
-use List::Util qw(first);
+use List::Util;
-has description =>
- 'Perform automatic checkout when users arrive at their destination';
+has description => 'Update real-time data of active journeys';
has usage => sub { shift->extract_usage };
sub run {
my ($self) = @_;
- my $now = DateTime->now( time_zone => 'Europe/Berlin' );
- my $json = JSON->new;
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ my $checkin_deadline = $now->clone->subtract( hours => 48 );
+ my $json = JSON->new;
+
+ my $num_incomplete = $self->app->in_transit->delete_incomplete_checkins(
+ earlier_than => $checkin_deadline );
- my $db = $self->app->pg->db;
+ if ($num_incomplete) {
+ $self->app->log->debug("Removed ${num_incomplete} incomplete checkins");
+ }
- for my $entry (
- $db->select( 'in_transit_str', '*', { cancelled => 0 } )->hashes->each )
- {
+ my $errors = 0;
+
+ for my $entry ( $self->app->in_transit->get_all_active ) {
my $uid = $entry->{user_id};
- my $dep = $entry->{dep_ds100};
- my $arr = $entry->{arr_ds100};
+ my $dep = $entry->{dep_eva};
+ my $arr = $entry->{arr_eva};
my $train_id = $entry->{train_id};
- $self->app->log->debug("Processing $uid");
+ if ( $train_id =~ m{[|]} ) {
+
+ $self->app->hafas->get_journey_p( trip_id => $train_id )->then(
+ sub {
+ my ($journey) = @_;
+
+ my $found_dep;
+ my $found_arr;
+ for my $stop ( $journey->route ) {
+ if ( $stop->loc->eva == $dep ) {
+ $found_dep = $stop;
+ }
+ if ( $arr and $stop->loc->eva == $arr ) {
+ $found_arr = $stop;
+ last;
+ }
+ }
+ if ( not $found_dep ) {
+ $self->app->log->debug(
+ "Did not find $dep within journey $train_id");
+ return;
+ }
+
+ if ( $found_dep->{rt_dep} ) {
+ $self->app->in_transit->update_departure_hafas(
+ uid => $uid,
+ journey => $journey,
+ stop => $found_dep,
+ dep_eva => $dep,
+ arr_eva => $arr
+ );
+ }
+
+ if ( $found_arr and $found_arr->{rt_arr} ) {
+ $self->app->in_transit->update_arrival_hafas(
+ uid => $uid,
+ journey => $journey,
+ stop => $found_arr,
+ dep_eva => $dep,
+ arr_eva => $arr
+ );
+ }
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ if ( $err =~ m{svcResL\[0\][.]err is (?:FAIL|PARAMETER)$} )
+ {
+ # HAFAS do be weird. These are not actionable.
+ $self->app->log->debug("work($uid)/journey: $err");
+ }
+ else {
+ $self->app->log->error("work($uid)/journey: $err");
+ }
+ }
+ )->wait;
+
+ if ( $arr
+ and $entry->{real_arr_ts}
+ and $now->epoch - $entry->{real_arr_ts} > 600 )
+ {
+ $self->app->checkout_p(
+ station => $arr,
+ force => 2,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->wait;
+ }
+ next;
+ }
# Note: IRIS data is not always updated in real-time. Both departure and
# arrival delays may take several minutes to appear, especially in case
@@ -37,57 +117,96 @@ sub run {
eval {
if ( $now->epoch - $entry->{real_dep_ts} < 900 ) {
- $self->app->log->debug(" - updating departure");
- my $status = $self->app->get_departures( $dep, 30, 30 );
+ my $status = $self->app->iris->get_departures(
+ station => $dep,
+ lookbehind => 30,
+ lookahead => 30
+ );
if ( $status->{errstr} ) {
die("get_departures($dep): $status->{errstr}\n");
}
- my ($train)
- = first { $_->train_id eq $train_id } @{ $status->{results} };
+ my ($train) = List::Util::first { $_->train_id eq $train_id }
+ @{ $status->{results} };
if ( not $train ) {
- die("could not find train $train_id at $dep\n");
+ $self->app->log->debug(
+ "could not find train $train_id at $dep\n");
+ return;
}
- $db->update(
- 'in_transit',
- {
- dep_platform => $train->platform,
- real_departure => $train->departure,
- route =>
- $json->encode( [ $self->app->route_diff($train) ] ),
- messages => $json->encode(
- [
- map { [ $_->[0]->epoch, $_->[1] ] }
- $train->messages
- ]
- ),
- },
- { user_id => $uid }
+ $self->app->in_transit->update_departure(
+ uid => $uid,
+ train => $train,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ route => [ $self->app->iris->route_diff($train) ]
);
- $self->app->add_route_timestamps( $uid, $train, 1 );
+
+ if ( $train->departure_is_cancelled and $arr ) {
+ my $checked_in
+ = $self->app->in_transit->update_departure_cancelled(
+ uid => $uid,
+ train => $train,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ );
+
+ # depending on the amount of users in transit, some time may
+ # have passed between fetching $entry from the database and
+ # now. Only check out if the user is still checked into this
+ # train.
+ if ($checked_in) {
+
+ # check out (adds a cancelled journey and resets journey state
+ # to checkin
+ $self->app->checkout_p(
+ station => $arr,
+ force => 2,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->wait;
+ }
+ }
+ else {
+ $self->app->add_route_timestamps( $uid, $train, 1 );
+ }
}
};
if ($@) {
+ $errors += 1;
$self->app->log->error("work($uid)/departure: $@");
}
eval {
if (
- $entry->{arr_name}
+ $arr
and ( not $entry->{real_arr_ts}
or $now->epoch - $entry->{real_arr_ts} < 600 )
)
{
- $self->app->log->debug(" - updating arrival");
- my $status = $self->app->get_departures( $arr, 20, 220 );
+ my $status = $self->app->iris->get_departures(
+ station => $arr,
+ lookbehind => 20,
+ lookahead => 220
+ );
if ( $status->{errstr} ) {
die("get_departures($arr): $status->{errstr}\n");
}
- my ($train)
- = first { $_->train_id eq $train_id } @{ $status->{results} };
+ # 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 > $entry->{sched_dep_ts}
+ }
+ @{ $status->{results} };
+
+ $train //= List::Util::first { $_->train_id eq $train_id }
+ @{ $status->{results} };
if ( not $train ) {
@@ -96,49 +215,80 @@ sub run {
return;
}
- $db->update(
- 'in_transit',
- {
- arr_platform => $train->platform,
- sched_arrival => $train->sched_arrival,
- real_arrival => $train->arrival,
- route =>
- $json->encode( [ $self->app->route_diff($train) ] ),
- messages => $json->encode(
- [
- map { [ $_->[0]->epoch, $_->[1] ] }
- $train->messages
- ]
- ),
- },
- { user_id => $uid }
+ my $checked_in = $self->app->in_transit->update_arrival(
+ uid => $uid,
+ train => $train,
+ route => [ $self->app->iris->route_diff($train) ],
+ dep_eva => $dep,
+ arr_eva => $arr,
);
- $self->app->add_route_timestamps( $uid, $train, 0 );
+
+ if ( $checked_in and $train->arrival_is_cancelled ) {
+
+ # check out (adds a cancelled journey and resets journey state
+ # to destination selection)
+ $self->app->checkout_p(
+ station => $arr,
+ force => 0,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->wait;
+ }
+ else {
+ $self->app->add_route_timestamps(
+ $uid, $train, 0,
+ (
+ defined $entry->{real_arr_ts}
+ and $now->epoch > $entry->{real_arr_ts}
+ ) ? 1 : 0
+ );
+ }
}
elsif ( $entry->{real_arr_ts} ) {
- $self->app->log->debug(" - checking out");
- my ( undef, $error ) = $self->app->checkout( $arr, 1, $uid );
- if ($error) {
- die("${error}\n");
- }
+ my ( undef, $error ) = $self->app->checkout_p(
+ station => $arr,
+ force => 2,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->catch(
+ sub {
+ my ($error) = @_;
+ $self->app->log->error("work($uid)/arrival: $error");
+ $errors += 1;
+ }
+ )->wait;
}
};
if ($@) {
$self->app->log->error("work($uid)/arrival: $@");
+ $errors += 1;
}
- eval { }
+ eval { };
+ }
+
+ my $started_at = $now;
+ my $main_finished_at = DateTime->now( time_zone => 'Europe/Berlin' );
+ my $worker_duration = $main_finished_at->epoch - $started_at->epoch;
+
+ if ( $self->app->config->{influxdb}->{url} ) {
+ if ( $self->app->mode eq 'development' ) {
+ $self->app->log->debug( 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . " worker runtime_seconds=${worker_duration},errors=${errors}"
+ );
+ }
+ else {
+ $self->app->ua->post_p( $self->app->config->{influxdb}->{url},
+ "worker runtime_seconds=${worker_duration},errors=${errors}" )
+ ->wait;
+ }
}
- # Computing yearly stats may take a while, but we've got all time in the
- # world here. This means users won't have to wait when loading their
- # own by-year journey log.
- for my $user ( $db->select( 'users', 'id', { status => 1 } )->hashes->each )
- {
- $self->app->get_journey_stats(
- uid => $user->{id},
- year => $now->year
- );
+ if ( not $self->app->config->{traewelling}->{separate_worker} ) {
+ $self->app->start('traewelling');
}
}
diff --git a/lib/Travelynx/Command/worker.pm b/lib/Travelynx/Command/worker.pm
index 6b70f2e..be7431f 100644
--- a/lib/Travelynx/Command/worker.pm
+++ b/lib/Travelynx/Command/worker.pm
@@ -1,24 +1,31 @@
package Travelynx::Command::worker;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Command';
use Mojo::IOLoop;
-has description =>
- 'travelynx background worker';
+has description => 'travelynx background worker';
has usage => sub { shift->extract_usage };
sub run {
my ($self) = @_;
- Mojo::IOLoop->recurring(180 => sub {
- $self->app->start('work');
- });
+ Mojo::IOLoop->recurring(
+ 180 => sub {
+ $self->app->start('work');
+ }
+ );
- Mojo::IOLoop->recurring(3600 => sub {
- $self->app->start('maintenance');
- });
+ Mojo::IOLoop->recurring(
+ 36000 => sub {
+ $self->app->start('maintenance');
+ }
+ );
- if (not Mojo::IOLoop->is_running) {
+ if ( not Mojo::IOLoop->is_running ) {
Mojo::IOLoop->start;
}
}
@@ -33,4 +40,4 @@ __END__
Background worker for cron-less setups, e.g. Docker.
- Calls "index.pl work" every 3 minutes and "index.pl maintenance" every 1 hour.
+ Calls "index.pl work" every 3 minutes and "index.pl maintenance" every 10 hours.
diff --git a/lib/Travelynx/Controller/Account.pm b/lib/Travelynx/Controller/Account.pm
index 0275b96..f1dc43e 100644
--- a/lib/Travelynx/Controller/Account.pm
+++ b/lib/Travelynx/Controller/Account.pm
@@ -1,21 +1,228 @@
package Travelynx::Controller::Account;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';
-use Crypt::Eksblowfish::Bcrypt qw(bcrypt en_base64);
+use JSON;
+use Mojo::Util qw(xml_escape);
+use Text::Markdown;
use UUID::Tiny qw(:std);
-sub hash_password {
- my ($password) = @_;
- my @salt_bytes = map { int( rand(255) ) + 1 } ( 1 .. 16 );
- my $salt = en_base64( pack( 'C[16]', @salt_bytes ) );
+my %visibility_itoa = (
+ 100 => 'public',
+ 80 => 'travelynx',
+ 60 => 'followers',
+ 30 => 'unlisted',
+ 10 => 'private',
+);
- return bcrypt( $password, '$2a$12$' . $salt );
-}
+my %visibility_atoi = (
+ public => 100,
+ travelynx => 80,
+ followers => 60,
+ unlisted => 30,
+ private => 10,
+);
+
+# Internal Helpers
sub make_token {
return create_uuid_as_string(UUID_V4);
}
+sub send_registration_mail {
+ my ( $self, %opt ) = @_;
+
+ my $email = $opt{email};
+ my $token = $opt{token};
+ my $user = $opt{user};
+ my $user_id = $opt{user_id};
+ my $ip = $opt{ip};
+ my $date = DateTime->now( time_zone => 'Europe/Berlin' )
+ ->strftime('%d.%m.%Y %H:%M:%S %z');
+
+ my $ua = $self->req->headers->user_agent;
+ my $reg_url = $self->url_for('reg')->to_abs->scheme('https');
+ my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
+
+ my $body = "Hallo, ${user}!\n\n";
+ $body .= "Mit deiner E-Mail-Adresse (${email}) wurde ein Account bei\n";
+ $body .= "travelynx angelegt.\n\n";
+ $body
+ .= "Falls die Registrierung von dir ausging, kannst du den Account unter\n";
+ $body .= "${reg_url}/${user_id}/${token}\n";
+ $body .= "freischalten.\n\n";
+ $body
+ .= "Falls nicht, ignoriere diese Mail bitte. Nach etwa 48 Stunden wird deine\n";
+ $body
+ .= "Mail-Adresse erneut zur Registrierung freigeschaltet. Falls auch diese fehlschlägt,\n";
+ $body
+ .= "werden wir sie dauerhaft sperren und keine Mails mehr dorthin schicken.\n\n";
+ $body .= "Daten zur Registrierung:\n";
+ $body .= " * Datum: ${date}\n";
+ $body .= " * Client: ${ip}\n";
+ $body .= " * UserAgent: ${ua}\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ return $self->sendmail->custom( $email, 'Registrierung bei travelynx',
+ $body );
+}
+
+sub send_address_confirmation_mail {
+ my ( $self, $email, $token ) = @_;
+
+ my $name = $self->current_user->{name};
+ my $ip = $self->req->headers->header('X-Forwarded-For');
+ my $ua = $self->req->headers->user_agent;
+ my $date = DateTime->now( time_zone => 'Europe/Berlin' )
+ ->strftime('%d.%m.%Y %H:%M:%S %z');
+
+ # In case Mojolicious is not running behind a reverse proxy
+ $ip
+ //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
+ my $confirm_url = $self->url_for('confirm_mail')->to_abs->scheme('https');
+ my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
+
+ my $body = "Hallo ${name},\n\n";
+ $body .= "Bitte bestätige unter <${confirm_url}/${token}>,\n";
+ $body .= "dass du mit dieser Adresse E-Mail empfangen kannst.\n\n";
+ $body
+ .= "Du erhältst diese Mail, da eine Änderung der deinem travelynx-Account\n";
+ $body .= "zugeordneten Mail-Adresse beantragt wurde.\n\n";
+ $body .= "Daten zur Anfrage:\n";
+ $body .= " * Datum: ${date}\n";
+ $body .= " * Client: ${ip}\n";
+ $body .= " * UserAgent: ${ua}\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ return $self->sendmail->custom( $email,
+ 'travelynx: Mail-Adresse bestätigen', $body );
+}
+
+sub send_name_notification_mail {
+ my ( $self, $old_name, $new_name ) = @_;
+
+ my $ip = $self->req->headers->header('X-Forwarded-For');
+ my $ua = $self->req->headers->user_agent;
+ my $date = DateTime->now( time_zone => 'Europe/Berlin' )
+ ->strftime('%d.%m.%Y %H:%M:%S %z');
+
+ # In case Mojolicious is not running behind a reverse proxy
+ $ip
+ //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
+ my $confirm_url = $self->url_for('confirm_mail')->to_abs->scheme('https');
+ my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
+
+ my $body = "Hallo ${new_name},\n\n";
+ $body .= "Der Name deines Travelynx-Accounts wurde erfolgreich geändert.\n";
+ $body
+ .= "Bitte beachte, dass du dich ab sofort nur mit dem neuen Namen anmelden kannst.\n\n";
+ $body .= "Alter Name: ${old_name}\n\n";
+ $body .= "Neue Name: ${new_name}\n\n";
+ $body .= "Daten zur Anfrage:\n";
+ $body .= " * Datum: ${date}\n";
+ $body .= " * Client: ${ip}\n";
+ $body .= " * UserAgent: ${ua}\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ return $self->sendmail->custom( $self->current_user->{email},
+ 'travelynx: Name geändert', $body );
+}
+
+sub send_password_notification_mail {
+ my ($self) = @_;
+ my $user = $self->current_user->{name};
+ my $email = $self->current_user->{email};
+ my $ip = $self->req->headers->header('X-Forwarded-For');
+ my $ua = $self->req->headers->user_agent;
+ my $date = DateTime->now( time_zone => 'Europe/Berlin' )
+ ->strftime('%d.%m.%Y %H:%M:%S %z');
+
+ # In case Mojolicious is not running behind a reverse proxy
+ $ip
+ //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
+ my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
+
+ my $body = "Hallo ${user},\n\n";
+ $body
+ .= "Das Passwort deines travelynx-Accounts wurde soeben geändert.\n\n";
+ $body .= "Daten zur Änderung:\n";
+ $body .= " * Datum: ${date}\n";
+ $body .= " * Client: ${ip}\n";
+ $body .= " * UserAgent: ${ua}\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ $self->sendmail->custom( $email, 'travelynx: Passwort geändert', $body );
+}
+
+sub send_lostpassword_confirmation_mail {
+ my ( $self, %opt ) = @_;
+ my $email = $opt{email};
+ my $name = $opt{name};
+ my $uid = $opt{uid};
+ my $token = $opt{token};
+
+ my $ip = $self->req->headers->header('X-Forwarded-For');
+ my $ua = $self->req->headers->user_agent;
+ my $date = DateTime->now( time_zone => 'Europe/Berlin' )
+ ->strftime('%d.%m.%Y %H:%M:%S %z');
+
+ # In case Mojolicious is not running behind a reverse proxy
+ $ip
+ //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
+ my $recover_url = $self->url_for('recover')->to_abs->scheme('https');
+ my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
+
+ my $body = "Hallo ${name},\n\n";
+ $body .= "Unter ${recover_url}/${uid}/${token}\n";
+ $body
+ .= "kannst du ein neues Passwort für deinen travelynx-Account vergeben.\n\n";
+ $body
+ .= "Du erhältst diese Mail, da mit deinem Accountnamen und deiner Mail-Adresse\n";
+ $body
+ .= "ein Passwort-Reset angefordert wurde. Falls diese Anfrage nicht von dir\n";
+ $body .= "ausging, kannst du sie ignorieren.\n\n";
+ $body .= "Daten zur Anfrage:\n";
+ $body .= " * Datum: ${date}\n";
+ $body .= " * Client: ${ip}\n";
+ $body .= " * UserAgent: ${ua}\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ my $success
+ = $self->sendmail->custom( $email, 'travelynx: Neues Passwort', $body );
+}
+
+sub send_lostpassword_notification_mail {
+ my ( $self, $account ) = @_;
+ my $user = $account->{name};
+ my $email = $account->{email};
+ my $ip = $self->req->headers->header('X-Forwarded-For');
+ my $ua = $self->req->headers->user_agent;
+ my $date = DateTime->now( time_zone => 'Europe/Berlin' )
+ ->strftime('%d.%m.%Y %H:%M:%S %z');
+
+ # In case Mojolicious is not running behind a reverse proxy
+ $ip
+ //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
+ my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
+
+ my $body = "Hallo ${user},\n\n";
+ $body .= "Das Passwort deines travelynx-Accounts wurde soeben über die";
+ $body .= " 'Passwort vergessen'-Funktion geändert.\n\n";
+ $body .= "Daten zur Änderung:\n";
+ $body .= " * Datum: ${date}\n";
+ $body .= " * Client: ${ip}\n";
+ $body .= " * UserAgent: ${ua}\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ return $self->sendmail->custom( $email, 'travelynx: Passwort geändert',
+ $body );
+}
+
+# Controllers
+
sub login_form {
my ($self) = @_;
$self->render('login');
@@ -31,22 +238,31 @@ sub do_login {
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
$self->render(
- 'login',
- invalid => 'csrf',
+ 'bad_request',
+ csrf => 1,
+ status => 400
);
}
else {
if ( $self->authenticate( $user, $password ) ) {
$self->redirect_to( $self->req->param('redirect_to') // '/' );
- $self->mark_seen( $self->current_user->{id} );
+ $self->users->mark_seen( uid => $self->current_user->{id} );
}
else {
- my $data = $self->get_user_password($user);
+ my $data = $self->users->get_login_data( name => $user );
if ( $data and $data->{status} == 0 ) {
- $self->render( 'login', invalid => 'confirmation' );
+ $self->render(
+ 'login',
+ status => 400,
+ invalid => 'confirmation'
+ );
}
else {
- $self->render( 'login', invalid => 'credentials' );
+ $self->render(
+ 'login',
+ status => 400,
+ invalid => 'credentials'
+ );
}
}
}
@@ -59,14 +275,12 @@ sub registration_form {
sub register {
my ($self) = @_;
+ my $dt = $self->req->param('dt');
my $user = $self->req->param('user');
my $email = $self->req->param('email');
my $password = $self->req->param('password');
my $password2 = $self->req->param('password2');
my $ip = $self->req->headers->header('X-Forwarded-For');
- my $ua = $self->req->headers->user_agent;
- my $date = DateTime->now( time_zone => 'Europe/Berlin' )
- ->strftime('%d.%m.%Y %H:%M:%S %z');
# In case Mojolicious is not running behind a reverse proxy
$ip
@@ -74,33 +288,44 @@ sub register {
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
$self->render(
- 'register',
- invalid => 'csrf',
+ 'bad_request',
+ csrf => 1,
+ status => 400
);
return;
}
- if ( not length($user) ) {
- $self->render( 'register', invalid => 'user_empty' );
- return;
- }
-
- if ( not length($email) ) {
- $self->render( 'register', invalid => 'mail_empty' );
- return;
+ if ( my $registration_denylist
+ = $self->app->config->{registration}->{denylist} )
+ {
+ if ( open( my $fh, "<", $registration_denylist ) ) {
+ while ( my $line = <$fh> ) {
+ chomp $line;
+ if ( $ip eq $line ) {
+ close($fh);
+ $self->render( 'register', invalid => "denylist" );
+ return;
+ }
+ }
+ close($fh);
+ }
+ else {
+ $self->log->error("Cannot open($registration_denylist): $!");
+ die("Cannot verify registration: $!");
+ }
}
- if ( $user !~ m{ ^ [0-9a-zA-Z_-]+ $ }x ) {
- $self->render( 'register', invalid => 'user_format' );
+ if ( my $error = $self->users->is_name_invalid( name => $user ) ) {
+ $self->render( 'register', invalid => $error );
return;
}
- if ( $self->check_if_user_name_exists($user) ) {
- $self->render( 'register', invalid => 'user_collision' );
+ if ( not length($email) ) {
+ $self->render( 'register', invalid => 'mail_empty' );
return;
}
- if ( $self->check_if_mail_is_blacklisted($email) ) {
+ if ( $self->users->mail_is_blacklisted( email => $email ) ) {
$self->render( 'register', invalid => 'mail_blacklisted' );
return;
}
@@ -115,35 +340,37 @@ sub register {
return;
}
- my $token = make_token();
- my $pw_hash = hash_password($password);
- my $db = $self->pg->db;
- my $tx = $db->begin;
- my $user_id = $self->add_user( $db, $user, $email, $token, $pw_hash );
- my $reg_url = $self->url_for('reg')->to_abs->scheme('https');
- my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
+ if ( not $dt
+ or DateTime->now( time_zone => 'Europe/Berlin' )->epoch - $dt < 6 )
+ {
+ # a human user should take at least five seconds to fill out the form.
+ # Throw a CSRF error at presumed spammers.
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
+ return;
+ }
- my $body = "Hallo, ${user}!\n\n";
- $body .= "Mit deiner E-Mail-Adresse (${email}) wurde ein Account bei\n";
- $body .= "travelynx angelegt.\n\n";
- $body
- .= "Falls die Registrierung von dir ausging, kannst du den Account unter\n";
- $body .= "${reg_url}/${user_id}/${token}\n";
- $body .= "freischalten.\n\n";
- $body
- .= "Falls nicht, ignoriere diese Mail bitte. Nach etwa 48 Stunden wird deine\n";
- $body
- .= "Mail-Adresse erneut zur Registrierung freigeschaltet. Falls auch diese fehlschlägt,\n";
- $body
- .= "werden wir sie dauerhaft sperren und keine Mails mehr dorthin schicken.\n\n";
- $body .= "Daten zur Registrierung:\n";
- $body .= " * Datum: ${date}\n";
- $body .= " * Client: ${ip}\n";
- $body .= " * UserAgent: ${ua}\n\n\n";
- $body .= "Impressum: ${imprint_url}\n";
+ my $token = make_token();
+ my $db = $self->pg->db;
+ my $tx = $db->begin;
+ my $user_id = $self->users->add(
+ db => $db,
+ name => $user,
+ email => $email,
+ token => $token,
+ password => $password,
+ );
- my $success
- = $self->sendmail->custom( $email, 'Registrierung bei travelynx', $body );
+ my $success = $self->send_registration_mail(
+ email => $email,
+ token => $token,
+ ip => $ip,
+ user => $user,
+ user_id => $user_id
+ );
if ($success) {
$tx->commit;
$self->render( 'login', from => 'register' );
@@ -164,7 +391,13 @@ sub verify {
return;
}
- if ( not $self->verify_registration_token( $id, $token ) ) {
+ if (
+ not $self->users->verify_registration_token(
+ uid => $id,
+ token => $token
+ )
+ )
+ {
$self->render( 'register', invalid => 'token' );
return;
}
@@ -174,8 +407,13 @@ sub verify {
sub delete {
my ($self) = @_;
+ my $uid = $self->current_user->{id};
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
- $self->render( 'account', invalid => 'csrf' );
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
@@ -187,13 +425,14 @@ sub delete {
)
)
{
- $self->render( 'account', invalid => 'deletion password' );
+ $self->flash( invalid => 'deletion password' );
+ $self->redirect_to('account');
return;
}
- $self->flag_user_deletion( $self->current_user->{id} );
+ $self->users->flag_deletion( uid => $uid );
}
else {
- $self->unflag_user_deletion( $self->current_user->{id} );
+ $self->users->unflag_deletion( uid => $uid );
}
$self->redirect_to('account');
}
@@ -201,7 +440,11 @@ sub delete {
sub do_logout {
my ($self) = @_;
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
- $self->render( 'login', invalid => 'csrf' );
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
$self->logout;
@@ -211,32 +454,353 @@ sub do_logout {
sub privacy {
my ($self) = @_;
- my $user = $self->current_user;
- my $public_level = $user->{is_public};
+ my $user = $self->current_user;
if ( $self->param('action') and $self->param('action') eq 'save' ) {
- if ( $self->param('public_status') ) {
- $public_level |= 0x02;
+ my %opt;
+ my $default_visibility
+ = $visibility_atoi{ $self->param('status_level') };
+ if ( defined $default_visibility ) {
+ $opt{default_visibility} = $default_visibility;
}
- else {
- $public_level &= ~0x02;
+
+ my $past_visibility = $visibility_atoi{ $self->param('history_level') };
+ if ( defined $past_visibility ) {
+ $opt{past_visibility} = $past_visibility;
}
- $self->set_privacy( $user->{id}, $public_level );
+
+ $opt{comments_visible} = $self->param('public_comment') ? 1 : 0;
+
+ $opt{past_all} = $self->param('history_age') eq 'infinite' ? 1 : 0;
+ $opt{past_status} = $self->param('past_status') ? 1 : 0;
+
+ $self->users->set_privacy(
+ uid => $user->{id},
+ %opt
+ );
$self->flash( success => 'privacy' );
$self->redirect_to('account');
}
else {
- $self->param( public_status => $public_level & 0x02 ? 1 : 0 );
+ $self->param(
+ status_level => $visibility_itoa{ $user->{default_visibility} } );
+ $self->param( public_comment => $user->{comments_visible} );
+ $self->param(
+ history_level => $visibility_itoa{ $user->{past_visibility} } );
+ $self->param( history_age => $user->{past_all} ? 'infinite' : 'month' );
+ $self->param( past_status => $user->{past_status} );
$self->render( 'privacy', name => $user->{name} );
}
}
+sub social {
+ my ($self) = @_;
+
+ my $user = $self->current_user;
+
+ if ( $self->param('action') and $self->param('action') eq 'save' ) {
+ if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
+ return;
+ }
+
+ my %opt;
+ my $accept_follow = $self->param('accept_follow');
+
+ if ( $accept_follow eq 'yes' ) {
+ $opt{accept_follows} = 1;
+ }
+ elsif ( $accept_follow eq 'request' ) {
+ $opt{accept_follow_requests} = 1;
+ }
+
+ $self->users->set_social(
+ uid => $user->{id},
+ %opt
+ );
+
+ $self->flash( success => 'social' );
+ $self->redirect_to('account');
+ }
+ else {
+ if ( $user->{accept_follows} ) {
+ $self->param( accept_follow => 'yes' );
+ }
+ elsif ( $user->{accept_follow_requests} ) {
+ $self->param( accept_follow => 'request' );
+ }
+ else {
+ $self->param( accept_follow => 'no' );
+ }
+ $self->render( 'social', name => $user->{name} );
+ }
+}
+
+sub social_list {
+ my ($self) = @_;
+
+ my $kind = $self->stash('kind');
+ my $user = $self->current_user;
+
+ if ( $kind eq 'follow-requests-received' ) {
+ my @follow_reqs
+ = $self->users->get_follow_requests( uid => $user->{id} );
+ $self->render(
+ 'social_list',
+ type => 'follow-requests-received',
+ entries => [@follow_reqs],
+ notifications => $user->{notifications},
+ );
+ }
+ elsif ( $kind eq 'follow-requests-sent' ) {
+ my @follow_reqs = $self->users->get_follow_requests(
+ uid => $user->{id},
+ sent => 1
+ );
+ $self->render(
+ 'social_list',
+ type => 'follow-requests-sent',
+ entries => [@follow_reqs],
+ notifications => $user->{notifications},
+ );
+ }
+ elsif ( $kind eq 'followers' ) {
+ my @followers = $self->users->get_followers( uid => $user->{id} );
+ $self->render(
+ 'social_list',
+ type => 'followers',
+ entries => [@followers],
+ notifications => $user->{notifications},
+ );
+ }
+ elsif ( $kind eq 'follows' ) {
+ my @following = $self->users->get_followees( uid => $user->{id} );
+ $self->render(
+ 'social_list',
+ type => 'follows',
+ entries => [@following],
+ notifications => $user->{notifications},
+ );
+ }
+ elsif ( $kind eq 'blocks' ) {
+ my @blocked = $self->users->get_blocked_users( uid => $user->{id} );
+ $self->render(
+ 'social_list',
+ type => 'blocks',
+ entries => [@blocked],
+ notifications => $user->{notifications},
+ );
+ }
+ else {
+ $self->render( 'not_found', status => 404 );
+ }
+}
+
+sub social_action {
+ my ($self) = @_;
+
+ my $user = $self->current_user;
+ my $action = $self->param('action');
+ my $target_ids = $self->param('target');
+ my $redirect_to = $self->param('redirect_to');
+
+ for my $key (
+ qw(follow request_follow follow_or_request unfollow remove_follower cancel_follow_request accept_follow_request reject_follow_request block unblock)
+ )
+ {
+ if ( $self->param($key) ) {
+ $action = $key;
+ $target_ids = $self->param($key);
+ }
+ }
+
+ if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
+ $self->redirect_to('/');
+ return;
+ }
+
+ if ( $action and $action eq 'clear_notifications' ) {
+ $self->users->update_notifications(
+ db => $self->pg->db,
+ uid => $user->{id},
+ has_follow_requests => 0
+ );
+ $self->flash( success => 'clear_notifications' );
+ $self->redirect_to('account');
+ return;
+ }
+
+ if ( not( $action and $target_ids and $redirect_to ) ) {
+ $self->redirect_to('/');
+ return;
+ }
+
+ for my $target_id ( split( qr{,}, $target_ids ) ) {
+ my $target = $self->users->get_privacy_by( uid => $target_id );
+
+ if ( not $target ) {
+ next;
+ }
+
+ if ( $action eq 'follow' and $target->{accept_follows} ) {
+ $self->users->follow(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ elsif ( $action eq 'request_follow'
+ and $target->{accept_follow_requests} )
+ {
+ $self->users->request_follow(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ elsif ( $action eq 'follow_or_request' ) {
+ if ( $target->{accept_follows} ) {
+ $self->users->follow(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ elsif ( $target->{accept_follow_requests} ) {
+ $self->users->request_follow(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ }
+ elsif ( $action eq 'unfollow' ) {
+ $self->users->unfollow(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ elsif ( $action eq 'remove_follower' ) {
+ $self->users->remove_follower(
+ uid => $user->{id},
+ follower => $target->{id}
+ );
+ }
+ elsif ( $action eq 'cancel_follow_request' ) {
+ $self->users->cancel_follow_request(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ elsif ( $action eq 'accept_follow_request' ) {
+ $self->users->accept_follow_request(
+ uid => $user->{id},
+ applicant => $target->{id}
+ );
+ }
+ elsif ( $action eq 'reject_follow_request' ) {
+ $self->users->reject_follow_request(
+ uid => $user->{id},
+ applicant => $target->{id}
+ );
+ }
+ elsif ( $action eq 'block' ) {
+ $self->users->block(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ elsif ( $action eq 'unblock' ) {
+ $self->users->unblock(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+
+ if ( $redirect_to eq 'profile' ) {
+
+ # profile links do not perform bulk actions
+ $self->redirect_to( '/p/' . $target->{name} );
+ return;
+ }
+ }
+
+ $self->redirect_to($redirect_to);
+}
+
+sub profile {
+ my ($self) = @_;
+ my $user = $self->current_user;
+
+ if ( $self->param('action') and $self->param('action') eq 'save' ) {
+ if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
+ return;
+ }
+ my $md = Text::Markdown->new;
+ my $bio = $self->param('bio');
+
+ if ( length($bio) > 2000 ) {
+ $bio = substr( $bio, 0, 2000 ) . '…';
+ }
+
+ my $profile = {
+ bio => {
+ markdown => $bio,
+ html => $md->markdown( xml_escape($bio) ),
+ },
+ metadata => [],
+ };
+ for my $i ( 0 .. 20 ) {
+ my $key = $self->param("key_$i");
+ my $value = $self->param("value_$i");
+ if ($key) {
+ if ( length($value) > 500 ) {
+ $value = substr( $value, 0, 500 ) . '…';
+ }
+ my $html_value
+ = ( $value
+ =~ s{ \[ ([^]]+) \]\( ([^)]+) \) }{'<a href="' . xml_escape($2) . '" rel="me">' . xml_escape($1) .'</a>' }egrx
+ );
+ $profile->{metadata}[$i] = {
+ key => $key,
+ value => {
+ markdown => $value,
+ html => $html_value,
+ },
+ };
+ }
+ else {
+ last;
+ }
+ }
+ $self->users->set_profile(
+ uid => $user->{id},
+ profile => $profile
+ );
+ $self->redirect_to( '/p/' . $user->{name} );
+ }
+
+ my $profile = $self->users->get_profile( uid => $user->{id} );
+ $self->param( bio => $profile->{bio}{markdown} );
+ for my $i ( 0 .. $#{ $profile->{metadata} } ) {
+ $self->param( "key_$i" => $profile->{metadata}[$i]{key} );
+ $self->param( "value_$i" => $profile->{metadata}[$i]{value}{markdown} );
+ }
+
+ $self->render( 'edit_profile', name => $user->{name} );
+}
+
sub insight {
my ($self) = @_;
my $user = $self->current_user;
- my $use_history = $self->account_use_history( $user->{id} );
+ my $use_history = $self->users->use_history( uid => $user->{id} );
if ( $self->param('action') and $self->param('action') eq 'save' ) {
if ( $self->param('on_departure') ) {
@@ -253,7 +817,10 @@ sub insight {
$use_history &= ~0x02;
}
- $self->account_use_history( $user->{id}, $use_history );
+ $self->users->use_history(
+ uid => $user->{id},
+ set => $use_history
+ );
$self->flash( success => 'use_history' );
$self->redirect_to('account');
}
@@ -264,16 +831,42 @@ sub insight {
}
+sub services {
+ my ($self) = @_;
+ my $user = $self->current_user;
+
+ if ( $self->param('action') and $self->param('action') eq 'save' ) {
+ my $sb = $self->param('stationboard');
+ my $value = 0;
+ if ( $sb =~ m{ ^ \d+ $ }x and $sb >= 0 and $sb <= 4 ) {
+ $value = int($sb);
+ }
+ $self->users->use_external_services(
+ uid => $user->{id},
+ set => $value
+ );
+ $self->flash( success => 'external' );
+ $self->redirect_to('account');
+ }
+
+ $self->param( stationboard =>
+ $self->users->use_external_services( uid => $user->{id} ) );
+ $self->render('use_external_links');
+}
+
sub webhook {
my ($self) = @_;
- my $hook = $self->get_webhook;
+ my $uid = $self->current_user->{id};
+
+ my $hook = $self->users->get_webhook( uid => $uid );
if ( $self->param('action') and $self->param('action') eq 'save' ) {
$hook->{url} = $self->param('url');
$hook->{token} = $self->param('token');
$hook->{enabled} = $self->param('enabled') // 0;
- $self->set_webhook(
+ $self->users->set_webhook(
+ uid => $uid,
url => $hook->{url},
token => $hook->{token},
enabled => $hook->{enabled}
@@ -284,7 +877,7 @@ sub webhook {
sub {
$self->render(
'webhooks',
- hook => $self->get_webhook,
+ hook => $self->users->get_webhook( uid => $uid ),
new_hook => 1
);
}
@@ -310,8 +903,9 @@ sub change_mail {
if ( $action and $action eq 'update_mail' ) {
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
$self->render(
- 'change_mail',
- invalid => 'csrf',
+ 'bad_request',
+ csrf => 1,
+ status => 400
);
return;
}
@@ -333,41 +927,17 @@ sub change_mail {
}
my $token = make_token();
- my $name = $self->current_user->{name};
my $db = $self->pg->db;
my $tx = $db->begin;
- $self->mark_for_mail_change( $db, $self->current_user->{id},
- $email, $token );
-
- my $ip = $self->req->headers->header('X-Forwarded-For');
- my $ua = $self->req->headers->user_agent;
- my $date = DateTime->now( time_zone => 'Europe/Berlin' )
- ->strftime('%d.%m.%Y %H:%M:%S %z');
-
- # In case Mojolicious is not running behind a reverse proxy
- $ip
- //= sprintf( '%s:%s', $self->tx->remote_address,
- $self->tx->remote_port );
- my $confirm_url
- = $self->url_for('confirm_mail')->to_abs->scheme('https');
- my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
-
- my $body = "Hallo ${name},\n\n";
- $body .= "Bitte bestätige unter <${confirm_url}/${token}>,\n";
- $body .= "dass du mit dieser Adresse E-Mail empfangen kannst.\n\n";
- $body
- .= "Du erhältst diese Mail, da eine Änderung der deinem travelynx-Account\n";
- $body .= "zugeordneten Mail-Adresse beantragt wurde.\n\n";
- $body .= "Daten zur Anfrage:\n";
- $body .= " * Datum: ${date}\n";
- $body .= " * Client: ${ip}\n";
- $body .= " * UserAgent: ${ua}\n\n\n";
- $body .= "Impressum: ${imprint_url}\n";
-
- my $success
- = $self->sendmail->custom( $email,
- 'travelynx: Mail-Adresse bestätigen', $body );
+ $self->users->mark_for_mail_change(
+ db => $db,
+ uid => $self->current_user->{id},
+ email => $email,
+ token => $token
+ );
+
+ my $success = $self->send_address_confirmation_mail( $email, $token );
if ($success) {
$tx->commit;
@@ -382,6 +952,70 @@ sub change_mail {
}
}
+sub change_name {
+ my ($self) = @_;
+
+ my $action = $self->req->param('action');
+ my $password = $self->req->param('password');
+ my $old_name = $self->current_user->{name};
+ my $new_name = $self->req->param('name');
+
+ if ( $action and $action eq 'update_name' ) {
+ if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
+ return;
+ }
+
+ if ( my $error = $self->users->is_name_invalid( name => $new_name ) ) {
+ $self->render(
+ 'change_name',
+ name => $old_name,
+ invalid => $error
+ );
+ return;
+ }
+
+ if ( not $self->authenticate( $old_name, $self->param('password') ) ) {
+ $self->render(
+ 'change_name',
+ name => $old_name,
+ invalid => 'password'
+ );
+ return;
+ }
+
+ # The users table has a unique constraint on the "name" column, so having
+ # two users with the same name is not possible. The race condition
+ # between the user_name_exists check in is_name_invalid and this
+ # change_name call is harmless.
+ my $success = $self->users->change_name(
+ uid => $self->current_user->{id},
+ name => $new_name
+ );
+
+ if ( not $success ) {
+ $self->render(
+ 'change_name',
+ name => $old_name,
+ invalid => 'user_collision'
+ );
+ return;
+ }
+
+ $self->flash( success => 'name' );
+ $self->redirect_to('account');
+
+ $self->send_name_notification_mail( $old_name, $new_name );
+ }
+ else {
+ $self->render( 'change_name', name => $old_name );
+ }
+}
+
sub password_form {
my ($self) = @_;
@@ -395,7 +1029,11 @@ sub change_password {
my $password2 = $self->req->param('newpw2');
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
- $self->render( 'change_password', invalid => 'csrf' );
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
@@ -420,34 +1058,14 @@ sub change_password {
return;
}
- my $pw_hash = hash_password($password);
- $self->set_user_password( $self->current_user->{id}, $pw_hash );
+ $self->users->set_password(
+ uid => $self->current_user->{id},
+ password => $password
+ );
$self->flash( success => 'password' );
$self->redirect_to('account');
-
- my $user = $self->current_user->{name};
- my $email = $self->current_user->{email};
- my $ip = $self->req->headers->header('X-Forwarded-For');
- my $ua = $self->req->headers->user_agent;
- my $date = DateTime->now( time_zone => 'Europe/Berlin' )
- ->strftime('%d.%m.%Y %H:%M:%S %z');
-
- # In case Mojolicious is not running behind a reverse proxy
- $ip
- //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
- my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
-
- my $body = "Hallo ${user},\n\n";
- $body
- .= "Das Passwort deines travelynx-Accounts wurde soeben geändert.\n\n";
- $body .= "Daten zur Änderung:\n";
- $body .= " * Datum: ${date}\n";
- $body .= " * Client: ${ip}\n";
- $body .= " * UserAgent: ${ua}\n\n\n";
- $body .= "Impressum: ${imprint_url}\n";
-
- $self->sendmail->custom( $email, 'travelynx: Passwort geändert', $body );
+ $self->send_password_notification_mail();
}
sub request_password_reset {
@@ -455,14 +1073,21 @@ sub request_password_reset {
if ( $self->param('action') and $self->param('action') eq 'initiate' ) {
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
- $self->render( 'recover_password', invalid => 'csrf' );
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
my $name = $self->param('user');
my $email = $self->param('email');
- my $uid = $self->get_uid_by_name_and_mail( $name, $email );
+ my $uid = $self->users->get_uid_by_name_and_mail(
+ name => $name,
+ email => $email
+ );
if ( not $uid ) {
$self->render( 'recover_password',
@@ -474,43 +1099,23 @@ sub request_password_reset {
my $db = $self->pg->db;
my $tx = $db->begin;
- my $error = $self->mark_for_password_reset( $db, $uid, $token );
+ my $error = $self->users->mark_for_password_reset(
+ db => $db,
+ uid => $uid,
+ token => $token
+ );
if ($error) {
$self->render( 'recover_password', invalid => $error );
return;
}
- my $ip = $self->req->headers->header('X-Forwarded-For');
- my $ua = $self->req->headers->user_agent;
- my $date = DateTime->now( time_zone => 'Europe/Berlin' )
- ->strftime('%d.%m.%Y %H:%M:%S %z');
-
- # In case Mojolicious is not running behind a reverse proxy
- $ip
- //= sprintf( '%s:%s', $self->tx->remote_address,
- $self->tx->remote_port );
- my $recover_url = $self->url_for('recover')->to_abs->scheme('https');
- my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
-
- my $body = "Hallo ${name},\n\n";
- $body .= "Unter ${recover_url}/${uid}/${token}\n";
- $body
- .= "kannst du ein neues Passwort für deinen travelynx-Account vergeben.\n\n";
- $body
- .= "Du erhältst diese Mail, da mit deinem Accountnamen und deiner Mail-Adresse\n";
- $body
- .= "ein Passwort-Reset angefordert wurde. Falls diese Anfrage nicht von dir\n";
- $body .= "ausging, kannst du sie ignorieren.\n\n";
- $body .= "Daten zur Anfrage:\n";
- $body .= " * Datum: ${date}\n";
- $body .= " * Client: ${ip}\n";
- $body .= " * UserAgent: ${ua}\n\n\n";
- $body .= "Impressum: ${imprint_url}\n";
-
- my $success
- = $self->sendmail->custom( $email, 'travelynx: Neues Passwort',
- $body );
+ my $success = $self->send_lostpassword_confirmation_mail(
+ email => $email,
+ name => $name,
+ uid => $uid,
+ token => $token
+ );
if ($success) {
$tx->commit;
@@ -529,10 +1134,20 @@ sub request_password_reset {
my $password2 = $self->param('newpw2');
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
- $self->render( 'set_password', invalid => 'csrf' );
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
- if ( not $self->verify_password_token( $id, $token ) ) {
+ if (
+ not $self->users->verify_password_token(
+ uid => $id,
+ token => $token
+ )
+ )
+ {
$self->render( 'recover_password', invalid => 'change token' );
return;
}
@@ -546,8 +1161,10 @@ sub request_password_reset {
return;
}
- my $pw_hash = hash_password($password);
- $self->set_user_password( $id, $pw_hash );
+ $self->users->set_password(
+ uid => $id,
+ password => $password
+ );
my $account = $self->get_user_data($id);
@@ -559,33 +1176,12 @@ sub request_password_reset {
$self->flash( success => 'password' );
$self->redirect_to('account');
- $self->remove_password_token( $id, $token );
-
- my $user = $account->{name};
- my $email = $account->{email};
- my $ip = $self->req->headers->header('X-Forwarded-For');
- my $ua = $self->req->headers->user_agent;
- my $date = DateTime->now( time_zone => 'Europe/Berlin' )
- ->strftime('%d.%m.%Y %H:%M:%S %z');
-
- # In case Mojolicious is not running behind a reverse proxy
- $ip
- //= sprintf( '%s:%s', $self->tx->remote_address,
- $self->tx->remote_port );
- my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
-
- my $body = "Hallo ${user},\n\n";
- $body
- .= "Das Passwort deines travelynx-Accounts wurde soeben über die";
- $body .= " 'Passwort vergessen'-Funktion geändert.\n\n";
- $body .= "Daten zur Änderung:\n";
- $body .= " * Datum: ${date}\n";
- $body .= " * Client: ${ip}\n";
- $body .= " * UserAgent: ${ua}\n\n\n";
- $body .= "Impressum: ${imprint_url}\n";
-
- $self->sendmail->custom( $email, 'travelynx: Passwort geändert',
- $body );
+ $self->users->remove_password_token(
+ uid => $id,
+ token => $token
+ );
+
+ $self->send_lostpassword_notification_mail($account);
}
else {
$self->render('recover_password');
@@ -603,7 +1199,13 @@ sub recover_password {
return;
}
- if ( $self->verify_password_token( $id, $token ) ) {
+ if (
+ $self->users->verify_password_token(
+ uid => $id,
+ token => $token
+ )
+ )
+ {
$self->render('set_password');
}
else {
@@ -616,7 +1218,22 @@ sub confirm_mail {
my $id = $self->current_user->{id};
my $token = $self->stash('token');
- if ( $self->change_mail_with_token( $id, $token ) ) {
+ # Some mail clients include the trailing ">" from the confirmation mail
+ # when opening/copying the confirmation link. A token will never contain
+ # this symbol, so remove it just in case.
+ $token =~ s{>}{};
+
+ # I did not yet find a mail client that also includes the trailing ",",
+ # but you never now...
+ $token =~ s{,}{};
+
+ if (
+ $self->users->change_mail_with_token(
+ uid => $id,
+ token => $token
+ )
+ )
+ {
$self->flash( success => 'mail' );
$self->redirect_to('account');
}
@@ -626,10 +1243,27 @@ sub confirm_mail {
}
sub account {
- my ($self) = @_;
+ my ($self) = @_;
+ my $uid = $self->current_user->{id};
+ my $rx_follow_requests = $self->users->has_follow_requests( uid => $uid );
+ my $tx_follow_requests = $self->users->has_follow_requests(
+ uid => $uid,
+ sent => 1
+ );
+ my $followers = $self->users->has_followers( uid => $uid );
+ my $following = $self->users->has_followees( uid => $uid );
+ my $blocked = $self->users->has_blocked_users( uid => $uid );
- $self->render('account');
- $self->mark_seen( $self->current_user->{id} );
+ $self->render(
+ 'account',
+ api_token => $self->users->get_api_token( uid => $uid ),
+ num_rx_follow_requests => $rx_follow_requests,
+ num_tx_follow_requests => $tx_follow_requests,
+ num_followers => $followers,
+ num_following => $following,
+ num_blocked => $blocked,
+ );
+ $self->users->mark_seen( uid => $uid );
}
sub json_export {
@@ -653,4 +1287,53 @@ sub json_export {
);
}
+sub webfinger {
+ my ($self) = @_;
+
+ my $resource = $self->param('resource');
+
+ if ( not $resource ) {
+ $self->render( 'not_found', status => 404 );
+ return;
+ }
+
+ my $root_url = $self->base_url_for('/')->to_abs->host;
+
+ if ( not $root_url
+ or not $resource
+ =~ m{ ^ acct: [@]? (?<name> [^@]+ ) [@] $root_url $ }x )
+ {
+ $self->render( 'not_found', status => 404 );
+ return;
+ }
+
+ my $name = $+{name};
+ my $user = $self->users->get_privacy_by( name => $name );
+
+ if ( not $user ) {
+ $self->render( 'not_found', status => 404 );
+ return;
+ }
+
+ my $profile_url
+ = $self->base_url_for("/p/${name}")->to_abs->scheme('https')->to_string;
+
+ $self->render(
+ text => JSON->new->encode(
+ {
+ subject => $resource,
+ aliases => [ $profile_url, ],
+ links => [
+ {
+ rel => 'http://webfinger.net/rel/profile-page',
+ type => 'text/html',
+ href => $profile_url,
+ },
+ ],
+ }
+ ),
+ format => 'json',
+ );
+}
+
1;
diff --git a/lib/Travelynx/Controller/Api.pm b/lib/Travelynx/Controller/Api.pm
index a442784..687243d 100755
--- a/lib/Travelynx/Controller/Api.pm
+++ b/lib/Travelynx/Controller/Api.pm
@@ -1,20 +1,54 @@
package Travelynx::Controller::Api;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';
-use Travel::Status::DE::IRIS::Stations;
+use DateTime;
+use List::Util;
+use Mojo::JSON qw(encode_json);
use UUID::Tiny qw(:std);
+# Internal Helpers
+
sub make_token {
return create_uuid_as_string(UUID_V4);
}
+sub sanitize {
+ my ( $type, $value ) = @_;
+ if ( not defined $value ) {
+ return undef;
+ }
+ if ( $type eq '' ) {
+ return '' . $value;
+ }
+ if ( $value =~ m{ ^ [0-9.e]+ $ }x ) {
+ return 0 + $value;
+ }
+ return 0;
+}
+
+# Contollers
+
sub documentation {
my ($self) = @_;
- $self->render('api_documentation');
+ if ( $self->is_user_authenticated ) {
+ my $uid = $self->current_user->{id};
+ $self->render(
+ 'api_documentation',
+ uid => $uid,
+ api_token => $self->users->get_api_token( uid => $uid ),
+ );
+ }
+ else {
+ $self->render('api_documentation');
+ }
}
-sub get_v0 {
+sub get_v1 {
my ($self) = @_;
my $api_action = $self->stash('user_action');
@@ -37,8 +71,21 @@ sub get_v0 {
}
my $uid = $+{id};
$api_token = $+{token};
- my $token = $self->get_api_token($uid);
- if ( $api_token ne $token->{$api_action} ) {
+
+ if ( $uid > 2147483647 ) {
+ $self->render(
+ json => {
+ error => 'Malformed token',
+ },
+ );
+ return;
+ }
+
+ my $token = $self->users->get_api_token( uid => $uid );
+ if ( not $api_token
+ or not $token->{$api_action}
+ or $api_token ne $token->{$api_action} )
+ {
$self->render(
json => {
error => 'Invalid token',
@@ -47,75 +94,347 @@ sub get_v0 {
return;
}
if ( $api_action eq 'status' ) {
- my $status = $self->get_user_status($uid);
+ $self->render( json => $self->get_user_status_json_v1( uid => $uid ) );
+ }
+ else {
+ $self->render(
+ json => {
+ error => 'not implemented',
+ },
+ );
+ }
+}
- my @station_descriptions;
- my $station_eva = undef;
- my $station_lon = undef;
- my $station_lat = undef;
+sub travel_v1 {
+ my ($self) = @_;
- if ( $status->{arr_ds100} // $status->{dep_ds100} ) {
- @station_descriptions
- = Travel::Status::DE::IRIS::Stations::get_station(
- $status->{arr_ds100} // $status->{dep_ds100} );
- }
- if ( @station_descriptions == 1 ) {
- ( undef, undef, $station_eva, $station_lon, $station_lat )
- = @{ $station_descriptions[0] };
- }
+ my $payload = $self->req->json;
+
+ if ( not $payload or ref($payload) ne 'HASH' ) {
$self->render(
json => {
- deprecated => \1,
- checked_in => (
- $status->{checked_in}
- or $status->{cancelled}
- ) ? \1 : \0,
- station => {
- ds100 => $status->{arr_ds100} // $status->{dep_ds100},
- name => $status->{arr_name} // $status->{dep_name},
- uic => $station_eva,
- longitude => $station_lon,
- latitude => $station_lat,
- },
- train => {
- type => $status->{train_type},
- line => $status->{train_line},
- no => $status->{train_no},
- },
- actionTime => $status->{timestamp}->epoch,
- scheduledTime => $status->{sched_arrival}->epoch
- || $status->{sched_departure}->epoch,
- realTime => $status->{real_arrival}->epoch
- || $status->{real_departure}->epoch,
+ success => \0,
+ deprecated => \0,
+ error => 'Malformed JSON',
},
);
+ return;
}
- else {
+
+ my $api_token = $payload->{token} // '';
+
+ if ( $api_token !~ qr{ ^ (?<id> \d+ ) - (?<token> .* ) $ }x ) {
$self->render(
json => {
- error => 'not implemented',
+ success => \0,
+ deprecated => \0,
+ error => 'Malformed token',
+ },
+ );
+ return;
+ }
+ my $uid = $+{id};
+ $api_token = $+{token};
+
+ if ( $uid > 2147483647 ) {
+ $self->render(
+ json => {
+ success => \0,
+ deprecated => \0,
+ error => 'Malformed token',
},
);
+ return;
+ }
+
+ my $token = $self->users->get_api_token( uid => $uid );
+ if ( not $token->{'travel'} or $api_token ne $token->{'travel'} ) {
+ $self->render(
+ json => {
+ success => \0,
+ deprecated => \0,
+ error => 'Invalid token',
+ },
+ );
+ return;
+ }
+
+ if ( not exists $payload->{action}
+ or $payload->{action} !~ m{^(checkin|checkout|undo)$} )
+ {
+ $self->render(
+ json => {
+ success => \0,
+ deprecated => \0,
+ error => 'Missing or invalid action',
+ status => $self->get_user_status_json_v1( uid => $uid )
+ },
+ );
+ return;
+ }
+
+ if ( $payload->{action} eq 'checkin' ) {
+ my $from_station = sanitize( q{}, $payload->{fromStation} );
+ my $to_station = sanitize( q{}, $payload->{toStation} );
+ my $train_id;
+ my $hafas = exists $payload->{train}{journeyID} ? 1 : 0;
+
+ if (
+ not(
+ $from_station
+ and ( ( $payload->{train}{type} and $payload->{train}{no} )
+ or $payload->{train}{id}
+ or $payload->{train}{journeyID} )
+ )
+ )
+ {
+ $self->render(
+ json => {
+ success => \0,
+ deprecated => \0,
+ error => 'Missing fromStation or train data',
+ status => $self->get_user_status_json_v1( uid => $uid )
+ },
+ );
+ return;
+ }
+
+ if ( not $hafas and not $self->stations->search($from_station) ) {
+ $self->render(
+ json => {
+ success => \0,
+ deprecated => \0,
+ error => 'Unknown fromStation',
+ status => $self->get_user_status_json_v1( uid => $uid )
+ },
+ );
+ return;
+ }
+
+ if ( $to_station
+ and not $hafas
+ and not $self->stations->search($to_station) )
+ {
+ $self->render(
+ json => {
+ success => \0,
+ deprecated => \0,
+ error => 'Unknown toStation',
+ status => $self->get_user_status_json_v1( uid => $uid )
+ },
+ );
+ return;
+ }
+
+ my $train_p;
+
+ if ( exists $payload->{train}{journeyID} ) {
+ $train_p = Mojo::Promise->resolve(
+ sanitize( q{}, $payload->{train}{journeyID} ) );
+ }
+ elsif ( exists $payload->{train}{id} ) {
+ $train_p
+ = Mojo::Promise->resolve( sanitize( 0, $payload->{train}{id} ) );
+ }
+ else {
+ my $train_type = sanitize( q{}, $payload->{train}{type} );
+ my $train_no = sanitize( q{}, $payload->{train}{no} );
+
+ $train_p = $self->iris->get_departures_p(
+ station => $from_station,
+ lookbehind => 140,
+ lookahead => 40
+ )->then(
+ sub {
+ my ($status) = @_;
+ if ( $status->{errstr} ) {
+ return Mojo::Promise->reject(
+ 'Error requesting departures from fromStation: '
+ . $status->{errstr} );
+ }
+ my ($train) = List::Util::first {
+ $_->type eq $train_type and $_->train_no eq $train_no
+ }
+ @{ $status->{results} };
+ if ( not defined $train ) {
+ return Mojo::Promise->reject(
+ 'Train not found at fromStation');
+ }
+ return Mojo::Promise->resolve( $train->train_id );
+ }
+ );
+ }
+
+ $self->render_later;
+
+ $train_p->then(
+ sub {
+ my ($train_id) = @_;
+ return $self->checkin_p(
+ station => $from_station,
+ train_id => $train_id,
+ uid => $uid
+ );
+ }
+ )->then(
+ sub {
+ my ($train) = @_;
+ if ( $payload->{comment} ) {
+ $self->in_transit->update_user_data(
+ uid => $uid,
+ user_data =>
+ { comment => sanitize( q{}, $payload->{comment} ) }
+ );
+ }
+ if ($to_station) {
+
+ # the user may not have provided the correct to_station, so
+ # request related stations for checkout.
+ return $self->checkout_p(
+ station => $to_station,
+ force => 0,
+ uid => $uid,
+ with_related => 1,
+ );
+ }
+ return Mojo::Promise->resolve;
+ }
+ )->then(
+ sub {
+ my ( undef, $error ) = @_;
+ if ($error) {
+ return Mojo::Promise->reject($error);
+ }
+ $self->render(
+ json => {
+ success => \1,
+ deprecated => \0,
+ status => $self->get_user_status_json_v1( uid => $uid )
+ }
+ );
+ }
+ )->catch(
+ sub {
+ my ($error) = @_;
+ $self->render(
+ json => {
+ success => \0,
+ deprecated => \0,
+ error => 'Checkin/Checkout error: ' . $error,
+ status => $self->get_user_status_json_v1( uid => $uid )
+ }
+ );
+ }
+ )->wait;
+ }
+ elsif ( $payload->{action} eq 'checkout' ) {
+ my $to_station = sanitize( q{}, $payload->{toStation} );
+
+ if ( not $to_station ) {
+ $self->render(
+ json => {
+ success => \0,
+ deprecated => \0,
+ error => 'Missing toStation',
+ status => $self->get_user_status_json_v1( uid => $uid )
+ },
+ );
+ return;
+ }
+
+ if ( $payload->{comment} ) {
+ $self->in_transit->update_user_data(
+ uid => $uid,
+ user_data => { comment => sanitize( q{}, $payload->{comment} ) }
+ );
+ }
+
+ $self->render_later;
+
+ # the user may not have provided the correct to_station, so
+ # request related stations for checkout.
+ $self->checkout_p(
+ station => $to_station,
+ force => $payload->{force} ? 1 : 0,
+ uid => $uid,
+ with_related => 1,
+ )->then(
+ sub {
+ my ( $train, $error ) = @_;
+ if ($error) {
+ return Mojo::Promise->reject($error);
+ }
+ $self->render(
+ json => {
+ success => \1,
+ deprecated => \0,
+ status => $self->get_user_status_json_v1( uid => $uid )
+ }
+ );
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->render(
+ json => {
+ success => \0,
+ deprecated => \0,
+ error => 'Checkout error: ' . $err,
+ status => $self->get_user_status_json_v1( uid => $uid )
+ }
+ );
+ }
+ )->wait;
+ }
+ elsif ( $payload->{action} eq 'undo' ) {
+ my $error = $self->undo( 'in_transit', $uid );
+ if ($error) {
+ $self->render(
+ json => {
+ success => \0,
+ deprecated => \0,
+ error => $error,
+ status => $self->get_user_status_json_v1( uid => $uid )
+ }
+ );
+ }
+ else {
+ $self->render(
+ json => {
+ success => \1,
+ deprecated => \0,
+ status => $self->get_user_status_json_v1( uid => $uid )
+ }
+ );
+ }
}
}
-sub get_v1 {
+sub import_v1 {
my ($self) = @_;
- my $api_action = $self->stash('user_action');
- my $api_token = $self->stash('token');
- if ( $api_action !~ qr{ ^ (?: status | history | action ) $ }x ) {
+ my $payload = $self->req->json;
+
+ if ( not $payload or ref($payload) ne 'HASH' ) {
$self->render(
json => {
- error => 'Invalid action',
+ success => \0,
+ deprecated => \0,
+ error => 'Malformed JSON',
},
);
return;
}
+
+ my $api_token = $payload->{token} // '';
+
if ( $api_token !~ qr{ ^ (?<id> \d+ ) - (?<token> .* ) $ }x ) {
$self->render(
json => {
- error => 'Malformed token',
+ success => \0,
+ deprecated => \0,
+ error => 'Malformed token',
},
);
return;
@@ -126,29 +445,162 @@ sub get_v1 {
if ( $uid > 2147483647 ) {
$self->render(
json => {
- error => 'Malformed token',
+ success => \0,
+ deprecated => \0,
+ error => 'Malformed token',
},
);
return;
}
- my $token = $self->get_api_token($uid);
- if ( $api_token ne $token->{$api_action} ) {
+ my $token = $self->users->get_api_token( uid => $uid );
+ if ( not $token->{'import'} or $api_token ne $token->{'import'} ) {
$self->render(
json => {
- error => 'Invalid token',
+ success => \0,
+ deprecated => \0,
+ error => 'Invalid token',
},
);
return;
}
- if ( $api_action eq 'status' ) {
- $self->render( json => $self->get_user_status_json_v1($uid) );
+
+ if ( not exists $payload->{fromStation}
+ or not exists $payload->{toStation} )
+ {
+ $self->render(
+ json => {
+ success => \0,
+ deprecated => \0,
+ error => 'missing fromStation or toStation',
+ },
+ );
+ return;
+ }
+
+ my %opt;
+
+ eval {
+
+ if ( not $payload->{fromStation}{name}
+ or not $payload->{toStation}{name} )
+ {
+ die("Missing fromStation/toStation name\n");
+ }
+ if ( not $payload->{train}{type} or not $payload->{train}{no} ) {
+ die("Missing train data\n");
+ }
+ if ( not $payload->{fromStation}{scheduledTime}
+ or not $payload->{toStation}{scheduledTime} )
+ {
+ die("Missing fromStation/toStation scheduledTime\n");
+ }
+
+ %opt = (
+ uid => $uid,
+ train_type => sanitize( q{}, $payload->{train}{type} ),
+ train_no => sanitize( q{}, $payload->{train}{no} ),
+ train_line => sanitize( q{}, $payload->{train}{line} ),
+ cancelled => $payload->{cancelled} ? 1 : 0,
+ dep_station => sanitize( q{}, $payload->{fromStation}{name} ),
+ arr_station => sanitize( q{}, $payload->{toStation}{name} ),
+ sched_departure =>
+ sanitize( 0, $payload->{fromStation}{scheduledTime} ),
+ rt_departure => sanitize(
+ 0,
+ $payload->{fromStation}{realTime}
+ // $payload->{fromStation}{scheduledTime}
+ ),
+ sched_arrival =>
+ sanitize( 0, $payload->{toStation}{scheduledTime} ),
+ rt_arrival => sanitize(
+ 0,
+ $payload->{toStation}{realTime}
+ // $payload->{toStation}{scheduledTime}
+ ),
+ comment => sanitize( q{}, $payload->{comment} ),
+ lax => $payload->{lax} ? 1 : 0,
+ );
+
+ if ( $payload->{intermediateStops}
+ and ref( $payload->{intermediateStops} ) eq 'ARRAY' )
+ {
+ $opt{route}
+ = [ map { sanitize( q{}, $_ ) }
+ @{ $payload->{intermediateStops} } ];
+ }
+
+ for my $key (qw(sched_departure rt_departure sched_arrival rt_arrival))
+ {
+ $opt{$key} = DateTime->from_epoch(
+ time_zone => 'Europe/Berlin',
+ epoch => $opt{$key}
+ );
+ }
+ };
+ if ($@) {
+ my ($first_line) = split( qr{\n}, $@ );
+ $self->render(
+ json => {
+ success => \0,
+ deprecated => \0,
+ error => $first_line
+ }
+ );
+ return;
+ }
+
+ my $db = $self->pg->db;
+ my $tx = $db->begin;
+
+ $opt{db} = $db;
+ my ( $journey_id, $error ) = $self->journeys->add(%opt);
+ my $journey;
+
+ if ( not $error ) {
+ $journey = $self->journeys->get_single(
+ uid => $uid,
+ db => $db,
+ journey_id => $journey_id,
+ verbose => 1
+ );
+ $error
+ = $self->journeys->sanity_check( $journey, $payload->{lax} ? 1 : 0 );
+ }
+
+ if ($error) {
+ $self->render(
+ json => {
+ success => \0,
+ deprecated => \0,
+ error => $error
+ }
+ );
+ }
+ elsif ( $payload->{dryRun} ) {
+ $self->render(
+ json => {
+ success => \1,
+ deprecated => \0,
+ id => $journey_id,
+ result => $journey
+ }
+ );
}
else {
+ $self->journey_stats_cache->invalidate(
+ ts => $opt{rt_departure},
+ db => $db,
+ uid => $uid
+ );
+ $tx->commit;
$self->render(
json => {
- error => 'not implemented',
- },
+ success => \1,
+ deprecated => \0,
+ id => $journey_id,
+ result => $journey
+ }
);
}
}
@@ -156,11 +608,15 @@ sub get_v1 {
sub set_token {
my ($self) = @_;
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
- $self->render( 'account', invalid => 'csrf' );
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
my $token = make_token();
- my $token_id = $self->app->token_type->{ $self->param('token') };
+ my $token_id = $self->users->get_token_id( $self->param('token') );
if ( not $token_id ) {
$self->redirect_to('account');
@@ -193,4 +649,21 @@ sub set_token {
$self->redirect_to('account');
}
+sub autocomplete {
+ my ($self) = @_;
+
+ $self->res->headers->cache_control('max-age=86400, immutable');
+
+ my $output
+ = "document.addEventListener('DOMContentLoaded',function(){M.Autocomplete.init(document.querySelectorAll('.autocomplete'),{\n";
+ $output .= 'minLength:3,limit:50,data:';
+ $output .= encode_json( $self->stations->get_for_autocomplete );
+ $output .= "\n});});\n";
+
+ $self->render(
+ format => 'js',
+ data => $output
+ );
+}
+
1;
diff --git a/lib/Travelynx/Controller/Passengerrights.pm b/lib/Travelynx/Controller/Passengerrights.pm
index ba299b1..d80f1ae 100644
--- a/lib/Travelynx/Controller/Passengerrights.pm
+++ b/lib/Travelynx/Controller/Passengerrights.pm
@@ -1,15 +1,22 @@
package Travelynx::Controller::Passengerrights;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';
use DateTime;
use CAM::PDF;
+# Internal Helpers
+
sub mark_if_missed_connection {
my ( $self, $journey, $next_journey ) = @_;
my $possible_delay
= ( $next_journey->{rt_departure}->epoch
- - $journey->{sched_arrival}->epoch ) / 60;
+ - $journey->{sched_arrival}->epoch )
+ / 60;
my $wait_time
= ( $next_journey->{rt_departure}->epoch - $journey->{rt_arrival}->epoch )
/ 60;
@@ -50,7 +57,8 @@ sub mark_if_missed_connection {
sub mark_substitute_connection {
my ( $self, $journey ) = @_;
- my @substitute_candidates = reverse $self->get_user_travels(
+ my @substitute_candidates = reverse $self->journeys->get(
+ uid => $self->current_user->{id},
after => $journey->{sched_departure}->clone->subtract( hours => 1 ),
before => $journey->{sched_departure}->clone->add( hours => 12 ),
with_datetime => 1,
@@ -81,13 +89,16 @@ sub mark_substitute_connection {
}
}
+# Controllers
+
sub list_candidates {
my ($self) = @_;
my $now = DateTime->now( time_zone => 'Europe/Berlin' );
my $range_start = $now->clone->subtract( months => 6 );
- my @journeys = $self->get_user_travels(
+ my @journeys = $self->journeys->get(
+ uid => $self->current_user->{id},
after => $range_start,
before => $now,
with_datetime => 1,
@@ -112,7 +123,8 @@ sub list_candidates {
@journeys = grep { $_->{delay} >= 60 or $_->{connection_missed} } @journeys;
- my @cancelled = $self->get_user_travels(
+ my @cancelled = $self->journeys->get(
+ uid => $self->current_user->{id},
after => $range_start,
before => $now,
cancelled => 1,
@@ -127,9 +139,9 @@ sub list_candidates {
$journey->{cancelled} = 1;
$self->mark_substitute_connection($journey);
- if ( not $journey->{has_substitute}
- or $journey->{to_substitute}->{rt_arr_ts} - $journey->{sched_arr_ts}
- >= 3600 )
+ if ( $journey->{has_substitute}
+ and $journey->{to_substitute}->{rt_arr_ts}
+ - $journey->{sched_arr_ts} >= 3600 )
{
push( @journeys, $journey );
}
@@ -163,7 +175,7 @@ sub generate {
return;
}
- my $journey = $self->get_journey(
+ my $journey = $self->journeys->get_single(
uid => $uid,
journey_id => $journey_id,
verbose => 1,
@@ -187,7 +199,7 @@ sub generate {
$self->mark_substitute_connection($journey);
}
elsif ( $journey->{delay} < 120 ) {
- my @connections = $self->get_user_travels(
+ my @connections = $self->journeys->get(
uid => $uid,
after => $journey->{rt_arrival},
before => $journey->{rt_arrival}->clone->add( hours => 2 ),
diff --git a/lib/Travelynx/Controller/Profile.pm b/lib/Travelynx/Controller/Profile.pm
new file mode 100755
index 0000000..fc2d38c
--- /dev/null
+++ b/lib/Travelynx/Controller/Profile.pm
@@ -0,0 +1,603 @@
+package Travelynx::Controller::Profile;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+use Mojo::Base 'Mojolicious::Controller';
+
+use DateTime;
+
+# Internal Helpers
+
+sub status_token_ok {
+ my ( $self, $status, $ts2_ext ) = @_;
+ my $token = $self->param('token') // q{};
+
+ my ( $eva, $ts, $ts2 ) = split( qr{-}, $token );
+ if ( not $ts ) {
+ return;
+ }
+
+ $ts2 //= $ts2_ext;
+
+ if ( $eva == $status->{dep_eva}
+ and $ts == $status->{timestamp}->epoch % 337
+ and $ts2 == $status->{sched_departure}->epoch )
+ {
+ return 1;
+ }
+ return;
+}
+
+sub journey_token_ok {
+ my ( $self, $journey, $ts2_ext ) = @_;
+ my $token = $self->param('token') // q{};
+
+ my ( $eva, $ts, $ts2 ) = split( qr{-}, $token );
+ if ( not $ts ) {
+ return;
+ }
+
+ $ts2 //= $ts2_ext;
+
+ if ( $eva == $journey->{from_eva}
+ and $ts == $journey->{checkin_ts} % 337
+ and $ts2 == $journey->{sched_dep_ts} )
+ {
+ return 1;
+ }
+ return;
+}
+
+# Controllers
+
+sub profile {
+ my ($self) = @_;
+
+ my $name = $self->stash('name');
+ my $user = $self->users->get_privacy_by( name => $name );
+
+ if ( not $user ) {
+ $self->render( 'not_found', status => 404 );
+ return;
+ }
+
+ my $profile = $self->users->get_profile( uid => $user->{id} );
+
+ my $my_user;
+ my $relation;
+ my $inverse_relation;
+ my $is_self;
+ if ( $self->is_user_authenticated ) {
+ $my_user = $self->current_user;
+ if ( $my_user->{id} == $user->{id} ) {
+ $is_self = 1;
+ $my_user = undef;
+ }
+ else {
+ $relation = $self->users->get_relation(
+ subject => $my_user->{id},
+ object => $user->{id}
+ );
+ $inverse_relation = $self->users->get_relation(
+ subject => $user->{id},
+ object => $my_user->{id}
+ );
+ }
+ }
+
+ my $status = $self->get_user_status( $user->{id} );
+ if ( $status->{checked_in} or $status->{arr_name} ) {
+ my $visibility = $status->{effective_visibility};
+ if (
+ not(
+ $visibility == 100
+ or ( $visibility >= 80 and $my_user )
+ or
+ ( $visibility >= 60 and $relation and $relation eq 'follows' )
+ or ( $visibility >= 60 and $is_self )
+ or ( $visibility >= 30 and $self->status_token_ok($status) )
+ )
+ )
+ {
+ $status->{checked_in} = 0;
+ $status->{arr_name} = undef;
+ }
+ }
+ if ( not $status->{checked_in}
+ and $status->{arr_name}
+ and not $user->{past_status} )
+ {
+ $status->{arr_name} = undef;
+ }
+
+ my @journeys;
+
+ if (
+ $user->{past_visibility_str} eq 'public'
+ or ( $user->{past_visibility_str} eq 'travelynx'
+ and ( $my_user or $is_self ) )
+ or ( $user->{past_visibility_str} eq 'followers'
+ and ( ( $relation and $relation eq 'follows' ) or $is_self ) )
+ )
+ {
+
+ my %opt = (
+ uid => $user->{id},
+ limit => 10,
+ with_datetime => 1
+ );
+
+ if ( not $user->{past_all} ) {
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ $opt{before} = DateTime->now( time_zone => 'Europe/Berlin' );
+ $opt{after} = $now->clone->subtract( weeks => 4 );
+ }
+
+ if ($is_self) {
+ $opt{min_visibility} = 'followers';
+ }
+ elsif ($my_user) {
+ if ( $relation and $relation eq 'follows' ) {
+ $opt{min_visibility} = 'followers';
+ }
+ else {
+ $opt{min_visibility} = 'travelynx';
+ }
+ }
+ else {
+ $opt{min_visibility} = 'public';
+ }
+
+ @journeys = $self->journeys->get(%opt);
+ }
+
+ $self->render(
+ 'profile',
+ title => "travelynx: $name",
+ name => $name,
+ uid => $user->{id},
+ privacy => $user,
+ bio => $profile->{bio}{html},
+ metadata => $profile->{metadata},
+ is_self => $is_self,
+ following => ( $relation and $relation eq 'follows' ) ? 1 : 0,
+ follow_requested => ( $relation and $relation eq 'requests_follow' )
+ ? 1
+ : 0,
+ can_follow => ( $my_user and $user->{accept_follows} and not $relation )
+ ? 1
+ : 0,
+ can_request_follow =>
+ ( $my_user and $user->{accept_follow_requests} and not $relation )
+ ? 1
+ : 0,
+ follows_me => ( $inverse_relation and $inverse_relation eq 'follows' )
+ ? 1
+ : 0,
+ follow_reqs_me =>
+ ( $inverse_relation and $inverse_relation eq 'requests_follow' ) ? 1
+ : 0,
+ journey => $status,
+ journeys => [@journeys],
+ );
+}
+
+sub journey_details {
+ my ($self) = @_;
+ my $name = $self->stash('name');
+ my $journey_id = $self->stash('id');
+ my $user = $self->users->get_privacy_by( name => $name );
+
+ $self->param( journey_id => $journey_id );
+
+ my $my_user;
+ my $relation;
+ my $inverse_relation;
+ my $is_self;
+ if ( $self->is_user_authenticated ) {
+ $my_user = $self->current_user;
+ if ( $my_user->{id} == $user->{id} ) {
+ $is_self = 1;
+ $my_user = undef;
+ }
+ else {
+ $relation = $self->users->get_relation(
+ subject => $my_user->{id},
+ object => $user->{id}
+ );
+ }
+ }
+
+ if ( not( $user and $journey_id and $journey_id =~ m{ ^ \d+ $ }x ) ) {
+ $self->render(
+ 'journey',
+ status => 404,
+ error => 'notfound',
+ journey => {}
+ );
+ return;
+ }
+
+ my $journey = $self->journeys->get_single(
+ uid => $user->{id},
+ journey_id => $journey_id,
+ verbose => 1,
+ with_datetime => 1,
+ with_polyline => 1,
+ with_visibility => 1,
+ );
+
+ if ( not $journey ) {
+ $self->render(
+ 'journey',
+ status => 404,
+ error => 'notfound',
+ journey => {}
+ );
+ return;
+ }
+
+ my $is_past;
+ if ( not $user->{past_all} ) {
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ if ( $journey->{sched_dep_ts} < $now->subtract( weeks => 4 )->epoch ) {
+ $is_past = 1;
+ }
+ }
+
+ my $visibility = $journey->{effective_visibility};
+
+ if (
+ not( ( $visibility == 100 and not $is_past )
+ or ( $visibility >= 80 and $my_user and not $is_past )
+ or ( $visibility >= 60 and $relation and $relation eq 'follows' )
+ or ( $visibility >= 60 and $is_self )
+ or ( $visibility >= 30 and $self->journey_token_ok($journey) ) )
+ )
+ {
+ $self->render(
+ 'journey',
+ status => 404,
+ error => 'notfound',
+ journey => {}
+ );
+ return;
+ }
+
+ my $title = sprintf( 'Fahrt von %s nach %s am %s',
+ $journey->{from_name}, $journey->{to_name},
+ $journey->{rt_arrival}->strftime('%d.%m.%Y') );
+ my $delay = 'pünktlich ';
+ if ( $journey->{rt_arrival} != $journey->{sched_arrival} ) {
+ $delay = sprintf(
+ 'mit %+d ',
+ (
+ $journey->{rt_arrival}->epoch
+ - $journey->{sched_arrival}->epoch
+ ) / 60
+ );
+ }
+ my $description = sprintf( 'Ankunft mit %s %s %s',
+ $journey->{type}, $journey->{no},
+ $journey->{rt_arrival}->strftime('um %H:%M') );
+ if ( $journey->{km_route} > 0.1 ) {
+ $description = sprintf( '%.0f km mit %s %s – Ankunft %sum %s',
+ $journey->{km_route}, $journey->{type}, $journey->{no},
+ $delay, $journey->{rt_arrival}->strftime('%H:%M') );
+ }
+ my %tw_data = (
+ card => 'summary',
+ site => '@derfnull',
+ image => $self->url_for('/static/icons/icon-512x512.png')
+ ->to_abs->scheme('https'),
+ title => $title,
+ description => $description,
+ );
+ my %og_data = (
+ type => 'article',
+ image => $tw_data{image},
+ url => $self->url_for->to_abs,
+ site_name => 'travelynx',
+ title => $title,
+ description => $description,
+ );
+
+ my $map_data = $self->journeys_to_map_data(
+ journeys => [$journey],
+ include_manual => 1,
+ );
+ if ( $journey->{user_data}{comment}
+ and not $user->{comments_visible} )
+ {
+ delete $journey->{user_data}{comment};
+ }
+ $self->render(
+ 'journey',
+ title => "travelynx: $title",
+ error => undef,
+ journey => $journey,
+ with_map => 1,
+ username => $name,
+ readonly => 1,
+ twitter => \%tw_data,
+ opengraph => \%og_data,
+ %{$map_data},
+ );
+}
+
+sub user_status {
+ my ($self) = @_;
+
+ my $name = $self->stash('name');
+ my $ts = $self->stash('ts') // 0;
+ my $user = $self->users->get_privacy_by( name => $name );
+
+ if ( not $user ) {
+ $self->respond_to(
+ json => {
+ json => { error => 'not found' },
+ status => 404,
+ },
+ any => {
+ template => 'not_found',
+ status => 404
+ }
+ );
+ return;
+ }
+
+ my $my_user;
+ my $relation;
+ my $inverse_relation;
+ my $is_self;
+ if ( $self->is_user_authenticated ) {
+ $my_user = $self->current_user;
+ if ( $my_user->{id} == $user->{id} ) {
+ $is_self = 1;
+ $my_user = undef;
+ }
+ else {
+ $relation = $self->users->get_relation(
+ subject => $my_user->{id},
+ object => $user->{id}
+ );
+ }
+ }
+
+ my $status = $self->get_user_status( $user->{id} );
+
+ if (
+ $ts
+ and ( not $status->{checked_in}
+ or $status->{sched_departure}->epoch != $ts )
+ )
+ {
+ for my $journey (
+ $self->journeys->get(
+ uid => $user->{id},
+ sched_dep_ts => $ts,
+ limit => 1,
+ with_visibility => 1,
+ )
+ )
+ {
+ my $visibility = $journey->{effective_visibility};
+ if (
+ $visibility == 100
+ or ( $visibility >= 80 and $my_user )
+ or
+ ( $visibility >= 60 and $relation and $relation eq 'follows' )
+ or ( $visibility >= 60 and $is_self )
+ or ( $visibility >= 30
+ and $self->journey_token_ok( $journey, $ts ) )
+ )
+ {
+ my $token = $self->param('token') // q{};
+ $self->redirect_to(
+ "/p/${name}/j/$journey->{id}?token=${token}-${ts}");
+ }
+ else {
+ $self->respond_to(
+ json => {
+ json => { error => 'not found' },
+ status => 404,
+ },
+ any => {
+ template => 'not_found',
+ status => 404
+ }
+ );
+ }
+ return;
+ }
+ $self->respond_to(
+ json => {
+ json => { error => 'not found' },
+ status => 404,
+ },
+ any => {
+ template => 'not_found',
+ status => 404
+ }
+ );
+ return;
+ }
+
+ my %tw_data = (
+ card => 'summary',
+ site => '@derfnull',
+ image => $self->url_for('/static/icons/icon-512x512.png')
+ ->to_abs->scheme('https'),
+ );
+ my %og_data = (
+ type => 'article',
+ image => $tw_data{image},
+ url => $self->url_for("/status/${name}")->to_abs->scheme('https'),
+ site_name => 'travelynx',
+ );
+
+ if ( $status->{checked_in} or $status->{arr_name} ) {
+ my $visibility = $status->{effective_visibility};
+ if (
+ not(
+ $visibility == 100
+ or ( $visibility >= 80 and $my_user )
+ or
+ ( $visibility >= 60 and $relation and $relation eq 'follows' )
+ or ( $visibility >= 60 and $is_self )
+ or
+ ( $visibility >= 30 and $self->status_token_ok( $status, $ts ) )
+ )
+ )
+ {
+ $status = {};
+ }
+ }
+ if ( not $status->{checked_in}
+ and $status->{arr_name}
+ and not $user->{past_status} )
+ {
+ $status = {};
+ }
+
+ if ( $status->{checked_in} ) {
+ $og_data{url} .= '/' . $status->{sched_departure}->epoch;
+ $og_data{title} = $tw_data{title} = "${name} ist unterwegs";
+ $og_data{description} = $tw_data{description} = sprintf(
+ '%s %s von %s nach %s',
+ $status->{train_type}, $status->{train_line} // $status->{train_no},
+ $status->{dep_name}, $status->{arr_name} // 'irgendwo'
+ );
+ if ( $status->{real_arrival}->epoch ) {
+ $tw_data{description} .= $status->{real_arrival}
+ ->strftime(' – Ankunft gegen %H:%M Uhr');
+ $og_data{description} .= $status->{real_arrival}
+ ->strftime(' – Ankunft gegen %H:%M Uhr');
+ }
+ }
+ else {
+ $og_data{title} = $tw_data{title}
+ = "${name} ist gerade nicht eingecheckt";
+ $og_data{description} = $tw_data{description} = q{};
+ }
+
+ $self->respond_to(
+ json => {
+ json => {
+ account => {
+ name => $name,
+ },
+ status => $self->get_user_status_json_v1(
+ status => $status,
+ privacy => $user,
+ public => 1
+ ),
+ version => $self->app->config->{version} // 'UNKNOWN',
+ },
+ },
+ any => {
+ template => 'user_status',
+ name => $name,
+ title => "travelynx: $tw_data{title}",
+ privacy => $user,
+ journey => $status,
+ twitter => \%tw_data,
+ opengraph => \%og_data,
+ version => $self->app->config->{version} // 'UNKNOWN',
+ },
+ );
+}
+
+sub status_card {
+ my ($self) = @_;
+
+ my $name = $self->stash('name');
+ $name =~ s{[.]html$}{};
+ my $user = $self->users->get_privacy_by( name => $name );
+
+ delete $self->stash->{layout};
+
+ if ( not $user ) {
+ $self->render( 'not_found', status => 404 );
+ return;
+ }
+
+ my $my_user;
+ my $relation;
+ my $inverse_relation;
+ my $is_self;
+ if ( $self->is_user_authenticated ) {
+ $my_user = $self->current_user;
+ if ( $my_user->{id} == $user->{id} ) {
+ $is_self = 1;
+ $my_user = undef;
+ }
+ else {
+ $relation = $self->users->get_relation(
+ subject => $my_user->{id},
+ object => $user->{id}
+ );
+ }
+ }
+
+ my $status = $self->get_user_status( $user->{id} );
+ my $visibility;
+ if ( $status->{checked_in} or $status->{arr_name} ) {
+ my $visibility = $status->{effective_visibility};
+ if (
+ not(
+ $visibility == 100
+ or ( $visibility >= 80 and $my_user )
+ or
+ ( $visibility >= 60 and $relation and $relation eq 'follows' )
+ or ( $visibility >= 60 and $is_self )
+ or ( $visibility >= 30 and $self->status_token_ok($status) )
+ )
+ )
+ {
+ $status->{checked_in} = 0;
+ $status->{arr_name} = undef;
+ }
+ }
+ if ( not $status->{checked_in}
+ and $status->{arr_name}
+ and not $user->{past_status} )
+ {
+ $status->{arr_name} = undef;
+ }
+
+ $self->render(
+ '_public_status_card',
+ name => $name,
+ privacy => $user,
+ journey => $status,
+ from_profile => $self->param('profile') ? 1 : 0,
+ );
+}
+
+sub checked_in {
+ my ($self) = @_;
+
+ my $uid = $self->current_user->{id};
+ my @journeys = $self->in_transit->get_timeline(
+ uid => $uid,
+ with_data => 1
+ );
+
+ if ( $self->param('ajax') ) {
+ delete $self->stash->{layout};
+ $self->render(
+ '_timeline-checked-in',
+ journeys => [@journeys],
+ );
+ }
+ else {
+ $self->render(
+ 'timeline-checked-in',
+ journeys => [@journeys],
+ );
+ }
+}
+
+1;
diff --git a/lib/Travelynx/Controller/Static.pm b/lib/Travelynx/Controller/Static.pm
index 2c35f1b..04c2d0f 100644
--- a/lib/Travelynx/Controller/Static.pm
+++ b/lib/Travelynx/Controller/Static.pm
@@ -1,26 +1,32 @@
package Travelynx::Controller::Static;
-use Mojo::Base 'Mojolicious::Controller';
-my $travelynx_version = qx{git describe --dirty} || 'experimental';
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+use Mojo::Base 'Mojolicious::Controller';
sub about {
my ($self) = @_;
- $self->render( 'about',
- version => $self->app->config->{version} // 'UNKNOWN' );
+ $self->render( 'about', title => 'Über travelynx' );
}
sub changelog {
my ($self) = @_;
- $self->render( 'changelog',
- version => $self->app->config->{version} // 'UNKNOWN' );
+ $self->render( 'changelog', title => 'travelynx: Changelog' );
}
sub imprint {
my ($self) = @_;
- $self->render('imprint');
+ $self->render( 'imprint', title => 'travelynx: Impressum' );
+}
+
+sub legend {
+ my ($self) = @_;
+
+ $self->render( 'legend', title => 'travelynx: Legende' );
}
sub offline {
diff --git a/lib/Travelynx/Controller/Traewelling.pm b/lib/Travelynx/Controller/Traewelling.pm
new file mode 100644
index 0000000..3cdeff8
--- /dev/null
+++ b/lib/Travelynx/Controller/Traewelling.pm
@@ -0,0 +1,154 @@
+package Travelynx::Controller::Traewelling;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+use Mojo::Base 'Mojolicious::Controller';
+use Mojo::Promise;
+
+sub oauth {
+ my ($self) = @_;
+
+ if ( $self->param('action')
+ and $self->validation->csrf_protect->has_error('csrf_token') )
+ {
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
+ return;
+ }
+
+ $self->render_later;
+
+ my $oa = $self->config->{traewelling}{oauth};
+
+ return $self->oauth2->get_token_p(
+ traewelling => {
+ redirect_uri =>
+ $self->base_url_for('/oauth/traewelling')->to_abs->scheme(
+ $self->app->mode eq 'development' ? 'http' : 'https'
+ )->to_string,
+ scope => 'read-statuses write-statuses'
+ }
+ )->then(
+ sub {
+ my ($provider) = @_;
+ if ( not defined $provider ) {
+
+ # OAuth2 plugin performed a redirect, no need to render
+ return;
+ }
+ if ( not $provider or not $provider->{access_token} ) {
+ $self->flash( new_traewelling => 1 );
+ $self->flash( login_error => 'no token received' );
+ $self->redirect_to('/account/traewelling');
+ return;
+ }
+ my $uid = $self->current_user->{id};
+ my $token = $provider->{access_token};
+ $self->traewelling->link(
+ uid => $self->current_user->{id},
+ token => $provider->{access_token},
+ refresh_token => $provider->{refresh_token},
+ expires_in => $provider->{expires_in},
+ );
+ return $self->traewelling_api->get_user_p( $uid, $token )->then(
+ sub {
+ $self->flash( new_traewelling => 1 );
+ $self->redirect_to('/account/traewelling');
+ }
+ );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ say "error $err";
+ $self->flash( new_traewelling => 1 );
+ $self->flash( login_error => $err );
+ $self->redirect_to('/account/traewelling');
+ return;
+ }
+ );
+}
+
+sub settings {
+ my ($self) = @_;
+
+ my $uid = $self->current_user->{id};
+
+ if ( $self->param('action')
+ and $self->validation->csrf_protect->has_error('csrf_token') )
+ {
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
+ return;
+ }
+
+ if ( $self->param('action') and $self->param('action') eq 'logout' ) {
+ $self->render_later;
+ my $traewelling = $self->traewelling->get( uid => $uid );
+ $self->traewelling_api->logout_p(
+ uid => $uid,
+ token => $traewelling->{token}
+ )->then(
+ sub {
+ $self->flash( success => 'traewelling' );
+ $self->redirect_to('account');
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->render(
+ 'traewelling',
+ traewelling => {},
+ new_traewelling => 1,
+ logout_error => $err,
+ );
+ }
+ )->wait;
+ return;
+ }
+ elsif ( $self->param('action') and $self->param('action') eq 'config' ) {
+ $self->traewelling->set_sync(
+ uid => $uid,
+ push_sync => $self->param('sync_source') eq 'travelynx' ? 1 : 0,
+ pull_sync => $self->param('sync_source') eq 'traewelling' ? 1 : 0,
+ toot => $self->param('toot') ? 1 : 0,
+ tweet => $self->param('tweet') ? 1 : 0,
+ );
+ $self->flash( success => 'traewelling' );
+ $self->redirect_to('account');
+ return;
+ }
+
+ my $traewelling = $self->traewelling->get( uid => $uid );
+
+ if ( $traewelling->{push_sync} ) {
+ $self->param( sync_source => 'travelynx' );
+ }
+ elsif ( $traewelling->{pull_sync} ) {
+ $self->param( sync_source => 'traewelling' );
+ }
+ else {
+ $self->param( sync_source => 'none' );
+ }
+ if ( $traewelling->{data}{toot} ) {
+ $self->param( toot => 1 );
+ }
+ if ( $traewelling->{data}{tweet} ) {
+ $self->param( tweet => 1 );
+ }
+
+ $self->stash( title => 'travelynx × träwelling' );
+ $self->render(
+ 'traewelling',
+ traewelling => $traewelling,
+ );
+}
+
+1;
diff --git a/lib/Travelynx/Controller/Traveling.pm b/lib/Travelynx/Controller/Traveling.pm
index 56de0fb..89385e1 100755
--- a/lib/Travelynx/Controller/Traveling.pm
+++ b/lib/Travelynx/Controller/Traveling.pm
@@ -1,108 +1,465 @@
package Travelynx::Controller::Traveling;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';
use DateTime;
use DateTime::Format::Strptime;
-use List::Util qw(uniq);
-use List::UtilsBy qw(uniq_by);
+use List::Util qw(uniq min max);
+use List::UtilsBy qw(max_by uniq_by);
use List::MoreUtils qw(first_index);
+use Mojo::Promise;
+use Text::CSV;
use Travel::Status::DE::IRIS::Stations;
-sub homepage {
- my ($self) = @_;
- if ( $self->is_user_authenticated ) {
- $self->render(
- 'landingpage',
- version => $self->app->config->{version} // 'UNKNOWN',
- with_autocomplete => 1,
- with_geolocation => 1
- );
- $self->mark_seen( $self->current_user->{id} );
- }
- else {
- $self->render(
- 'landingpage',
- version => $self->app->config->{version} // 'UNKNOWN',
- intro => 1
- );
+# Internal Helpers
+
+sub has_str_in_list {
+ my ( $str, @strs ) = @_;
+ if ( List::Util::any { $str eq $_ } @strs ) {
+ return 1;
}
+ return;
}
-sub user_status {
- my ($self) = @_;
+sub get_connecting_trains_p {
+ my ( $self, %opt ) = @_;
- my $name = $self->stash('name');
- my $ts = $self->stash('ts');
- my $user = $self->get_privacy_by_name($name);
+ my $uid = $opt{uid} //= $self->current_user->{id};
+ my $use_history = $self->users->use_history( uid => $uid );
- if ( $user and ( $user->{public_level} & 0x02 ) ) {
- my $status = $self->get_user_status( $user->{id} );
+ my ( $eva, $exclude_via, $exclude_train_id, $exclude_before );
+ my $now = $self->now->epoch;
+ my ( $stationinfo, $arr_epoch, $arr_platform, $arr_countdown );
- my %tw_data = (
- card => 'summary',
- site => '@derfnull',
- image => $self->url_for('/static/icons/icon-512x512.png')
- ->to_abs->scheme('https'),
- );
+ my $promise = Mojo::Promise->new;
- if (
- $ts
- and ( not $status->{checked_in}
- or $status->{sched_departure}->epoch != $ts )
- )
- {
- $tw_data{title} = "Bahnfahrt beendet";
- $tw_data{description} = "${name} hat das Ziel erreicht";
- }
- elsif ( $status->{checked_in} ) {
- $tw_data{title} = "${name} ist unterwegs";
- $tw_data{description} = sprintf(
- '%s %s von %s nach %s',
- $status->{train_type},
- $status->{train_line} // $status->{train_no},
- $status->{dep_name},
- $status->{arr_name} // 'irgendwo'
- );
- if ( $status->{real_arrival}->epoch ) {
- $tw_data{description} .= $status->{real_arrival}
- ->strftime(' – Ankunft gegen %H:%M Uhr');
- }
+ if ( $opt{eva} ) {
+ if ( $use_history & 0x01 ) {
+ $eva = $opt{eva};
}
- else {
- $tw_data{title} = "${name} ist gerade nicht eingecheckt";
- $tw_data{description} = "Letztes Fahrtziel: $status->{arr_name}";
+ elsif ( $opt{destination_name} ) {
+ $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};
+ $arr_platform = $status->{arr_platform};
+ $stationinfo = $status->{extra_data}{stationinfo_arr};
+ if ( $status->{real_arrival} ) {
+ $exclude_before = $arr_epoch = $status->{real_arrival}->epoch;
+ $arr_countdown = $status->{arrival_countdown};
+ }
}
+ }
- $self->render(
- 'user_status',
- name => $name,
- journey => $status,
- twitter => \%tw_data,
- );
+ $exclude_before //= $now - 300;
+
+ if ( not $eva ) {
+ return $promise->reject;
+ }
+
+ my ( $dest_ids, $destinations )
+ = $self->journeys->get_connection_targets(%opt);
+
+ my @destinations = uniq_by { $_->{name} } @{$destinations};
+
+ if ($exclude_via) {
+ @destinations = grep { $_->{name} ne $exclude_via } @destinations;
+ }
+
+ if ( not @destinations ) {
+ return $promise->reject;
+ }
+
+ my $iris_eva = $eva;
+ if ( $eva < 8000000 ) {
+ $iris_eva = ( List::Util::first { $_ >= 8000000 } @{$dest_ids} )
+ // $eva;
+ }
+
+ my $can_check_in = not $arr_epoch || ( $arr_countdown // 1 ) < 0;
+ my $lookahead
+ = $can_check_in ? 40 : ( ( ${arr_countdown} // 0 ) / 60 + 40 );
+
+ my $iris_promise = Mojo::Promise->new;
+ my %via_count = map { $_->{name} => 0 } @destinations;
+
+ if ( $iris_eva >= 8000000
+ and List::Util::any { $_->{eva} >= 8000000 } @destinations )
+ {
+ $self->iris->get_departures_p(
+ station => $iris_eva,
+ lookbehind => 10,
+ lookahead => $lookahead,
+ with_related => 1
+ )->then(
+ sub {
+ my ($stationboard) = @_;
+ if ( $stationboard->{errstr} ) {
+ $iris_promise->resolve( [] );
+ return;
+ }
+
+ @{ $stationboard->{results} } = map { $_->[0] }
+ sort { $a->[1] <=> $b->[1] }
+ map { [ $_, $_->departure ? $_->departure->epoch : 0 ] }
+ @{ $stationboard->{results} };
+ my @results;
+ my @cancellations;
+ my $excluded_train;
+ for my $train ( @{ $stationboard->{results} } ) {
+ if ( not $train->departure ) {
+ next;
+ }
+ if ( $exclude_before
+ and $train->departure
+ and $train->departure->epoch < $exclude_before )
+ {
+ next;
+ }
+ if ( $exclude_train_id
+ and $train->train_id eq $exclude_train_id )
+ {
+ $excluded_train = $train;
+ 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 ( has_str_in_list( $dest->{name}, @via ) ) {
+ push( @cancellations, [ $train, $dest ] );
+ next;
+ }
+ }
+ }
+ else {
+ my @via = ( $train->route_post, $train->route_end );
+ for my $dest (@destinations) {
+ if ( $via_count{ $dest->{name} } < 2
+ and has_str_in_list( $dest->{name}, @via ) )
+ {
+ push( @results, [ $train, $dest ] );
+
+ # Show all past and up to two future departures per destination
+ if ( not $train->departure
+ or $train->departure->epoch >= $now )
+ {
+ $via_count{ $dest->{name} }++;
+ }
+ 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;
+
+ # remove trains whose route matches the excluded one's
+ if ($excluded_train) {
+ my $route_pre
+ = join( '|', reverse $excluded_train->route_pre );
+ @results
+ = grep { join( '|', $_->[0]->route_post ) ne $route_pre }
+ @results;
+ my $route_post = join( '|', $excluded_train->route_post );
+ @results
+ = grep { join( '|', $_->[0]->route_post ) ne $route_post }
+ @results;
+ }
+
+ # add message IDs and 'transfer short' hints
+ for my $result (@results) {
+ my $train = $result->[0];
+ my @message_ids
+ = List::Util::uniq map { $_->[1] } $train->raw_messages;
+ $train->{message_id} = { map { $_ => 1 } @message_ids };
+ my $interchange_duration;
+ if ( exists $stationinfo->{i} ) {
+ if ( defined $arr_platform
+ and defined $train->platform )
+ {
+ $interchange_duration
+ = $stationinfo->{i}{$arr_platform}
+ { $train->platform };
+ }
+ $interchange_duration //= $stationinfo->{i}{"*"};
+ }
+ if ( defined $interchange_duration ) {
+ my $interchange_time
+ = ( $train->departure->epoch - $arr_epoch ) / 60;
+ if ( $interchange_time < $interchange_duration ) {
+ $train->{interchange_text} = 'Anschluss knapp';
+ $train->{interchange_icon} = 'directions_run';
+ }
+ elsif ( $interchange_time == $interchange_duration ) {
+ $train->{interchange_text}
+ = 'Anschluss könnte knapp werden';
+ $train->{interchange_icon} = 'directions_run';
+ }
+ }
+ }
+
+ $iris_promise->resolve( [ @results, @cancellations ] );
+ return;
+ }
+ )->catch(
+ sub {
+ $iris_promise->resolve( [] );
+ return;
+ }
+ )->wait;
}
else {
- $self->render('not_found');
+ $iris_promise->resolve( [] );
}
-}
-sub public_status_card {
- my ($self) = @_;
+ my $hafas_promise = Mojo::Promise->new;
+ $self->hafas->get_departures_p(
+ eva => $eva,
+ lookbehind => 10,
+ lookahead => $lookahead
+ )->then(
+ sub {
+ my ($status) = @_;
+ $hafas_promise->resolve( [ $status->results ] );
+ return;
+ }
+ )->catch(
+ sub {
+ # HAFAS data is optional.
+ # Errors are logged by get_json_p and can be silently ignored here.
+ $hafas_promise->resolve( [] );
+ return;
+ }
+ )->wait;
+
+ Mojo::Promise->all( $iris_promise, $hafas_promise )->then(
+ sub {
+ my ( $iris, $hafas ) = @_;
+ my @iris_trains = @{ $iris->[0] };
+ my @all_hafas_trains = @{ $hafas->[0] };
+ my @hafas_trains;
+
+ # We've already got a list of connecting trains; this function
+ # only adds further information to them. We ignore errors, as
+ # partial data is better than no data.
+ eval {
+ for my $iris_train (@iris_trains) {
+ if ( $iris_train->[0]->departure_is_cancelled ) {
+ for my $hafas_train (@all_hafas_trains) {
+ if ( $hafas_train->number
+ and $hafas_train->number
+ == $iris_train->[0]->train_no )
+ {
+ $hafas_train->{iris_seen} = 1;
+ next;
+ }
+ }
+ next;
+ }
+ for my $hafas_train (@all_hafas_trains) {
+ if ( $hafas_train->number
+ and $hafas_train->number
+ == $iris_train->[0]->train_no )
+ {
+ $hafas_train->{iris_seen} = 1;
+ if ( $hafas_train->load
+ and $hafas_train->load->{SECOND} )
+ {
+ $iris_train->[3] = $hafas_train->load;
+ }
+ for my $stop ( $hafas_train->route ) {
+ if ( $stop->loc->name
+ and $stop->loc->name eq
+ $iris_train->[1]->{name}
+ and $stop->arr )
+ {
+ $iris_train->[2] = $stop->arr;
+ if ( $iris_train->[0]->departure_delay
+ and not $stop->arr_delay )
+ {
+ $iris_train->[2]
+ ->add( minutes => $iris_train->[0]
+ ->departure_delay );
+ }
+ last;
+ }
+ }
+ last;
+ }
+ }
+ }
+ for my $hafas_train (@all_hafas_trains) {
+ if ( $hafas_train->{iris_seen} ) {
+ next;
+ }
+ if ( $hafas_train->station_eva >= 8000000 ) {
+
+ # better safe than sorry, for now
+ next;
+ }
+ for my $stop ( $hafas_train->route ) {
+ for my $dest (@destinations) {
+ if ( $stop->loc->name
+ and $stop->loc->name eq $dest->{name}
+ and $via_count{ $dest->{name} } < 2
+ and $hafas_train->datetime )
+ {
+ my $departure = $hafas_train->datetime;
+ my $arrival = $stop->arr;
+ my $delay = $hafas_train->delay;
+ if ( $delay
+ and $stop->arr == $stop->sched_arr )
+ {
+ $arrival->add( minutes => $delay );
+ }
+ if ( $departure->epoch >= $exclude_before ) {
+ $via_count{ $dest->{name} }++;
+ push( @hafas_trains,
+ [ $hafas_train, $dest, $arrival ] );
+ }
+ }
+ }
+ }
+ }
+ };
+ if ($@) {
+ $self->app->log->error(
+ "get_connecting_trains_p($uid): IRIS/HAFAS merge failed: $@"
+ );
+ }
+
+ $promise->resolve( \@iris_trains, \@hafas_trains );
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
- my $name = $self->stash('name');
- my $user = $self->get_privacy_by_name($name);
+ return $promise;
+}
- delete $self->stash->{layout};
+sub compute_effective_visibility {
+ my ( $self, $default_visibility, $journey_visibility ) = @_;
+ if ( $journey_visibility eq 'default' ) {
+ return $default_visibility;
+ }
+ return $journey_visibility;
+}
- if ( $user and ( $user->{public_level} & 0x02 ) ) {
- my $status = $self->get_user_status( $user->{id} );
+# Controllers
+
+sub homepage {
+ my ($self) = @_;
+ if ( $self->is_user_authenticated ) {
+ my $uid = $self->current_user->{id};
+ my $status = $self->get_user_status;
+ my @timeline = $self->in_transit->get_timeline(
+ uid => $uid,
+ short => 1
+ );
+ $self->stash( timeline => [@timeline] );
+ my @recent_targets;
+ if ( $status->{checked_in} ) {
+ my $journey_visibility
+ = $self->compute_effective_visibility(
+ $self->current_user->{default_visibility_str},
+ $status->{visibility_str} );
+ if ( defined $status->{arrival_countdown}
+ and $status->{arrival_countdown} < ( 40 * 60 ) )
+ {
+ $self->render_later;
+ $self->get_connecting_trains_p->then(
+ sub {
+ my ( $connections_iris, $connections_hafas ) = @_;
+ $self->render(
+ 'landingpage',
+ user_status => $status,
+ journey_visibility => $journey_visibility,
+ connections_iris => $connections_iris,
+ connections_hafas => $connections_hafas,
+ );
+ $self->users->mark_seen( uid => $uid );
+ }
+ )->catch(
+ sub {
+ $self->render(
+ 'landingpage',
+ user_status => $status,
+ journey_visibility => $journey_visibility,
+ );
+ $self->users->mark_seen( uid => $uid );
+ }
+ )->wait;
+ return;
+ }
+ else {
+ $self->render(
+ 'landingpage',
+ user_status => $status,
+ journey_visibility => $journey_visibility,
+ );
+ $self->users->mark_seen( uid => $uid );
+ return;
+ }
+ }
+ else {
+ @recent_targets = uniq_by { $_->{eva} }
+ $self->journeys->get_latest_checkout_stations( uid => $uid );
+ }
$self->render(
- '_public_status_card',
- name => $name,
- journey => $status
+ 'landingpage',
+ user_status => $status,
+ recent_targets => \@recent_targets,
+ with_autocomplete => 1,
+ with_geolocation => 1
);
+ $self->users->mark_seen( uid => $uid );
}
else {
- $self->render('not_found');
+ $self->render( 'landingpage', intro => 1 );
}
}
@@ -112,10 +469,93 @@ sub status_card {
delete $self->stash->{layout};
+ my @timeline = $self->in_transit->get_timeline(
+ uid => $self->current_user->{id},
+ short => 1
+ );
+ $self->stash( timeline => [@timeline] );
+
if ( $status->{checked_in} ) {
- $self->render( '_checked_in', journey => $status );
+ my $journey_visibility
+ = $self->compute_effective_visibility(
+ $self->current_user->{default_visibility_str},
+ $status->{visibility_str} );
+ if ( defined $status->{arrival_countdown}
+ and $status->{arrival_countdown} < ( 40 * 60 ) )
+ {
+ $self->render_later;
+ $self->get_connecting_trains_p->then(
+ sub {
+ my ( $connections_iris, $connections_hafas ) = @_;
+ $self->render(
+ '_checked_in',
+ journey => $status,
+ journey_visibility => $journey_visibility,
+ connections_iris => $connections_iris,
+ connections_hafas => $connections_hafas,
+ );
+ }
+ )->catch(
+ sub {
+ $self->render(
+ '_checked_in',
+ journey => $status,
+ journey_visibility => $journey_visibility,
+ );
+ }
+ )->wait;
+ return;
+ }
+ $self->render(
+ '_checked_in',
+ journey => $status,
+ journey_visibility => $journey_visibility,
+ );
+ }
+ elsif ( $status->{cancellation} ) {
+ $self->render_later;
+ $self->get_connecting_trains_p(
+ eva => $status->{cancellation}{dep_eva},
+ destination_name => $status->{cancellation}{arr_name}
+ )->then(
+ sub {
+ my ($connecting_trains) = @_;
+ $self->render(
+ '_cancelled_departure',
+ journey => $status->{cancellation},
+ connections_iris => $connecting_trains
+ );
+ }
+ )->catch(
+ sub {
+ $self->render( '_cancelled_departure',
+ journey => $status->{cancellation} );
+ }
+ )->wait;
+ return;
}
else {
+ my @connecting_trains;
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ if ( $now->epoch - $status->{timestamp}->epoch < ( 30 * 60 ) ) {
+ $self->render_later;
+ $self->get_connecting_trains_p->then(
+ sub {
+ my ( $connections_iris, $connections_hafas ) = @_;
+ $self->render(
+ '_checked_out',
+ journey => $status,
+ connections_iris => $connections_iris,
+ connections_hafas => $connections_hafas,
+ );
+ }
+ )->catch(
+ sub {
+ $self->render( '_checked_out', journey => $status );
+ }
+ )->wait;
+ return;
+ }
$self->render( '_checked_out', journey => $status );
}
}
@@ -128,38 +568,71 @@ sub geolocation {
if ( not $lon or not $lat ) {
$self->render( json => { error => 'Invalid lon/lat received' } );
+ return;
}
- else {
- my @candidates = map {
- {
- ds100 => $_->[0][0],
- name => $_->[0][1],
- eva => $_->[0][2],
- lon => $_->[0][3],
- lat => $_->[0][4],
- distance => $_->[1],
- }
- } Travel::Status::DE::IRIS::Stations::get_station_by_location( $lon,
- $lat, 10 );
- @candidates = uniq_by { $_->{name} } @candidates;
- if ( @candidates > 5 ) {
+ $self->render_later;
+
+ my @iris = map {
+ {
+ ds100 => $_->[0][0],
+ name => $_->[0][1],
+ eva => $_->[0][2],
+ lon => $_->[0][3],
+ lat => $_->[0][4],
+ distance => $_->[1],
+ hafas => 0,
+ }
+ } Travel::Status::DE::IRIS::Stations::get_station_by_location( $lon,
+ $lat, 10 );
+ @iris = uniq_by { $_->{name} } @iris;
+ if ( @iris > 5 ) {
+ @iris = @iris[ 0 .. 4 ];
+ }
+
+ Travel::Status::DE::HAFAS->new_p(
+ promise => 'Mojo::Promise',
+ user_agent => $self->ua,
+ geoSearch => {
+ lat => $lat,
+ lon => $lon
+ }
+ )->then(
+ sub {
+ my ($hafas) = @_;
+ my @hafas = map {
+ {
+ name => $_->name,
+ eva => $_->eva,
+ distance => $_->distance_m / 1000,
+ hafas => 1
+ }
+ } $hafas->results;
+ if ( @hafas > 10 ) {
+ @hafas = @hafas[ 0 .. 9 ];
+ }
+ my @results = map { $_->[0] }
+ sort { $a->[1] <=> $b->[1] }
+ map { [ $_, $_->{distance} ] } ( @iris, @hafas );
$self->render(
json => {
- candidates => [ @candidates[ 0 .. 4 ] ],
+ candidates => [@results],
}
);
}
- else {
+ )->catch(
+ sub {
+ my ($err) = @_;
$self->render(
json => {
- candidates => [@candidates],
+ candidates => [@iris],
+ warning => $err,
}
);
}
- }
+ )->wait;
}
-sub log_action {
+sub travel_action {
my ($self) = @_;
my $params = $self->req->json;
@@ -194,61 +667,124 @@ sub log_action {
if ( $params->{action} eq 'checkin' ) {
- my ( $train, $error )
- = $self->checkin( $params->{station}, $params->{train} );
- my $destination = $params->{dest};
+ my $status = $self->get_user_status;
+ my $promise;
- if ($error) {
- $self->render(
- json => {
- success => 0,
- error => $error,
- },
- );
- }
- elsif ( not $destination ) {
- $self->render(
- json => {
- success => 1,
- redirect_to => '/',
- },
- );
+ if ( $status->{checked_in}
+ and $status->{arr_eva}
+ and $status->{arrival_countdown} <= 0 )
+ {
+ $promise = $self->checkout_p( station => $status->{arr_eva} );
}
else {
- # Silently ignore errors -- if they are permanent, the user will see
- # them when selecting the destination manually.
- my ( $still_checked_in, undef )
- = $self->checkout( $destination, 0 );
- my $station_link = '/s/' . $destination;
- $self->render(
- json => {
- success => 1,
- redirect_to => $still_checked_in ? '/' : $station_link,
- },
- );
+ $promise = Mojo::Promise->resolve;
}
+
+ $self->render_later;
+ $promise->then(
+ sub {
+ return $self->checkin_p(
+ station => $params->{station},
+ train_id => $params->{train}
+ );
+ }
+ )->then(
+ sub {
+ my $destination = $params->{dest};
+ if ( not $destination ) {
+ $self->render(
+ json => {
+ success => 1,
+ redirect_to => '/',
+ },
+ );
+ return;
+ }
+
+ # Silently ignore errors -- if they are permanent, the user will see
+ # them when selecting the destination manually.
+ return $self->checkout_p(
+ station => $destination,
+ force => 0
+ );
+ }
+ )->then(
+ sub {
+ my ( $still_checked_in, undef ) = @_;
+ if ( my $destination = $params->{dest} ) {
+ my $station_link = '/s/' . $destination;
+ if ( $status->{train_id} =~ m{[|]} ) {
+ $station_link .= '?hafas=1';
+ }
+ $self->render(
+ json => {
+ success => 1,
+ redirect_to => $still_checked_in
+ ? '/'
+ : $station_link,
+ },
+ );
+ }
+ return;
+ }
+ )->catch(
+ sub {
+ my ($error) = @_;
+ $self->render(
+ json => {
+ success => 0,
+ error => $error,
+ },
+ );
+ }
+ )->wait;
}
elsif ( $params->{action} eq 'checkout' ) {
- my ( $still_checked_in, $error )
- = $self->checkout( $params->{station}, $params->{force} );
- my $station_link = '/s/' . $params->{station};
+ $self->render_later;
+ my $status = $self->get_user_status;
+ $self->checkout_p(
+ station => $params->{station},
+ force => $params->{force}
+ )->then(
+ sub {
+ my ( $still_checked_in, $error ) = @_;
+ my $station_link = '/s/' . $params->{station};
+ if ( $status->{train_id} =~ m{[|]} ) {
+ $station_link .= '?hafas=1';
+ }
- if ($error) {
- $self->render(
- json => {
- success => 0,
- error => $error,
- },
- );
- }
- else {
- $self->render(
- json => {
- success => 1,
- redirect_to => $still_checked_in ? '/' : $station_link,
- },
- );
- }
+ if ($error) {
+ $self->render(
+ json => {
+ success => 0,
+ error => $error,
+ },
+ );
+ }
+ else {
+ $self->render(
+ json => {
+ success => 1,
+ redirect_to => $still_checked_in
+ ? '/'
+ : $station_link,
+ },
+ );
+ }
+ return;
+ }
+ )->catch(
+ sub {
+ my ($error) = @_;
+ $self->render(
+ json => {
+ success => 0,
+ error => $error,
+ },
+ );
+ return;
+ }
+ )->wait;
}
elsif ( $params->{action} eq 'undo' ) {
my $status = $self->get_user_status;
@@ -264,7 +800,12 @@ sub log_action {
else {
my $redir = '/';
if ( $status->{checked_in} or $status->{cancelled} ) {
- $redir = '/s/' . $status->{dep_ds100};
+ if ( $status->{train_id} =~ m{[|]} ) {
+ $redir = '/s/' . $status->{dep_eva} . '?hafas=1';
+ }
+ else {
+ $redir = '/s/' . $status->{dep_ds100};
+ }
}
$self->render(
json => {
@@ -275,50 +816,77 @@ sub log_action {
}
}
elsif ( $params->{action} eq 'cancelled_from' ) {
- my ( undef, $error )
- = $self->checkin( $params->{station}, $params->{train} );
-
- if ($error) {
- $self->render(
- json => {
- success => 0,
- error => $error,
- },
- );
- }
- else {
- $self->render(
- json => {
- success => 1,
- redirect_to => '/',
- },
- );
- }
+ $self->render_later;
+ $self->checkin_p(
+ station => $params->{station},
+ train_id => $params->{train}
+ )->then(
+ sub {
+ $self->render(
+ json => {
+ success => 1,
+ redirect_to => '/',
+ },
+ );
+ }
+ )->catch(
+ sub {
+ my ($error) = @_;
+ $self->render(
+ json => {
+ success => 0,
+ error => $error,
+ },
+ );
+ }
+ )->wait;
}
elsif ( $params->{action} eq 'cancelled_to' ) {
- my ( undef, $error )
- = $self->checkout( $params->{station}, 1 );
-
- if ($error) {
- $self->render(
- json => {
- success => 0,
- error => $error,
- },
- );
- }
- else {
- $self->render(
- json => {
- success => 1,
- redirect_to => '/',
- },
- );
- }
+ $self->render_later;
+ $self->checkout_p(
+ station => $params->{station},
+ force => 1
+ )->then(
+ sub {
+ my ( undef, $error ) = @_;
+ if ($error) {
+ $self->render(
+ json => {
+ success => 0,
+ error => $error,
+ },
+ );
+ }
+ else {
+ $self->render(
+ json => {
+ success => 1,
+ redirect_to => '/',
+ },
+ );
+ }
+ return;
+ }
+ )->catch(
+ sub {
+ my ($error) = @_;
+ $self->render(
+ json => {
+ success => 0,
+ error => $error,
+ },
+ );
+ return;
+ }
+ )->wait;
}
elsif ( $params->{action} eq 'delete' ) {
- my $error = $self->delete_journey( $params->{id}, $params->{checkin},
- $params->{checkout} );
+ my $error = $self->journeys->delete(
+ uid => $self->current_user->{id},
+ id => $params->{id},
+ checkin => $params->{checkin},
+ checkout => $params->{checkout}
+ );
if ($error) {
$self->render(
json => {
@@ -347,57 +915,277 @@ sub log_action {
}
sub station {
- my ($self) = @_;
- my $station = $self->stash('station');
- my $train = $self->param('train');
+ my ($self) = @_;
+ my $station = $self->stash('station');
+ my $train = $self->param('train');
+ my $trip_id = $self->param('trip_id');
+ my $timestamp = $self->param('timestamp');
+ my $uid = $self->current_user->{id};
+
+ my @timeline = $self->in_transit->get_timeline(
+ uid => $uid,
+ short => 1
+ );
+ my %checkin_by_train;
+ for my $checkin (@timeline) {
+ say $checkin->{train_id};
+ push( @{ $checkin_by_train{ $checkin->{train_id} } }, $checkin );
+ }
+ $self->stash( checkin_by_train => \%checkin_by_train );
- my $status = $self->get_departures( $station, 120, 30, 1 );
+ $self->render_later;
- if ( $status->{errstr} ) {
- $self->render(
- 'landingpage',
- version => $self->app->config->{version} // 'UNKNOWN',
- with_autocomplete => 1,
- with_geolocation => 1,
- error => $status->{errstr}
+ if ( $timestamp and $timestamp =~ m{ ^ \d+ $ }x ) {
+ $timestamp = DateTime->from_epoch(
+ epoch => $timestamp,
+ time_zone => 'Europe/Berlin'
);
}
else {
- # You can't check into a train which terminates here
- my @results = grep { $_->departure } @{ $status->{results} };
-
- @results = map { $_->[0] }
- sort { $b->[1] <=> $a->[1] }
- map { [ $_, $_->departure->epoch // $_->sched_departure->epoch ] }
- @results;
-
- if ($train) {
- @results
- = grep { $_->type . ' ' . $_->train_no eq $train } @results;
- }
+ $timestamp = DateTime->now( time_zone => 'Europe/Berlin' );
+ }
- $self->render(
- 'departures',
- ds100 => $status->{station_ds100},
- results => \@results,
- station => $status->{station_name},
- related_stations => $status->{related_stations},
- title => "travelynx: $status->{station_name}",
+ my $use_hafas = $self->param('hafas');
+ my $promise;
+ if ($use_hafas) {
+ $promise = $self->hafas->get_departures_p(
+ eva => $station,
+ timestamp => $timestamp,
+ lookbehind => 30,
+ lookahead => 30,
);
}
- $self->mark_seen( $self->current_user->{id} );
+ else {
+ $promise = $self->iris->get_departures_p(
+ station => $station,
+ lookbehind => 120,
+ lookahead => 30,
+ with_related => 1,
+ );
+ }
+ $promise->then(
+ sub {
+ my ($status) = @_;
+ my $api_link;
+ my @results;
+
+ my $now = $self->now->epoch;
+ my $now_within_range
+ = abs( $timestamp->epoch - $now ) < 1800 ? 1 : 0;
+
+ if ($use_hafas) {
+
+ my $iris_eva = List::Util::min grep { $_ >= 1000000 }
+ @{ $status->station->{evas} // [] };
+ if ($iris_eva) {
+ $api_link = '/s/' . $iris_eva;
+ }
+
+ @results = map { $_->[0] }
+ sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $_->datetime->epoch ] } $status->results;
+ $self->stations->add_meta(
+ eva => $status->station->{eva},
+ meta => $status->station->{evas} // []
+ );
+ $status = {
+ station_eva => $status->station->{eva},
+ station_name => (
+ List::Util::reduce { length($a) < length($b) ? $a : $b }
+ @{ $status->station->{names} }
+ ),
+ related_stations => [],
+ };
+ }
+ else {
+
+ $api_link = '/s/' . $status->{station_eva} . '?hafas=1';
+
+ # You can't check into a train which terminates here
+ @results = grep { $_->departure } @{ $status->{results} };
+
+ @results = map { $_->[0] }
+ sort { $b->[1] <=> $a->[1] }
+ map {
+ [ $_, $_->departure->epoch // $_->sched_departure->epoch ]
+ } @results;
+ }
+
+ my $user_status = $self->get_user_status;
+
+ my $can_check_out = 0;
+ if ( $user_status->{checked_in} ) {
+ for my $stop ( @{ $user_status->{route_after} } ) {
+ if (
+ $stop->[1] eq $status->{station_eva}
+ or List::Util::any { $stop->[1] eq $_->{uic} }
+ @{ $status->{related_stations} }
+ )
+ {
+ $can_check_out = 1;
+ last;
+ }
+ }
+ }
+
+ my $connections_p;
+ if ( $trip_id and $use_hafas ) {
+ @results = grep { $_->id eq $trip_id } @results;
+ }
+ elsif ( $train and not $use_hafas ) {
+ @results
+ = grep { $_->type . ' ' . $_->train_no eq $train } @results;
+ }
+ else {
+ if ( $user_status->{cancellation}
+ and $status->{station_eva} eq
+ $user_status->{cancellation}{dep_eva} )
+ {
+ $connections_p = $self->get_connecting_trains_p(
+ eva => $user_status->{cancellation}{dep_eva},
+ destination_name =>
+ $user_status->{cancellation}{arr_name}
+ );
+ }
+ else {
+ $connections_p = $self->get_connecting_trains_p(
+ eva => $status->{station_eva} );
+ }
+ }
+
+ if ($connections_p) {
+ $connections_p->then(
+ sub {
+ my ( $connections_iris, $connections_hafas ) = @_;
+ $self->render(
+ 'departures',
+ eva => $status->{station_eva},
+ datetime => $timestamp,
+ now_in_range => $now_within_range,
+ results => \@results,
+ hafas => $use_hafas,
+ station => $status->{station_name},
+ related_stations => $status->{related_stations},
+ user_status => $user_status,
+ can_check_out => $can_check_out,
+ connections_iris => $connections_iris,
+ connections_hafas => $connections_hafas,
+ api_link => $api_link,
+ title => "travelynx: $status->{station_name}",
+ );
+ }
+ )->catch(
+ sub {
+ $self->render(
+ 'departures',
+ eva => $status->{station_eva},
+ datetime => $timestamp,
+ now_in_range => $now_within_range,
+ results => \@results,
+ hafas => $use_hafas,
+ station => $status->{station_name},
+ related_stations => $status->{related_stations},
+ user_status => $user_status,
+ can_check_out => $can_check_out,
+ api_link => $api_link,
+ title => "travelynx: $status->{station_name}",
+ );
+ }
+ )->wait;
+ }
+ else {
+ $self->render(
+ 'departures',
+ eva => $status->{station_eva},
+ datetime => $timestamp,
+ now_in_range => $now_within_range,
+ results => \@results,
+ hafas => $use_hafas,
+ station => $status->{station_name},
+ related_stations => $status->{related_stations},
+ user_status => $user_status,
+ can_check_out => $can_check_out,
+ api_link => $api_link,
+ title => "travelynx: $status->{station_name}",
+ );
+ }
+ }
+ )->catch(
+ sub {
+ my ( $err, $status ) = @_;
+ if ( $status and $status->{suggestions} ) {
+ $self->render(
+ 'disambiguation',
+ suggestions => $status->{suggestions},
+ status => 300,
+ );
+ }
+ elsif ( $use_hafas and $status and $status->errcode eq 'LOCATION' )
+ {
+ $self->hafas->search_location_p( query => $station )->then(
+ sub {
+ my ($hafas2) = @_;
+ my @suggestions = $hafas2->results;
+ if ( @suggestions == 1 ) {
+ $self->redirect_to(
+ '/s/' . $suggestions[0]->eva . '?hafas=1' );
+ }
+ else {
+ $self->render(
+ 'disambiguation',
+ suggestions => [
+ map { { name => $_->name, eva => $_->eva } }
+ @suggestions
+ ],
+ status => 300,
+ );
+ }
+ }
+ )->catch(
+ sub {
+ my ($err2) = @_;
+ $self->render(
+ 'exception',
+ exception =>
+"locationSearch threw '$err2' when handling '$err'",
+ status => 502
+ );
+ }
+ )->wait;
+ }
+ else {
+ $self->render(
+ 'exception',
+ exception => $err,
+ status => 502
+ );
+ }
+ }
+ )->wait;
+ $self->users->mark_seen( uid => $uid );
}
sub redirect_to_station {
my ($self) = @_;
my $station = $self->param('station');
- $self->redirect_to("/s/${station}");
+ if ( my $s = $self->app->stations->search($station) ) {
+ if ( $s->{source} == 1 ) {
+ $self->redirect_to("/s/${station}?hafas=1");
+ }
+ else {
+ $self->redirect_to("/s/${station}");
+ }
+ }
+ else {
+ $self->redirect_to("/s/${station}?hafas=1");
+ }
}
sub cancelled {
my ($self) = @_;
- my @journeys = $self->get_user_travels(
+ my @journeys = $self->journeys->get(
+ uid => $self->current_user->{id},
cancelled => 1,
with_datetime => 1
);
@@ -414,7 +1202,122 @@ sub cancelled {
sub history {
my ($self) = @_;
- $self->render( template => 'history' );
+ $self->render(
+ template => 'history',
+ title => 'travelynx: History'
+ );
+}
+
+sub commute {
+ my ($self) = @_;
+
+ my $year = $self->param('year');
+ my $filter_type = $self->param('filter_type') || 'exact';
+ my $station = $self->param('station');
+
+ # DateTime is very slow when looking far into the future due to DST changes
+ # -> Limit time range to avoid accidental DoS.
+ if (
+ not( $year
+ and $year =~ m{ ^ [0-9]{4} $ }x
+ and $year > 1990
+ and $year < 2100 )
+ )
+ {
+ $year = DateTime->now( time_zone => 'Europe/Berlin' )->year - 1;
+ }
+ my $interval_start = DateTime->new(
+ time_zone => 'Europe/Berlin',
+ year => $year,
+ month => 1,
+ day => 1,
+ hour => 0,
+ minute => 0,
+ second => 0,
+ );
+ my $interval_end = $interval_start->clone->add( years => 1 );
+
+ my @journeys = $self->journeys->get(
+ uid => $self->current_user->{id},
+ after => $interval_start,
+ before => $interval_end,
+ with_datetime => 1,
+ );
+
+ if ( not $station ) {
+ my %candidate_count;
+ for my $journey (@journeys) {
+ my $dep = $journey->{rt_departure};
+ my $arr = $journey->{rt_arrival};
+ if ( $arr->dow <= 5 and $arr->hour <= 12 ) {
+ $candidate_count{ $journey->{to_name} }++;
+ }
+ elsif ( $dep->dow <= 5 and $dep->hour > 12 ) {
+ $candidate_count{ $journey->{from_name} }++;
+ }
+ else {
+ # Avoid selecting an intermediate station for multi-leg commutes.
+ # Assumption: The intermediate station is also used for private
+ # travels -> penalize stations which are used on weekends or at
+ # unexpected times.
+ $candidate_count{ $journey->{from_name} }--;
+ $candidate_count{ $journey->{to_name} }--;
+ }
+ }
+ $station = max_by { $candidate_count{$_} } keys %candidate_count;
+ }
+
+ my %journeys_by_month;
+ my %count_by_month;
+ my $total = 0;
+
+ my $prev_doy = 0;
+ for my $journey ( reverse @journeys ) {
+ my $month = $journey->{rt_departure}->month;
+ if (
+ (
+ $filter_type eq 'exact' and ( $journey->{to_name} eq $station
+ or $journey->{from_name} eq $station )
+ )
+ or (
+ $filter_type eq 'substring'
+ and ( $journey->{to_name} =~ m{\Q$station\E}
+ or $journey->{from_name} =~ m{\Q$station\E} )
+ )
+ or (
+ $filter_type eq 'regex'
+ and ( $journey->{to_name} =~ m{$station}
+ or $journey->{from_name} =~ m{$station} )
+ )
+ )
+ {
+ push( @{ $journeys_by_month{$month} }, $journey );
+
+ my $doy = $journey->{rt_departure}->day_of_year;
+ if ( $doy != $prev_doy ) {
+ $count_by_month{$month}++;
+ $total++;
+ }
+
+ $prev_doy = $doy;
+ }
+ }
+
+ $self->param( year => $year );
+ $self->param( filter_type => $filter_type );
+ $self->param( station => $station );
+
+ $self->render(
+ template => 'commute',
+ with_autocomplete => 1,
+ journeys_by_month => \%journeys_by_month,
+ count_by_month => \%count_by_month,
+ total_journeys => $total,
+ title => 'travelynx: Reisen nach Station',
+ months => [
+ qw(Januar Februar März April Mai Juni Juli August September Oktober November Dezember)
+ ],
+ );
}
sub map_history {
@@ -422,116 +1325,278 @@ sub map_history {
my $location = $self->app->coordinates_by_station;
- my @journeys = $self->get_user_travels;
+ if ( not $self->param('route_type') ) {
+ $self->param( route_type => 'polybee' );
+ }
+
+ my $route_type = $self->param('route_type');
+ my $filter_from = $self->param('filter_from');
+ my $filter_until = $self->param('filter_to');
+ my $filter_type = $self->param('filter_type');
+ my $with_polyline = $route_type eq 'beeline' ? 0 : 1;
+
+ my $parser = DateTime::Format::Strptime->new(
+ pattern => '%d.%m.%Y',
+ locale => 'de_DE',
+ time_zone => 'Europe/Berlin'
+ );
+
+ if ( $filter_from
+ and $filter_from =~ m{ ^ (\d+) [.] (\d+) [.] (\d+) $ }x )
+ {
+ $filter_from = $parser->parse_datetime($filter_from);
+ }
+ else {
+ $filter_from = undef;
+ }
+
+ if ( $filter_until
+ and $filter_until =~ m{ ^ (\d+) [.] (\d+) [.] (\d+) $ }x )
+ {
+ $filter_until = $parser->parse_datetime($filter_until)->set(
+ hour => 23,
+ minute => 59,
+ second => 58
+ );
+ }
+ else {
+ $filter_until = undef;
+ }
+
+ my $year;
+ if ( $filter_from
+ and $filter_from->day == 1
+ and $filter_from->month == 1
+ and $filter_until
+ and $filter_until->day == 31
+ and $filter_until->month == 12
+ and $filter_from->year == $filter_until->year )
+ {
+ $year = $filter_from->year;
+ }
+
+ my @journeys = $self->journeys->get(
+ uid => $self->current_user->{id},
+ with_polyline => $with_polyline,
+ after => $filter_from,
+ before => $filter_until,
+ );
+
+ if ($filter_type) {
+ my @filter = split( qr{, *}, $filter_type );
+ @journeys
+ = grep { has_str_in_list( $_->{type}, @filter ) } @journeys;
+ }
if ( not @journeys ) {
$self->render(
template => 'history_map',
with_map => 1,
+ skipped_journeys => [],
station_coordinates => [],
- station_pairs => [],
+ polyline_groups => [],
);
return;
}
- my $first_departure = $journeys[-1]->{rt_departure};
- my $last_departure = $journeys[0]->{rt_departure};
+ my $include_manual = $self->param('include_manual') ? 1 : 0;
- my @stations = uniq map { $_->{to_name} } @journeys;
- push( @stations, uniq map { $_->{from_name} } @journeys );
- @stations = uniq @stations;
- my @station_coordinates = map { [ $location->{$_}, $_ ] }
- grep { exists $location->{$_} } @stations;
+ my $res = $self->journeys_to_map_data(
+ journeys => \@journeys,
+ route_type => $route_type,
+ include_manual => $include_manual
+ );
- my @station_pairs;
- my %seen;
+ $self->render(
+ template => 'history_map',
+ year => $year,
+ with_map => 1,
+ title => 'travelynx: Karte',
+ %{$res}
+ );
+}
+
+sub json_history {
+ my ($self) = @_;
+
+ $self->render(
+ json => [ $self->journeys->get( uid => $self->current_user->{id} ) ] );
+}
- for my $journey (@journeys) {
+sub csv_history {
+ my ($self) = @_;
- 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;
+ my $csv = Text::CSV->new( { eol => "\r\n" } );
+ my $buf = q{};
+
+ $csv->combine(
+ qw(Zugtyp Linie Nummer Start Ziel),
+ 'Start (DS100)',
+ 'Ziel (DS100)',
+ 'Abfahrt (soll)',
+ 'Abfahrt (ist)',
+ 'Ankunft (soll)',
+ 'Ankunft (ist)',
+ 'Kommentar',
+ 'ID'
+ );
+ $buf .= $csv->string;
- if ( $from_index == -1
- or $to_index == -1
- or $journey->{edited} == 0x3fff )
+ for my $journey (
+ $self->journeys->get(
+ uid => $self->current_user->{id},
+ with_datetime => 1
+ )
+ )
+ {
+ if (
+ $csv->combine(
+ $journey->{type},
+ $journey->{line},
+ $journey->{no},
+ $journey->{from_name},
+ $journey->{to_name},
+ $journey->{from_ds100},
+ $journey->{to_ds100},
+ $journey->{sched_departure}->strftime('%Y-%m-%d %H:%M'),
+ $journey->{rt_departure}->strftime('%Y-%m-%d %H:%M'),
+ $journey->{sched_arrival}->strftime('%Y-%m-%d %H:%M'),
+ $journey->{rt_arrival}->strftime('%Y-%m-%d %H:%M'),
+ $journey->{user_data}{comment} // q{},
+ $journey->{id}
+ )
+ )
{
- next;
+ $buf .= $csv->string;
}
+ }
- @route = @route[ $from_index .. $to_index ];
-
- my $key = join( '|', @route );
+ $self->render(
+ text => $buf,
+ format => 'csv'
+ );
+}
- if ( $seen{$key} ) {
- next;
- }
+sub year_in_review {
+ my ($self) = @_;
+ my $year = $self->stash('year');
+ my @journeys;
- $seen{$key} = 1;
+ # DateTime is very slow when looking far into the future due to DST changes
+ # -> Limit time range to avoid accidental DoS.
+ if ( not( $year =~ m{ ^ [0-9]{4} $ }x and $year > 1990 and $year < 2100 ) )
+ {
+ $self->render( 'not_found', status => 404 );
+ return;
+ }
- # direction does not matter at the moment
- $seen{ join( '|', reverse @route ) } = 1;
+ my $interval_start = DateTime->new(
+ time_zone => 'Europe/Berlin',
+ year => $year,
+ month => 1,
+ day => 1,
+ hour => 0,
+ minute => 0,
+ second => 0,
+ );
+ my $interval_end = $interval_start->clone->add( years => 1 );
+ @journeys = $self->journeys->get(
+ uid => $self->current_user->{id},
+ after => $interval_start,
+ before => $interval_end,
+ with_datetime => 1
+ );
- my $prev_station = shift @route;
- for my $station (@route) {
- push( @station_pairs, [ $prev_station, $station ] );
- $prev_station = $station;
- }
+ if ( not @journeys ) {
+ $self->render(
+ 'not_found',
+ message => 'Keine Zugfahrten im angefragten Jahr gefunden.',
+ status => 404
+ );
+ return;
}
- @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 $now = $self->now;
+ if (
+ not( $year < $now->year or ( $now->month == 12 and $now->day == 31 ) ) )
+ {
+ $self->render(
+ 'not_found',
+ message =>
+'Der aktuelle Jahresrückblick wird erst zum Jahresende (am 31.12.) freigeschaltet',
+ status => 404
+ );
+ return;
+ }
- my @routes;
+ my ( $stats, $review ) = $self->journeys->get_stats(
+ uid => $self->current_user->{id},
+ year => $year,
+ review => 1
+ );
$self->render(
- template => 'history_map',
- with_map => 1,
- station_coordinates => \@station_coordinates,
- station_pairs => \@station_pairs,
+ 'year_in_review',
+ title => "travelynx: Jahresrückblick $year",
+ year => $year,
+ stats => $stats,
+ review => $review,
);
-}
-
-sub json_history {
- my ($self) = @_;
- $self->render( json => [ $self->get_user_travels ] );
}
sub yearly_history {
my ($self) = @_;
- my $year = $self->stash('year');
+ my $year = $self->stash('year');
+ my $filter = $self->param('filter');
my @journeys;
- my $stats;
# DateTime is very slow when looking far into the future due to DST changes
# -> Limit time range to avoid accidental DoS.
if ( not( $year =~ m{ ^ [0-9]{4} $ }x and $year > 1990 and $year < 2100 ) )
{
- @journeys = $self->get_user_travels( with_datetime => 1 );
+ $self->render( 'not_found', status => 404 );
+ return;
}
- else {
- my $interval_start = DateTime->new(
- time_zone => 'Europe/Berlin',
- year => $year,
- month => 1,
- day => 1,
- hour => 0,
- minute => 0,
- second => 0,
- );
- my $interval_end = $interval_start->clone->add( years => 1 );
- @journeys = $self->get_user_travels(
- after => $interval_start,
- before => $interval_end,
- with_datetime => 1
+ my $interval_start = DateTime->new(
+ time_zone => 'Europe/Berlin',
+ year => $year,
+ month => 1,
+ day => 1,
+ hour => 0,
+ minute => 0,
+ second => 0,
+ );
+ my $interval_end = $interval_start->clone->add( years => 1 );
+ @journeys = $self->journeys->get(
+ uid => $self->current_user->{id},
+ after => $interval_start,
+ before => $interval_end,
+ with_datetime => 1
+ );
+
+ if ( $filter and $filter eq 'single' ) {
+ @journeys = $self->journeys->grep_single(@journeys);
+ }
+
+ if ( not @journeys ) {
+ $self->render(
+ 'not_found',
+ status => 404,
+ message => 'Keine Zugfahrten im angefragten Jahr gefunden.'
);
- $stats = $self->get_journey_stats( year => $year );
+ return;
+ }
+
+ my $stats = $self->journeys->get_stats(
+ uid => $self->current_user->{id},
+ year => $year
+ );
+
+ my $with_review;
+ my $now = $self->now;
+ if ( $year < $now->year or ( $now->month == 12 and $now->day == 31 ) ) {
+ $with_review = 1;
}
$self->respond_to(
@@ -542,10 +1607,12 @@ sub yearly_history {
}
},
any => {
- template => 'history_by_year',
- journeys => [@journeys],
- year => $year,
- statistics => $stats
+ template => 'history_by_year',
+ title => "travelynx: $year",
+ journeys => [@journeys],
+ year => $year,
+ have_review => $with_review,
+ statistics => $stats
}
);
@@ -556,7 +1623,6 @@ sub monthly_history {
my $year = $self->stash('year');
my $month = $self->stash('month');
my @journeys;
- my $stats;
my @months
= (
qw(Januar Februar März April Mai Juni Juli August September Oktober November Dezember)
@@ -571,30 +1637,43 @@ sub monthly_history {
and $month < 13 )
)
{
- @journeys = $self->get_user_travels( with_datetime => 1 );
+ $self->render( 'not_found', status => 404 );
+ return;
}
- else {
- my $interval_start = DateTime->new(
- time_zone => 'Europe/Berlin',
- year => $year,
- month => $month,
- day => 1,
- hour => 0,
- minute => 0,
- second => 0,
- );
- my $interval_end = $interval_start->clone->add( months => 1 );
- @journeys = $self->get_user_travels(
- after => $interval_start,
- before => $interval_end,
- with_datetime => 1
- );
- $stats = $self->get_journey_stats(
- year => $year,
- month => $month
+ my $interval_start = DateTime->new(
+ time_zone => 'Europe/Berlin',
+ year => $year,
+ month => $month,
+ day => 1,
+ hour => 0,
+ minute => 0,
+ second => 0,
+ );
+ my $interval_end = $interval_start->clone->add( months => 1 );
+ @journeys = $self->journeys->get(
+ uid => $self->current_user->{id},
+ after => $interval_start,
+ before => $interval_end,
+ with_datetime => 1
+ );
+
+ if ( not @journeys ) {
+ $self->render(
+ 'not_found',
+ message => 'Keine Zugfahrten im angefragten Monat gefunden.',
+ status => 404
);
+ return;
}
+ my $stats = $self->journeys->get_stats(
+ uid => $self->current_user->{id},
+ year => $year,
+ month => $month
+ );
+
+ my $month_name = $months[ $month - 1 ];
+
$self->respond_to(
json => {
json => {
@@ -604,10 +1683,11 @@ sub monthly_history {
},
any => {
template => 'history_by_month',
+ title => "travelynx: $month_name $year",
journeys => [@journeys],
year => $year,
month => $month,
- month_name => $months[ $month - 1 ],
+ month_name => $month_name,
statistics => $stats
}
);
@@ -618,36 +1698,89 @@ sub journey_details {
my ($self) = @_;
my $journey_id = $self->stash('id');
- my $uid = $self->current_user->{id};
+ my $user = $self->current_user;
+ my $uid = $user->{id};
$self->param( journey_id => $journey_id );
- if ( not($journey_id) ) {
+ if ( not( $journey_id and $journey_id =~ m{ ^ \d+ $ }x ) ) {
$self->render(
'journey',
+ status => 404,
error => 'notfound',
journey => {}
);
return;
}
- my $journey = $self->get_journey(
- uid => $uid,
- journey_id => $journey_id,
- verbose => 1,
- with_datetime => 1,
+ my $journey = $self->journeys->get_single(
+ uid => $uid,
+ journey_id => $journey_id,
+ verbose => 1,
+ with_datetime => 1,
+ with_polyline => 1,
+ with_visibility => 1,
);
if ($journey) {
+ my $map_data = $self->journeys_to_map_data(
+ journeys => [$journey],
+ include_manual => 1,
+ );
+ my $with_share;
+ my $share_text;
+
+ my $visibility
+ = $self->compute_effective_visibility(
+ $user->{default_visibility_str},
+ $journey->{visibility_str} );
+
+ if ( $visibility eq 'public'
+ or $visibility eq 'travelynx'
+ or $visibility eq 'followers'
+ or $visibility eq 'unlisted' )
+ {
+ my $delay = 'pünktlich ';
+ if ( $journey->{rt_arrival} != $journey->{sched_arrival} ) {
+ $delay = sprintf(
+ 'mit %+d ',
+ (
+ $journey->{rt_arrival}->epoch
+ - $journey->{sched_arrival}->epoch
+ ) / 60
+ );
+ }
+ $with_share = 1;
+ $share_text
+ = $journey->{km_route}
+ ? sprintf( '%.0f km', $journey->{km_route} )
+ : 'Fahrt';
+ $share_text .= sprintf( ' mit %s %s – Ankunft %sum %s',
+ $journey->{type}, $journey->{no},
+ $delay, $journey->{rt_arrival}->strftime('%H:%M') );
+ }
+
$self->render(
'journey',
- error => undef,
- journey => $journey,
+ title => sprintf(
+ 'travelynx: Fahrt %s %s %s am %s',
+ $journey->{type}, $journey->{line} // '',
+ $journey->{no},
+ $journey->{sched_departure}->strftime('%d.%m.%Y um %H:%M')
+ ),
+ error => undef,
+ journey => $journey,
+ journey_visibility => $visibility,
+ with_map => 1,
+ with_share => $with_share,
+ share_text => $share_text,
+ %{$map_data},
);
}
else {
$self->render(
'journey',
+ status => 404,
error => 'notfound',
journey => {}
);
@@ -655,6 +1788,144 @@ sub journey_details {
}
+sub visibility_form {
+ my ($self) = @_;
+ my $dep_ts = $self->param('dep_ts');
+ my $journey_id = $self->param('id');
+ my $action = $self->param('action') // 'none';
+ my $user = $self->current_user;
+ my $user_level = $user->{default_visibility_str};
+ my $uid = $user->{id};
+ my $status = $self->get_user_status;
+ my $visibility = $status->{visibility_str};
+ my $journey;
+
+ if ($journey_id) {
+ $journey = $self->journeys->get_single(
+ uid => $uid,
+ journey_id => $journey_id,
+ with_datetime => 1,
+ with_visibility => 1,
+ );
+ $visibility = $journey->{visibility_str};
+ }
+
+ if ( $action eq 'save' ) {
+ if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
+ }
+ elsif ( $dep_ts and $dep_ts != $status->{sched_departure}->epoch ) {
+ $self->render(
+ 'edit_visibility',
+ error => 'old',
+ user_level => $user_level,
+ journey => {}
+ );
+ }
+ else {
+ if ($dep_ts) {
+ $self->in_transit->update_visibility(
+ uid => $uid,
+ visibility => $self->param('status_level'),
+ );
+ $self->redirect_to('/');
+ $self->run_hook( $uid, 'update' );
+ }
+ elsif ($journey_id) {
+ $self->journeys->update_visibility(
+ uid => $uid,
+ id => $journey_id,
+ visibility => $self->param('status_level'),
+ );
+ $self->redirect_to( '/journey/' . $journey_id );
+ }
+ }
+ return;
+ }
+
+ $self->param( status_level => $visibility );
+
+ if ($journey_id) {
+ $self->render(
+ 'edit_visibility',
+ error => undef,
+ user_level => $user_level,
+ journey => $journey
+ );
+ }
+ elsif ( $status->{checked_in} ) {
+ $self->param( dep_ts => $status->{sched_departure}->epoch );
+ $self->render(
+ 'edit_visibility',
+ error => undef,
+ user_level => $user_level,
+ journey => $status
+ );
+ }
+ else {
+ $self->render(
+ 'edit_visibility',
+ error => 'notfound',
+ user_level => $user_level,
+ journey => {}
+ );
+ }
+}
+
+sub comment_form {
+ my ($self) = @_;
+ my $dep_ts = $self->param('dep_ts');
+ my $status = $self->get_user_status;
+
+ if ( not $status->{checked_in} ) {
+ $self->render(
+ 'edit_comment',
+ error => 'notfound',
+ journey => {}
+ );
+ }
+ elsif ( not $dep_ts ) {
+ $self->param( dep_ts => $status->{sched_departure}->epoch );
+ $self->param( comment => $status->{comment} );
+ $self->render(
+ 'edit_comment',
+ error => undef,
+ journey => $status
+ );
+ }
+ elsif ( $self->validation->csrf_protect->has_error('csrf_token') ) {
+ $self->render(
+ 'edit_comment',
+ error => undef,
+ journey => $status
+ );
+ }
+ elsif ( $dep_ts != $status->{sched_departure}->epoch ) {
+
+ # TODO find and update appropriate past journey (if it exists)
+ $self->param( comment => $status->{comment} );
+ $self->render(
+ 'edit_comment',
+ error => undef,
+ journey => $status
+ );
+ }
+ else {
+ $self->app->log->debug("set comment");
+ my $uid = $self->current_user->{id};
+ $self->in_transit->update_user_data(
+ uid => $uid,
+ user_data => { comment => $self->param('comment') }
+ );
+ $self->redirect_to('/');
+ $self->run_hook( $uid, 'update' );
+ }
+}
+
sub edit_journey {
my ($self) = @_;
my $journey_id = $self->param('journey_id');
@@ -663,21 +1934,24 @@ sub edit_journey {
if ( not( $journey_id =~ m{ ^ \d+ $ }x ) ) {
$self->render(
'edit_journey',
+ status => 404,
error => 'notfound',
journey => {}
);
return;
}
- my $journey = $self->get_journey(
+ my $journey = $self->journeys->get_single(
uid => $uid,
journey_id => $journey_id,
+ verbose => 1,
with_datetime => 1,
);
if ( not $journey ) {
$self->render(
'edit_journey',
+ status => 404,
error => 'notfound',
journey => {}
);
@@ -700,8 +1974,27 @@ sub edit_journey {
{
my $datetime = $parser->parse_datetime( $self->param($key) );
if ( $datetime and $datetime->epoch ne $journey->{$key}->epoch ) {
- $error = $self->update_journey_part( $db, $journey->{id},
- $key, $datetime );
+ $error = $self->journeys->update(
+ uid => $uid,
+ db => $db,
+ id => $journey->{id},
+ $key => $datetime
+ );
+ if ($error) {
+ last;
+ }
+ }
+ }
+ for my $key (qw(from_name to_name)) {
+ if ( defined $self->param($key)
+ and $self->param($key) ne $journey->{$key} )
+ {
+ $error = $self->journeys->update(
+ uid => $uid,
+ db => $db,
+ id => $journey->{id},
+ $key => $self->param($key)
+ );
if ($error) {
last;
}
@@ -714,8 +2007,12 @@ sub edit_journey {
or $journey->{user_data}{$key} ne $self->param($key) )
)
{
- $error = $self->update_journey_part( $db, $journey->{id}, $key,
- $self->param($key) );
+ $error = $self->journeys->update(
+ uid => $uid,
+ db => $db,
+ id => $journey->{id},
+ $key => $self->param($key)
+ );
if ($error) {
last;
}
@@ -726,21 +2023,36 @@ sub edit_journey {
my @route_new = split( qr{\r?\n\r?}, $self->param('route') );
@route_new = grep { $_ ne '' } @route_new;
if ( join( '|', @route_old ) ne join( '|', @route_new ) ) {
- $error
- = $self->update_journey_part( $db, $journey->{id}, 'route',
- [@route_new] );
+ $error = $self->journeys->update(
+ uid => $uid,
+ db => $db,
+ id => $journey->{id},
+ route => [@route_new]
+ );
+ }
+ }
+ {
+ my $cancelled_old = $journey->{cancelled} // 0;
+ my $cancelled_new = $self->param('cancelled') // 0;
+ if ( $cancelled_old != $cancelled_new ) {
+ $error = $self->journeys->update(
+ uid => $uid,
+ db => $db,
+ id => $journey->{id},
+ cancelled => $cancelled_new
+ );
}
}
if ( not $error ) {
- $journey = $self->get_journey(
+ $journey = $self->journeys->get_single(
uid => $uid,
db => $db,
journey_id => $journey_id,
verbose => 1,
with_datetime => 1,
);
- $error = $self->journey_sanity_check($journey);
+ $error = $self->journeys->sanity_check($journey);
}
if ( not $error ) {
$tx->commit;
@@ -759,6 +2071,10 @@ sub edit_journey {
$self->param(
route => join( "\n", map { $_->[0] } @{ $journey->{route} } ) );
+ $self->param( cancelled => $journey->{cancelled} ? 1 : 0 );
+ $self->param( from_name => $journey->{from_name} );
+ $self->param( to_name => $journey->{to_name} );
+
for my $key (qw(comment)) {
if ( $journey->{user_data} and $journey->{user_data}{$key} ) {
$self->param( $key => $journey->{user_data}{$key} );
@@ -767,8 +2083,9 @@ sub edit_journey {
$self->render(
'edit_journey',
- error => $error,
- journey => $journey
+ with_autocomplete => 1,
+ error => $error,
+ journey => $journey
);
}
@@ -795,7 +2112,7 @@ sub add_journey_form {
$self->render(
'add_journey',
with_autocomplete => 1,
- error =>
+ error =>
'Zug muss als „Typ Nummer“ oder „Typ Linie Nummer“ eingegeben werden.'
);
return;
@@ -831,18 +2148,19 @@ sub add_journey_form {
my $db = $self->pg->db;
my $tx = $db->begin;
- $opt{db} = $db;
+ $opt{db} = $db;
+ $opt{uid} = $self->current_user->{id};
- my ( $journey_id, $error ) = $self->add_journey(%opt);
+ my ( $journey_id, $error ) = $self->journeys->add(%opt);
if ( not $error ) {
- my $journey = $self->get_journey(
+ my $journey = $self->journeys->get_single(
uid => $self->current_user->{id},
db => $db,
journey_id => $journey_id,
verbose => 1
);
- $error = $self->journey_sanity_check($journey);
+ $error = $self->journeys->sanity_check($journey);
}
if ($error) {
diff --git a/lib/Travelynx/Helper/DBDB.pm b/lib/Travelynx/Helper/DBDB.pm
new file mode 100644
index 0000000..b98a372
--- /dev/null
+++ b/lib/Travelynx/Helper/DBDB.pm
@@ -0,0 +1,150 @@
+package Travelynx::Helper::DBDB;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use strict;
+use warnings;
+use 5.020;
+
+use Encode qw(decode);
+use Mojo::Promise;
+use JSON;
+
+sub new {
+ my ( $class, %opt ) = @_;
+
+ my $version = $opt{version};
+
+ $opt{header}
+ = { 'User-Agent' =>
+"travelynx/${version} on $opt{root_url} +https://finalrewind.org/projects/travelynx"
+ };
+
+ return bless( \%opt, $class );
+
+}
+
+sub has_wagonorder_p {
+ my ( $self, $ts, $train_no ) = @_;
+ my $api_ts = $ts->strftime('%Y%m%d%H%M');
+ my $url
+ = "https://ist-wr.noncd.db.de/wagenreihung/1.0/${train_no}/${api_ts}";
+ my $cache = $self->{cache};
+ my $promise = Mojo::Promise->new;
+
+ if ( my $content = $cache->get("HEAD $url") ) {
+ if ( $content eq 'n' ) {
+ return $promise->reject;
+ }
+ else {
+ return $promise->resolve($content);
+ }
+ }
+
+ $self->{user_agent}->request_timeout(5)->head_p( $url => $self->{header} )
+ ->then(
+ sub {
+ my ($tx) = @_;
+ if ( $tx->result->is_success ) {
+ $cache->set( "HEAD $url", 'a' );
+ $promise->resolve('a');
+ }
+ else {
+ $cache->set( "HEAD $url", 'n' );
+ $promise->reject;
+ }
+ return;
+ }
+ )->catch(
+ sub {
+ $cache->set( "HEAD $url", 'n' );
+ $promise->reject;
+ return;
+ }
+ )->wait;
+ return $promise;
+}
+
+sub get_wagonorder_p {
+ my ( $self, $api, $ts, $train_no ) = @_;
+ my $api_ts = $ts->strftime('%Y%m%d%H%M');
+ my $url
+ = "https://ist-wr.noncd.db.de/wagenreihung/1.0/${train_no}/${api_ts}";
+
+ my $cache = $self->{cache};
+ my $promise = Mojo::Promise->new;
+
+ if ( my $content = $cache->thaw($url) ) {
+ $promise->resolve($content);
+ return $promise;
+ }
+
+ $self->{user_agent}->request_timeout(5)->get_p( $url => $self->{header} )
+ ->then(
+ sub {
+ my ($tx) = @_;
+
+ if ( $tx->result->is_success ) {
+ my $body = decode( 'utf-8', $tx->res->body );
+ my $json = JSON->new->decode($body);
+ $cache->freeze( $url, $json );
+ $promise->resolve($json);
+ }
+ else {
+ my $code = $tx->code;
+ $promise->reject("HTTP ${code}");
+ }
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+ return $promise;
+}
+
+sub get_stationinfo_p {
+ my ( $self, $eva ) = @_;
+
+ my $url = "https://lib.finalrewind.org/dbdb/s/${eva}.json";
+
+ my $cache = $self->{cache};
+ my $promise = Mojo::Promise->new;
+
+ if ( my $content = $cache->thaw($url) ) {
+ return $promise->resolve($content);
+ }
+
+ $self->{user_agent}->request_timeout(5)->get_p( $url => $self->{header} )
+ ->then(
+ sub {
+ my ($tx) = @_;
+
+ if ( my $err = $tx->error ) {
+ $cache->freeze( $url, {} );
+ $promise->reject("HTTP $err->{code} $err->{message}");
+ return;
+ }
+
+ my $json = $tx->result->json;
+ $cache->freeze( $url, $json );
+ $promise->resolve($json);
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $cache->freeze( $url, {} );
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+ return $promise;
+}
+
+1;
diff --git a/lib/Travelynx/Helper/HAFAS.pm b/lib/Travelynx/Helper/HAFAS.pm
new file mode 100644
index 0000000..7671d78
--- /dev/null
+++ b/lib/Travelynx/Helper/HAFAS.pm
@@ -0,0 +1,315 @@
+package Travelynx::Helper::HAFAS;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use strict;
+use warnings;
+use 5.020;
+
+use DateTime;
+use Encode qw(decode);
+use JSON;
+use Mojo::Promise;
+use Travel::Status::DE::HAFAS;
+
+sub _epoch {
+ my ($dt) = @_;
+
+ return $dt ? $dt->epoch : 0;
+}
+
+sub new {
+ my ( $class, %opt ) = @_;
+
+ my $version = $opt{version};
+
+ $opt{header}
+ = { 'User-Agent' =>
+"travelynx/${version} on $opt{root_url} +https://finalrewind.org/projects/travelynx"
+ };
+
+ return bless( \%opt, $class );
+}
+
+sub get_json_p {
+ my ( $self, $url, %opt ) = @_;
+
+ my $cache = $self->{main_cache};
+ my $promise = Mojo::Promise->new;
+
+ if ( $opt{realtime} ) {
+ $cache = $self->{realtime_cache};
+ }
+ $opt{encoding} //= 'ISO-8859-15';
+
+ if ( my $content = $cache->thaw($url) ) {
+ return $promise->resolve($content);
+ }
+
+ $self->{user_agent}->request_timeout(5)->get_p( $url => $self->{header} )
+ ->then(
+ sub {
+ my ($tx) = @_;
+
+ if ( my $err = $tx->error ) {
+ $promise->reject(
+"hafas->get_json_p($url) returned HTTP $err->{code} $err->{message}"
+ );
+ return;
+ }
+
+ my $body = decode( $opt{encoding}, $tx->res->body );
+
+ $body =~ s{^TSLs[.]sls = }{};
+ $body =~ s{;$}{};
+ $body =~ s{&#x0028;}{(}g;
+ $body =~ s{&#x0029;}{)}g;
+ my $json = JSON->new->decode($body);
+ $cache->freeze( $url, $json );
+ $promise->resolve($json);
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->{log}->info("hafas->get_json_p($url): $err");
+ $promise->reject("hafas->get_json_p($url): $err");
+ return;
+ }
+ )->wait;
+ return $promise;
+}
+
+sub get_departures_p {
+ my ( $self, %opt ) = @_;
+
+ my $when = (
+ $opt{timestamp}
+ ? $opt{timestamp}->clone
+ : DateTime->now( time_zone => 'Europe/Berlin' )
+ )->subtract( minutes => $opt{lookbehind} );
+ return Travel::Status::DE::HAFAS->new_p(
+ station => $opt{eva},
+ datetime => $when,
+ lookahead => $opt{lookahead} + $opt{lookbehind},
+ results => 300,
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $self->{user_agent}->request_timeout(5),
+ );
+}
+
+sub search_location_p {
+ my ( $self, %opt ) = @_;
+
+ return Travel::Status::DE::HAFAS->new_p(
+ locationSearch => $opt{query},
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $self->{user_agent}->request_timeout(5),
+ );
+}
+
+sub get_tripid_p {
+ my ( $self, %opt ) = @_;
+
+ my $promise = Mojo::Promise->new;
+
+ my $train = $opt{train};
+ my $train_desc = $train->type . ' ' . $train->train_no;
+ $train_desc =~ s{^- }{};
+
+ Travel::Status::DE::HAFAS->new_p(
+ journeyMatch => $train_desc,
+ datetime => $train->start,
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $self->{user_agent}->request_timeout(10),
+ )->then(
+ sub {
+ my ($hafas) = @_;
+ my @results = $hafas->results;
+
+ if ( not @results ) {
+ $promise->reject(
+ "journeyMatch($train_desc) returned no results");
+ return;
+ }
+
+ my $result = $results[0];
+ if ( @results > 1 ) {
+ for my $journey (@results) {
+ if ( ( $journey->route )[0]->loc->name eq $train->origin ) {
+ $result = $journey;
+ last;
+ }
+ }
+ }
+
+ $promise->resolve( $result->id );
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+sub get_journey_p {
+ my ( $self, %opt ) = @_;
+
+ my $promise = Mojo::Promise->new;
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+
+ Travel::Status::DE::HAFAS->new_p(
+ journey => {
+ id => $opt{trip_id},
+ },
+ with_polyline => $opt{with_polyline},
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $self->{user_agent}->request_timeout(10),
+ )->then(
+ sub {
+ my ($hafas) = @_;
+ my $journey = $hafas->result;
+
+ if ($journey) {
+ $promise->resolve($journey);
+ return;
+ }
+ $promise->reject('no journey');
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+sub get_route_timestamps_p {
+ my ( $self, %opt ) = @_;
+
+ my $promise = Mojo::Promise->new;
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+
+ Travel::Status::DE::HAFAS->new_p(
+ journey => {
+ id => $opt{trip_id},
+
+ # name => $opt{train_no},
+ },
+ with_polyline => $opt{with_polyline},
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $self->{user_agent}->request_timeout(10),
+ )->then(
+ sub {
+ my ($hafas) = @_;
+ my $journey = $hafas->result;
+ my $ret = {};
+ my $polyline;
+
+ my $station_is_past = 1;
+ for my $stop ( $journey->route ) {
+ my $name = $stop->loc->name;
+ $ret->{$name} = $ret->{ $stop->loc->eva } = {
+ name => $stop->loc->name,
+ eva => $stop->loc->eva,
+ sched_arr => _epoch( $stop->sched_arr ),
+ sched_dep => _epoch( $stop->sched_dep ),
+ rt_arr => _epoch( $stop->rt_arr ),
+ rt_dep => _epoch( $stop->rt_dep ),
+ arr_delay => $stop->arr_delay,
+ dep_delay => $stop->dep_delay,
+ load => $stop->load
+ };
+ if ( $stop->tz_offset ) {
+ $ret->{$name}{tz_offset} = $stop->tz_offset;
+ }
+ if ( ( $stop->arr_cancelled or not $stop->sched_arr )
+ and ( $stop->dep_cancelled or not $stop->sched_dep ) )
+ {
+ $ret->{$name}{isCancelled} = 1;
+ }
+ if (
+ $station_is_past
+ and not $ret->{$name}{isCancelled}
+ and $now->epoch < (
+ $ret->{$name}{rt_arr} // $ret->{$name}{rt_dep}
+ // $ret->{$name}{sched_arr}
+ // $ret->{$name}{sched_dep} // $now->epoch
+ )
+ )
+ {
+ $station_is_past = 0;
+ }
+ $ret->{$name}{isPast} = $station_is_past;
+ }
+
+ 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} ] );
+ }
+ }
+ my $iris_stations = join( '|', $opt{train}->route );
+
+ # borders (Gr" as in "Grenze") are only returned by HAFAS.
+ # They are not stations.
+ my $hafas_stations
+ = join( '|', grep { $_ !~ m{(\(Gr\)|\)Gr)$} } @station_list );
+
+ if ( $iris_stations eq $hafas_stations
+ or index( $hafas_stations, $iris_stations ) != -1 )
+ {
+ $polyline = {
+ from_eva => ( $journey->route )[0]->loc->eva,
+ to_eva => ( $journey->route )[-1]->loc->eva,
+ coords => \@coordinate_list,
+ };
+ }
+ else {
+ $self->{log}->debug( 'Ignoring polyline for '
+ . $opt{train}->line
+ . ": IRIS route does not agree with HAFAS route: $iris_stations != $hafas_stations"
+ );
+ }
+ }
+
+ $promise->resolve( $ret, $journey, $polyline );
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+1;
diff --git a/lib/Travelynx/Helper/IRIS.pm b/lib/Travelynx/Helper/IRIS.pm
new file mode 100644
index 0000000..deed79a
--- /dev/null
+++ b/lib/Travelynx/Helper/IRIS.pm
@@ -0,0 +1,237 @@
+package Travelynx::Helper::IRIS;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use strict;
+use warnings;
+use 5.020;
+
+use utf8;
+
+use Mojo::Promise;
+use Mojo::UserAgent;
+use Travel::Status::DE::IRIS;
+use Travel::Status::DE::IRIS::Stations;
+
+sub new {
+ my ( $class, %opt ) = @_;
+
+ return bless( \%opt, $class );
+}
+
+sub get_departures {
+ my ( $self, %opt ) = @_;
+ my $station = $opt{station};
+ my $lookbehind = $opt{lookbehind} // 180;
+ my $lookahead = $opt{lookahead} // 30;
+ my $with_related = $opt{with_related} // 0;
+
+ # Berlin Hbf exists twice:
+ # - BLS / 8011160
+ # - BL / 8098160 (formerly "Berlin Hbf (tief)")
+ # Right now, travelynx assumes that station name -> EVA / DS100 is a unique
+ # map. This is not the case. Work around it here until travelynx has been
+ # adjusted properly.
+ if ( $station eq 'Berlin Hbf' or $station eq '8011160' ) {
+ $with_related = 1;
+ }
+
+ 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->{main_cache},
+ realtime_cache => $self->{realtime_cache},
+ 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->{version}
+ . ' +https://travelynx.de',
+ },
+ 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: '$station'. Mögliche Eingaben: "
+ . join( q{, }, map { $_->[1] } @station_matches ),
+ };
+ }
+ else {
+ return {
+ results => [],
+ errstr => 'Unbekannte Station',
+ };
+ }
+}
+
+sub get_departures_p {
+ my ( $self, %opt ) = @_;
+ my $station = $opt{station};
+ my $lookbehind = $opt{lookbehind} // 180;
+ my $lookahead = $opt{lookahead} // 30;
+ my $with_related = $opt{with_related} // 0;
+
+ # Berlin Hbf exists twice:
+ # - BLS / 8011160
+ # - BL / 8098160 (formerly "Berlin Hbf (tief)")
+ # Right now, travelynx assumes that station name -> EVA / DS100 is a unique
+ # map. This is not the case. Work around it here until travelynx has been
+ # adjusted properly.
+ if ( $station eq 'Berlin Hbf' or $station eq '8011160' ) {
+ $with_related = 1;
+ }
+
+ my @station_matches
+ = Travel::Status::DE::IRIS::Stations::get_station($station);
+
+ if ( @station_matches == 1 ) {
+ $station = $station_matches[0][0];
+ my $promise = Mojo::Promise->new;
+ Travel::Status::DE::IRIS->new_p(
+ station => $station,
+ main_cache => $self->{main_cache},
+ realtime_cache => $self->{realtime_cache},
+ 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->{version}
+ . ' +https://travelynx.de',
+ },
+ with_related => $with_related,
+ promise => 'Mojo::Promise',
+ user_agent => Mojo::UserAgent->new,
+ get_station => \&Travel::Status::DE::IRIS::Stations::get_station,
+ meta => Travel::Status::DE::IRIS::Stations::get_meta(),
+ )->then(
+ sub {
+ my ($status) = @_;
+ $promise->resolve(
+ {
+ 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 ],
+ }
+ );
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject(
+ $err,
+ {
+ results => [],
+ errstr => "Error in promise: $err",
+ }
+ );
+ return;
+ }
+ )->wait;
+ return $promise;
+ }
+ elsif ( @station_matches > 1 ) {
+ return Mojo::Promise->reject(
+ 'ambiguous station name',
+ {
+ results => [],
+ errstr => "Mehrdeutiger Stationsname: '$station'",
+ suggestions => [
+ map { { name => $_->[1], eva => $_->[2] } }
+ @station_matches
+ ],
+ }
+ );
+ }
+ else {
+ return Mojo::Promise->reject(
+ 'unknown station',
+ {
+ results => [],
+ errstr => 'Unbekannte Station',
+ }
+ );
+ }
+}
+
+sub route_diff {
+ 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], undef, { isAdditional => 1 } ], );
+ $route_idx++;
+ }
+ else {
+ push( @json_route,
+ [ $sched_route[$sched_idx], undef, { isCancelled => 1 } ], );
+ $sched_idx++;
+ }
+ }
+ while ( $route_idx <= $#route ) {
+ push( @json_route,
+ [ $route[$route_idx], undef, { isAdditional => 1 } ], );
+ $route_idx++;
+ }
+ while ( $sched_idx <= $#sched_route ) {
+ push( @json_route,
+ [ $sched_route[$sched_idx], undef, { isCancelled => 1 } ], );
+ $sched_idx++;
+ }
+ return @json_route;
+}
+
+1;
diff --git a/lib/Travelynx/Helper/Sendmail.pm b/lib/Travelynx/Helper/Sendmail.pm
index fa3c4fd..baa1156 100644
--- a/lib/Travelynx/Helper/Sendmail.pm
+++ b/lib/Travelynx/Helper/Sendmail.pm
@@ -1,5 +1,9 @@
package Travelynx::Helper::Sendmail;
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
use strict;
use warnings;
@@ -7,7 +11,7 @@ use 5.020;
use Encode qw(encode);
use Email::Sender::Simple qw(try_to_sendmail);
-use Email::Simple;
+use MIME::Entity;
sub new {
my ( $class, %opt ) = @_;
@@ -18,14 +22,14 @@ sub new {
sub custom {
my ( $self, $to, $subject, $body ) = @_;
- my $reg_mail = Email::Simple->create(
- header => [
- To => $to,
- From => 'Travelynx <travelynx@finalrewind.org>',
- Subject => $subject,
- 'Content-Type' => 'text/plain; charset=UTF-8',
- ],
- body => encode( 'utf-8', $body ),
+ my $reg_mail = MIME::Entity->build(
+ To => $to,
+ From => $self->{config}{from},
+ Subject => encode( 'MIME-Header', $subject ),
+ Type => 'text/plain',
+ Charset => 'UTF-8',
+ Encoding => 'quoted-printable',
+ Data => encode( 'utf-8', $body ),
);
if ( $self->{config}->{disabled} ) {
@@ -38,4 +42,34 @@ sub custom {
return try_to_sendmail($reg_mail);
}
+sub age_deletion_notification {
+ my ( $self, %opt ) = @_;
+ my $name = $opt{name};
+ my $email = $opt{email};
+ my $last_seen = $opt{last_seen};
+ my $login_url = $opt{login_url};
+ my $account_url = $opt{account_url};
+ my $imprint_url = $opt{imprint_url};
+
+ my $body = "Hallo ${name},\n\n";
+ $body
+ .= "Dein travelynx-Account wurde seit dem ${last_seen} nicht verwendet.\n";
+ $body
+ .= "Im Sinne der Datensparsamkeit wird er daher in vier Wochen gelöscht.\n";
+ $body
+ .= "Falls du den Account weiterverwenden möchtest, kannst du dich unter\n";
+ $body .= "<$login_url> anmelden.\n";
+ $body
+ .= "Durch die Anmeldung wird die Löschung automatisch abgebrochen.\n\n";
+ $body
+ .= "Falls du den Account löschen, aber zuvor deine Daten exportieren möchtest,\n";
+ $body .= "kannst du dich unter obiger URL anmelden, unter <$account_url>\n";
+ $body
+ .= "deine Daten exportieren und anschließend den Account löschen lassen.\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ return $self->custom( $email,
+ 'travelynx: Löschung deines Accounts', $body );
+}
+
1;
diff --git a/lib/Travelynx/Helper/Traewelling.pm b/lib/Travelynx/Helper/Traewelling.pm
new file mode 100644
index 0000000..d688004
--- /dev/null
+++ b/lib/Travelynx/Helper/Traewelling.pm
@@ -0,0 +1,387 @@
+package Travelynx::Helper::Traewelling;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+# Copyright (C) 2023 networkException <git@nwex.de>
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use strict;
+use warnings;
+use 5.020;
+use utf8;
+
+use DateTime;
+use DateTime::Format::Strptime;
+use Mojo::Promise;
+
+sub new {
+ my ( $class, %opt ) = @_;
+
+ my $version = $opt{version};
+
+ $opt{header} = {
+ 'User-Agent' =>
+"travelynx/${version} on $opt{root_url} +https://finalrewind.org/projects/travelynx",
+ 'Accept' => 'application/json',
+ };
+ $opt{strp1} = DateTime::Format::Strptime->new(
+ pattern => '%Y-%m-%dT%H:%M:%S.000000Z',
+ time_zone => 'UTC',
+ );
+ $opt{strp2} = DateTime::Format::Strptime->new(
+ pattern => '%Y-%m-%d %H:%M:%S',
+ time_zone => 'Europe/Berlin',
+ );
+ $opt{strp3} = DateTime::Format::Strptime->new(
+ pattern => '%Y-%m-%dT%H:%M:%S%z',
+ time_zone => 'Europe/Berlin',
+ );
+
+ return bless( \%opt, $class );
+}
+
+sub epoch_to_dt_or_undef {
+ my ($epoch) = @_;
+
+ if ( not $epoch ) {
+ return undef;
+ }
+
+ return DateTime->from_epoch(
+ epoch => $epoch,
+ time_zone => 'Europe/Berlin',
+ locale => 'de-DE',
+ );
+}
+
+sub parse_datetime {
+ my ( $self, $dt ) = @_;
+
+ return $self->{strp1}->parse_datetime($dt)
+ // $self->{strp2}->parse_datetime($dt)
+ // $self->{strp3}->parse_datetime($dt);
+}
+
+sub get_status_p {
+ my ( $self, %opt ) = @_;
+
+ my $username = $opt{username};
+ my $token = $opt{token};
+ my $promise = Mojo::Promise->new;
+
+ my $header = {
+ 'User-Agent' => $self->{header}{'User-Agent'},
+ 'Accept' => 'application/json',
+ 'Authorization' => "Bearer $token",
+ };
+
+ $self->{user_agent}->request_timeout(20)
+ ->get_p(
+ "https://traewelling.de/api/v1/user/${username}/statuses?limit=1" =>
+ $header )->then(
+ sub {
+ my ($tx) = @_;
+ if ( my $err = $tx->error ) {
+ my $err_msg
+ = "v1/user/${username}/statuses: HTTP $err->{code} $err->{message}";
+ $promise->reject( { http => $err->{code}, text => $err_msg } );
+ return;
+ }
+ else {
+ if ( my $status = $tx->result->json->{data}[0] ) {
+ my $status_id = $status->{id};
+ my $message = $status->{body};
+ my $checkin_at
+ = $self->parse_datetime( $status->{createdAt} );
+
+ my $dep_dt = $self->parse_datetime(
+ $status->{train}{origin}{departurePlanned} );
+ my $arr_dt = $self->parse_datetime(
+ $status->{train}{destination}{arrivalPlanned} );
+
+ my $dep_eva
+ = $status->{train}{origin}{evaIdentifier};
+ my $arr_eva
+ = $status->{train}{destination}{evaIdentifier};
+
+ my $dep_ds100
+ = $status->{train}{origin}{rilIdentifier};
+ my $arr_ds100
+ = $status->{train}{destination}{rilIdentifier};
+
+ my $dep_name
+ = $status->{train}{origin}{name};
+ my $arr_name
+ = $status->{train}{destination}{name};
+
+ my $category = $status->{train}{category};
+ my $linename = $status->{train}{lineName};
+ my $trip_id = $status->{train}{hafasId};
+ my ( $train_type, $train_line ) = split( qr{ }, $linename );
+ $promise->resolve(
+ {
+ http => $tx->res->code,
+ status_id => $status_id,
+ message => $message,
+ checkin => $checkin_at,
+ dep_dt => $dep_dt,
+ dep_eva => $dep_eva,
+ dep_ds100 => $dep_ds100,
+ dep_name => $dep_name,
+ arr_dt => $arr_dt,
+ arr_eva => $arr_eva,
+ arr_ds100 => $arr_ds100,
+ arr_name => $arr_name,
+ trip_id => $trip_id,
+ train_type => $train_type,
+ line => $linename,
+ line_no => $train_line,
+ category => $category,
+ }
+ );
+ return;
+ }
+ else {
+ $promise->reject(
+ { text => "v1/${username}/statuses: unknown error" } );
+ return;
+ }
+ }
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject( { text => "v1/${username}/statuses: $err" } );
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+sub get_user_p {
+ my ( $self, $uid, $token ) = @_;
+ my $ua = $self->{user_agent}->request_timeout(20);
+
+ my $header = {
+ 'User-Agent' => $self->{header}{'User-Agent'},
+ 'Accept' => 'application/json',
+ 'Authorization' => "Bearer $token",
+ };
+ my $promise = Mojo::Promise->new;
+
+ $ua->get_p( "https://traewelling.de/api/v1/auth/user" => $header )->then(
+ sub {
+ my ($tx) = @_;
+ if ( my $err = $tx->error ) {
+ my $err_msg = "v1/auth/user: HTTP $err->{code} $err->{message}";
+ $promise->reject($err_msg);
+ return;
+ }
+ else {
+ my $user_data = $tx->result->json->{data};
+ $self->{model}->set_user(
+ uid => $uid,
+ trwl_id => $user_data->{id},
+ screen_name => $user_data->{displayName},
+ user_name => $user_data->{username},
+ );
+ $promise->resolve;
+ return;
+ }
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject("v1/auth/user: $err");
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+sub logout_p {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $token = $opt{token};
+
+ my $ua = $self->{user_agent}->request_timeout(20);
+
+ my $header = {
+ 'User-Agent' => $self->{header}{'User-Agent'},
+ 'Accept' => 'application/json',
+ 'Authorization' => "Bearer $token",
+ };
+ my $request = {};
+
+ $self->{model}->unlink( uid => $uid );
+
+ my $promise = Mojo::Promise->new;
+
+ $ua->post_p(
+ "https://traewelling.de/api/v1/auth/logout" => $header => json =>
+ $request )->then(
+ sub {
+ my ($tx) = @_;
+ if ( my $err = $tx->error ) {
+ my $err_msg
+ = "v1/auth/logout: HTTP $err->{code} $err->{message}";
+ $promise->reject($err_msg);
+ return;
+ }
+ else {
+ $promise->resolve;
+ return;
+ }
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject("v1/auth/logout: $err");
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+sub convert_travelynx_to_traewelling_visibility {
+ my ($travelynx_visibility) = @_;
+
+ my %visibilities = (
+
+ # public => StatusVisibility::PUBLIC
+ 100 => 0,
+
+ # travelynx => StatusVisibility::AUTHENTICATED
+ # (only visible for logged in users)
+ 80 => 4,
+
+ # followers => StatusVisibility::FOLLOWERS
+ 60 => 2,
+
+ # unlisted => StatusVisibility::PRIVATE
+ # (there is no träwelling equivalent to unlisted, their
+ # StatusVisibility::UNLISTED shows the journey on the profile)
+ 30 => 3,
+
+ # private => StatusVisibility::PRIVATE
+ 10 => 3,
+ );
+
+ return $visibilities{$travelynx_visibility};
+}
+
+sub checkin_p {
+ my ( $self, %opt ) = @_;
+
+ my $header = {
+ 'User-Agent' => $self->{header}{'User-Agent'},
+ 'Accept' => 'application/json',
+ 'Authorization' => "Bearer $opt{token}",
+ };
+
+ my $departure_ts = epoch_to_dt_or_undef( $opt{dep_ts} );
+ my $arrival_ts = epoch_to_dt_or_undef( $opt{arr_ts} );
+
+ if ($departure_ts) {
+ $departure_ts = $departure_ts->rfc3339;
+ }
+ if ($arrival_ts) {
+ $arrival_ts = $arrival_ts->rfc3339;
+ }
+
+ my $request = {
+ tripId => $opt{trip_id},
+ lineName => $opt{train_type} . ' '
+ . ( $opt{train_line} // $opt{train_no} ),
+ ibnr => \1,
+ start => q{} . $opt{dep_eva},
+ destination => q{} . $opt{arr_eva},
+ departure => $departure_ts,
+ arrival => $arrival_ts,
+ toot => $opt{data}{toot} ? \1 : \0,
+ tweet => $opt{data}{tweet} ? \1 : \0,
+ visibility =>
+ convert_travelynx_to_traewelling_visibility( $opt{visibility} )
+ };
+
+ if ( $opt{user_data}{comment} ) {
+ $request->{body} = $opt{user_data}{comment};
+ }
+
+ my $debug_prefix
+ = "v1/trains/checkin('$request->{lineName}' $request->{tripId} $request->{start} -> $request->{destination})";
+
+ my $promise = Mojo::Promise->new;
+
+ $self->{user_agent}->request_timeout(20)
+ ->post_p(
+ "https://traewelling.de/api/v1/trains/checkin" => $header => json =>
+ $request )->then(
+ sub {
+ my ($tx) = @_;
+ if ( my $err = $tx->error ) {
+ my $err_msg = "HTTP $err->{code} $err->{message}";
+ if ( $tx->res->body ) {
+ if ( $err->{code} == 409 ) {
+ my $j = $tx->res->json;
+ $err_msg .= sprintf(
+': Bereits in %s eingecheckt: https://traewelling.de/status/%d',
+ $j->{message}{lineName},
+ $j->{message}{status_id}
+ );
+ }
+ else {
+ $err_msg .= ' ' . $tx->res->body;
+ }
+ }
+ $self->{log}
+ ->debug("Traewelling $debug_prefix error: $err_msg");
+ $self->{model}->log(
+ uid => $opt{uid},
+ message =>
+"Konnte $opt{train_type} $opt{train_no} nicht übertragen: $debug_prefix returned $err_msg",
+ is_error => 1
+ );
+ $promise->reject( { http => $err->{code} } );
+ return;
+ }
+ $self->{log}->debug( "... success! " . $tx->res->body );
+
+ $self->{model}->log(
+ uid => $opt{uid},
+ message => "Eingecheckt in $opt{train_type} $opt{train_no}",
+ status_id => $tx->res->json->{statusId}
+ );
+ $self->{model}->set_latest_push_ts(
+ uid => $opt{uid},
+ ts => $opt{checkin_ts}
+ );
+ $promise->resolve( { http => $tx->res->code } );
+
+ # TODO store status_id in in_transit object so that it can be shown
+ # on the user status page
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->{log}->debug("... $debug_prefix error: $err");
+ $self->{model}->log(
+ uid => $opt{uid},
+ message =>
+"Konnte $opt{train_type} $opt{train_no} nicht übertragen: $debug_prefix returned $err",
+ is_error => 1
+ );
+ $promise->reject( { connection => $err } );
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+1;
diff --git a/lib/Travelynx/Model/InTransit.pm b/lib/Travelynx/Model/InTransit.pm
new file mode 100644
index 0000000..69026ac
--- /dev/null
+++ b/lib/Travelynx/Model/InTransit.pm
@@ -0,0 +1,900 @@
+package Travelynx::Model::InTransit;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use strict;
+use warnings;
+use 5.020;
+
+use DateTime;
+use JSON;
+
+my %visibility_itoa = (
+ 100 => 'public',
+ 80 => 'travelynx',
+ 60 => 'followers',
+ 30 => 'unlisted',
+ 10 => 'private',
+);
+
+my %visibility_atoi = (
+ public => 100,
+ travelynx => 80,
+ followers => 60,
+ unlisted => 30,
+ private => 10,
+);
+
+sub _epoch {
+ my ($dt) = @_;
+
+ return $dt ? $dt->epoch : 0;
+}
+
+sub epoch_to_dt {
+ my ($epoch) = @_;
+
+ # Bugs (and user errors) may lead to undefined timestamps. Set them to
+ # 1970-01-01 to avoid crashing and show obviously wrong data instead.
+ $epoch //= 0;
+
+ return DateTime->from_epoch(
+ epoch => $epoch,
+ time_zone => 'Europe/Berlin',
+ locale => 'de-DE',
+ );
+}
+
+sub new {
+ my ( $class, %opt ) = @_;
+
+ return bless( \%opt, $class );
+}
+
+# merge [name, eva, data] from old_route into [name, undef, undef] from new_route.
+# If new_route already has eva/data, it is kept as-is.
+# changes new_route.
+sub _merge_old_route {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db};
+ my $uid = $opt{uid};
+ my $new_route = $opt{route};
+
+ my $res_h = $db->select( 'in_transit', ['route'], { user_id => $uid } )
+ ->expand->hash;
+ my $old_route = $res_h ? $res_h->{route} : [];
+
+ for my $i ( 0 .. $#{$new_route} ) {
+ if ( $old_route->[$i] and $old_route->[$i][0] eq $new_route->[$i][0] ) {
+ $new_route->[$i][1] //= $old_route->[$i][1];
+ if ( not keys %{ $new_route->[$i][2] // {} } ) {
+ $new_route->[$i][2] = $old_route->[$i][2];
+ }
+ }
+ }
+
+ return $new_route;
+}
+
+sub add {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $train = $opt{train};
+ my $journey = $opt{journey};
+ my $stop = $opt{stop};
+ my $checkin_station_id = $opt{departure_eva};
+ my $route = $opt{route};
+
+ my $json = JSON->new;
+
+ if ($train) {
+ $db->insert(
+ 'in_transit',
+ {
+ user_id => $uid,
+ cancelled => $train->departure_is_cancelled ? 1
+ : 0,
+ checkin_station_id => $checkin_station_id,
+ 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($route),
+ messages => $json->encode(
+ [ map { [ $_->[0]->epoch, $_->[1] ] } $train->messages ]
+ ),
+ data => JSON->new->encode(
+ {
+ rt => $train->departure_has_realtime ? 1
+ : 0
+ }
+ ),
+ }
+ );
+ }
+ elsif ( $journey and $stop ) {
+ my @route;
+ my $product = $journey->product_at( $stop->loc->eva )
+ // $journey->product;
+ for my $j_stop ( $journey->route ) {
+ push(
+ @route,
+ [
+ $j_stop->loc->name,
+ $j_stop->loc->eva,
+ {
+ sched_arr => _epoch( $j_stop->sched_arr ),
+ sched_dep => _epoch( $j_stop->sched_dep ),
+ rt_arr => _epoch( $j_stop->rt_arr ),
+ rt_dep => _epoch( $j_stop->rt_dep ),
+ arr_delay => $j_stop->arr_delay,
+ dep_delay => $j_stop->dep_delay,
+ load => $j_stop->load
+ }
+ ]
+ );
+ if ( defined $j_stop->tz_offset ) {
+ $route[-1][2]{tz_offset} = $j_stop->tz_offset;
+ }
+ }
+ $db->insert(
+ 'in_transit',
+ {
+ user_id => $uid,
+ cancelled => $stop->{dep_cancelled}
+ ? 1
+ : 0,
+ checkin_station_id => $stop->loc->eva,
+ checkin_time => DateTime->now( time_zone => 'Europe/Berlin' ),
+ dep_platform => $stop->{platform},
+ train_type => $product->type // q{},
+ train_line => $product->line_no,
+ train_no => $product->number // q{},
+ train_id => $journey->id,
+ sched_departure => $stop->{sched_dep},
+ real_departure => $stop->{rt_dep} // $stop->{sched_dep},
+ route => $json->encode( \@route ),
+ data => JSON->new->encode( { rt => $stop->{rt_dep} ? 1 : 0 } ),
+ }
+ );
+ }
+ else {
+ die('neither train nor journey specified');
+ }
+}
+
+sub add_from_journey {
+ my ( $self, %opt ) = @_;
+
+ my $journey = $opt{journey};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ $db->insert( 'in_transit', $journey );
+}
+
+sub delete {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ $db->delete( 'in_transit', { user_id => $uid } );
+}
+
+sub delete_incomplete_checkins {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+
+ return $db->delete( 'in_transit',
+ { checkin_time => { '<', $opt{earlier_than} } } )->rows;
+}
+
+sub postprocess {
+ my ( $self, $ret ) = @_;
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ my $epoch = $now->epoch;
+ my @route = @{ $ret->{route} // [] };
+ my @route_after;
+ my $dep_info;
+ my $is_after = 0;
+
+ for my $station (@route) {
+ if ($is_after) {
+ push( @route_after, $station );
+ }
+ if ( $ret->{dep_name}
+ and $station->[0] eq $ret->{dep_name} )
+ {
+ $is_after = 1;
+ if ( @{$station} > 1 and not $dep_info ) {
+ $dep_info = $station->[2];
+ }
+ }
+ }
+
+ my $ts = $ret->{checkout_ts} // $ret->{checkin_ts};
+ my $action_time = epoch_to_dt($ts);
+
+ $ret->{checked_in} = !$ret->{cancelled};
+ $ret->{timestamp} = $action_time;
+ $ret->{timestamp_delta} = $now->epoch - $action_time->epoch;
+ $ret->{boarding_countdown} = -1;
+ $ret->{sched_departure} = epoch_to_dt( $ret->{sched_dep_ts} );
+ $ret->{real_departure} = epoch_to_dt( $ret->{real_dep_ts} );
+ $ret->{sched_arrival} = epoch_to_dt( $ret->{sched_arr_ts} );
+ $ret->{real_arrival} = epoch_to_dt( $ret->{real_arr_ts} );
+ $ret->{route_after} = \@route_after;
+ $ret->{extra_data} = $ret->{data};
+ $ret->{comment} = $ret->{user_data}{comment};
+
+ $ret->{visibility_str}
+ = $ret->{visibility}
+ ? $visibility_itoa{ $ret->{visibility} }
+ : 'default';
+ $ret->{effective_visibility_str}
+ = $visibility_itoa{ $ret->{effective_visibility} };
+
+ 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} = epoch_to_dt( $dep_info->{rt_arr} );
+ $dep_info->{rt_arr_countdown} = $ret->{boarding_countdown}
+ = $dep_info->{rt_arr}->epoch - $epoch;
+ }
+
+ for my $station (@route_after) {
+ if ( @{$station} > 1 ) {
+
+ # Note: $station->[2]{sched_arr} may already have been
+ # converted to a DateTime object. 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->[2] // {};
+ if ( $times->{sched_arr}
+ and ref( $times->{sched_arr} ) ne 'DateTime' )
+ {
+ $times->{sched_arr}
+ = epoch_to_dt( $times->{sched_arr} );
+ if ( $times->{rt_arr} ) {
+ $times->{rt_arr}
+ = epoch_to_dt( $times->{rt_arr} );
+ $times->{arr_delay}
+ = $times->{rt_arr}->epoch - $times->{sched_arr}->epoch;
+ }
+ $times->{arr} = $times->{rt_arr} || $times->{sched_arr};
+ $times->{arr_countdown} = $times->{arr}->epoch - $epoch;
+ }
+ if ( $times->{sched_dep}
+ and ref( $times->{sched_dep} ) ne 'DateTime' )
+ {
+ $times->{sched_dep}
+ = epoch_to_dt( $times->{sched_dep} );
+ if ( $times->{rt_dep} ) {
+ $times->{rt_dep}
+ = epoch_to_dt( $times->{rt_dep} );
+ $times->{dep_delay}
+ = $times->{rt_dep}->epoch - $times->{sched_dep}->epoch;
+ }
+ $times->{dep} = $times->{rt_dep} || $times->{sched_dep};
+ $times->{dep_countdown} = $times->{dep}->epoch - $epoch;
+ }
+ }
+ }
+
+ $ret->{departure_countdown} = $ret->{real_departure}->epoch - $now->epoch;
+
+ if ( $ret->{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;
+ }
+
+ }
+ else {
+ $ret->{arrival_countdown} = undef;
+ $ret->{journey_duration} = undef;
+ $ret->{journey_completion} = undef;
+ }
+
+ return $ret;
+}
+
+sub get {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $table = 'in_transit';
+
+ if ( $opt{with_timestamps} ) {
+ $table = 'in_transit_str';
+ }
+
+ my $res = $db->select( $table, '*', { user_id => $uid } );
+ my $ret;
+
+ if ( $opt{with_data} ) {
+ $ret = $res->expand->hash;
+ }
+ else {
+ $ret = $res->hash;
+ }
+
+ if ( $opt{with_visibility} and $ret ) {
+ $ret->{visibility_str}
+ = $ret->{visibility}
+ ? $visibility_itoa{ $ret->{visibility} }
+ : 'default';
+ $ret->{effective_visibility_str}
+ = $visibility_itoa{ $ret->{effective_visibility} };
+ }
+
+ if ( $opt{postprocess} and $ret ) {
+ return $self->postprocess($ret);
+ }
+
+ return $ret;
+}
+
+sub get_timeline {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $where = {
+ follower_id => $uid,
+ effective_visibility => { '>=', 60 }
+ };
+
+ if ( $opt{short} ) {
+ return $db->select(
+ 'follows_in_transit',
+ [
+ qw(followee_name train_type train_line train_no train_id dep_eva dep_name arr_eva arr_name)
+ ],
+ $where
+ )->hashes->each;
+ }
+
+ my $res = $db->select( 'follows_in_transit', '*', $where );
+ my $ret;
+
+ if ( $opt{with_data} ) {
+ return map { $self->postprocess($_) } $res->expand->hashes->each;
+ }
+ else {
+ return $res->hashes->each;
+ }
+}
+
+sub get_all_active {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ return $db->select( 'in_transit_str', '*', { cancelled => 0 } )
+ ->hashes->each;
+}
+
+sub get_checkout_station_id {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $status = $db->select( 'in_transit', ['checkout_station_id'],
+ { user_id => $uid } )->hash;
+
+ if ($status) {
+ return $status->{checkout_station_id};
+ }
+ return;
+}
+
+sub set_cancelled_destination {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $cancelled_destination = $opt{cancelled_destination};
+
+ my $res_h = $db->select( 'in_transit', ['data'], { user_id => $uid } )
+ ->expand->hash;
+
+ my $data = $res_h ? $res_h->{data} : {};
+
+ $data->{cancelled_destination} = $cancelled_destination;
+
+ $db->update(
+ 'in_transit',
+ {
+ checkout_station_id => undef,
+ checkout_time => undef,
+ arr_platform => undef,
+ sched_arrival => undef,
+ real_arrival => undef,
+ data => JSON->new->encode($data),
+ },
+ { user_id => $uid }
+ );
+}
+
+sub set_arrival {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $train = $opt{train};
+ my $route = $opt{route};
+
+ $route = $self->_merge_old_route(
+ db => $db,
+ uid => $uid,
+ route => $route
+ );
+
+ my $json = JSON->new;
+
+ $db->update(
+ 'in_transit',
+ {
+ checkout_time => DateTime->now( time_zone => 'Europe/Berlin' ),
+ arr_platform => $train->platform,
+ sched_arrival => $train->sched_arrival,
+ real_arrival => $train->arrival,
+ route => $json->encode($route),
+ messages => $json->encode(
+ [ map { [ $_->[0]->epoch, $_->[1] ] } $train->messages ]
+ )
+ },
+ { user_id => $uid }
+ );
+}
+
+sub set_arrival_eva {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $checkout_station_id = $opt{arrival_eva};
+
+ $db->update(
+ 'in_transit',
+ {
+ checkout_station_id => $checkout_station_id,
+ },
+ { user_id => $uid }
+ );
+}
+
+sub set_arrival_times {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $sched_arr = $opt{sched_arrival};
+ my $rt_arr = $opt{rt_arrival};
+
+ $db->update(
+ 'in_transit',
+ {
+ sched_arrival => $sched_arr,
+ real_arrival => $rt_arr
+ },
+ { user_id => $uid }
+ );
+}
+
+sub set_polyline {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $polyline = $opt{polyline};
+ my $old_id = $opt{old_id};
+
+ my $coords = $polyline->{coords};
+ my $from_eva = $polyline->{from_eva};
+ my $to_eva = $polyline->{to_eva};
+
+ my $polyline_str = JSON->new->encode($coords);
+
+ my $pl_res = $db->select(
+ 'polylines',
+ ['id'],
+ {
+ origin_eva => $from_eva,
+ destination_eva => $to_eva,
+ polyline => $polyline_str,
+ },
+ { limit => 1 }
+ );
+
+ my $polyline_id;
+ if ( my $h = $pl_res->hash ) {
+ $polyline_id = $h->{id};
+ }
+ else {
+ eval {
+ $polyline_id = $db->insert(
+ 'polylines',
+ {
+ origin_eva => $from_eva,
+ destination_eva => $to_eva,
+ polyline => $polyline_str
+ },
+ { returning => 'id' }
+ )->hash->{id};
+ };
+ if ($@) {
+ $self->{log}->warn("add_route_timestamps: insert polyline: $@");
+ }
+ }
+ if ( $polyline_id and ( not defined $old_id or $polyline_id != $old_id ) ) {
+ $self->set_polyline_id(
+ uid => $uid,
+ db => $db,
+ polyline_id => $polyline_id
+ );
+ }
+
+}
+
+sub set_polyline_id {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $polyline_id = $opt{polyline_id};
+
+ $db->update(
+ 'in_transit',
+ { polyline_id => $polyline_id },
+ { user_id => $uid }
+ );
+}
+
+sub set_route_data {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $route = $opt{route};
+ my $delay_msg = $opt{delay_messages};
+ my $qos_msg = $opt{qos_messages};
+ my $him_msg = $opt{him_messages};
+
+ my $res_h = $db->select( 'in_transit', ['data'], { user_id => $uid } )
+ ->expand->hash;
+
+ my $data = $res_h ? $res_h->{data} : {};
+
+ $data->{delay_msg} = $opt{delay_messages};
+ $data->{qos_msg} = $opt{qos_messages};
+ $data->{him_msg} = $opt{him_messages};
+
+ # no need to merge $route, it already contains HAFAS data
+ $db->update(
+ 'in_transit',
+ {
+ route => JSON->new->encode($route),
+ data => JSON->new->encode($data)
+ },
+ { user_id => $uid }
+ );
+}
+
+sub unset_arrival_data {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ $db->update(
+ 'in_transit',
+ {
+ checkout_time => undef,
+ arr_platform => undef,
+ sched_arrival => undef,
+ real_arrival => undef,
+ },
+ { user_id => $uid }
+ );
+}
+
+sub update_departure {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $train = $opt{train};
+ my $route = $opt{route};
+ my $json = JSON->new;
+
+ $route = $self->_merge_old_route(
+ db => $db,
+ uid => $uid,
+ route => $route
+ );
+
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ dep_platform => $train->platform,
+ real_departure => $train->departure,
+ route => $json->encode($route),
+ messages => $json->encode(
+ [ map { [ $_->[0]->epoch, $_->[1] ] } $train->messages ]
+ ),
+ },
+ {
+ user_id => $uid,
+ train_no => $train->train_no,
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_departure_cancelled {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $train = $opt{train};
+
+ # depending on the amount of users in transit, some time may
+ # have passed between fetching $entry from the database and
+ # now. Ensure that the user is still checked into this train
+ # by selecting on uid, train no, and checkin/checkout station ID.
+ my $rows = $db->update(
+ 'in_transit',
+ {
+ cancelled => 1,
+ },
+ {
+ user_id => $uid,
+ train_no => $train->train_no,
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ )->rows;
+
+ return $rows;
+}
+
+sub update_departure_hafas {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $journey = $opt{journey};
+ my $stop = $opt{stop};
+ my $json = JSON->new;
+
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ real_departure => $stop->{rt_dep},
+ },
+ {
+ user_id => $uid,
+ train_id => $journey->id,
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_arrival {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $train = $opt{train};
+ my $route = $opt{route};
+ my $json = JSON->new;
+
+ $route = $self->_merge_old_route(
+ db => $db,
+ uid => $uid,
+ route => $route
+ );
+
+ # selecting on user_id, train_no and checkout_station_id avoids a
+ # race condition when a user checks into a new train or changes
+ # their destination station while we are fetching times based on no
+ # longer valid database entries.
+ my $rows = $db->update(
+ 'in_transit',
+ {
+ arr_platform => $train->platform,
+ sched_arrival => $train->sched_arrival,
+ real_arrival => $train->arrival,
+ route => $json->encode($route),
+ messages => $json->encode(
+ [ map { [ $_->[0]->epoch, $_->[1] ] } $train->messages ]
+ ),
+ },
+ {
+ user_id => $uid,
+ train_no => $train->train_no,
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ )->rows;
+
+ return $rows;
+}
+
+sub update_arrival_hafas {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $journey = $opt{journey};
+ my $stop = $opt{stop};
+ my $json = JSON->new;
+
+ # TODO use old rt data if available
+ my @route;
+ for my $j_stop ( $journey->route ) {
+ push(
+ @route,
+ [
+ $j_stop->loc->name,
+ $j_stop->loc->eva,
+ {
+ sched_arr => _epoch( $j_stop->sched_arr ),
+ sched_dep => _epoch( $j_stop->sched_dep ),
+ rt_arr => _epoch( $j_stop->rt_arr ),
+ rt_dep => _epoch( $j_stop->rt_dep ),
+ arr_delay => $j_stop->arr_delay,
+ dep_delay => $j_stop->dep_delay,
+ load => $j_stop->load
+ }
+ ]
+ );
+ if ( defined $j_stop->tz_offset ) {
+ $route[-1][2]{tz_offset} = $j_stop->tz_offset;
+ }
+ }
+
+ my $res_h = $db->select( 'in_transit', ['route'], { user_id => $uid } )
+ ->expand->hash;
+ my $old_route = $res_h ? $res_h->{route} : [];
+
+ for my $i ( 0 .. $#route ) {
+ if ( $old_route->[$i] and $old_route->[$i][1] == $route[$i][1] ) {
+ for my $k (qw(rt_arr rt_dep arr_delay dep_delay)) {
+ $route[$i][2]{$k} //= $old_route->[$i][2]{$k};
+ }
+ }
+ }
+
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ real_arrival => $stop->{rt_arr},
+ route => $json->encode( [@route] ),
+ },
+ {
+ user_id => $uid,
+ train_id => $journey->id,
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_data {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $new_data = $opt{data} // {};
+
+ my $res_h = $db->select( 'in_transit', ['data'], { user_id => $uid } )
+ ->expand->hash;
+
+ my $data = $res_h ? $res_h->{data} : {};
+
+ while ( my ( $k, $v ) = each %{$new_data} ) {
+ $data->{$k} = $v;
+ }
+
+ $db->update(
+ 'in_transit',
+ { data => JSON->new->encode($data) },
+ { user_id => $uid }
+ );
+}
+
+sub update_user_data {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $new_data = $opt{user_data} // {};
+
+ my $res_h = $db->select( 'in_transit', ['user_data'], { user_id => $uid } )
+ ->expand->hash;
+
+ my $data = $res_h ? $res_h->{user_data} : {};
+
+ while ( my ( $k, $v ) = each %{$new_data} ) {
+ $data->{$k} = $v;
+ }
+
+ $db->update(
+ 'in_transit',
+ { user_data => JSON->new->encode($data) },
+ { user_id => $uid }
+ );
+}
+
+sub update_visibility {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $visibility;
+
+ if ( $opt{visibility} and $visibility_atoi{ $opt{visibility} } ) {
+ $visibility = $visibility_atoi{ $opt{visibility} };
+ }
+
+ $db->update(
+ 'in_transit',
+ { visibility => $visibility },
+ { user_id => $uid }
+ );
+}
+
+1;
diff --git a/lib/Travelynx/Model/JourneyStatsCache.pm b/lib/Travelynx/Model/JourneyStatsCache.pm
new file mode 100755
index 0000000..d23eb04
--- /dev/null
+++ b/lib/Travelynx/Model/JourneyStatsCache.pm
@@ -0,0 +1,122 @@
+package Travelynx::Model::JourneyStatsCache;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use strict;
+use warnings;
+use 5.020;
+use utf8;
+
+import JSON;
+
+sub new {
+ my ( $class, %opt ) = @_;
+
+ return bless( \%opt, $class );
+}
+
+sub add {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+
+ eval {
+ $db->insert(
+ 'journey_stats',
+ {
+ user_id => $opt{uid},
+ year => $opt{year},
+ month => $opt{month},
+ data => JSON->new->encode( $opt{stats} ),
+ }
+ );
+ };
+ if ( my $err = $@ ) {
+ if ( $err =~ m{duplicate key value violates unique constraint} ) {
+
+ # If 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($@);
+ }
+ }
+}
+
+sub get {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $stats = $db->select(
+ 'journey_stats',
+ ['data'],
+ {
+ user_id => $opt{uid},
+ year => $opt{year},
+ month => $opt{month}
+ }
+ )->expand->hash;
+
+ return $stats->{data};
+}
+
+# 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.
+sub invalidate {
+ my ( $self, %opt ) = @_;
+
+ my $ts = $opt{ts};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ $db->delete(
+ 'journey_stats',
+ {
+ user_id => $uid,
+ year => $ts->year,
+ month => $ts->month,
+ }
+ );
+ $db->delete(
+ 'journey_stats',
+ {
+ user_id => $uid,
+ year => $ts->year,
+ month => 0,
+ }
+ );
+}
+
+sub get_yyyymm_having_stats {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $res = $db->select(
+ 'journey_stats',
+ [ 'year', 'month' ],
+ { user_id => $uid },
+ { order_by => { -asc => [ 'year', 'month' ] } }
+ );
+
+ my @ret;
+ for my $row ( $res->hashes->each ) {
+ if ( $row->{month} != 0 ) {
+ push( @ret, [ $row->{year}, $row->{month} ] );
+ }
+ }
+
+ return @ret;
+}
+
+1;
diff --git a/lib/Travelynx/Model/Journeys.pm b/lib/Travelynx/Model/Journeys.pm
new file mode 100755
index 0000000..97c4681
--- /dev/null
+++ b/lib/Travelynx/Model/Journeys.pm
@@ -0,0 +1,1785 @@
+package Travelynx::Model::Journeys;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use GIS::Distance;
+use List::MoreUtils qw(after_incl before_incl);
+
+use strict;
+use warnings;
+use 5.020;
+use utf8;
+
+use DateTime;
+use JSON;
+
+my %visibility_itoa = (
+ 100 => 'public',
+ 80 => 'travelynx',
+ 60 => 'followers',
+ 30 => 'unlisted',
+ 10 => 'private',
+);
+
+my %visibility_atoi = (
+ public => 100,
+ travelynx => 80,
+ followers => 60,
+ unlisted => 30,
+ private => 10,
+);
+
+my @month_name
+ = (
+ qw(Januar Februar März April Mai Juni Juli August September Oktober November Dezember)
+ );
+
+sub epoch_to_dt {
+ my ($epoch) = @_;
+
+ # Bugs (and user errors) may lead to undefined timestamps. Set them to
+ # 1970-01-01 to avoid crashing and show obviously wrong data instead.
+ $epoch //= 0;
+
+ return DateTime->from_epoch(
+ epoch => $epoch,
+ time_zone => 'Europe/Berlin',
+ locale => 'de-DE',
+ );
+}
+
+sub min_to_human {
+ my ( $self, $minutes ) = @_;
+
+ my @ret;
+
+ if ( $minutes >= 14 * 24 * 60 ) {
+ push( @ret, int( $minutes / ( 7 * 24 * 60 ) ) . ' Wochen' );
+ }
+ elsif ( $minutes >= 7 * 24 * 60 ) {
+ push( @ret, '1 Woche' );
+ }
+ $minutes %= 7 * 24 * 60;
+
+ if ( $minutes >= 2 * 24 * 60 ) {
+ push( @ret, int( $minutes / ( 24 * 60 ) ) . ' Tage' );
+ }
+ elsif ( $minutes >= 24 * 60 ) {
+ push( @ret, '1 Tag' );
+ }
+ $minutes %= 24 * 60;
+
+ if ( $minutes >= 2 * 60 ) {
+ push( @ret, int( $minutes / 60 ) . ' Stunden' );
+ }
+ elsif ( $minutes >= 60 ) {
+ push( @ret, '1 Stunde' );
+ }
+ $minutes %= 60;
+
+ if ( $minutes >= 2 ) {
+ push( @ret, "$minutes Minuten" );
+ }
+ elsif ($minutes) {
+ push( @ret, '1 Minute' );
+ }
+
+ if ( @ret == 0 ) {
+ return '0 Minuten';
+ }
+
+ if ( @ret == 1 ) {
+ return $ret[0];
+ }
+
+ my $last = pop(@ret);
+ return join( ', ', @ret ) . " und $last";
+}
+
+sub new {
+ my ( $class, %opt ) = @_;
+
+ return bless( \%opt, $class );
+}
+
+sub stats_cache {
+ my ($self) = @_;
+ return $self->{stats_cache};
+}
+
+# Returns (journey id, error)
+# Must be called during a transaction.
+# Must perform a rollback on error.
+sub add {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db};
+ my $uid = $opt{uid};
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ my $dep_station = $self->{stations}->search( $opt{dep_station} );
+ my $arr_station = $self->{stations}->search( $opt{arr_station} );
+
+ if ( not $dep_station ) {
+ return ( undef, 'Unbekannter Startbahnhof' );
+ }
+ if ( not $arr_station ) {
+ return ( undef, 'Unbekannter Zielbahnhof' );
+ }
+
+ my $daily_journey_count = $db->select(
+ 'journeys_str',
+ 'count(*) as count',
+ {
+ user_id => $uid,
+ real_dep_ts => {
+ -between => [
+ $opt{rt_departure}->clone->subtract( days => 1 )->epoch,
+ $opt{rt_departure}->epoch
+ ],
+ },
+ }
+ )->hash->{count};
+
+ if ( $daily_journey_count >= 100 ) {
+ return ( undef,
+"In den 24 Stunden vor der angegebenen Abfahrtszeit wurden ${daily_journey_count} weitere Fahrten angetreten. Das kann nicht stimmen."
+ );
+ }
+
+ my $route_has_start = 0;
+ my $route_has_stop = 0;
+
+ for my $station ( @{ $opt{route} || [] } ) {
+ if ( $station eq $dep_station->{name}
+ or $station eq $dep_station->{ds100} )
+ {
+ $route_has_start = 1;
+ }
+ if ( $station eq $arr_station->{name}
+ or $station eq $arr_station->{ds100} )
+ {
+ $route_has_stop = 1;
+ }
+ }
+
+ my @route;
+
+ if ( not $route_has_start ) {
+ push( @route, [ $dep_station->{name}, $dep_station->{eva}, {} ] );
+ }
+
+ if ( $opt{route} ) {
+ my @unknown_stations;
+ for my $station ( @{ $opt{route} } ) {
+ my $station_info = $self->{stations}->search($station);
+ if ($station_info) {
+ push( @route,
+ [ $station_info->{name}, $station_info->{eva}, {} ] );
+ }
+ else {
+ push( @route, [ $station, undef, {} ] );
+ push( @unknown_stations, $station );
+ }
+ }
+
+ 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 ( not $route_has_stop ) {
+ push( @route, [ $arr_station->{name}, $arr_station->{eva}, {} ] );
+ }
+
+ 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->{eva},
+ checkin_time => $now,
+ sched_departure => $opt{sched_departure},
+ real_departure => $opt{rt_departure},
+ checkout_station_id => $arr_station->{eva},
+ 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 ( $opt{comment} ) {
+ $entry->{user_data}
+ = JSON->new->encode( { comment => $opt{comment} } );
+ }
+
+ my $journey_id = undef;
+ eval {
+ $journey_id
+ = $db->insert( 'journeys', $entry, { returning => 'id' } )
+ ->hash->{id};
+ $self->stats_cache->invalidate(
+ ts => $opt{rt_departure},
+ db => $db,
+ uid => $uid
+ );
+ };
+
+ if ($@) {
+ $self->{log}->error("add_journey($uid): $@");
+ return ( undef, 'add_journey failed: ' . $@ );
+ }
+
+ return ( $journey_id, undef );
+}
+
+sub add_from_in_transit {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db};
+ my $journey = $opt{journey};
+
+ delete $journey->{data};
+ $journey->{edited} = 0;
+ $journey->{checkout_time} = DateTime->now( time_zone => 'Europe/Berlin' );
+
+ return $db->insert( 'journeys', $journey, { returning => 'id' } )
+ ->hash->{id};
+}
+
+sub update {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $journey_id = $opt{id};
+
+ my $rows;
+
+ my $journey = $self->get_single(
+ uid => $uid,
+ db => $db,
+ journey_id => $journey_id,
+ with_datetime => 1,
+ );
+
+ eval {
+ if ( exists $opt{from_name} ) {
+ my $from_station = $self->{stations}->search( $opt{from_name} );
+ if ( not $from_station ) {
+ die("Unbekannter Startbahnhof\n");
+ }
+ $rows = $db->update(
+ 'journeys',
+ {
+ checkin_station_id => $from_station->{eva},
+ edited => $journey->{edited} | 0x0004,
+ },
+ {
+ id => $journey_id,
+ }
+ )->rows;
+ }
+ if ( exists $opt{to_name} ) {
+ my $to_station = $self->{stations}->search( $opt{to_name} );
+ if ( not $to_station ) {
+ die("Unbekannter Zielbahnhof\n");
+ }
+ $rows = $db->update(
+ 'journeys',
+ {
+ checkout_station_id => $to_station->{eva},
+ edited => $journey->{edited} | 0x0400,
+ },
+ {
+ id => $journey_id,
+ }
+ )->rows;
+ }
+ if ( exists $opt{sched_departure} ) {
+ $rows = $db->update(
+ 'journeys',
+ {
+ sched_departure => $opt{sched_departure},
+ edited => $journey->{edited} | 0x0001,
+ },
+ {
+ id => $journey_id,
+ }
+ )->rows;
+ }
+ if ( exists $opt{rt_departure} ) {
+ $rows = $db->update(
+ 'journeys',
+ {
+ real_departure => $opt{rt_departure},
+ edited => $journey->{edited} | 0x0002,
+ },
+ {
+ 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->stats_cache->invalidate(
+ ts => $opt{rt_departure},
+ db => $db,
+ uid => $uid,
+ );
+ }
+ if ( exists $opt{sched_arrival} ) {
+ $rows = $db->update(
+ 'journeys',
+ {
+ sched_arrival => $opt{sched_arrival},
+ edited => $journey->{edited} | 0x0100,
+ },
+ {
+ id => $journey_id,
+ }
+ )->rows;
+ }
+ if ( exists $opt{rt_arrival} ) {
+ $rows = $db->update(
+ 'journeys',
+ {
+ real_arrival => $opt{rt_arrival},
+ edited => $journey->{edited} | 0x0200,
+ },
+ {
+ id => $journey_id,
+ }
+ )->rows;
+ }
+ if ( exists $opt{route} ) {
+ my @new_route = map { [ $_, undef, {} ] } @{ $opt{route} };
+ $rows = $db->update(
+ 'journeys',
+ {
+ route => JSON->new->encode( \@new_route ),
+ edited => $journey->{edited} | 0x0010,
+ },
+ {
+ id => $journey_id,
+ }
+ )->rows;
+ }
+ if ( exists $opt{cancelled} ) {
+ $rows = $db->update(
+ 'journeys',
+ {
+ cancelled => $opt{cancelled},
+ edited => $journey->{edited} | 0x0020,
+ },
+ {
+ id => $journey_id,
+ }
+ )->rows;
+ }
+ if ( exists $opt{comment} ) {
+ $journey->{user_data}{comment} = $opt{comment};
+ $rows = $db->update(
+ 'journeys',
+ {
+ user_data => JSON->new->encode( $journey->{user_data} ),
+ },
+ {
+ id => $journey_id,
+ }
+ )->rows;
+ }
+ if ( not defined $rows ) {
+ die("Invalid update key\n");
+ }
+ };
+
+ if ($@) {
+ $self->{log}->error("update($journey_id): $@");
+ return "update($journey_id): $@";
+ }
+ if ( $rows == 1 ) {
+ $self->stats_cache->invalidate(
+ ts => $journey->{rt_departure},
+ db => $db,
+ uid => $uid,
+ );
+ return undef;
+ }
+ return "update($journey_id): did not match any journey part";
+}
+
+sub delete {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $journey_id = $opt{id};
+ my $checkin_epoch = $opt{checkin};
+ my $checkout_epoch = $opt{checkout};
+
+ my @journeys = $self->get(
+ 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 = $db->delete(
+ 'journeys',
+ {
+ user_id => $uid,
+ id => $journey_id,
+ }
+ )->rows;
+ };
+
+ if ($@) {
+ $self->{log}->error("Delete($uid, $journey_id): $@");
+ return 'DELETE failed: ' . $@;
+ }
+
+ if ( $rows == 1 ) {
+ $self->stats_cache->invalidate(
+ ts => epoch_to_dt( $journey->{rt_dep_ts} ),
+ uid => $uid
+ );
+ return undef;
+ }
+ return sprintf( 'Deleted %d rows, expected 1', $rows );
+}
+
+# Used for undo (move journey entry to in_transit)
+sub pop {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db};
+ my $journey_id = $opt{journey_id};
+
+ my $journey = $db->select(
+ 'journeys',
+ '*',
+ {
+ user_id => $uid,
+ id => $journey_id
+ }
+ )->hash;
+
+ $db->delete(
+ 'journeys',
+ {
+ user_id => $uid,
+ id => $journey_id
+ }
+ );
+
+ return $journey;
+}
+
+sub get {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+
+ # If get 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 @select
+ = (
+ qw(journey_id train_type train_line train_no checkin_ts sched_dep_ts real_dep_ts dep_eva dep_ds100 dep_name dep_lat dep_lon checkout_ts sched_arr_ts real_arr_ts arr_eva arr_ds100 arr_name arr_lat arr_lon cancelled edited route messages user_data visibility effective_visibility)
+ );
+ 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{sched_dep_ts} ) {
+ $where{sched_dep_ts} = $opt{sched_dep_ts};
+ }
+
+ 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, ] };
+ }
+ elsif ( $opt{after} ) {
+ $where{real_dep_ts} = { '>=', $opt{after}->epoch };
+ }
+ elsif ( $opt{before} ) {
+ $where{real_dep_ts} = { '<=', $opt{before}->epoch };
+ }
+
+ if ( $opt{with_polyline} ) {
+ push( @select, 'polyline' );
+ }
+
+ if ( $opt{min_visibility} ) {
+ if ( $visibility_atoi{ $opt{min_visibility} } ) {
+ $opt{min_visibility} = $visibility_atoi{ $opt{min_visibility} };
+ }
+ $where{effective_visibility} = { '>=', $opt{min_visibility} };
+ }
+
+ my @travels;
+
+ my $res = $db->select( 'journeys_str', \@select, \%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},
+ from_ds100 => $entry->{dep_ds100},
+ from_name => $entry->{dep_name},
+ from_latlon => [ $entry->{dep_lat}, $entry->{dep_lon} ],
+ checkin_ts => $entry->{checkin_ts},
+ sched_dep_ts => $entry->{sched_dep_ts},
+ rt_dep_ts => $entry->{real_dep_ts},
+ to_eva => $entry->{arr_eva},
+ to_ds100 => $entry->{arr_ds100},
+ to_name => $entry->{arr_name},
+ to_latlon => [ $entry->{arr_lat}, $entry->{arr_lon} ],
+ 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},
+ visibility => $entry->{visibility},
+ effective_visibility => $entry->{effective_visibility},
+ };
+
+ if ( $opt{with_visibility} ) {
+ $ref->{visibility_str}
+ = $ref->{visibility}
+ ? $visibility_itoa{ $ref->{visibility} }
+ : 'default';
+ $ref->{effective_visibility_str}
+ = $visibility_itoa{ $ref->{effective_visibility} };
+ }
+
+ if ( $opt{with_polyline} ) {
+ $ref->{polyline} = $entry->{polyline};
+ }
+
+ 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} );
+ for my $stop ( @{ $ref->{route} } ) {
+ for my $k (qw(rt_arr rt_dep sched_arr sched_dep)) {
+ if ( $stop->[2]{$k} ) {
+ $stop->[2]{$k} = epoch_to_dt( $stop->[2]{$k} );
+ }
+ }
+ }
+ }
+
+ if ( $opt{verbose} ) {
+ my $rename = $self->{renamed_station};
+ for my $stop ( @{ $ref->{route} } ) {
+ if ( $stop->[0] =~ m{^Betriebsstelle nicht bekannt (\d+)$} ) {
+ if ( my $s = $self->{stations}->get_by_eva($1) ) {
+ $stop->[0] = $s->{name};
+ }
+ }
+ if ( $rename->{ $stop->[0] } ) {
+ $stop->[0] = $rename->{ $stop->[0] };
+ }
+ }
+ $ref->{cancelled} = $entry->{cancelled};
+ my @parsed_messages;
+ for my $message ( @{ $ref->{messages} // [] } ) {
+ my ( $ts, $msg ) = @{$message};
+ push( @parsed_messages, [ epoch_to_dt($ts), $msg ] );
+ }
+ $ref->{messages} = [ reverse @parsed_messages ];
+ $ref->{sched_duration}
+ = defined $ref->{sched_arr_ts}
+ ? $ref->{sched_arr_ts} - $ref->{sched_dep_ts}
+ : undef;
+ $ref->{rt_duration}
+ = defined $ref->{rt_arr_ts}
+ ? $ref->{rt_arr_ts} - $ref->{rt_dep_ts}
+ : undef;
+ my ( $km_polyline, $km_route, $km_beeline, $skip )
+ = $self->get_travel_distance($ref);
+ $ref->{km_route} = $km_polyline || $km_route;
+ $ref->{skip_route} = $km_polyline ? 0 : $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 );
+ }
+
+ return @travels;
+}
+
+sub get_single {
+ my ( $self, %opt ) = @_;
+
+ $opt{cancelled} = 'any';
+ my @journeys = $self->get(%opt);
+ if ( @journeys == 0 ) {
+ return undef;
+ }
+
+ return $journeys[0];
+}
+
+sub get_latest {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $latest_successful = $db->select(
+ 'journeys_str',
+ '*',
+ {
+ user_id => $uid,
+ cancelled => 0
+ },
+ {
+ order_by => { -desc => 'real_dep_ts' },
+ limit => 1
+ }
+ )->expand->hash;
+
+ if ($latest_successful) {
+ $latest_successful->{visibility_str}
+ = $latest_successful->{visibility}
+ ? $visibility_itoa{ $latest_successful->{visibility} }
+ : 'default';
+ $latest_successful->{effective_visibility_str}
+ = $visibility_itoa{ $latest_successful->{effective_visibility} };
+ }
+
+ my $latest = $db->select(
+ 'journeys_str',
+ '*',
+ {
+ user_id => $uid,
+ },
+ {
+ order_by => { -desc => 'journey_id' },
+ limit => 1
+ }
+ )->expand->hash;
+
+ if ($latest) {
+ $latest->{visibility_str}
+ = $latest->{visibility}
+ ? $visibility_itoa{ $latest->{visibility} }
+ : 'default';
+ $latest->{effective_visibility_str}
+ = $visibility_itoa{ $latest->{effective_visibility} };
+ }
+
+ return ( $latest_successful, $latest );
+}
+
+sub get_oldest_ts {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $res_h = $db->select(
+ 'journeys_str',
+ ['sched_dep_ts'],
+ {
+ user_id => $uid,
+ },
+ {
+ limit => 1,
+ order_by => {
+ -asc => 'real_dep_ts',
+ },
+ }
+ )->hash;
+
+ if ($res_h) {
+ return epoch_to_dt( $res_h->{sched_dep_ts} );
+ }
+ return undef;
+}
+
+sub get_latest_checkout_station_id {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $res_h = $db->select(
+ 'journeys',
+ ['checkout_station_id'],
+ {
+ user_id => $uid,
+ cancelled => 0
+ },
+ {
+ limit => 1,
+ order_by => { -desc => 'real_departure' }
+ }
+ )->hash;
+
+ if ( not $res_h ) {
+ return;
+ }
+
+ return $res_h->{checkout_station_id};
+}
+
+sub get_latest_checkout_stations {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $limit = $opt{limit} // 5;
+
+ my $res = $db->select(
+ 'journeys_str',
+ [ 'arr_name', 'arr_eva', 'train_id' ],
+ {
+ user_id => $uid,
+ cancelled => 0
+ },
+ {
+ limit => $limit,
+ order_by => { -desc => 'journey_id' }
+ }
+ );
+
+ if ( not $res ) {
+ return;
+ }
+
+ my @ret;
+
+ while ( my $row = $res->hash ) {
+ push(
+ @ret,
+ {
+ name => $row->{arr_name},
+ eva => $row->{arr_eva},
+ hafas => ( $row->{train_id} =~ m{[|]} ? 1 : 0 ),
+ }
+ );
+ }
+
+ return @ret;
+}
+
+sub get_nav_years {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $res = $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;
+}
+
+sub get_years {
+ my ( $self, %opt ) = @_;
+
+ my @years = $self->get_nav_years(%opt);
+
+ for my $year (@years) {
+ my $stats = $self->stats_cache->get(
+ uid => $opt{uid},
+ year => $year,
+ month => 0,
+ );
+ $year->[2] = $stats // {};
+ }
+ return @years;
+}
+
+sub get_months_for_year {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $year = $opt{year};
+
+ my $res = $db->select(
+ 'journeys',
+'distinct extract(year from real_departure) as year, extract(month from real_departure) as month',
+ { user_id => $uid },
+ { order_by => { -asc => 'year' } }
+ );
+
+ my @ret;
+
+ for my $month ( 1 .. 12 ) {
+ push( @ret,
+ [ sprintf( '%d/%02d', $year, $month ), $month_name[ $month - 1 ] ]
+ );
+ }
+
+ for my $row ( $res->hashes->each ) {
+ if ( $row->{year} == $year ) {
+
+ my $stats = $self->stats_cache->get(
+ db => $db,
+ uid => $uid,
+ year => $year,
+ month => $row->{month}
+ );
+
+ # undef -> no journeys for this month; empty hash -> no cached stats
+ $ret[ $row->{month} - 1 ][2] = $stats // {};
+ }
+ }
+ return @ret;
+}
+
+sub get_yyyymm_having_journeys {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $res = $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 ) {
+ push( @ret, [ split( qr{[.]}, $row->{yearmonth} ) ] );
+ }
+
+ return @ret;
+}
+
+sub generate_missing_stats {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my @journey_months = $self->get_yyyymm_having_journeys(
+ uid => $uid,
+ db => $db
+ );
+ my @stats_months = $self->stats_cache->get_yyyymm_having_stats(
+ uid => $uid,
+ $db => $db
+ );
+
+ my $stats_index = 0;
+
+ for my $journey_index ( 0 .. $#journey_months ) {
+ if ( $stats_index < @stats_months
+ and $journey_months[$journey_index][0]
+ == $stats_months[$stats_index][0]
+ and $journey_months[$journey_index][1]
+ == $stats_months[$stats_index][1] )
+ {
+ $stats_index++;
+ }
+ else {
+ my ( $year, $month ) = @{ $journey_months[$journey_index] };
+ $self->get_stats(
+ uid => $uid,
+ db => $db,
+ year => $year,
+ month => $month,
+ write_only => 1
+ );
+ }
+ }
+}
+
+sub get_nav_months {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $filter_year = $opt{year};
+ my $filter_month = $opt{month};
+
+ my $selected_index = undef;
+
+ my $res = $db->select(
+ 'journeys',
+ "distinct to_char(real_departure, 'YYYY.MM') as yearmonth",
+ { user_id => $uid },
+ { order_by => { -asc => 'yearmonth' } }
+ );
+
+ my @months;
+ for my $row ( $res->hashes->each ) {
+ my ( $year, $month ) = split( qr{[.]}, $row->{yearmonth} );
+ push( @months, [ $year, $month ] );
+ if ( $year eq $filter_year and $month eq $filter_month ) {
+ $selected_index = $#months;
+ }
+ }
+
+ # returns (previous entry, current month, next entry). if there is no
+ # previous or next entry, the corresponding field is undef. Previous/next
+ # entry is usually previous/next month, but may also have a distance of
+ # more than one month if there are months without travels
+ my @ret = ( undef, undef, undef );
+
+ $ret[1] = [
+ "${filter_year}/${filter_month}",
+ $month_name[ $filter_month - 1 ] // $filter_month
+ ];
+
+ if ( not defined $selected_index ) {
+ return @ret;
+ }
+
+ if ( $selected_index > 0 and $months[ $selected_index - 1 ] ) {
+ my ( $year, $month ) = @{ $months[ $selected_index - 1 ] };
+ $ret[0] = [ "${year}/${month}", "${month}.${year}" ];
+ }
+ if ( $selected_index < $#months ) {
+ my ( $year, $month ) = @{ $months[ $selected_index + 1 ] };
+ $ret[2] = [ "${year}/${month}", "${month}.${year}" ];
+ }
+
+ return @ret;
+}
+
+sub sanity_check {
+ my ( $self, $journey, $lax ) = @_;
+
+ if ( defined $journey->{sched_duration}
+ and $journey->{sched_duration} <= 0 )
+ {
+ return
+'Die geplante Dauer dieser Fahrt ist ≤ 0. Teleportation und Zeitreisen werden aktuell nicht unterstützt.';
+ }
+ if ( defined $journey->{rt_duration}
+ and $journey->{rt_duration} <= 0 )
+ {
+ return
+'Die Dauer dieser Fahrt ist ≤ 0. Teleportation und Zeitreisen werden aktuell nicht unterstützt.';
+ }
+ if ( $journey->{sched_duration}
+ and $journey->{sched_duration} > 60 * 60 * 24 )
+ {
+ return 'Die Fahrt ist länger als 24 Stunden.';
+ }
+ if ( $journey->{rt_duration}
+ and $journey->{rt_duration} > 60 * 60 * 24 )
+ {
+ return 'Die Fahrt ist länger als 24 Stunden.';
+ }
+ if ( $journey->{kmh_route} > 500 or $journey->{kmh_beeline} > 500 ) {
+ return 'Fahrten mit über 500 km/h? Schön wär\'s.';
+ }
+ if ( $journey->{route} and @{ $journey->{route} } > 199 ) {
+ my $stop_count = @{ $journey->{route} };
+ return
+"Die Fahrt hat $stop_count Unterwegshalte. Also ich weiß ja nicht so recht.";
+ }
+ if ( $journey->{edited} & 0x0010 and not $lax ) {
+ my @unknown_stations
+ = $self->{stations}
+ ->grep_unknown( map { $_->[0] } @{ $journey->{route} } );
+ if (@unknown_stations) {
+ return 'Unbekannte Station(en): ' . join( ', ', @unknown_stations );
+ }
+ }
+
+ return undef;
+}
+
+sub get_travel_distance {
+ my ( $self, $journey ) = @_;
+
+ my $from = $journey->{from_name};
+ my $from_eva = $journey->{from_eva};
+ my $from_latlon = $journey->{from_latlon};
+ my $to = $journey->{to_name};
+ my $to_eva = $journey->{to_eva};
+ my $to_latlon = $journey->{to_latlon};
+ my $route_ref = $journey->{route};
+ my $polyline_ref = $journey->{polyline};
+
+ if ( not $to ) {
+ $self->{log}
+ ->warn("Journey $journey->{id} has no to_name for EVA $to_eva");
+ }
+
+ if ( not $from ) {
+ $self->{log}
+ ->warn("Journey $journey->{id} has no from_name for EVA $from_eva");
+ }
+
+ my $distance_polyline = 0;
+ my $distance_intermediate = 0;
+ my $distance_beeline = 0;
+ my $skipped = 0;
+ my $geo = GIS::Distance->new();
+ my @stations = map { $_->[0] } @{$route_ref};
+ my @route = after_incl { $_ eq $from } @stations;
+ @route = before_incl { $_ eq $to } @route;
+
+ if ( @route < 2 ) {
+
+ # I AM ERROR
+ return ( 0, 0, 0 );
+ }
+
+ my @polyline = after_incl { $_->[2] and $_->[2] == $from_eva }
+ @{ $polyline_ref // [] };
+ @polyline
+ = before_incl { $_->[2] and $_->[2] == $to_eva } @polyline;
+
+ my $prev_station = shift @polyline;
+ for my $station (@polyline) {
+ $distance_polyline += $geo->distance_metal(
+ $prev_station->[1], $prev_station->[0],
+ $station->[1], $station->[0]
+ );
+ $prev_station = $station;
+ }
+
+ $prev_station = $self->{latlon_by_station}->{ shift @route };
+ if ( not $prev_station ) {
+ return ( $distance_polyline, 0, 0 );
+ }
+
+ for my $station_name (@route) {
+ if ( my $station = $self->{latlon_by_station}->{$station_name} ) {
+ $distance_intermediate += $geo->distance_metal(
+ $prev_station->[0], $prev_station->[1],
+ $station->[0], $station->[1]
+ );
+ $prev_station = $station;
+ }
+ }
+
+ $distance_beeline = $geo->distance_metal( @{$from_latlon}, @{$to_latlon} );
+
+ return ( $distance_polyline, $distance_intermediate,
+ $distance_beeline, $skipped );
+}
+
+sub grep_single {
+ my ( $self, @journeys ) = @_;
+
+ my %num_by_trip;
+ for my $journey (@journeys) {
+ if ( $journey->{from_name} and $journey->{to_name} ) {
+ $num_by_trip{ $journey->{from_name} . '|' . $journey->{to_name} }
+ += 1;
+ }
+ }
+
+ return
+ grep { $num_by_trip{ $_->{from_name} . '|' . $_->{to_name} } == 1 }
+ @journeys;
+}
+
+sub compute_review {
+ my ( $self, $stats, @journeys ) = @_;
+ my $longest_km;
+ my $longest_t;
+ my $shortest_km;
+ my $shortest_t;
+ my $most_delayed;
+ my $most_delay;
+ my $most_undelay;
+ my $num_cancelled = 0;
+ my $num_fgr = 0;
+ my $num_punctual = 0;
+ my $message_count = 0;
+ my %num_by_message;
+ my %num_by_wrtype;
+ my %num_by_linetype;
+ my %num_by_stop;
+ my %num_by_trip;
+
+ if ( not $stats or not @journeys or $stats->{num_trains} == 0 ) {
+ return;
+ }
+
+ my %review;
+
+ for my $journey (@journeys) {
+ if ( $journey->{cancelled} ) {
+ $num_cancelled += 1;
+ next;
+ }
+
+ my %seen;
+
+ if ( $journey->{rt_duration} and $journey->{rt_duration} > 0 ) {
+ if ( not $longest_t
+ or $journey->{rt_duration} > $longest_t->{rt_duration} )
+ {
+ $longest_t = $journey;
+ }
+ if ( not $shortest_t
+ or $journey->{rt_duration} < $shortest_t->{rt_duration} )
+ {
+ $shortest_t = $journey;
+ }
+ }
+
+ if ( $journey->{km_route} ) {
+ if ( not $longest_km
+ or $journey->{km_route} > $longest_km->{km_route} )
+ {
+ $longest_km = $journey;
+ }
+ if ( not $shortest_km
+ or $journey->{km_route} < $shortest_km->{km_route} )
+ {
+ $shortest_km = $journey;
+ }
+ }
+
+ if ( $journey->{messages} and @{ $journey->{messages} } ) {
+ $message_count += 1;
+ for my $message ( @{ $journey->{messages} } ) {
+ if ( not $seen{ $message->[1] } ) {
+ $num_by_message{ $message->[1] } += 1;
+ $seen{ $message->[1] } = 1;
+ }
+ }
+ }
+
+ if ( $journey->{type} ) {
+ $num_by_linetype{ $journey->{type} } += 1;
+ }
+
+ if ( $journey->{from_name} ) {
+ $num_by_stop{ $journey->{from_name} } += 1;
+ }
+ if ( $journey->{to_name} ) {
+ $num_by_stop{ $journey->{to_name} } += 1;
+ }
+ if ( $journey->{from_name} and $journey->{to_name} ) {
+ $num_by_trip{ $journey->{from_name} . '|' . $journey->{to_name} }
+ += 1;
+ }
+
+ if ( $journey->{sched_dep_ts} and $journey->{rt_dep_ts} ) {
+ $journey->{delay_dep}
+ = ( $journey->{rt_dep_ts} - $journey->{sched_dep_ts} ) / 60;
+ }
+ if ( $journey->{sched_arr_ts} and $journey->{rt_arr_ts} ) {
+ $journey->{delay_arr}
+ = ( $journey->{rt_arr_ts} - $journey->{sched_arr_ts} ) / 60;
+ }
+
+ if ( $journey->{delay_arr} and $journey->{delay_arr} >= 60 ) {
+ $num_fgr += 1;
+ }
+ if ( not $journey->{delay_arr} and not $journey->{delay_dep} ) {
+ $num_punctual += 1;
+ }
+
+ if ( $journey->{delay_arr} and $journey->{delay_arr} > 0 ) {
+ if ( not $most_delayed
+ or $journey->{delay_arr} > $most_delayed->{delay_arr} )
+ {
+ $most_delayed = $journey;
+ }
+ }
+
+ if ( $journey->{rt_duration}
+ and $journey->{sched_duration}
+ and $journey->{rt_duration} > 0
+ and $journey->{sched_duration} > 0 )
+ {
+ my $slowdown = $journey->{rt_duration} - $journey->{sched_duration};
+ my $speedup = -$slowdown;
+ if (
+ not $most_delay
+ or $slowdown > (
+ $most_delay->{rt_duration} - $most_delay->{sched_duration}
+ )
+ )
+ {
+ $most_delay = $journey;
+ }
+ if (
+ not $most_undelay
+ or $speedup > (
+ $most_undelay->{sched_duration}
+ - $most_undelay->{rt_duration}
+ )
+ )
+ {
+ $most_undelay = $journey;
+ }
+ }
+ }
+
+ my @linetypes = sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $num_by_linetype{$_} ] } keys %num_by_linetype;
+ my @stops = sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $num_by_stop{$_} ] } keys %num_by_stop;
+ my @trips = sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $num_by_trip{$_} ] } keys %num_by_trip;
+
+ my @reasons = sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $num_by_message{$_} ] } keys %num_by_message;
+
+ $review{num_stops} = scalar @stops;
+ $review{km_circle} = $stats->{km_route} / 40030;
+ $review{km_diag} = $stats->{km_route} / 12742;
+
+ $review{trains_per_day} = sprintf( '%.1f', $stats->{num_trains} / 365 );
+ $review{km_route} = sprintf( '%.0f', $stats->{km_route} );
+ $review{km_beeline} = sprintf( '%.0f', $stats->{km_beeline} );
+ $review{km_circle_h} = sprintf( '%.1f', $review{km_circle} );
+ $review{km_diag_h} = sprintf( '%.1f', $review{km_diag} );
+
+ $review{trains_per_day} =~ tr{.}{,};
+ $review{km_circle_h} =~ tr{.}{,};
+ $review{km_diag_h} =~ tr{.}{,};
+
+ my $min_total = $stats->{min_travel_real} + $stats->{min_interchange_real};
+ $review{traveling_min_total} = $min_total;
+ $review{traveling_percentage_year}
+ = sprintf( "%.1f%%", $min_total * 100 / 525948.77 );
+ $review{traveling_percentage_year} =~ tr{.}{,};
+ $review{traveling_time_year} = $self->min_to_human($min_total);
+
+ if (@linetypes) {
+ $review{typical_type_1} = $linetypes[0][0];
+ }
+ if ( @linetypes > 1 ) {
+ $review{typical_type_2} = $linetypes[1][0];
+ }
+ if ( @stops >= 3 ) {
+ my $desc = q{};
+ $review{typical_stops_3} = [ $stops[0][0], $stops[1][0], $stops[2][0] ];
+ }
+ elsif ( @stops == 2 ) {
+ $review{typical_stops_2} = [ $stops[0][0], $stops[1][0] ];
+ }
+ $review{typical_time}
+ = $self->min_to_human( $stats->{min_travel_real} / $stats->{num_trains} );
+ $review{typical_km}
+ = sprintf( '%.0f', $stats->{km_route} / $stats->{num_trains} );
+ $review{typical_kmh} = sprintf( '%.0f',
+ $stats->{km_route} / ( $stats->{min_travel_real} / 60 ) );
+ $review{typical_delay_dep}
+ = sprintf( '%.0f', $stats->{delay_dep} / $stats->{num_trains} );
+ $review{typical_delay_dep_h}
+ = $self->min_to_human( $review{typical_delay_dep} );
+ $review{typical_delay_arr}
+ = sprintf( '%.0f', $stats->{delay_arr} / $stats->{num_trains} );
+ $review{typical_delay_arr_h}
+ = $self->min_to_human( $review{typical_delay_arr} );
+
+ if ($longest_t) {
+ $review{longest_t_time}
+ = $self->min_to_human( $longest_t->{rt_duration} / 60 );
+ $review{longest_t_type} = $longest_t->{type};
+ $review{longest_t_lineno} = $longest_t->{line} // $longest_t->{no};
+ $review{longest_t_from} = $longest_t->{from_name};
+ $review{longest_t_to} = $longest_t->{to_name};
+ $review{longest_t_id} = $longest_t->{id};
+ }
+
+ if ($longest_km) {
+ $review{longest_km_km} = sprintf( '%.0f', $longest_km->{km_route} );
+ $review{longest_km_type} = $longest_km->{type};
+ $review{longest_km_lineno} = $longest_km->{line} // $longest_km->{no};
+ $review{longest_km_from} = $longest_km->{from_name};
+ $review{longest_km_to} = $longest_km->{to_name};
+ $review{longest_km_id} = $longest_km->{id};
+ }
+
+ if ($shortest_t) {
+ $review{shortest_t_time}
+ = $self->min_to_human( $shortest_t->{rt_duration} / 60 );
+ $review{shortest_t_type} = $shortest_t->{type};
+ $review{shortest_t_lineno} = $shortest_t->{line} // $shortest_t->{no};
+ $review{shortest_t_from} = $shortest_t->{from_name};
+ $review{shortest_t_to} = $shortest_t->{to_name};
+ $review{shortest_t_id} = $shortest_t->{id};
+ }
+
+ if ($shortest_km) {
+ $review{shortest_km_m}
+ = sprintf( '%.0f', $shortest_km->{km_route} * 1000 );
+ $review{shortest_km_type} = $shortest_km->{type};
+ $review{shortest_km_lineno} = $shortest_km->{line}
+ // $shortest_km->{no};
+ $review{shortest_km_from} = $shortest_km->{from_name};
+ $review{shortest_km_to} = $shortest_km->{to_name};
+ $review{shortest_km_id} = $shortest_km->{id};
+ }
+
+ if ($most_delayed) {
+ $review{most_delayed_type} = $most_delayed->{type};
+ $review{most_delayed_delay_dep}
+ = $self->min_to_human( $most_delayed->{delay_dep} );
+ $review{most_delayed_delay_arr}
+ = $self->min_to_human( $most_delayed->{delay_arr} );
+ $review{most_delayed_lineno} = $most_delayed->{line}
+ // $most_delayed->{no};
+ $review{most_delayed_from} = $most_delayed->{from_name};
+ $review{most_delayed_to} = $most_delayed->{to_name};
+ $review{most_delayed_id} = $most_delayed->{id};
+ }
+
+ if ($most_delay) {
+ $review{most_delay_type} = $most_delay->{type};
+ $review{most_delay_delay_dep} = $most_delay->{delay_dep};
+ $review{most_delay_delay_arr} = $most_delay->{delay_arr};
+ $review{most_delay_sched_time}
+ = $self->min_to_human( $most_delay->{sched_duration} / 60 );
+ $review{most_delay_real_time}
+ = $self->min_to_human( $most_delay->{rt_duration} / 60 );
+ $review{most_delay_delta}
+ = $self->min_to_human(
+ ( $most_delay->{rt_duration} - $most_delay->{sched_duration} )
+ / 60 );
+ $review{most_delay_lineno} = $most_delay->{line} // $most_delay->{no};
+ $review{most_delay_from} = $most_delay->{from_name};
+ $review{most_delay_to} = $most_delay->{to_name};
+ $review{most_delay_id} = $most_delay->{id};
+ }
+
+ if ($most_undelay) {
+ $review{most_undelay_type} = $most_undelay->{type};
+ $review{most_undelay_delay_dep} = $most_undelay->{delay_dep};
+ $review{most_undelay_delay_arr} = $most_undelay->{delay_arr};
+ $review{most_undelay_sched_time}
+ = $self->min_to_human( $most_undelay->{sched_duration} / 60 );
+ $review{most_undelay_real_time}
+ = $self->min_to_human( $most_undelay->{rt_duration} / 60 );
+ $review{most_undelay_delta}
+ = $self->min_to_human(
+ ( $most_undelay->{sched_duration} - $most_undelay->{rt_duration} )
+ / 60 );
+ $review{most_undelay_lineno} = $most_undelay->{line}
+ // $most_undelay->{no};
+ $review{most_undelay_from} = $most_undelay->{from_name};
+ $review{most_undelay_to} = $most_undelay->{to_name};
+ $review{most_undelay_id} = $most_undelay->{id};
+ }
+
+ $review{issue_percent}
+ = sprintf( '%.0f%%', $message_count * 100 / $stats->{num_trains} );
+ for my $i ( 0 .. 2 ) {
+ if ( $reasons[$i] ) {
+ my $p = 'issue' . ( $i + 1 );
+ $review{"${p}_count"} = $reasons[$i][1];
+ $review{"${p}_text"} = $reasons[$i][0];
+ }
+ }
+
+ $review{cancel_count} = $num_cancelled;
+ $review{fgr_percent} = $num_fgr * 100 / $stats->{num_trains};
+ $review{fgr_percent_h} = sprintf( '%.1f%%', $review{fgr_percent} );
+ $review{fgr_percent_h} =~ tr{.}{,};
+ $review{punctual_percent} = $num_punctual * 100 / $stats->{num_trains};
+ $review{punctual_percent_h}
+ = sprintf( '%.1f%%', $review{punctual_percent} );
+ $review{punctual_percent_h} =~ tr{.}{,};
+
+ my $top_trip_count = 0;
+ my $single_trip_count = 0;
+ for my $i ( 0 .. 3 ) {
+ if ( $trips[$i] ) {
+ my ( $from, $to ) = split( qr{[|]}, $trips[$i][0] );
+ my $found = 0;
+ for my $j ( 0 .. $#{ $review{top_trips} } ) {
+ if ( $review{top_trips}[$j][0] eq $to
+ and $review{top_trips}[$j][2] eq $from )
+ {
+ $review{top_trips}[$j][1] = '↔';
+ $found = 1;
+ last;
+ }
+ }
+ if ( not $found ) {
+ push( @{ $review{top_trips} }, [ $from, '→', $to ] );
+ }
+ $top_trip_count += $trips[$i][1];
+ }
+ }
+
+ for my $trip (@trips) {
+ if ( $trip->[1] == 1 ) {
+ $single_trip_count += 1;
+ if ( @{ $review{single_trips} // [] } < 3 ) {
+ push(
+ @{ $review{single_trips} },
+ [ split( qr{[|]}, $trip->[0] ) ]
+ );
+ }
+ }
+ }
+
+ $review{top_trip_count} = $top_trip_count;
+ $review{top_trip_percent_h}
+ = sprintf( '%.1f%%', $top_trip_count * 100 / $stats->{num_trains} );
+ $review{top_trip_percent_h} =~ tr{.}{,};
+
+ $review{single_trip_count} = $single_trip_count;
+ $review{single_trip_percent_h}
+ = sprintf( '%.1f%%', $single_trip_count * 100 / $stats->{num_trains} );
+ $review{single_trip_percent_h} =~ tr{.}{,};
+
+ return \%review;
+}
+
+sub compute_stats {
+ 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;
+ my $next_id;
+ my $next_train;
+
+ 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 )
+ {
+ $min_travel_sched += $journey->{sched_duration} / 60;
+ }
+ if ( $journey->{rt_duration} and $journey->{rt_duration} > 0 ) {
+ $min_travel_real += $journey->{rt_duration} / 60;
+ }
+ if ( $journey->{sched_dep_ts} and $journey->{rt_dep_ts} ) {
+ $delay_dep
+ += ( $journey->{rt_dep_ts} - $journey->{sched_dep_ts} ) / 60;
+ }
+ if ( $journey->{sched_arr_ts} and $journey->{rt_arr_ts} ) {
+ $delay_arr
+ += ( $journey->{rt_arr_ts} - $journey->{sched_arr_ts} ) / 60;
+ }
+
+ # 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,
+ {
+ conflict => {
+ train => $journey->{type} . ' '
+ . ( $journey->{line} // $journey->{no} ),
+ arr => epoch_to_dt( $journey->{rt_arr_ts} )
+ ->strftime('%d.%m.%Y %H:%M'),
+ id => $journey->{id},
+ },
+ ignored => {
+ train => $next_train,
+ dep => epoch_to_dt($next_departure)
+ ->strftime('%d.%m.%Y %H:%M'),
+ id => $next_id,
+ },
+ }
+ );
+ }
+ else {
+ $interchange_real
+ += ( $next_departure - $journey->{rt_arr_ts} ) / 60;
+ }
+ }
+ else {
+ $num_journeys++;
+ }
+ $next_departure = $journey->{rt_dep_ts};
+ $next_id = $journey->{id};
+ $next_train
+ = $journey->{type} . ' ' . ( $journey->{line} // $journey->{no} ),;
+ }
+ my $ret = {
+ 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,
+ };
+ for my $key (
+ qw(min_travel_sched min_travel_real min_interchange_real delay_dep delay_arr)
+ )
+ {
+ my $strf_key = $key . '_strf';
+ my $value = $ret->{$key};
+ $ret->{$strf_key} = q{};
+ if ( $ret->{$key} < 0 ) {
+ $ret->{$strf_key} .= '-';
+ $value *= -1;
+ }
+ $ret->{$strf_key} .= sprintf( '%02d:%02d', $value / 60, $value % 60 );
+ }
+ return $ret;
+}
+
+sub get_stats {
+ my ( $self, %opt ) = @_;
+
+ if ( $opt{cancelled} ) {
+ $self->{log}
+ ->warn('get_journey_stats called with illegal option cancelled => 1');
+ return {};
+ }
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ 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.
+
+ if (
+ not $opt{write_only}
+ and not $opt{review}
+ and my $stats = $self->stats_cache->get(
+ uid => $uid,
+ db => $db,
+ year => $year,
+ month => $month
+ )
+ )
+ {
+ return $stats;
+ }
+
+ 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(
+ uid => $uid,
+ cancelled => 0,
+ verbose => 1,
+ with_polyline => 1,
+ after => $interval_start,
+ before => $interval_end
+ );
+ my $stats = $self->compute_stats(@journeys);
+
+ $self->stats_cache->add(
+ uid => $uid,
+ db => $db,
+ year => $year,
+ month => $month,
+ stats => $stats
+ );
+
+ if ( $opt{review} ) {
+ my @cancelled_journeys = $self->get(
+ uid => $uid,
+ cancelled => 1,
+ verbose => 1,
+ after => $interval_start,
+ before => $interval_end
+ );
+ return ( $stats,
+ $self->compute_review( $stats, @journeys, @cancelled_journeys ) );
+ }
+
+ return $stats;
+}
+
+sub get_latest_dest_id {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ if (
+ my $id = $self->{in_transit}->get_checkout_station_id(
+ uid => $uid,
+ db => $db
+ )
+ )
+ {
+ return $id;
+ }
+
+ return $self->get_latest_checkout_station_id(
+ uid => $uid,
+ db => $db
+ );
+}
+
+sub get_connection_targets {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ 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;
+
+ if ( $opt{destination_name} ) {
+ return (
+ [],
+ [ { eva => $opt{eva}, name => $opt{destination_name} } ]
+ );
+ }
+
+ my $dest_id = $opt{eva} // $self->get_latest_dest_id(%opt);
+
+ if ( not $dest_id ) {
+ return ( [], [] );
+ }
+
+ my $dest_ids = [ $dest_id, $self->{stations}->get_meta( eva => $dest_id ) ];
+
+ my $res = $db->select(
+ 'journeys',
+ 'count(checkout_station_id) as count, checkout_station_id as dest',
+ {
+ user_id => $uid,
+ checkin_station_id => $dest_ids,
+ real_departure => { '>', $threshold }
+ },
+ {
+ group_by => ['checkout_station_id'],
+ order_by => { -desc => 'count' }
+ }
+ );
+ my @destinations
+ = $res->hashes->grep( sub { shift->{count} >= $min_count } )
+ ->map( sub { shift->{dest} } )->each;
+ @destinations = $self->{stations}->get_by_evas(@destinations);
+ return ( $dest_ids, \@destinations );
+}
+
+sub update_visibility {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $visibility;
+
+ if ( $opt{visibility} and $visibility_atoi{ $opt{visibility} } ) {
+ $visibility = $visibility_atoi{ $opt{visibility} };
+ }
+
+ $db->update(
+ 'journeys',
+ { visibility => $visibility },
+ {
+ user_id => $uid,
+ id => $opt{id}
+ }
+ );
+}
+
+1;
diff --git a/lib/Travelynx/Model/Stations.pm b/lib/Travelynx/Model/Stations.pm
new file mode 100644
index 0000000..ac4019c
--- /dev/null
+++ b/lib/Travelynx/Model/Stations.pm
@@ -0,0 +1,199 @@
+package Travelynx::Model::Stations;
+
+# Copyright (C) 2022 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use strict;
+use warnings;
+use 5.020;
+
+sub new {
+ my ( $class, %opt ) = @_;
+
+ return bless( \%opt, $class );
+}
+
+sub add_or_update {
+ my ( $self, %opt ) = @_;
+ my $stop = $opt{stop};
+ my $loc = $stop->loc;
+ my $source = 1;
+ my $db = $opt{db} // $self->{pg}->db;
+
+ if ( my $s = $self->get_by_eva( $loc->eva, db => $db ) ) {
+ if ( $source == 1 and $s->{source} == 0 and not $s->{archived} ) {
+ return;
+ }
+ $db->update(
+ 'stations',
+ {
+ name => $loc->name,
+ lat => $loc->lat,
+ lon => $loc->lon,
+ source => $source,
+ archived => 0
+ },
+ { eva => $loc->eva }
+ );
+ return;
+ }
+ $db->insert(
+ 'stations',
+ {
+ eva => $loc->eva,
+ name => $loc->name,
+ lat => $loc->lat,
+ lon => $loc->lon,
+ source => $source,
+ archived => 0
+ }
+ );
+}
+
+sub add_meta {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $eva = $opt{eva};
+ my @meta = @{ $opt{meta} };
+
+ for my $meta (@meta) {
+ if ( $meta != $eva ) {
+ $db->insert(
+ 'related_stations',
+ {
+ eva => $eva,
+ meta => $meta
+ },
+ { on_conflict => undef }
+ );
+ }
+ }
+}
+
+sub get_db_iterator {
+ my ($self) = @_;
+
+ return $self->{pg}->db->select( 'stations', '*' );
+}
+
+sub get_meta {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $eva = $opt{eva};
+
+ my $res = $db->select( 'related_stations', ['meta'], { eva => $eva } );
+ my @ret;
+
+ while ( my $row = $res->hash ) {
+ push( @ret, $row->{meta} );
+ }
+
+ return @ret;
+}
+
+sub get_for_autocomplete {
+ my ($self) = @_;
+
+ my $res = $self->{pg}->db->select( 'stations', ['name'] );
+ my %ret;
+
+ while ( my $row = $res->hash ) {
+ $ret{ $row->{name} } = undef;
+ }
+
+ return \%ret;
+}
+
+# Fast
+sub get_by_eva {
+ my ( $self, $eva, %opt ) = @_;
+
+ if ( not $eva ) {
+ return;
+ }
+
+ my $db = $opt{db} // $self->{pg}->db;
+
+ return $db->select( 'stations', '*', { eva => $eva } )->hash;
+}
+
+# Fast
+sub get_by_evas {
+ my ( $self, @evas ) = @_;
+
+ my @ret
+ = $self->{pg}->db->select( 'stations', '*', { eva => { '=', \@evas } } )
+ ->hashes->each;
+ return @ret;
+}
+
+# Slow
+sub get_latlon_by_name {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my %location;
+ my $res = $db->select( 'stations', [ 'name', 'lat', 'lon' ] );
+ while ( my $row = $res->hash ) {
+ $location{ $row->{name} } = [ $row->{lat}, $row->{lon} ];
+ }
+ return \%location;
+}
+
+# Slow
+sub get_by_name {
+ my ( $self, $name, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+
+ return $db->select( 'stations', '*', { name => $name }, { limit => 1 } )
+ ->hash;
+}
+
+# Slow
+sub get_by_names {
+ my ( $self, @names ) = @_;
+
+ my @ret
+ = $self->{pg}->db->select( 'stations', '*', { name => { '=', \@names } } )
+ ->hashes->each;
+ return @ret;
+}
+
+# Slow
+sub get_by_ds100 {
+ my ( $self, $ds100, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+
+ return $db->select( 'stations', '*', { ds100 => $ds100 }, { limit => 1 } )
+ ->hash;
+}
+
+# Can be slow
+sub search {
+ my ( $self, $identifier, %opt ) = @_;
+
+ if ( $identifier =~ m{ ^ \d+ $ }x ) {
+ return $self->get_by_eva( $identifier, %opt )
+ // $self->get_by_ds100( $identifier, %opt )
+ // $self->get_by_name( $identifier, %opt );
+ }
+
+ return $self->get_by_ds100( $identifier, %opt )
+ // $self->get_by_name( $identifier, %opt );
+}
+
+# Slow
+sub grep_unknown {
+ my ( $self, @stations ) = @_;
+
+ my %station = map { $_->{name} => 1 } $self->get_by_names(@stations);
+ my @unknown_stations = grep { not $station{$_} } @stations;
+
+ return @unknown_stations;
+}
+
+1;
diff --git a/lib/Travelynx/Model/Traewelling.pm b/lib/Travelynx/Model/Traewelling.pm
new file mode 100644
index 0000000..25648cc
--- /dev/null
+++ b/lib/Travelynx/Model/Traewelling.pm
@@ -0,0 +1,243 @@
+package Travelynx::Model::Traewelling;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use strict;
+use warnings;
+use 5.020;
+
+use DateTime;
+
+sub epoch_to_dt {
+ my ($epoch) = @_;
+
+ # Bugs (and user errors) may lead to undefined timestamps. Set them to
+ # 1970-01-01 to avoid crashing and show obviously wrong data instead.
+ $epoch //= 0;
+
+ return DateTime->from_epoch(
+ epoch => $epoch,
+ time_zone => 'Europe/Berlin',
+ locale => 'de-DE',
+ );
+
+}
+
+sub new {
+ my ( $class, %opt ) = @_;
+
+ return bless( \%opt, $class );
+}
+
+sub now {
+ return DateTime->now( time_zone => 'Europe/Berlin' );
+}
+
+sub link {
+ my ( $self, %opt ) = @_;
+
+ my $log = [ [ $self->now->epoch, "Erfolgreich mittels OAuth2 verbunden" ] ];
+
+ my $data = { log => $log };
+
+ my $user_entry = {
+ user_id => $opt{uid},
+ push_sync => 0,
+ pull_sync => 0,
+ token => $opt{token},
+ refresh_token => $opt{refresh_token},
+ expiry => epoch_to_dt( $self->now->epoch + $opt{expires_in} ),
+ data => JSON->new->encode($data),
+ };
+
+ $self->{pg}->db->insert(
+ 'traewelling',
+ $user_entry,
+ {
+ on_conflict => \
+'(user_id) do update set token = EXCLUDED.token, refresh_token = EXCLUDED.refresh_token, expiry = EXCLUDED.expiry, push_sync = false, pull_sync = false, data = null, errored = false, latest_run = null'
+ }
+ );
+
+ return $user_entry;
+}
+
+sub set_user {
+ my ( $self, %opt ) = @_;
+
+ my $res_h
+ = $self->{pg}
+ ->db->select( 'traewelling', 'data', { user_id => $opt{uid} } )
+ ->expand->hash;
+
+ $res_h->{data}{user_id} = $opt{trwl_id};
+ $res_h->{data}{screen_name} = $opt{screen_name};
+ $res_h->{data}{user_name} = $opt{user_name};
+
+ $self->{pg}->db->update(
+ 'traewelling',
+ { data => JSON->new->encode( $res_h->{data} ) },
+ { user_id => $opt{uid} }
+ );
+}
+
+sub unlink {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+
+ $self->{pg}->db->delete( 'traewelling', { user_id => $uid } );
+}
+
+sub get {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $res_h
+ = $db->select( 'traewelling_str', '*', { user_id => $uid } )
+ ->expand->hash;
+
+ $res_h->{latest_run} = epoch_to_dt( $res_h->{latest_run_ts} );
+ for my $log_entry ( @{ $res_h->{data}{log} // [] } ) {
+ $log_entry->[0] = epoch_to_dt( $log_entry->[0] );
+ }
+ $res_h->{expires_on}
+ = epoch_to_dt( $res_h->{expiry_ts} // $res_h->{data}{expires} );
+
+ my $expires_in = ( $res_h->{expiry_ts} // $res_h->{data}{expires} // 0 )
+ - $self->now->epoch;
+
+ if ( $expires_in < 0 ) {
+ $res_h->{expired} = 1;
+ }
+ elsif ( $expires_in < 14 * 24 * 3600 ) {
+ $res_h->{expiring} = 1;
+ }
+
+ return $res_h;
+}
+
+sub log {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $message = $opt{message};
+ my $is_error = $opt{is_error};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $res_h
+ = $db->select( 'traewelling', 'data', { user_id => $uid } )->expand->hash;
+ splice( @{ $res_h->{data}{log} // [] }, 9 );
+ unshift(
+ @{ $res_h->{data}{log} },
+ [ $self->now->epoch, $message, $opt{status_id} ]
+ );
+
+ if ($is_error) {
+ $res_h->{data}{error} = $message;
+ }
+ $db->update(
+ 'traewelling',
+ {
+ errored => $is_error ? 1 : 0,
+ latest_run => $self->now,
+ data => JSON->new->encode( $res_h->{data} )
+ },
+ { user_id => $uid }
+ );
+}
+
+sub set_latest_pull_status_id {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $status_id = $opt{status_id};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $res_h
+ = $db->select( 'traewelling', 'data', { user_id => $uid } )->expand->hash;
+
+ $res_h->{data}{latest_pull_status_id} = $status_id;
+
+ $db->update(
+ 'traewelling',
+ { data => JSON->new->encode( $res_h->{data} ) },
+ { user_id => $uid }
+ );
+}
+
+sub set_latest_push_ts {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $ts = $opt{ts};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $res_h
+ = $db->select( 'traewelling', 'data', { user_id => $uid } )->expand->hash;
+
+ $res_h->{data}{latest_push_ts} = $ts;
+
+ $db->update(
+ 'traewelling',
+ { data => JSON->new->encode( $res_h->{data} ) },
+ { user_id => $uid }
+ );
+}
+
+sub set_sync {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $res_h
+ = $db->select( 'traewelling', 'data', { user_id => $uid } )->expand->hash;
+
+ $res_h->{data}{toot} = $opt{toot};
+ $res_h->{data}{tweet} = $opt{tweet};
+
+ $db->update(
+ 'traewelling',
+ {
+ push_sync => $opt{push_sync},
+ pull_sync => $opt{pull_sync},
+ data => JSON->new->encode( $res_h->{data} ),
+ },
+ { user_id => $uid }
+ );
+}
+
+sub get_pushable_accounts {
+ my ($self) = @_;
+ my $res = $self->{pg}->db->query(
+ qq{select t.user_id as uid, t.token as token, t.data as data,
+ i.user_data as user_data,
+ i.dep_eva as dep_eva, i.arr_eva as arr_eva,
+ i.data as journey_data, i.train_type as train_type,
+ i.train_line as train_line, i.train_no as train_no,
+ i.checkin_ts as checkin_ts,
+ i.sched_dep_ts as dep_ts,
+ i.sched_arr_ts as arr_ts,
+ i.effective_visibility as visibility
+ from traewelling as t
+ join in_transit_str as i on t.user_id = i.user_id
+ where t.push_sync = True
+ and i.arr_eva is not null
+ and i.cancelled = False
+ }
+ );
+ return $res->expand->hashes->each;
+}
+
+sub get_pull_accounts {
+ my ($self) = @_;
+ my $res = $self->{pg}->db->select(
+ 'traewelling',
+ [ 'user_id', 'token', 'data' ],
+ { pull_sync => 1 }
+ );
+ return $res->expand->hashes->each;
+}
+
+1;
diff --git a/lib/Travelynx/Model/Users.pm b/lib/Travelynx/Model/Users.pm
new file mode 100644
index 0000000..4602fa2
--- /dev/null
+++ b/lib/Travelynx/Model/Users.pm
@@ -0,0 +1,1160 @@
+package Travelynx::Model::Users;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use strict;
+use warnings;
+use 5.020;
+
+use Crypt::Eksblowfish::Bcrypt qw(bcrypt en_base64);
+use DateTime;
+use JSON;
+
+my %visibility_itoa = (
+ 100 => 'public',
+ 80 => 'travelynx',
+ 60 => 'followers',
+ 30 => 'unlisted',
+ 10 => 'private',
+);
+
+my %visibility_atoi = (
+ public => 100,
+ travelynx => 80,
+ followers => 60,
+ unlisted => 30,
+ private => 10,
+);
+
+my %predicate_itoa = (
+ 1 => 'follows',
+ 2 => 'requests_follow',
+ 3 => 'is_blocked_by',
+);
+
+my %predicate_atoi = (
+ follows => 1,
+ requests_follow => 2,
+ is_blocked_by => 3,
+);
+
+my @sb_templates = (
+ undef,
+ [ 'DBF', 'https://dbf.finalrewind.org/{name}?rt=1#{tt}{tn}' ],
+ [ 'bahn.expert', 'https://bahn.expert/{name}#{id}' ],
+ [ 'DBF HAFAS', 'https://dbf.finalrewind.org/{name}?rt=1&hafas=1#{tt}{tn}' ],
+ [ 'bahn.expert/regional', 'https://bahn.expert/regional/{name}#{id}' ],
+);
+
+my %token_id = (
+ status => 1,
+ history => 2,
+ travel => 3,
+ import => 4,
+);
+my @token_types = (qw(status history travel import));
+
+sub new {
+ my ( $class, %opt ) = @_;
+
+ return bless( \%opt, $class );
+}
+
+sub hash_password {
+ my ( $self, $password ) = @_;
+ my @salt_bytes = map { int( rand(255) ) + 1 } ( 1 .. 16 );
+ my $salt = en_base64( pack( 'C[16]', @salt_bytes ) );
+
+ return bcrypt( substr( $password, 0, 10000 ), '$2a$12$' . $salt );
+}
+
+sub get_token_id {
+ my ( $self, $type ) = @_;
+
+ return $token_id{$type};
+}
+
+sub mark_seen {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ $db->update(
+ 'users',
+ {
+ last_seen => DateTime->now( time_zone => 'Europe/Berlin' ),
+ deletion_notified => undef
+ },
+ { id => $uid }
+ );
+}
+
+sub mark_deletion_notified {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ $db->update(
+ 'users',
+ {
+ deletion_notified => DateTime->now( time_zone => 'Europe/Berlin' ),
+ },
+ { id => $uid }
+ );
+}
+
+sub verify_registration_token {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $token = $opt{token};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
+
+ my $res = $db->select(
+ 'pending_registrations',
+ 'count(*) as count',
+ {
+ user_id => $uid,
+ token => $token
+ }
+ );
+
+ if ( $res->hash->{count} ) {
+ $db->update( 'users', { status => 1 }, { id => $uid } );
+ $db->delete( 'pending_registrations', { user_id => $uid } );
+ if ($tx) {
+ $tx->commit;
+ }
+ return 1;
+ }
+ return;
+}
+
+sub get_api_token {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $token = {};
+ my $res = $db->select( 'tokens', [ 'type', 'token' ], { user_id => $uid } );
+
+ for my $entry ( $res->hashes->each ) {
+ $token->{ $token_types[ $entry->{type} - 1 ] }
+ = $entry->{token};
+ }
+
+ return $token;
+}
+
+sub get_uid_by_name_and_mail {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $name = $opt{name};
+ my $email = $opt{email};
+
+ my $res = $db->select(
+ 'users',
+ ['id'],
+ {
+ name => $name,
+ email => $email,
+ status => 1
+ }
+ );
+
+ if ( my $user = $res->hash ) {
+ return $user->{id};
+ }
+ return;
+}
+
+sub get_privacy_by {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my %where;
+
+ if ( $opt{name} ) {
+ $where{name} = $opt{name};
+ }
+ else {
+ $where{id} = $opt{uid};
+ }
+
+ my $res = $db->select(
+ 'users',
+ [ 'id', 'name', 'public_level', 'accept_follows' ],
+ { %where, status => 1 }
+ );
+
+ if ( my $user = $res->hash ) {
+ return {
+ id => $user->{id},
+ name => $user->{name},
+ default_visibility => $user->{public_level} & 0x7f,
+ default_visibility_str =>
+ $visibility_itoa{ $user->{public_level} & 0x7f },
+ comments_visible => $user->{public_level} & 0x80 ? 1 : 0,
+ past_visibility => ( $user->{public_level} & 0x7f00 ) >> 8,
+ past_visibility_str =>
+ $visibility_itoa{ ( $user->{public_level} & 0x7f00 ) >> 8 },
+ past_status => $user->{public_level} & 0x08000 ? 1 : 0,
+ past_all => $user->{public_level} & 0x10000 ? 1 : 0,
+ accept_follows => $user->{accept_follows} == 2 ? 1 : 0,
+ accept_follow_requests => $user->{accept_follows} == 1 ? 1 : 0,
+ };
+ }
+ return;
+}
+
+sub set_privacy {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $public_level = $opt{level};
+
+ if ( not defined $public_level and defined $opt{default_visibility} ) {
+ $public_level
+ = ( $opt{default_visibility} & 0x7f )
+ | ( $opt{comments_visible} ? 0x80 : 0 )
+ | ( ( $opt{past_visibility} & 0x7f ) << 8 )
+ | ( $opt{past_status} ? 0x08000 : 0 )
+ | ( $opt{past_all} ? 0x10000 : 0 );
+ }
+
+ $db->update( 'users', { public_level => $public_level }, { id => $uid } );
+}
+
+sub set_social {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $accept_follows = 0;
+
+ if ( $opt{accept_follows} ) {
+ $accept_follows = 2;
+ }
+ elsif ( $opt{accept_follow_requests} ) {
+ $accept_follows = 1;
+ }
+
+ $db->update(
+ 'users',
+ { accept_follows => $accept_follows },
+ { id => $uid }
+ );
+}
+
+sub mark_for_password_reset {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $token = $opt{token};
+
+ my $res = $db->select(
+ 'pending_passwords',
+ 'count(*) as count',
+ { user_id => $uid }
+ );
+ if ( $res->hash->{count} ) {
+ return 'in progress';
+ }
+
+ $db->insert(
+ 'pending_passwords',
+ {
+ user_id => $uid,
+ token => $token,
+ requested_at => DateTime->now( time_zone => 'Europe/Berlin' )
+ }
+ );
+
+ return undef;
+}
+
+sub verify_password_token {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $token = $opt{token};
+
+ my $res = $db->select(
+ 'pending_passwords',
+ 'count(*) as count',
+ {
+ user_id => $uid,
+ token => $token
+ }
+ );
+
+ if ( $res->hash->{count} ) {
+ return 1;
+ }
+ return;
+}
+
+sub mark_for_mail_change {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $email = $opt{email};
+ my $token = $opt{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'
+ },
+ );
+}
+
+sub change_mail_with_token {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $token = $opt{token};
+
+ my $tx = $db->begin;
+
+ my $res_h = $db->select(
+ 'pending_mails',
+ ['email'],
+ {
+ user_id => $uid,
+ token => $token
+ }
+ )->hash;
+
+ if ($res_h) {
+ $db->update( 'users', { email => $res_h->{email} }, { id => $uid } );
+ $db->delete( 'pending_mails', { user_id => $uid } );
+ $tx->commit;
+ return 1;
+ }
+ return;
+}
+
+sub is_name_invalid {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $name = $opt{name};
+
+ if ( not length($name) ) {
+ return 'user_empty';
+ }
+
+ if ( $name !~ m{ ^ [0-9a-zA-Z_-]+ $ }x ) {
+ return 'user_format';
+ }
+
+ if (
+ $self->user_name_exists(
+ db => $db,
+ name => $name
+ )
+ )
+ {
+ return 'user_collision';
+ }
+
+ return;
+}
+
+sub change_name {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ eval { $db->update( 'users', { name => $opt{name} }, { id => $uid } ); };
+
+ if ($@) {
+ return 0;
+ }
+
+ return 1;
+}
+
+sub remove_password_token {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $token = $opt{token};
+
+ $db->delete(
+ 'pending_passwords',
+ {
+ user_id => $uid,
+ token => $token
+ }
+ );
+}
+
+sub get {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $user = $db->select(
+ 'users',
+ 'id, name, status, public_level, email, '
+ . 'external_services, accept_follows, notifications, '
+ . '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) {
+ return {
+ id => $user->{id},
+ name => $user->{name},
+ status => $user->{status},
+ notifications => $user->{notifications},
+ accept_follows => $user->{accept_follows} == 2 ? 1 : 0,
+ accept_follow_requests => $user->{accept_follows} == 1 ? 1 : 0,
+ default_visibility => $user->{public_level} & 0x7f,
+ default_visibility_str =>
+ $visibility_itoa{ $user->{public_level} & 0x7f },
+ comments_visible => $user->{public_level} & 0x80 ? 1 : 0,
+ past_visibility => ( $user->{public_level} & 0x7f00 ) >> 8,
+ past_visibility_str =>
+ $visibility_itoa{ ( $user->{public_level} & 0x7f00 ) >> 8 },
+ past_status => $user->{public_level} & 0x08000 ? 1 : 0,
+ past_all => $user->{public_level} & 0x10000 ? 1 : 0,
+ email => $user->{email},
+ sb_name => $user->{external_services}
+ ? $sb_templates[ $user->{external_services} & 0x07 ][0]
+ : undef,
+ sb_template => $user->{external_services}
+ ? $sb_templates[ $user->{external_services} & 0x07 ][1]
+ : undef,
+ registered_at => DateTime->from_epoch(
+ epoch => $user->{registered_at_ts},
+ time_zone => 'Europe/Berlin'
+ ),
+ last_seen => DateTime->from_epoch(
+ epoch => $user->{last_seen_ts},
+ time_zone => 'Europe/Berlin'
+ ),
+ deletion_requested => $user->{deletion_requested_ts}
+ ? DateTime->from_epoch(
+ epoch => $user->{deletion_requested_ts},
+ time_zone => 'Europe/Berlin'
+ )
+ : undef,
+ };
+ }
+ return undef;
+}
+
+sub get_login_data {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $name = $opt{name};
+
+ my $res_h = $db->select(
+ 'users',
+ 'id, name, status, password as password_hash',
+ { name => $name }
+ )->hash;
+
+ return $res_h;
+}
+
+sub add {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $user_name = $opt{name};
+ my $email = $opt{email};
+ my $token = $opt{token};
+ my $password = $self->hash_password( $opt{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 => $visibility_atoi{unlisted}
+ | ( $visibility_atoi{unlisted} << 8 ),
+ 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;
+}
+
+sub flag_deletion {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+
+ $db->update(
+ 'users',
+ { deletion_requested => $now },
+ {
+ id => $uid,
+ }
+ );
+}
+
+sub unflag_deletion {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ $db->update(
+ 'users',
+ {
+ deletion_requested => undef,
+ },
+ {
+ id => $uid,
+ }
+ );
+}
+
+sub delete {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
+
+ my %res;
+
+ $res{tokens} = $db->delete( 'tokens', { user_id => $uid } );
+ $res{stats} = $db->delete( 'journey_stats', { user_id => $uid } );
+ $res{journeys} = $db->delete( 'journeys', { user_id => $uid } );
+ $res{transit} = $db->delete( 'in_transit', { user_id => $uid } );
+ $res{hooks} = $db->delete( 'webhooks', { user_id => $uid } );
+ $res{trwl} = $db->delete( 'traewelling', { user_id => $uid } );
+ $res{password} = $db->delete( 'pending_passwords', { user_id => $uid } );
+ $res{relations} = $db->delete( 'relations',
+ [ { subject_id => $uid }, { object_id => $uid } ] );
+ $res{users} = $db->delete( 'users', { id => $uid } );
+
+ for my $key ( keys %res ) {
+ $res{$key} = $res{$key}->rows;
+ }
+
+ if ( $res{users} != 1 ) {
+ die("Deleted $res{users} rows from users, expected 1. Rolling back.\n");
+ }
+
+ if ($tx) {
+ $tx->commit;
+ }
+
+ return \%res;
+}
+
+sub set_password {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $password = $self->hash_password( $opt{password} );
+
+ $db->update( 'users', { password => $password }, { id => $uid } );
+}
+
+sub user_name_exists {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $user_name = $opt{name};
+
+ my $count
+ = $db->select( 'users', 'count(*) as count', { name => $user_name } )
+ ->hash->{count};
+
+ if ($count) {
+ return 1;
+ }
+ return 0;
+}
+
+sub mail_is_blacklisted {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $mail = $opt{email};
+
+ my $count = $db->select(
+ 'users',
+ 'count(*) as count',
+ {
+ email => $mail,
+ status => 0,
+ }
+ )->hash->{count};
+
+ if ($count) {
+ return 1;
+ }
+
+ $count = $db->select(
+ 'mail_blacklist',
+ 'count(*) as count',
+ {
+ email => $mail,
+ num_tries => { '>', 1 },
+ }
+ )->hash->{count};
+
+ if ($count) {
+ return 1;
+ }
+ return 0;
+}
+
+sub use_history {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $value = $opt{set};
+
+ if ($value) {
+ $db->update( 'users', { use_history => $value }, { id => $uid } );
+ }
+ else {
+ return $db->select( 'users', ['use_history'], { id => $uid } )
+ ->hash->{use_history};
+ }
+}
+
+sub use_external_services {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $value = $opt{set};
+
+ if ( defined $value ) {
+ if ( $value < 0 or $value > 4 ) {
+ $value = 0;
+ }
+ $db->update( 'users', { external_services => $value }, { id => $uid } );
+ }
+ else {
+ return $db->select( 'users', ['external_services'], { id => $uid } )
+ ->hash->{external_services};
+ }
+}
+
+sub get_webhook {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $res_h = $db->select( 'webhooks_str', '*', { user_id => $uid } )->hash;
+
+ $res_h->{latest_run} = DateTime->from_epoch(
+ epoch => $res_h->{latest_run_ts} // 0,
+ time_zone => 'Europe/Berlin',
+ locale => 'de-DE',
+ );
+
+ return $res_h;
+}
+
+sub set_webhook {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+
+ if ( $opt{token} ) {
+ $opt{token} =~ tr{\r\n}{}d;
+ }
+
+ my $res = $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'
+ }
+ );
+}
+
+sub update_webhook_status {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $url = $opt{url};
+ my $success = $opt{success};
+ my $text = $opt{text};
+
+ if ( length($text) > 1000 ) {
+ $text = substr( $text, 0, 1000 ) . '…';
+ }
+
+ $db->update(
+ 'webhooks',
+ {
+ errored => $success ? 0 : 1,
+ latest_run => DateTime->now( time_zone => 'Europe/Berlin' ),
+ output => $text,
+ },
+ {
+ user_id => $uid,
+ url => $url
+ }
+ );
+}
+
+sub set_profile {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $profile = $opt{profile};
+
+ $db->update(
+ 'users',
+ { profile => JSON->new->encode($profile) },
+ { id => $uid }
+ );
+}
+
+sub get_profile {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ return $db->select( 'users', ['profile'], { id => $uid } )
+ ->expand->hash->{profile};
+}
+
+sub get_relation {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $subject = $opt{subject};
+ my $object = $opt{object};
+
+ my $res_h = $db->select(
+ 'relations',
+ ['predicate'],
+ {
+ subject_id => $subject,
+ object_id => $object,
+ }
+ )->hash;
+
+ if ($res_h) {
+ return $predicate_itoa{ $res_h->{predicate} };
+ }
+ return;
+
+ #my $res_h = $db->select( 'relations', ['subject_id', 'predicate'],
+ # { subject_id => [$uid, $target], object_id => [$target, $target] } )->hash;
+}
+
+sub update_notifications {
+ my ( $self, %opt ) = @_;
+
+ # must be called inside a transaction, so $opt{db} is mandatory.
+ my $db = $opt{db};
+ my $uid = $opt{uid};
+
+ my $has_follow_requests = $opt{has_follow_requests}
+ // $self->has_follow_requests(
+ db => $db,
+ uid => $uid
+ );
+
+ my $notifications
+ = $db->select( 'users', ['notifications'], { id => $uid } )
+ ->hash->{notifications};
+ if ($has_follow_requests) {
+ $notifications |= 0x01;
+ }
+ else {
+ $notifications &= ~0x01;
+ }
+ $db->update( 'users', { notifications => $notifications }, { id => $uid } );
+}
+
+sub follow {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $target = $opt{target};
+
+ $db->insert(
+ 'relations',
+ {
+ subject_id => $uid,
+ predicate => $predicate_atoi{follows},
+ object_id => $target,
+ ts => DateTime->now( time_zone => 'Europe/Berlin' ),
+ }
+ );
+}
+
+sub request_follow {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $target = $opt{target};
+
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
+
+ $db->insert(
+ 'relations',
+ {
+ subject_id => $uid,
+ predicate => $predicate_atoi{requests_follow},
+ object_id => $target,
+ ts => DateTime->now( time_zone => 'Europe/Berlin' ),
+ }
+ );
+ $self->update_notifications(
+ db => $db,
+ uid => $target,
+ has_follow_requests => 1,
+ );
+
+ if ($tx) {
+ $tx->commit;
+ }
+}
+
+sub accept_follow_request {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $applicant = $opt{applicant};
+
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
+
+ $db->update(
+ 'relations',
+ {
+ predicate => $predicate_atoi{follows},
+ ts => DateTime->now( time_zone => 'Europe/Berlin' ),
+ },
+ {
+ subject_id => $applicant,
+ predicate => $predicate_atoi{requests_follow},
+ object_id => $uid
+ }
+ );
+ $self->update_notifications(
+ db => $db,
+ uid => $uid
+ );
+
+ if ($tx) {
+ $tx->commit;
+ }
+}
+
+sub reject_follow_request {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $applicant = $opt{applicant};
+
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
+
+ $db->delete(
+ 'relations',
+ {
+ subject_id => $applicant,
+ predicate => $predicate_atoi{requests_follow},
+ object_id => $uid
+ }
+ );
+ $self->update_notifications(
+ db => $db,
+ uid => $uid
+ );
+
+ if ($tx) {
+ $tx->commit;
+ }
+}
+
+sub cancel_follow_request {
+ my ( $self, %opt ) = @_;
+
+ $self->reject_follow_request(
+ db => $opt{db},
+ uid => $opt{target},
+ applicant => $opt{uid},
+ );
+}
+
+sub unfollow {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $target = $opt{target};
+
+ $db->delete(
+ 'relations',
+ {
+ subject_id => $uid,
+ predicate => $predicate_atoi{follows},
+ object_id => $target
+ }
+ );
+}
+
+sub remove_follower {
+ my ( $self, %opt ) = @_;
+
+ $self->unfollow(
+ db => $opt{db},
+ uid => $opt{follower},
+ target => $opt{uid},
+ );
+}
+
+sub block {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $target = $opt{target};
+
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
+
+ $db->insert(
+ 'relations',
+ {
+ subject_id => $target,
+ predicate => $predicate_atoi{is_blocked_by},
+ object_id => $uid,
+ ts => DateTime->now( time_zone => 'Europe/Berlin' ),
+ },
+ {
+ on_conflict => \
+'(subject_id, object_id) do update set predicate = EXCLUDED.predicate'
+ },
+ );
+ $self->update_notifications(
+ db => $db,
+ uid => $uid
+ );
+
+ if ($tx) {
+ $tx->commit;
+ }
+}
+
+sub unblock {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $target = $opt{target};
+
+ $db->delete(
+ 'relations',
+ {
+ subject_id => $target,
+ predicate => $predicate_atoi{is_blocked_by},
+ object_id => $uid
+ },
+ );
+}
+
+sub get_followers {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $res = $db->select(
+ 'followers',
+ [ 'id', 'name', 'accept_follows', 'inverse_predicate' ],
+ { self_id => $uid }
+ );
+
+ my @ret;
+ while ( my $row = $res->hash ) {
+ push(
+ @ret,
+ {
+ id => $row->{id},
+ name => $row->{name},
+ following_back => (
+ $row->{inverse_predicate}
+ and $row->{inverse_predicate} == $predicate_atoi{follows}
+ ) ? 1 : 0,
+ followback_requested => (
+ $row->{inverse_predicate}
+ and $row->{inverse_predicate}
+ == $predicate_atoi{requests_follow}
+ ) ? 1 : 0,
+ can_follow_back => (
+ not $row->{inverse_predicate}
+ and $row->{accept_follows} == 2
+ ) ? 1 : 0,
+ can_request_follow_back => (
+ not $row->{inverse_predicate}
+ and $row->{accept_follows} == 1
+ ) ? 1 : 0,
+ }
+ );
+ }
+ return @ret;
+}
+
+sub has_followers {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ return $db->select( 'followers', 'count(*) as count', { self_id => $uid } )
+ ->hash->{count};
+}
+
+sub get_follow_requests {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $table = $opt{sent} ? 'tx_follow_requests' : 'rx_follow_requests';
+
+ my $res
+ = $db->select( $table, [ 'id', 'name' ], { self_id => $uid } );
+
+ return $res->hashes->each;
+}
+
+sub has_follow_requests {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $table = $opt{sent} ? 'tx_follow_requests' : 'rx_follow_requests';
+
+ return $db->select( $table, 'count(*) as count', { self_id => $uid } )
+ ->hash->{count};
+}
+
+sub get_followees {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $res = $db->select(
+ 'followees',
+ [ 'id', 'name', 'inverse_predicate' ],
+ { self_id => $uid }
+ );
+
+ my @ret;
+ while ( my $row = $res->hash ) {
+ push(
+ @ret,
+ {
+ id => $row->{id},
+ name => $row->{name},
+ following_back => (
+ $row->{inverse_predicate}
+ and $row->{inverse_predicate} == $predicate_atoi{follows}
+ ) ? 1 : 0,
+ }
+ );
+ }
+ return @ret;
+}
+
+sub has_followees {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ return $db->select( 'followees', 'count(*) as count', { self_id => $uid } )
+ ->hash->{count};
+}
+
+sub get_blocked_users {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $res
+ = $db->select( 'blocked_users', [ 'id', 'name' ], { self_id => $uid } );
+
+ return $res->hashes->each;
+}
+
+sub has_blocked_users {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ return $db->select( 'blocked_users', 'count(*) as count',
+ { self_id => $uid } )->hash->{count};
+}
+
+1;