From 2a5c839e03a29a2e50b370b59f913c30580197ae Mon Sep 17 00:00:00 2001 From: Spine Date: Sat, 19 Jun 2021 13:03:35 +0000 Subject: [PATCH] Add an invite source toolbox to track recruitments --- app/Manager/InviteSource.php | 174 ++++++++++++++++++ app/Schedule/Tasks/ExpireInvites.php | 13 +- app/User.php | 9 +- app/UserCreator.php | 1 + classes/config.template.php | 34 ++-- classes/permissions.class.php | 1 + .../20210616100351_invite_source.php | 49 +++++ db/seeds/InviteSource.php | 12 ++ docs/05-InviteSource.txt | 56 ++++++ sections/tools/index.php | 9 +- sections/tools/managers/invite_source.php | 18 ++ .../tools/managers/invite_source_config.php | 26 +++ sections/tools/tools.php | 1 + sections/user/invite.php | 49 +++-- sections/user/take_invite.php | 97 +++++----- sections/user/takemoderate.php | 13 ++ sections/user/user.php | 22 ++- templates/admin/invite-source-config.twig | 43 +++++ templates/admin/invite-source.twig | 37 ++++ templates/admin/privilege-list.twig | 1 + templates/macro/form.twig | 13 +- templates/user/edit-invite-sources.twig | 38 ++++ templates/user/header.twig | 2 +- templates/user/invited.twig | 73 +++++++- 24 files changed, 681 insertions(+), 110 deletions(-) create mode 100644 app/Manager/InviteSource.php create mode 100644 db/migrations/20210616100351_invite_source.php create mode 100644 db/seeds/InviteSource.php create mode 100644 docs/05-InviteSource.txt create mode 100644 sections/tools/managers/invite_source.php create mode 100644 sections/tools/managers/invite_source_config.php create mode 100644 templates/admin/invite-source-config.twig create mode 100644 templates/admin/invite-source.twig create mode 100644 templates/user/edit-invite-sources.twig diff --git a/app/Manager/InviteSource.php b/app/Manager/InviteSource.php new file mode 100644 index 000000000..abb1136bb --- /dev/null +++ b/app/Manager/InviteSource.php @@ -0,0 +1,174 @@ +db->prepared_query(" + INSERT INTO invite_source (name) VALUES (?) + ", $name + ); + return $this->db->inserted_id(); + } + + public function createPendingInviteSource(int $inviteSourceId, string $inviteKey): int { + $this->db->prepared_query(" + INSERT INTO invite_source_pending + (invite_source_id, invite_key) + VALUES (?, ?) + ", $inviteSourceId, $inviteKey + ); + return $this->db->affected_rows(); + } + + public function resolveInviteSource(string $inviteKey, int $userId): int { + $inviteSourceId = $this->db->scalar(" + SELECT invite_source_id + FROM invite_source_pending + WHERE invite_key = ? + ", $inviteKey + ); + if (!$inviteSourceId) { + return 0; + } + $this->db->prepared_query(" + DELETE FROM invite_source_pending WHERE invite_key = ? + ", $inviteKey + ); + $this->db->prepared_query(" + INSERT INTO user_has_invite_source + (user_id, invite_source_id) + VALUES (?, ?) + ", $userId, $inviteSourceId + ); + return $this->db->affected_rows(); + } + + public function findSourceNameByUserId(int $userId): ?string { + return $this->db->scalar(" + SELECT i.name + FROM invite_source i + INNER JOIN user_has_invite_source uhis USING (invite_source_id) + WHERE uhis.user_id = ? + ", $userId + ); + } + + public function remove(int $id): int { + $this->db->prepared_query(" + DELETE FROM invite_source WHERE invite_source_id = ? + ", $id + ); + return $this->db->affected_rows(); + } + + public function listByUse(): array { + $this->db->prepared_query(" + SELECT i.invite_source_id, + i.name, + count(DISTINCT ihis.user_id) AS inviter_total, + count(DISTINCT uhis.user_id) AS user_total + FROM invite_source i + LEFT JOIN inviter_has_invite_source ihis USING (invite_source_id) + LEFT JOIN user_has_invite_source uhis USING (invite_source_id) + GROUP BY i.invite_source_id, i.name + ORDER BY i.name + "); + return $this->db->to_array(false, MYSQLI_ASSOC, false); + } + + public function summaryByInviter(): array { + $this->db->prepared_query(" + SELECT ihis.user_id, + group_concat(i.name ORDER BY i.name SEPARATOR ', ') as name_list + FROM inviter_has_invite_source ihis + INNER JOIN invite_source i USING (invite_source_id) + INNER JOIN users_main um ON (um.ID = ihis.user_id) + GROUP BY ihis.user_id + ORDER BY um.username + "); + return $this->db->to_array(false, MYSQLI_ASSOC, false); + } + + public function inviterConfiguration(int $userId): array { + $this->db->prepared_query(" + SELECT i.invite_source_id, + i.name, + ihis.invite_source_id IS NOT NULL AS active + FROM invite_source i + LEFT JOIN inviter_has_invite_source ihis ON (i.invite_source_id = ihis.invite_source_id AND ihis.user_id = ?) + ORDER BY i.name + ", $userId + ); + return $this->db->to_array(false, MYSQLI_ASSOC, false); + } + + public function inviterConfigurationActive(int $userId): array { + $this->db->prepared_query(" + SELECT i.invite_source_id, + i.name, + ihis.invite_source_id IS NOT NULL AS active + FROM invite_source i + INNER JOIN inviter_has_invite_source ihis ON (i.invite_source_id = ihis.invite_source_id AND ihis.user_id = ?) + ORDER BY i.name + ", $userId + ); + return $this->db->to_array(false, MYSQLI_ASSOC, false); + } + + public function modifyInviterConfiguration(int $userId, array $ids): int { + $this->db->begin_transaction(); + $this->db->prepared_query(" + DELETE FROM inviter_has_invite_source WHERE user_id = ? + ", $userId + ); + $userAndSourceId = []; + foreach ($ids as $sourceId) { + $userAndSourceId[] = $userId; + $userAndSourceId[] = $sourceId; + } + $this->db->prepared_query(" + INSERT INTO inviter_has_invite_source (user_id, invite_source_id) + VALUES " . placeholders($ids, '(?, ?)'), ...$userAndSourceId + ); + $this->db->commit(); + return $this->db->affected_rows(); + } + + public function userSource(int $userId) { + $this->db->prepared_query(" + SELECT ui.UserID AS user_id, + uhis.invite_source_id, + i.name + FROM users_info ui + LEFT JOIN user_has_invite_source uhis ON (uhis.user_id = ui.UserID) + LEFT JOIN invite_source i USING (invite_source_id) + WHERE ui.inviter = ? + ", $userId + ); + return $this->db->to_array('user_id', MYSQLI_ASSOC, false); + } + + public function modifyUserSource(int $userId, array $ids): int { + $userAndSourceId = []; + foreach ($ids as $inviteeId => $sourceId) { + $userAndSourceId[] = $inviteeId; + $userAndSourceId[] = $sourceId; + } + $this->db->begin_transaction(); + $this->db->prepared_query(" + DELETE uhis + FROM user_has_invite_source uhis + INNER JOIN users_info ui ON (ui.UserID = uhis.user_id) + WHERE ui.Inviter = ? + ", $userId + ); + $this->db->prepared_query(" + INSERT INTO user_has_invite_source (user_id, invite_source_id) + VALUES " . placeholders($ids, '(?, ?)'), ...$userAndSourceId + ); + $this->db->commit(); + return $this->db->affected_rows(); + } +} diff --git a/app/Schedule/Tasks/ExpireInvites.php b/app/Schedule/Tasks/ExpireInvites.php index 0dba767d0..38504fd45 100644 --- a/app/Schedule/Tasks/ExpireInvites.php +++ b/app/Schedule/Tasks/ExpireInvites.php @@ -6,16 +6,23 @@ class ExpireInvites extends \Gazelle\Schedule\Task { public function run() { - $userQuery = $this->db->prepared_query("SELECT InviterID FROM invites WHERE Expires < now()"); + $this->db->begin_transaction(); + $this->db->prepared_query("SELECT InviterID FROM invites WHERE Expires < now()"); + $users = $this->db->collect('InviterID', false); + $this->db->prepared_query("DELETE FROM invites WHERE Expires < now()"); + $this->db->prepared_query(" + DELETE isp FROM invite_source_pending isp + LEFT JOIN invites i ON (i.InviteKey = isp.invite_key) + WHERE i.InviteKey IS NULL + "); - $this->db->set_query_id($userQuery); - $users = $this->db->collect('InviterID', false); foreach ($users as $user) { $this->db->prepared_query("UPDATE users_main SET Invites = Invites + 1 WHERE ID = ?", $user); $this->cache->deleteMulti(["u_$user", "user_info_heavy_$user"]); $this->debug("Expired invite from user $user", $user); $this->processed++; } + $this->db->commit(); } } diff --git a/app/User.php b/app/User.php index 146d94037..d0ef53b9b 100644 --- a/app/User.php +++ b/app/User.php @@ -1492,6 +1492,8 @@ public function isWarned(): bool { return !is_null($this->warningExpiry()); public function isStaff(): bool { return $this->info()['isStaff']; } public function isDonor(): bool { return isset($this->info()['secondary_class'][DONOR]) || $this->isStaff(); } public function isFLS(): bool { return isset($this->info()['secondary_class'][FLS_TEAM]); } + public function isInterviewer(): bool { return isset($this->info()['secondary_class'][INTERVIEWER]); } + public function isRecruiter(): bool { return isset($this->info()['secondary_class'][RECRUITER]); } public function isStaffPMReader(): bool { return $this->isFLS() || $this->isStaff(); } public function warningExpiry(): ?string { @@ -2436,17 +2438,14 @@ public function canInvite(): bool { } /** - * Checks whether a user is allowed to purchase an invite. User classes up to Elite are capped, + * Checks whether a user is allowed to purchase an invite. Lower classes are capped, * users above this class will always return true. * * @param integer $minClass Minimum class level necessary to purchase invites * @return boolean false if insufficient funds, otherwise true */ public function canPurchaseInvite(): bool { - if ($this->info()['DisableInvites']) { - return false; - } - return $this->info()['effective_class'] >= MIN_INVITE_CLASS; + return !$this->disableInvites() && $this->effectiveClass() >= MIN_INVITE_CLASS; } /** diff --git a/app/UserCreator.php b/app/UserCreator.php index 91c1ee9d9..b04d0a037 100644 --- a/app/UserCreator.php +++ b/app/UserCreator.php @@ -125,6 +125,7 @@ public function create() { ); if ($inviterId) { + (new \Gazelle\Manager\InviteSource)->resolveInviteSource($this->inviteKey, $this->id); $this->db->prepared_query(" DELETE FROM invites WHERE InviteKey = ? ", $this->inviteKey diff --git a/classes/config.template.php b/classes/config.template.php index fb700ed7e..6ba003d46 100644 --- a/classes/config.template.php +++ b/classes/config.template.php @@ -162,24 +162,24 @@ define('FEATURE_EMAIL_REENABLE', true); } -// User class IDs needed for automatic promotions. Found in the 'permissions' table // Name of class Class ID (NOT level) -define('ADMIN', '1'); -define('USER', '2'); -define('MEMBER', '3'); -define('POWER', '4'); -define('ELITE', '5'); -define('VIP', '6'); -define('TORRENT_MASTER','7'); -define('MOD', '11'); -define('SYSOP', '15'); -define('ARTIST', '19'); -define('DONOR', '20'); -define('FLS_TEAM', '23'); -define('POWER_TM', '22'); -define('ELITE_TM', '23'); -define('FORUM_MOD', '28'); -define('ULTIMATE_TM', '48'); +define('USER', 2); +define('MEMBER', 3); +define('POWER', 4); +define('ELITE', 5); +define('TORRENT_MASTER', 7); +define('POWER_TM', 22); +define('ELITE_TM', 23); +define('ULTIMATE_TM', 48); +define('FORUM_MOD', 28); +define('MOD', 11); +define('SYSOP', 15); + +define('DONOR', 20); +define('FLS_TEAM', 23); +define('INTERVIEWER', 30); +define('RECRUITER', 41); +define('VIP', 6); // Locked account constant define('STAFF_LOCKED', 1); diff --git a/classes/permissions.class.php b/classes/permissions.class.php index 0af922645..231c1ff10 100644 --- a/classes/permissions.class.php +++ b/classes/permissions.class.php @@ -84,6 +84,7 @@ public static function list() { 'admin_manage_polls' => 'Can manage polls', 'admin_manage_forums' => 'Can manage forums (add/edit/delete)', 'admin_manage_fls' => 'Can manage First Line Support (FLS) crew', + 'admin_manage_invite_source' => 'Can manage invite sources', 'admin_manage_user_fls' => 'Can manage user FL tokens', 'admin_manage_applicants' => 'Can manage job roles and user applications', 'admin_manage_referrals' => 'Can manage referrals', diff --git a/db/migrations/20210616100351_invite_source.php b/db/migrations/20210616100351_invite_source.php new file mode 100644 index 000000000..add12692a --- /dev/null +++ b/db/migrations/20210616100351_invite_source.php @@ -0,0 +1,49 @@ +table('invite_source', ['id' => false, 'primary_key' => ['invite_source_id']]) + ->addColumn('invite_source_id', 'integer', ['limit' => 10, 'signed' => false, 'identity' => true]) + ->addColumn('name', 'string', ['limit' => 20, 'encoding' => 'ascii']) + ->addIndex(['name'], ['unique' => true, 'name' => 'is_name_uidx']) + ->create(); + + $this->table('invite_source_pending', ['id' => false, 'primary_key' => ['invite_key']]) + ->addColumn('user_id', 'integer', ['limit' => 10, 'signed' => false]) + ->addColumn('invite_source_id', 'integer', ['limit' => 10, 'signed' => false]) + ->addColumn('invite_key', 'string', ['limit' => 32]) + ->addForeignKey('user_id', 'users_main', 'ID', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->addForeignKey('invite_source_id', 'invite_source', 'invite_source_id', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->create(); + + $this->table('user_has_invite_source', ['id' => false, 'primary_key' => ['user_id']]) + ->addColumn('user_id', 'integer', ['limit' => 10, 'signed' => false]) + ->addColumn('invite_source_id', 'integer', ['limit' => 10, 'signed' => false]) + ->addForeignKey('user_id', 'users_main', 'ID', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->addForeignKey('invite_source_id', 'invite_source', 'invite_source_id', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->create(); + + $this->table('inviter_has_invite_source', ['id' => false, 'primary_key' => ['user_id', 'invite_source_id']]) + ->addColumn('user_id', 'integer', ['limit' => 10, 'signed' => false]) + ->addColumn('invite_source_id', 'integer', ['limit' => 10, 'signed' => false]) + ->addForeignKey('user_id', 'users_main', 'ID', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->addForeignKey('invite_source_id', 'invite_source', 'invite_source_id', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->create(); + } +} diff --git a/db/seeds/InviteSource.php b/db/seeds/InviteSource.php new file mode 100644 index 000000000..493ee71d7 --- /dev/null +++ b/db/seeds/InviteSource.php @@ -0,0 +1,12 @@ +table('invite_source')->insert(['name' => 'Personal'])->save(); + } +} diff --git a/docs/05-InviteSource.txt b/docs/05-InviteSource.txt new file mode 100644 index 000000000..072740167 --- /dev/null +++ b/docs/05-InviteSource.txt @@ -0,0 +1,56 @@ +From: Spine +To: Moderators +Date: 2021-06-19 +Subject: Orpheus Development Papers #5 - Invite Sources +Version: 1 + +This feature helps you keep track of how people were invited via interviews +and recruitments from other trackers. Most people who buy invites from the +Bonus Shop for personal friends are not concerned by this. But for people +who interview or recruit, it is helpful for them to be able to keep the +source origins distinguished, as it is for staff. + +1. Configure admin permissions for the appropriate user classes. Grant +admin_manage_invite_source to moderators and above. This will allow these +people to configure invite sources. It can also be done on a per-user basis +via custom permissions. + +Run the seeder: `phinx seed:run -s InviteSource` + +/tools.php?action=permission + +2. Add the invite sources: mnemonic names of trackers that everyone +understands. Consider adding an Interview source as well if you do +interviews, for interviewers and personal invites. These are stored in the +invite_source table. + +/tools.php?action=invite_source_config + +Once added, and referred to (by a member who has been granted its use, a +member who has been sourced from it), a source may no longer be removed. +You will need to tidy up the database directly. + +3. Grant sources to members. Search for users who have the R or IN +secondary classes. On their profile page, a new "Invite Sources" section +will appear. Check the sources that are appropriate for this user. These +grants are stored in the inviter_has_invite_source table. + +/tools.php?action=invite_source + +4. Inform the members. When they invite people, they will see an additional +select box showing the options that have been configured for them. They can +then set the source when the invitation is issued. The source is tied to +the invite key via the invite_source_pending table, so that when the +invitee creates their account, the source is attached to their profile (in +the user_has_invite_source table). + +They can also go the list of their invitees and backfill the existing +invitees. + +/user.php?action=invite&edit=source + +5. On the profile page, the Invite: information will now say "by +from ". + +6. If a source is removed from a recruiter, all their invitees that were +tagged as coming from that source will remain. diff --git a/sections/tools/index.php b/sections/tools/index.php index 9345287bb..a5d85232a 100644 --- a/sections/tools/index.php +++ b/sections/tools/index.php @@ -111,6 +111,13 @@ require_once('managers/take_global_notification.php'); break; + case 'invite_source': + require_once('managers/invite_source.php'); + break; + case 'invite_source_config': + require_once('managers/invite_source_config.php'); + break; + case 'irc': require_once('managers/irc_list.php'); break; @@ -296,7 +303,7 @@ break; case 'periodic': - $mode = isset($_REQUEST['mode']) ? $_REQUEST['mode'] : 'view'; + $mode = $_REQUEST['mode'] ?? 'view'; switch ($mode) { case 'run_now': case 'view': diff --git a/sections/tools/managers/invite_source.php b/sections/tools/managers/invite_source.php new file mode 100644 index 000000000..eeb1ad199 --- /dev/null +++ b/sections/tools/managers/invite_source.php @@ -0,0 +1,18 @@ +permitted('admin_manage_invite_source')) { + error(403); +} +$user = (new Gazelle\Manager\User)->find(trim($_POST['user'] ?? '')); +if ($user) { + header("Location: user.php?id=" . $user->id() . "#invite_source"); + exit; +} + +View::show_header('Invite Sources Summary'); +echo $Twig->render('admin/invite-source.twig', [ + 'auth' => $Viewer->auth(), + 'list' => (new Gazelle\Manager\InviteSource)->summaryByInviter(), +]); +View::show_footer(); diff --git a/sections/tools/managers/invite_source_config.php b/sections/tools/managers/invite_source_config.php new file mode 100644 index 000000000..720c699b9 --- /dev/null +++ b/sections/tools/managers/invite_source_config.php @@ -0,0 +1,26 @@ +permitted('admin_manage_invite_source')) { + error(403); +} +$manager = new Gazelle\Manager\InviteSource; + +if (!empty($_POST['name'])) { + authorize(); + $manager->create(trim($_POST['name'])); +} +$remove = array_keys(array_filter($_POST, function ($x) { return preg_match('/^remove-\d+$/', $x);}, ARRAY_FILTER_USE_KEY)); +if ($remove) { + authorize(); + foreach ($remove as $r) { + $manager->remove((int)explode('-', $r)[1]); + } +} + +View::show_header('Invite Sources'); +echo $Twig->render('admin/invite-source-config.twig', [ + 'auth' => $Viewer->auth(), + 'list' => $manager->listByUse(), +]); +View::show_footer(); diff --git a/sections/tools/tools.php b/sections/tools/tools.php index b11781736..abc27e65b 100644 --- a/sections/tools/tools.php +++ b/sections/tools/tools.php @@ -124,6 +124,7 @@ function Category($Title, array $Entries) { Item('Forum categories', 'tools.php?action=categories', All(['admin_manage_forums'])), Item('Forum departments', 'tools.php?action=forum', All(['admin_manage_forums'])), Item('Forum transitions', 'tools.php?action=forum_transitions', All(['admin_manage_forums'])), + Item('Invite Sources', 'tools.php?action=invite_source', All(['admin_manage_invite_source'])), Item('IRC manager', 'tools.php?action=irc', All(['admin_manage_forums'])), Item('Navigation link manager', 'tools.php?action=navigation', All(['admin_manage_navigation'])), ]); diff --git a/sections/user/invite.php b/sections/user/invite.php index a6901f3df..be7958b3a 100644 --- a/sections/user/invite.php +++ b/sections/user/invite.php @@ -1,18 +1,28 @@ findById(isset($_REQUEST['userid']) ? (int)$_REQUEST['userid'] : $LoggedUser['ID']); +if (is_null($user)) { + error(404); +} +$userId = $user->id(); +$ownProfile = $user->id() == $Viewer->id(); +if (!($Viewer->permitted('users_view_invites') || ($ownProfile && $user->canPurchaseInvite()))) { + error(403); +} -if (isset($_GET['userid'])) { - if (!check_perms('users_view_invites')) { - error(403); +$userSourceRaw = array_filter($_POST, function ($x) { return preg_match('/^user-\d+$/', $x); }, ARRAY_FILTER_USE_KEY); +$userSource = []; +foreach ($userSourceRaw as $fieldName => $fieldValue) { + if (preg_match('/^user-(\d+)$/', $fieldName, $userMatch) && preg_match('/^s-(\d+)$/', $fieldValue, $sourceMatch)) { + $userSource[$userMatch[1]] = (int)$sourceMatch[1]; } - $UserID = (int)$_GET['userid']; -} else { - $UserID = $LoggedUser['ID']; } -$user = $userMan->findById($UserID); -if (is_null($user)) { - error(404); + +$invSourceMan = new Gazelle\Manager\InviteSource; +if (count($userSource)) { + $invSourceMan->modifyUserSource($userId, $userSource); } $heading = new \Gazelle\Util\SortableTableHeader('joined', [ @@ -28,14 +38,19 @@ ]); View::show_header('Invites'); + echo $Twig->render('user/invited.twig', [ - 'auth' => $LoggedUser['AuthKey'], - 'heading' => $heading, - 'invited' => $user->inviteList($heading->getOrderBy(), $heading->getOrderDir()), - 'invites_open' => $userMan->newUsersAllowed() || $user->permitted('site_can_invite_always'), - 'own_profile' => $user->id() == $LoggedUser['ID'], - 'user' => $user, - 'view_pool' => check_perms('users_view_invites'), - 'wiki_article' => 116, + 'auth' => $user->auth(), + 'edit_source' => ($_GET['edit'] ?? '') === 'source', + 'heading' => $heading, + 'invited' => $user->inviteList($heading->getOrderBy(), $heading->getOrderDir()), + 'inviter_config' => $invSourceMan->inviterConfigurationActive($userId), + 'invites_open' => $userMan->newUsersAllowed() || $user->permitted('site_can_invite_always'), + 'invite_source' => $invSourceMan->userSource($userId), + 'own_profile' => $ownProfile, + 'user' => $user, + 'user_source' => $invSourceMan->userSource($userId), + 'view_pool' => $user->permitted('users_view_invites'), + 'wiki_article' => 116, ]); View::show_footer(); diff --git a/sections/user/take_invite.php b/sections/user/take_invite.php index 0b5addbf5..244be5b69 100644 --- a/sections/user/take_invite.php +++ b/sections/user/take_invite.php @@ -9,70 +9,63 @@ * Super sorry for doing that, but this is totally not reusable. */ -$user = new Gazelle\User($LoggedUser['ID']); +$Viewer = new Gazelle\User($LoggedUser['ID']); // Can the member issue an invite? -if (!$user->canInvite()) { +if (!$Viewer->canInvite()) { error(403); } // Can the site allow an invite to be spent? -if (!((new Gazelle\Manager\User)->newUsersAllowed() || check_perms('site_can_invite_always'))) { +if (!((new Gazelle\Manager\User)->newUsersAllowed() || $Viewer->permitted('site_can_invite_always'))) { error(403); } -//MultiInvite -$Email = $_POST['email']; -if (strpos($Email, '|') !== false && check_perms('site_send_unlimited_invites')) { - $Emails = explode('|', $Email); -} else { - $Emails = [$Email]; +if (!isset($_POST['agreement'])) { + error("You must agree to the conditions for sending invitations."); } -foreach ($Emails as $CurEmail) { - $CurEmail = trim($CurEmail); - if (!preg_match(EMAIL_REGEXP, $CurEmail)) { - if (count($Emails) > 1) { - continue; - } else { - error('Invalid email.'); - } - } - $DB->prepared_query(" - SELECT 1 - FROM invites - WHERE InviterID = ? - AND Email = ? - ", $LoggedUser['ID'], $CurEmail - ); - if ($DB->has_results()) { - error('You already have a pending invite to that address!'); - } +$email = trim($_POST['email']); +if (!preg_match(EMAIL_REGEXP, $email)) { + error('Invalid email.'); +} +$prior = $DB->scalar(" + SELECT Email + FROM invites + WHERE InviterID = ? + AND Email = ? + ", $Viewer->id(), $email +); +if ($prior) { + error('You already have a pending invite to that address!'); +} - $InviteKey = randomString(); - $DB->begin_transaction(); +$inviteKey = randomString(); +$DB->begin_transaction(); +$DB->prepared_query(" + INSERT INTO invites + (InviterID, InviteKey, Email, Reason, Expires) + VALUES (?, ?, ?, ?, now() + INTERVAL 3 DAY) + ", $Viewer->id(), $inviteKey, $email, trim($_POST['reason'] ?? '') +); +if (!$Viewer->permitted('site_send_unlimited_invites')) { $DB->prepared_query(" - INSERT INTO invites - (InviterID, InviteKey, Email, Reason, Expires) - VALUES (?, ?, ?, ?, now() + INTERVAL 3 DAY) - ", $LoggedUser['ID'], $InviteKey, $CurEmail, (check_perms('users_invite_notes') ? trim($_POST['reason'] ?? '') : '') - ); - if (!check_perms('site_send_unlimited_invites')) { - $DB->prepared_query(" - UPDATE users_main SET - Invites = GREATEST(Invites, 1) - 1 - WHERE ID = ? - ", $LoggedUser['ID'] - ); - $user->flush(); - } - $DB->commit(); - - (new Mail)->send($CurEmail, 'You have been invited to ' . SITE_NAME, - $Twig->render('email/invite-member.twig', [ - 'email' => $CurEmail, - 'key' => $InviteKey, - 'username' => $LoggedUser['Username'], - ]) + UPDATE users_main SET + Invites = GREATEST(Invites, 1) - 1 + WHERE ID = ? + ", $Viewer->id() ); + $Viewer->flush(); } +if (isset($_POST['user-0']) && preg_match('/^s-(\d+)$/', $_POST['user-0'], $match)) { + (new Gazelle\Manager\InviteSource)->createPendingInviteSource($match[1], $inviteKey); +} +$DB->commit(); + +(new Mail)->send($email, 'You have been invited to ' . SITE_NAME, + $Twig->render('email/invite-member.twig', [ + 'email' => $email, + 'key' => $inviteKey, + 'username' => $Viewer->username(), + ]) +); header('Location: user.php?action=invite'); diff --git a/sections/user/takemoderate.php b/sections/user/takemoderate.php index 24398ed31..3b5cd56ea 100644 --- a/sections/user/takemoderate.php +++ b/sections/user/takemoderate.php @@ -672,4 +672,17 @@ function ($id) { return $id > 0; } $user->flush(); } +if (isset($_POST['invite_source_update'])) { + $source = array_keys(array_filter($_POST, function ($x) { return preg_match('/^source-\d+$/', $x);}, ARRAY_FILTER_USE_KEY)); + if ($source) { + $ids = []; + foreach ($source as $s) { + $ids[] = ((int)explode('-', $s)[1]); + } + (new Gazelle\Manager\InviteSource)->modifyInviterConfiguration($user->id(), $ids); + header("Location: tools.php?action=invite_source"); + exit; + } +} + header("location: user.php?id=$userId"); diff --git a/sections/user/user.php b/sections/user/user.php index 99c2dfc46..278e9a433 100644 --- a/sections/user/user.php +++ b/sections/user/user.php @@ -343,14 +343,24 @@ function display_rank(Gazelle\UserRank $r, string $dimension) {
  • Passkey: View
  • permitted('users_view_invites')) { if (is_null($User->inviter())) { $Invited = 'Nobody'; } else { - $Invited = '' . $User->inviter()->username() . ""; + $inviter = $userMan->findById($User->inviter()->id()); + $Invited = '' . $User->inviter()->username() . ""; + if ($viewer->permitted('admin_manage_invite_source')) { + $source = (new Gazelle\Manager\InviteSource)->findSourceNameByUserId($UserID); + if (is_null($source) && ($inviter->isInterviewer() || $inviter->isRecruiter())) { + $source = "unconfirmed"; + } + if (!is_null($source)) { + $Invited .= " from $source"; + } + } } ?> -
  • Invited by:
  • +
  • Invited by:
  • Invites: disableInvites() ? 'X' : number_format($User->inviteCount()) ?> pendingInviteCount() . ' in use)' ?>
  • isInterviewer() || $User->isRecruiter() || $User->isStaff()) { + echo $Twig->render('user/edit-invite-sources.twig', [ + 'list' => (new \Gazelle\Manager\InviteSource)->inviterConfiguration($User->id()), + ]); + } + if (check_perms('users_give_donor')) { echo $Twig->render('donation/admin-panel.twig', [ 'user' => $User, diff --git a/templates/admin/invite-source-config.twig b/templates/admin/invite-source-config.twig new file mode 100644 index 000000000..ee5198b4f --- /dev/null +++ b/templates/admin/invite-source-config.twig @@ -0,0 +1,43 @@ +
    +

    Invite Source Configuration

    +
    + +
    +
    +

    Sources

    +

    Add the name of the trackers from whence members recruit. You can then assign tracker names on an individual basis for recruiters. +

    + + + + + + + +{% for source in list %} + + + + + + +{% endfor %} +
    NameInviter (recruiter) usageInvitee totalsRemove?
    {{ source.name }}{{ source.inviter_total|number_format }}{{ source.user_total|number_format }} + {% if source.inviter_total == 0 and source.user_total == 0 %} + + {% else %} + in use + {% endif %} +
    +
    + New name: +
    +
    + + + +
    +
    +
    diff --git a/templates/admin/invite-source.twig b/templates/admin/invite-source.twig new file mode 100644 index 000000000..24c488bb4 --- /dev/null +++ b/templates/admin/invite-source.twig @@ -0,0 +1,37 @@ +
    +

    Invite Source Inviter Summary

    +
    + +
    +
    +

    Recruitment Summary

    +
    +{% for u in list %} + {% if loop.first %} + + + + + + {% endif %} + + + + + {% if loop.last %} +
    Inviter (recruiter)Recruitment sites
    {{ u.user_id|user_full }}{{ u.name_list }}
    + {% endif %} +{% else %} +

    No inviters have been configured

    +{% endfor %} +
    + + + Go to user page to edit sources (user id or @name): +
    +
    +
    diff --git a/templates/admin/privilege-list.twig b/templates/admin/privilege-list.twig index 10fa9279c..ffaf880a2 100644 --- a/templates/admin/privilege-list.twig +++ b/templates/admin/privilege-list.twig @@ -139,6 +139,7 @@ {{ privilege(default, user, 'admin_manage_contest') }} {{ privilege(default, user, 'admin_manage_polls') }} {{ privilege(default, user, 'admin_manage_fls') }} + {{ privilege(default, user, 'admin_manage_invite_source') }} {{ privilege(default, user, 'admin_manage_user_fls') }} {{ privilege(default, user, 'admin_manage_applicants') }} {{ privilege(default, user, 'admin_manage_referrals') }} diff --git a/templates/macro/form.twig b/templates/macro/form.twig index bb9047eae..b5cba8b31 100644 --- a/templates/macro/form.twig +++ b/templates/macro/form.twig @@ -1,7 +1,16 @@ -{% macro checked(flag) -%} +{%- macro checked(flag) -%} {%- if flag %} checked="checked"{% endif -%} {%- endmacro %} -{% macro selected(flag) -%} +{%- macro selected(flag) -%} {%- if flag %} selected="selected"{% endif -%} {%- endmacro %} + +{%- macro select_invite_source(user_id, config, source) -%} + +{%- endmacro %} diff --git a/templates/user/edit-invite-sources.twig b/templates/user/edit-invite-sources.twig new file mode 100644 index 000000000..62c04ca60 --- /dev/null +++ b/templates/user/edit-invite-sources.twig @@ -0,0 +1,38 @@ +{% from 'macro/form.twig' import checked %} + + + + +{% for site in list %} + {% if loop.first %} + + + + + + + + + {% endif %} + + + + + {% if loop.last %} + + + + + {% endif %} +{% else %} + + + + +{% endfor %} +
    + Invite Sources +
     Check the sources from whence this member recruits or interviews. + They will be able to qualify their invites with the source tracker, to distinguish them from personal invitations.
     Source
     
      + +
     No sources have been configured. Configure!
    diff --git a/templates/user/header.twig b/templates/user/header.twig index 30d56a64f..c2b72ead0 100644 --- a/templates/user/header.twig +++ b/templates/user/header.twig @@ -35,7 +35,7 @@ {% if own_profile or viewer.permitted('users_edit_profiles') %} Edit {% endif %} -{% if viewer.permitted('users_view_invites') %} +{% if viewer.permitted('users_view_invites') or (user.canPurchaseInvite and own_profile) %} Invites {% endif %} {% if viewer.permitted('admin_reports') %} diff --git a/templates/user/invited.twig b/templates/user/invited.twig index c3176e985..e5dad0dc7 100644 --- a/templates/user/invited.twig +++ b/templates/user/invited.twig @@ -1,3 +1,5 @@ +{% from 'macro/form.twig' import select_invite_source %} +

    {{ user.id|user_url }} › Invites

    @@ -9,6 +11,8 @@
    +{% set is_site_inviter = inviter_config|length %} + {% if user.disableInvites %}
    Your invites have been disabled. @@ -29,33 +33,51 @@ {% elseif own_profile and user.canInvite %}
    -

    Please note that selling, trading, or publicly giving away our invitations — or responding - to public invite requests — is strictly forbidden, and may result in you and your entire invite tree being banned.

    +

    Please note that selling, trading, or publicly giving away our invitations — or responding + to public invite requests — is strictly forbidden, and may result in you and your entire invite tree being banned.

    Do not send an invite to anyone who has previously had an {{ constant('SITE_NAME') }} account. Please direct them to {{ constant('BOT_DISABLED_CHAN') }} on {{ constant('BOT_SERVER') }} if they wish to reactivate their account.

    Remember that you are responsible for ALL invitees, and your account and/or privileges may be disabled due to your invitees' actions. You should know and trust the person you're inviting. If you aren't familiar enough with the user to trust them, do not invite them.

    -

    Do not send an invite if you have not read or do not understand the information above.

    - - + {% if is_site_inviter %} +
    +
    Invite source:
    +
    + {{ select_invite_source(0, inviter_config, user_source) }} +
    +
    + {% endif %}
    Email address:
    - - +
    {% if user.permitted('users_invite_notes') %}
    Staff Note:
    - +
    {% endif %} +
    +
     
    +
    + +
    +
    +
    +
     
    +
    + + + +
    +
    {% endif %} @@ -87,7 +109,18 @@
    {% endfor %} -

    Invitee list

    +{% if is_site_inviter %} +
    +{% endif %} +

    Invitee list +{% if is_site_inviter %} + {% if edit_source %} + View + {% else %} + Edit sources + {% endif %} +{% endif %} +

    @@ -98,6 +131,9 @@ +{% if is_site_inviter %} + +{% endif %} {% for u in invited %} @@ -108,8 +144,27 @@ + {% if is_site_inviter %} + {% if edit_source %} + + {% else %} + + {% endif %} + {% endif %} {% endfor %} +{% if is_site_inviter and edit_source %} + + + + +{% endif %}
    {{ heading.emit('uploaded')|raw }} {{ heading.emit('downloaded')|raw }} {{ heading.emit('ratio')|raw }}Source
    {{ u.uploaded|octet_size }} {{ u.downloaded|octet_size }} {{ ratio(u.uploaded, u.downloaded) }}{{ select_invite_source(u.user_id, inviter_config, user_source) }}{{ user_source[u.user_id].name|default('not set')|raw }}
     
    +{% if is_site_inviter %} + + + + +{% endif %}