Add an invite source toolbox to track recruitments

This commit is contained in:
Spine
2021-06-19 13:03:35 +00:00
parent c7fa859e23
commit 7c4639700d
24 changed files with 686 additions and 115 deletions

View File

@@ -0,0 +1,174 @@
<?php
namespace Gazelle\Manager;
class InviteSource extends \Gazelle\Base {
public function create(string $name): int {
$this->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();
}
}

View File

@@ -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->prepared_query("DELETE FROM invites WHERE Expires < now()");
$this->db->set_query_id($userQuery);
$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
");
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();
}
}

View File

@@ -1492,6 +1492,8 @@ class User extends BaseObject {
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 @@ class User extends BaseObject {
}
/**
* 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;
}
/**

View File

@@ -125,6 +125,7 @@ class UserCreator extends Base {
);
if ($inviterId) {
(new \Gazelle\Manager\InviteSource)->resolveInviteSource($this->inviteKey, $this->id);
$this->db->prepared_query("
DELETE FROM invites WHERE InviteKey = ?
", $this->inviteKey

View File

@@ -162,24 +162,24 @@ if (!defined('FEATURE_EMAIL_REENABLE')) {
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);

View File

@@ -84,6 +84,7 @@ class Permissions {
'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',

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class InviteSource extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$this->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();
}
}

12
db/seeds/InviteSource.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
use Phinx\Seed\AbstractSeed;
class InviteSource extends AbstractSeed
{
public function run()
{
$this->table('invite_source')->insert(['name' => 'Personal'])->save();
}
}

56
docs/05-InviteSource.txt Normal file
View File

@@ -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 <user>
from <source>".
6. If a source is removed from a recruiter, all their invitees that were
tagged as coming from that source will remain.

View File

@@ -111,6 +111,13 @@ switch ($_REQUEST['action']) {
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 @@ switch ($_REQUEST['action']) {
break;
case 'periodic':
$mode = isset($_REQUEST['mode']) ? $_REQUEST['mode'] : 'view';
$mode = $_REQUEST['mode'] ?? 'view';
switch ($mode) {
case 'run_now':
case 'view':

View File

@@ -0,0 +1,18 @@
<?php
$Viewer = new Gazelle\User($LoggedUser['ID']);
if (!$Viewer->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();

View File

@@ -0,0 +1,26 @@
<?php
$Viewer = new Gazelle\User($LoggedUser['ID']);
if (!$Viewer->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();

View File

@@ -124,6 +124,7 @@ Category('Community', [
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'])),
]);

View File

@@ -1,19 +1,29 @@
<?php
$Viewer = new Gazelle\User($LoggedUser['ID']);
$userMan = new Gazelle\Manager\User;
if (isset($_GET['userid'])) {
if (!check_perms('users_view_invites')) {
error(403);
}
$UserID = (int)$_GET['userid'];
} else {
$UserID = $LoggedUser['ID'];
}
$user = $userMan->findById($UserID);
$user = $userMan->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);
}
$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];
}
}
$invSourceMan = new Gazelle\Manager\InviteSource;
if (count($userSource)) {
$invSourceMan->modifyUserSource($userId, $userSource);
}
$heading = new \Gazelle\Util\SortableTableHeader('joined', [
// see Gazelle\User::inviteList() for these table aliases
@@ -28,14 +38,19 @@ $heading = new \Gazelle\Util\SortableTableHeader('joined', [
]);
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();

View File

@@ -9,70 +9,63 @@ authorize();
* 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!');
}
$InviteKey = randomString();
$DB->begin_transaction();
$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'],
])
);
$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();
$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("
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');

View File

@@ -672,4 +672,17 @@ if (count($set) || count($leechSet)) {
$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");

View File

@@ -343,14 +343,24 @@ if (check_perms('users_view_keys') || $OwnProfile) {
<li>Passkey: <a href="#" id="passkey" onclick="togglePassKey('<?= display_str($User->announceKey()) ?>'); return false;" class="brackets">View</a></li>
<?php
}
if (check_perms('users_view_invites')) {
if ($viewer->permitted('users_view_invites')) {
if (is_null($User->inviter())) {
$Invited = '<span style="font-style: italic;">Nobody</span>';
} else {
$Invited = '<a href="user.php?id=' . $User->inviter()->id() . '">' . $User->inviter()->username() . "</a>";
$inviter = $userMan->findById($User->inviter()->id());
$Invited = '<a href="user.php?id=' . $inviter->id() . '">' . $User->inviter()->username() . "</a>";
if ($viewer->permitted('admin_manage_invite_source')) {
$source = (new Gazelle\Manager\InviteSource)->findSourceNameByUserId($UserID);
if (is_null($source) && ($inviter->isInterviewer() || $inviter->isRecruiter())) {
$source = "<i>unconfirmed</i>";
}
if (!is_null($source)) {
$Invited .= " from $source";
}
}
}
?>
<li>Invited by: <?=$Invited?></li>
<li>Invited by: <?= $Invited ?></li>
<li>Invites: <?= $User->disableInvites() ? 'X' : number_format($User->inviteCount()) ?>
<?= '(' . $User->pendingInviteCount() . ' in use)' ?></li>
<?php
@@ -812,6 +822,12 @@ if (check_perms('users_mod') || $viewer->isStaff()) { ?>
]);
}
if ($User->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,

View File

@@ -0,0 +1,43 @@
<div class="header">
<h2>Invite Source Configuration</h2>
</div>
<div class="linkbox">
<a href="tools.php?action=invite_source" class="brackets">Inviter Summary</a>
</div>
<div class="thin pad">
<div class="box pad">
<h2>Sources</h2>
<p>Add the name of the trackers from whence members recruit. You can then assign tracker names on an individual basis for recruiters.
<form action="" method="post">
<table>
<tr>
<th>Name</th>
<th>Inviter (recruiter) usage</th>
<th>Invitee totals</th>
<th>Remove?</th>
<tr>
{% for source in list %}
<tr>
<td>{{ source.name }}</td>
<td>{{ source.inviter_total|number_format }}</td>
<td>{{ source.user_total|number_format }}</td>
<td>
{% if source.inviter_total == 0 and source.user_total == 0 %}
<input type="checkbox" name="remove-{{ source.invite_source_id }}" />
{% else %}
<i>in use</i>
{% endif %}
</td>
<tr>
{% endfor %}
</table>
<br />
New name: <input type="text" length="40" name="name" />
<br />
<br />
<input type="hidden" name="auth" value="{{ auth }}" />
<input type="hidden" name="action" value="invite_source_config" />
<input type="submit" value="Update" />
</form>
</div>
</div>

View File

@@ -0,0 +1,37 @@
<div class="header">
<h2>Invite Source Inviter Summary</h2>
</div>
<div class="linkbox">
<a href="tools.php?action=invite_source_config" class="brackets">Configuration</a>
<a href="user.php?action=search&secclass={{ constant('INTERVIEWER') }}&order=Joined&way=Ascending" class="brackets">Search Interviewers</a>
<a href="user.php?action=search&secclass={{ constant('RECRUITER') }}&order=Joined&way=Ascending" class="brackets">Search Recruiters</a>
</div>
<div class="thin pad">
<div class="box pad">
<h2>Recruitment Summary</h2>
<form action="" method="post">
{% for u in list %}
{% if loop.first %}
<table>
<tr>
<th>Inviter (recruiter)</th>
<th>Recruitment sites</th>
</tr>
{% endif %}
<tr>
<td>{{ u.user_id|user_full }}</td>
<td>{{ u.name_list }}</td>
</tr>
{% if loop.last %}
</table>
{% endif %}
{% else %}
<h3>No inviters have been configured</h3>
{% endfor %}
<br />
<input type="hidden" name="auth" value="{{ auth }}" />
<input type="hidden" name="action" value="invite_source" />
Go to user page to edit sources (user id or @name): <input type="text" length="40" name="user" /> <input type="submit" value="Go!" />
</form>
</div>
</div>

View File

@@ -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') }}

View File

@@ -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) -%}
<select name="user-{{ user_id }}">
<option name="s-none">---</option>
{%- for c in config -%}
<option value="s-{{ c.invite_source_id }}"{{ _self.selected(c.invite_source_id == source[user_id].invite_source_id) }}>{{ c.name }}</option>
{%- endfor -%}
</select>
{%- endmacro %}

View File

@@ -0,0 +1,38 @@
{% from 'macro/form.twig' import checked %}
<table class="layout" id="user_invite_source_box">
<tr class="colhead">
<td colspan="2">
<a name="invite_source">Invite Sources</a>
</td>
</tr>
{% for site in list %}
{% if loop.first %}
<tr>
<td class="label">&nbsp;</td>
<td>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.</td>
</tr>
<tr>
<td class="label">&nbsp;</td>
<td>Source</td>
</tr>
{% endif %}
<tr>
<td>&nbsp;</td>
<td><label><input type="checkbox" name="source-{{ site.invite_source_id }}"{{ checked(site.active == 1) }} /> {{ site.name }}</label></td>
</tr>
{% if loop.last %}
<tr>
<td class="label">&nbsp;</td>
<td>
<input type="submit" name="invite_source_update" value="Update" />
</td>
</tr>
{% endif %}
{% else %}
<tr>
<td class="label">&nbsp;</td>
<td>No sources have been configured. <a href="tools.php?action=invite_source_config">Configure</a>!</td>
</tr>
{% endfor %}
</table>

View File

@@ -35,7 +35,7 @@
{% if own_profile or viewer.permitted('users_edit_profiles') %}
<a href="user.php?action=edit&amp;userid={{ user_id }}" class="brackets">Edit</a>
{% endif %}
{% if viewer.permitted('users_view_invites') %}
{% if viewer.permitted('users_view_invites') or (user.canPurchaseInvite and own_profile) %}
<a href="user.php?action=invite&amp;userid={{ user_id }}" class="brackets">Invites</a>
{% endif %}
{% if viewer.permitted('admin_reports') %}

View File

@@ -1,3 +1,5 @@
{% from 'macro/form.twig' import select_invite_source %}
<div class="thin">
<div class="header">
<h2>{{ user.id|user_url }} &rsaquo; Invites</h2>
@@ -9,6 +11,8 @@
</div>
</div>
{% set is_site_inviter = inviter_config|length %}
{% if user.disableInvites %}
<div class="box pad" style="text-align: center;">
<strong class="important_text">Your invites have been disabled.
@@ -29,33 +33,51 @@
{% elseif own_profile and user.canInvite %}
<div class="box pad">
<p>Please note that selling, trading, or publicly giving away our invitations&#8202;&mdash;&#8202;or responding
to public invite requests&#8202;&mdash;&#8202;is strictly forbidden, and may result in you and your entire invite tree being banned.</p>
<p>Please note that selling, trading, or publicly giving away our invitations &mdash;&nbsp;or responding
to public invite requests&nbsp;&mdash; is strictly forbidden, and may result in you and your entire invite tree being banned.</p>
<p>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.</p>
<p>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.</p>
<p><em>Do not send an invite if you have not read or do not understand the information above.</em></p>
</div>
<div class="box box2">
<form class="send_form pad" name="invite" action="user.php" method="post">
<input type="hidden" name="action" value="take_invite" />
<input type="hidden" name="auth" value="{{ auth }}" />
{% if is_site_inviter %}
<div class="field_div">
<div class="label">Invite source:</div>
<div class="input">
<td class="nobr">{{ select_invite_source(0, inviter_config, user_source) }}</td>
</div>
</div>
{% endif %}
<div class="field_div">
<div class="label">Email address:</div>
<div class="input">
<input type="email" name="email" size="60" />
<input type="submit" value="Invite" />
<input type="email" name="email" size="40" />
</div>
</div>
{% if user.permitted('users_invite_notes') %}
<div class="field_div">
<div class="label">Staff Note:</div>
<div class="input">
<input type="text" name="reason" size="60" maxlength="255" />
<input type="text" name="reason" size="40" maxlength="255" />
</div>
</div>
{% endif %}
<div class="field_div">
<div class="label">&nbsp;</div>
<div class="input">
<label><input type="checkbox" name="agreement" /> I have read and agree to the information written above.</label>
</div>
</div>
<div class="field_div">
<div class="label">&nbsp;</div>
<div class="input">
<input type="hidden" name="action" value="take_invite" />
<input type="hidden" name="auth" value="{{ auth }}" />
<input type="submit" value="Invite" />
</div>
</div>
</form>
</div>
{% endif %}
@@ -87,7 +109,18 @@
</div>
{% endfor %}
<h3>Invitee list</h3>
{% if is_site_inviter %}
<form action="" method="post">
{% endif %}
<h3>Invitee list
{% if is_site_inviter %}
{% if edit_source %}
<a class="brackets" href="user.php?action=invite">View</a>
{% else %}
<a class="brackets" href="user.php?action=invite&edit=source">Edit sources</a>
{% endif %}
{% endif %}
</h3>
<div class="box pad">
<table class="invite_table m_table" width="100%">
<tr class="colhead">
@@ -98,6 +131,9 @@
<td class="m_th_right nobr">{{ heading.emit('uploaded')|raw }}</td>
<td class="m_th_right nobr">{{ heading.emit('downloaded')|raw }}</td>
<td class="m_th_right nobr">{{ heading.emit('ratio')|raw }}</td>
{% if is_site_inviter %}
<td class="nobr">Source</td>
{% endif %}
</tr>
{% for u in invited %}
<tr class="row{{ cycle(['a', 'b'], loop.index0) }}">
@@ -108,8 +144,27 @@
<td class="td_up m_td_right">{{ u.uploaded|octet_size }}</td>
<td class="td_dl m_td_right">{{ u.downloaded|octet_size }}</td>
<td class="td_ratio m_td_right">{{ ratio(u.uploaded, u.downloaded) }}</td>
{% if is_site_inviter %}
{% if edit_source %}
<td class="nobr">{{ select_invite_source(u.user_id, inviter_config, user_source) }}</td>
{% else %}
<td class="nobr">{{ user_source[u.user_id].name|default('<i>not set</i>')|raw }}</td>
{% endif %}
{% endif %}
</tr>
{% endfor %}
{% if is_site_inviter and edit_source %}
<tr>
<td colspan="7">&nbsp;</td>
<td><input type="submit" value="Update" /></td>
</tr>
{% endif %}
</table>
{% if is_site_inviter %}
<input type="hidden" name="action" value="invite" />
<input type="hidden" name="auth" value="{{ auth }}" />
<input type="hidden" name="user_id" value="{{ user.id }}" />
</form>
{% endif %}
</div>
</div>