<?php
|
namespace JVBase\managers;
|
|
use Exception;
|
use JVBase\managers\queue\executors\InvitationExecutor;
|
use JVBase\managers\queue\TypeConfig;
|
use JVBase\utility\Features;
|
use WP_Error;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
class InvitationsManager
|
{
|
protected array $inviteConfig;
|
protected CustomTable $table;
|
protected int $expiryDays = 14;
|
public function __construct()
|
{
|
$this->setInviteConfig();
|
$this->table = CustomTable::for('invitations');
|
add_action('init', [$this, 'registerInvitationExecutors'], 5);
|
|
add_action('user_register', [$this, 'checkInvitation']);
|
add_filter('jvbLoginLabels', [$this, 'modifyLoginLabels'], 10, 2);
|
add_action('jvb_daily_maintenance', [$this, 'cleanupExpiredInvitations']);
|
add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
|
}
|
|
protected function setInviteConfig():void
|
{
|
$this->inviteConfig = get_option(BASE.'invitation_config', [
|
'roles' => [],
|
'terms' => []
|
]);
|
}
|
|
public function invitableTerms():array
|
{
|
return $this->inviteConfig['terms'];
|
}
|
|
public function invitableRoles():array
|
{
|
return $this->inviteConfig['roles'];
|
}
|
|
public function getInviteConfig():array
|
{
|
return $this->inviteConfig;
|
}
|
|
|
/**
|
* Register invitation operation types with queue TypeRegistry
|
*/
|
public function registerInvitationExecutors(): void
|
{
|
$registry = JVB()->queue()->registry();
|
$executor = new InvitationExecutor();
|
|
// Create invitations - chunked at 20
|
$registry->register('invitation_create', new TypeConfig(
|
executor: $executor,
|
chunkKey: 'invitations',
|
chunkSize: 20
|
));
|
|
// Resend invitations - chunked at 10
|
$registry->register('invitation_resend', new TypeConfig(
|
executor: $executor,
|
chunkKey: 'invitations',
|
chunkSize: 10
|
));
|
|
// Revoke invitations
|
$registry->register('invitation_revoke', new TypeConfig(
|
executor: $executor
|
));
|
}
|
|
/**
|
* Build invite types from JVB constants
|
* Combines JVB_MEMBERSHIP['can_invite'] and invitable taxonomies
|
*/
|
protected function buildInviteTypes(): array
|
{
|
$types = [];
|
|
// Global invitations from JVB_MEMBERSHIP
|
if (!empty(JVB_MEMBERSHIP['can_invite'])) {
|
foreach (JVB_MEMBERSHIP['can_invite'] as $role => $canInvite) {
|
$types[$role] = [
|
'can_invite' => $canInvite,
|
'to_terms' => []
|
];
|
}
|
}
|
|
// Term invitations from invitable content taxonomies
|
foreach (JVB_TAXONOMY as $taxonomy => $config) {
|
if (Features::forTaxonomy($taxonomy)->has('invitable') &&
|
Features::forTaxonomy($taxonomy)->has('is_content') &&
|
Features::forTaxonomy($taxonomy)->has('is_ownable')) {
|
|
$forContent = $config['for_content'] ?? [];
|
foreach ($forContent as $content) {
|
// Find which user roles can create this content
|
foreach (JVB_USER as $role => $userConfig) {
|
$creatable = Features::forUser($role)->getCreatableContent();
|
if (in_array($content, $creatable)) {
|
if (!isset($types[$role])) {
|
$types[$role] = [
|
'can_invite' => [],
|
'to_terms' => []
|
];
|
}
|
if (!in_array($taxonomy, $types[$role]['to_terms'])) {
|
$types[$role]['to_terms'][] = $taxonomy;
|
}
|
}
|
}
|
}
|
}
|
}
|
|
return $types;
|
}
|
|
/******************************************************************
|
* UTILITY
|
******************************************************************/
|
public function canInviteToTerm(int $userID, string $taxonomy, int $termID):bool
|
{
|
$taxonomy = jvbNoBase($taxonomy);
|
|
// Check if taxonomy is invitable
|
if (!in_array($taxonomy, $this->invitableTerms())) {
|
return false;
|
}
|
|
// User must be owner or manager of the term
|
return JVB()->roles()->isManager($userID, $termID);
|
}
|
|
/**
|
* Check if user can send global invitations
|
*/
|
public function canInviteGlobally(int $userID, string $targetRole): bool
|
{
|
$userRole = jvbUserRole($userID);
|
$allowedRoles = $this->inviteConfig['roles'][$userRole] ?? [];
|
|
return in_array($targetRole, $allowedRoles);
|
}
|
|
public function validateInvitations(int $userID, array $invites): array
|
{
|
$validated = [];
|
|
foreach ($invites as $invite) {
|
$sanitized = $this->sanitizeInvitation($invite);
|
if (!$sanitized) {
|
continue;
|
}
|
|
// Check permissions
|
if ($sanitized['to_term'] && $sanitized['taxonomy']) {
|
// Term invitation - check management capability
|
if (!$this->canInviteToTerm($userID, $sanitized['taxonomy'], $sanitized['to_term'])) {
|
continue;
|
}
|
} else {
|
// Global invitation - check if allowed to invite this role
|
if (!$this->canInviteGlobally($userID, $sanitized['invited_role'])) {
|
continue;
|
}
|
}
|
|
$validated[] = $sanitized;
|
}
|
|
return $validated;
|
}
|
|
/**
|
* Sanitize single invitation
|
*/
|
protected function sanitizeInvitation(array $invite): ?array
|
{
|
$email = sanitize_email($invite['email'] ?? '');
|
$name = sanitize_text_field($invite['name'] ?? '');
|
$role = $invite['role'] ?? '';
|
|
if (!is_email($email) || empty($name) || empty($role)) {
|
return null;
|
}
|
|
// Check if user already exists
|
if (email_exists($email)) {
|
return null;
|
}
|
|
$sanitized = [
|
'email' => $email,
|
'name' => $name,
|
'invited_role' => $role,
|
'to_term' => isset($invite['to_term']) ? (int) $invite['to_term'] : null,
|
'taxonomy' => isset($invite['taxonomy']) ? jvbNoBase($invite['taxonomy']) : null
|
];
|
|
// Validate term if provided
|
if ($sanitized['to_term'] && $sanitized['taxonomy']) {
|
$term = get_term($sanitized['to_term'], BASE . $sanitized['taxonomy']);
|
if (!$term || is_wp_error($term)) {
|
$sanitized['to_term'] = null;
|
$sanitized['taxonomy'] = null;
|
}
|
}
|
|
return $sanitized;
|
}
|
|
/**************************************************************************
|
* QUEUE
|
**************************************************************************/
|
/**
|
* Process bulk invitations (called by queue)
|
*/
|
public function processOperation(WP_Error|array $result, object $operation, array $data): array|WP_Error
|
{
|
if ($operation->type !== 'invitation_create') {
|
return $result;
|
}
|
|
return $this->processInvitations($data, $operation->user_id);
|
}
|
|
/**
|
* Process invitations with transaction support
|
*/
|
protected function processInvitations(array $data, int $userID): array
|
{
|
$invitations = $data['invitations'] ?? [];
|
|
$results = [
|
'success' => [],
|
'failed' => []
|
];
|
|
$this->table->startTransaction();
|
|
try {
|
foreach ($invitations as $invite) {
|
$result = $this->createInvitation(
|
$invite['name'],
|
$invite['email'],
|
$userID,
|
$invite['invited_role'],
|
$invite['to_term'],
|
$invite['taxonomy'],
|
false // Don't send email yet
|
);
|
|
if (is_wp_error($result)) {
|
$results['failed'][] = [
|
'email' => $invite['email'],
|
'name' => $invite['name'],
|
'reason' => $result->get_error_message()
|
];
|
} else {
|
$results['success'][] = array_merge($result, [
|
'email' => $invite['email'],
|
'name' => $invite['name']
|
]);
|
}
|
}
|
|
if (!empty($results['success'])) {
|
$this->table->commit();
|
|
// Send emails
|
foreach ($results['success'] as $invitation) {
|
$terms = [];
|
if ($invitation['to_term'] && $invitation['taxonomy']) {
|
$terms[$invitation['taxonomy']] = $invitation['to_term'];
|
}
|
|
$this->sendInvitationEmail(
|
$invitation['name'],
|
$invitation['email'],
|
$invitation['token'],
|
$userID,
|
$terms,
|
$invitation['invited_role']
|
);
|
}
|
} else {
|
$this->table->rollback();
|
}
|
|
return [
|
'success' => count($results['success']) > 0,
|
'results' => $results
|
];
|
|
} catch (Exception $e) {
|
$this->table->rollback();
|
|
JVB()->error()->log(
|
'invitation_create',
|
'Error processing invitations: ' . $e->getMessage(),
|
['user_id' => $userID],
|
);
|
|
return [
|
'success' => false,
|
'result' => [
|
'failed' => $invitations,
|
'error' => $e->getMessage()
|
]
|
];
|
}
|
}
|
|
/**
|
* Create or update an invitation
|
*/
|
public function createInvitation(
|
string $name,
|
string $email,
|
int $inviterID,
|
string $invitedRole,
|
?int $termID = null,
|
?string $taxonomy = null,
|
bool $sendEmail = true
|
): WP_Error|array {
|
|
$email = sanitize_email($email);
|
if (!is_email($email)) {
|
return new WP_Error('invalid_email', 'Invalid email address');
|
}
|
|
if (email_exists($email)) {
|
return new WP_Error('user_exists', 'User already registered');
|
}
|
|
// Check for existing invitation
|
$existing = $this->table->get([
|
'email' => $email,
|
'invited_role' => $invitedRole
|
]);
|
|
$token = wp_generate_password(32, false);
|
$expiresAt = date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days"));
|
|
if ($existing) {
|
// Update existing
|
$inviters = json_decode($existing->inviters, true) ?: [];
|
|
$inviterExists = false;
|
foreach ($inviters as &$inviter) {
|
if ($inviter['user_id'] == $inviterID) {
|
$inviterExists = true;
|
$inviter['invited_at'] = current_time('mysql');
|
break;
|
}
|
}
|
|
if (!$inviterExists) {
|
$inviters[] = [
|
'user_id' => $inviterID,
|
'invited_at' => current_time('mysql')
|
];
|
}
|
|
$updateData = [
|
'inviters' => json_encode($inviters),
|
'status' => 'pending',
|
'expires_at' => $expiresAt
|
];
|
|
if ($termID && $taxonomy) {
|
$updateData['to_' . $taxonomy] = $termID;
|
}
|
|
if ($existing->status === 'expired') {
|
$updateData['invitation_token'] = $token;
|
} else {
|
$token = $existing->invitation_token;
|
}
|
|
$this->table->update($updateData, ['id' => $existing->id]);
|
$invitationID = $existing->id;
|
|
} else {
|
// Create new
|
$insertData = [
|
'name' => sanitize_text_field($name),
|
'email' => $email,
|
'invitation_token' => $token,
|
'invited_role' => $invitedRole,
|
'status' => 'pending',
|
'inviters' => json_encode([[
|
'user_id' => $inviterID,
|
'invited_at' => current_time('mysql')
|
]]),
|
'expires_at' => $expiresAt
|
];
|
|
if ($termID && $taxonomy) {
|
$insertData['to_' . $taxonomy] = $termID;
|
}
|
|
$invitationID = $this->table->insert($insertData);
|
}
|
|
if ($sendEmail) {
|
$terms = [];
|
if ($termID && $taxonomy) {
|
$terms[$taxonomy] = $termID;
|
}
|
$this->sendInvitationEmail($name, $email, $token, $inviterID, $terms, $invitedRole);
|
}
|
|
return [
|
'id' => $invitationID,
|
'token' => $token,
|
'expires_at' => $expiresAt,
|
'to_term' => $termID,
|
'taxonomy' => $taxonomy,
|
'invited_role' => $invitedRole
|
];
|
}
|
|
/**
|
* Send invitation email
|
*/
|
public function sendInvitationEmail(
|
string $name,
|
string $email,
|
string $token,
|
int $inviterID,
|
array $terms,
|
string $role
|
): void {
|
$inviterName = jvbGetUsername($inviterID);
|
$siteName = get_bloginfo('name');
|
|
$subject = apply_filters('jvbInvitationSubject',
|
sprintf("%s invited you to join %s!", $inviterName, $siteName),
|
$inviterName
|
);
|
|
$signupUrl = add_query_arg([
|
'invite' => $token,
|
'email' => urlencode($email),
|
'name' => urlencode($name),
|
'role' => $role
|
], wp_registration_url());
|
|
// Build term-specific content
|
$termContent = [];
|
foreach ($terms as $taxonomy => $termID) {
|
if (!$termID) continue;
|
|
$term = get_term($termID, BASE . $taxonomy);
|
if ($term && !is_wp_error($term)) {
|
$config = JVB_TAXONOMY[$taxonomy] ?? [];
|
$singular = $config['singular'] ?? $taxonomy;
|
|
$termContent[] = sprintf(
|
"<p>%s has also invited you to join %s. You'll be automatically added to this %s when you register.</p>",
|
$inviterName,
|
html_entity_decode($term->name),
|
$singular
|
);
|
}
|
}
|
$termText = implode('', $termContent);
|
|
$button = JVB()->email()->button($signupUrl, 'Join the Scene!');
|
$link = JVB()->email()->link($signupUrl);
|
$signature = JVB()->email()->signature();
|
|
$message = sprintf(
|
'<p>Hi %s!</p>
|
<p>%s has invited you to join them on %s.</p>
|
%s
|
<h2>Interested?</h2>
|
<p>Join in by clicking the button below:</p>
|
%s
|
<p>Or by copying and pasting the link below into your browser:</p>
|
%s
|
<div class="divider"></div>
|
<p>This invitation expires in %d days.</p>
|
<p>Ink on,</p>
|
%s',
|
$name,
|
$inviterName,
|
$siteName,
|
$termText,
|
$button,
|
$link,
|
$this->expiryDays,
|
$signature
|
);
|
|
$message = apply_filters('jvbInvitationMessage',
|
$message,
|
$name,
|
$inviterName,
|
$role,
|
$terms,
|
$this->expiryDays,
|
$button,
|
$link,
|
$signature
|
);
|
|
JVB()->email()->sendEmail($email, $subject, $message);
|
}
|
|
/**
|
* Check invitation on user registration
|
* @throws Exception
|
*/
|
public function checkInvitation(int $userID): void
|
{
|
$token = sanitize_text_field($_GET['invite'] ?? '');
|
$email = sanitize_email($_GET['email'] ?? '');
|
|
if (!$token || !$email) {
|
return;
|
}
|
|
$user = get_userdata($userID);
|
if (!$user || $user->user_email !== $email) {
|
return;
|
}
|
|
$this->acceptInvitation($token, $email, $userID);
|
}
|
|
/**
|
* Accept invitation and grant appropriate capabilities
|
* @throws Exception
|
*/
|
public function acceptInvitation(string $token, string $email, int $userID): bool
|
{
|
// Verify invitation using fluent CustomTable
|
$invitation = $this->table
|
->where([
|
'invitation_token' => $token,
|
'email' => $email,
|
'status' => 'pending'
|
])
|
->first();
|
|
if (!$invitation) {
|
return false;
|
}
|
|
// Check expiry
|
if (strtotime($invitation->expires_at) < time()) {
|
return false;
|
}
|
|
// Update in transaction
|
$success = $this->table->transaction(function($table) use ($invitation, $userID) {
|
// Update invitation status
|
$table->update(
|
[
|
'status' => 'accepted',
|
'new_user_id' => $userID,
|
'accepted_at' => current_time('mysql')
|
],
|
['id' => $invitation->id]
|
);
|
|
// Grant capabilities
|
$user = get_userdata($userID);
|
$user->add_cap('skip_moderation', true);
|
|
return true;
|
});
|
|
if (!$success) {
|
return false;
|
}
|
|
// Handle term membership
|
$this->processTermMembership($userID, $invitation);
|
|
// Notify inviters
|
$this->notifyInvitersOfAcceptance($invitation, $userID);
|
|
// Invalidate cache
|
Cache::for('invitations')->forget('user_' . $userID);
|
|
return true;
|
}
|
|
/**
|
* Process term membership for accepted invitation
|
*/
|
protected function processTermMembership(int $userID, object $invitation): void
|
{
|
foreach (JVB()->roles()->getInvitableTaxonomies() as $taxonomy) {
|
$column = 'to_' . $taxonomy;
|
if (isset($invitation->$column) && $invitation->$column) {
|
$termID = (int) $invitation->$column;
|
|
do_action(
|
BASE . 'add_user_to_term',
|
$userID,
|
$termID,
|
$taxonomy,
|
'member'
|
);
|
}
|
}
|
}
|
|
/**
|
* Notify inviters that invitation was accepted
|
*/
|
protected function notifyInvitersOfAcceptance(object $invitation, int $userID): void
|
{
|
$inviters = json_decode($invitation->inviters, true) ?: [];
|
$userData = get_userdata($userID);
|
|
foreach ($inviters as $inviter) {
|
JVB()->notification()->addNotification(
|
$inviter['user_id'],
|
'invitation_accepted',
|
[
|
'invited_email' => $invitation->email,
|
'user_id' => $userID,
|
'display_name' => $userData->display_name
|
]
|
);
|
}
|
}
|
|
/**
|
* Clean up expired invitations (daily cron)
|
*/
|
public function cleanupExpiredInvitations(): void
|
{
|
// Use raw query for date comparison
|
$this->table->query(
|
"UPDATE {$this->table->getFullTableName()}
|
SET status = 'expired'
|
WHERE status = 'pending'
|
AND expires_at < NOW()"
|
);
|
|
// Clear cache after cleanup
|
Cache::for('invitations')->flush();
|
}
|
|
/*****************************************************************
|
* Emails
|
*****************************************************************/
|
/**
|
* Send revocation email
|
*/
|
public function sendRevocationEmail(string $email, string $name): void
|
{
|
$siteName = get_bloginfo('name');
|
|
$subject = apply_filters('jvbInvitationRevokedSubject',
|
sprintf('[%s] Your invitation has been revoked', $siteName)
|
);
|
|
$content = apply_filters('jvbInvitationRevokedMessage',
|
sprintf(
|
'<p>Hey %s,</p>
|
<p>This is to let you know that your invitation to join %s has been revoked.</p>
|
<p>If you believe this was done in error, please contact the person who invited you or the site admin.</p>',
|
$name,
|
$siteName
|
),
|
$name
|
);
|
|
JVB()->email()->sendEmail($email, $subject, $content, 'INVITATION REVOKED');
|
}
|
|
/**
|
* TODO: Check with LoginManager.php
|
* Modify login labels for invitation flow
|
*/
|
public function modifyLoginLabels(array $labels, array $getParams): array
|
{
|
if (!isset($getParams['invite']) || !isset($getParams['email'])) {
|
return $labels;
|
}
|
|
$email = sanitize_email($getParams['email']);
|
$token = sanitize_text_field($getParams['invite']);
|
$role = $getParams['role'] ?? '';
|
|
if (empty($role)) {
|
return $labels;
|
}
|
|
// Use fluent interface
|
$invitation = $this->table
|
->where([
|
'invitation_token' => $token,
|
'email' => $email,
|
'invited_role' => $role,
|
'status' => 'pending'
|
])
|
->first();
|
|
if (!$invitation) {
|
return $labels;
|
}
|
|
// Build inviter names
|
$inviters = json_decode($invitation->inviters, true) ?: [];
|
$names = array_map(fn($inviter) => jvbGetUsername($inviter['user_id']), $inviters);
|
|
$message = count($names) > 1
|
? 'are already here, and have invited you to join in!'
|
: ' is already here, and invited you to join in!';
|
|
$labels['title'] = 'Join the Scene, ' . $invitation->name;
|
$labels['description'] = [jvbCommaList($names) . ' ' . $message];
|
|
return $labels;
|
}
|
}
|