summaryrefslogtreecommitdiff
path: root/lib/Travelynx/Model/Users.pm
diff options
context:
space:
mode:
Diffstat (limited to 'lib/Travelynx/Model/Users.pm')
-rw-r--r--lib/Travelynx/Model/Users.pm1160
1 files changed, 1160 insertions, 0 deletions
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;