From 235ce5716edc2f7cbe80fdccf26eac7269587839 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Mon, 08 Jun 2026 04:38:18 +0000
Subject: [PATCH] =FavouritesManager.php and FavouritesRoutes.php fixes. Moving all logic to FavouritesManager.php. Still some left to do
---
inc/rest/routes/Invitations.php | 1653 ++++++++++------------------------------------------------
1 files changed, 296 insertions(+), 1,357 deletions(-)
diff --git a/inc/rest/routes/Invitations.php b/inc/rest/routes/Invitations.php
index 4022790..1839f44 100644
--- a/inc/rest/routes/Invitations.php
+++ b/inc/rest/routes/Invitations.php
@@ -1,1394 +1,333 @@
<?php
namespace JVBase\rest\routes;
-use JVBase\JVB;
-use JVBase\rest\RestRouteManager;
-use Exception;
-use JVBase\utility\Features;
+use JVBase\rest\Rest;
+use JVBase\managers\CustomTable;
+use JVBase\rest\Route;
+use WP_REST_Request;
use WP_REST_Response;
-use WP_Error;
if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
+ exit;
}
-// TODO: Get this to work with the constants setup
-/***
- * WORKFLOW:
- * 1) Verified user (userA) invites user
- * a) USER EXISTS -> notify user they're already up
- * b) USER DOESN'T EXIST:
- * i) check if user exists in invitation table
- * ii) if they exist, add userA to inviters
- * - if status is expired, resent email invite and set status to 'pending'
- * iii) if they don't exist, add to table
- * iv) once invited user registers:
- * - set status to 'accepted', add new_user_id
- * - set user as verified
- * - if user was invited to a specific shop, pass user along to that shop
+
+/**
+ * Invitations Route Manager
+ *
+ * Handles user invitations:
+ * - Global invitations (user to user, based on JVB_MEMBERSHIP['can_invite'])
+ * - Term invitations (to ownable content taxonomies with 'invitable' flag)
*/
-class Invitations extends RestRouteManager
+class Invitations extends Rest
{
- protected string $tableName;
- protected array $inviteTypes;
- protected $wpdb;
- protected string $prefix;
- protected array $tableNames;
- protected int $expiryDays = 14; // Invitations expire after 14 days
+ protected array $inviteConfig;
+ protected CustomTable $table;
- public function __construct()
- {
- $this->cache_name = 'invitations';
- parent::__construct();
- global $wpdb;
- $this->inviteTypes = jvbInviteTableTypes();
- $this->tableNames = jvbInviteTables();
- $this->wpdb = $wpdb;
- $this->prefix = $wpdb->prefix;
+ public function __construct()
+ {
+ $this->cacheName = 'invitations';
+ parent::__construct();
- // Add hooks for processing accepted invitations
- add_action('user_register', [$this, 'checkInvitation'], 10, 1);
+ // Get invitation configuration
+ $this->inviteConfig = JVB()->invitations()->getInviteConfig();
+ $this->table = CustomTable::for('invitations');
-
- add_filter('jvbLoginLabels', [$this, 'modifyLoginLabels'], 10, 2);
-
-
-
- add_action('jvb_daily_maintenance', [$this, 'cleanupExpiredInvitations']);
-
- // Add filter for bulk operation handling
- add_filter(BASE . 'handle_bulk_operation', [ $this, 'processOperation' ], 10, 3);
- }
-
- /**
- * Registers the routes for invitations
- * @return void
- */
- public function registerRoutes():void
- {
- register_rest_route($this->namespace, '/invitations', [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'getInvitations'],
- 'permission_callback' => [$this, 'checkPermission']
- ],
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'createInvitationRequest'],
- 'permission_callback' => [$this, 'checkPermission']
- ]
- ]);
- }
-
- protected function buildParams(object $request):array
- {
- $data = $request->get_params();
- $role = (array_key_exists('role', $data) && array_key_exists($data['role'], $this->tableNames)) ? $data['role'] : false;
- $toTerm = (array_key_exists('to_term', $data)) ? (int)$data['to_term'] : false;
- $taxonomy = (array_key_exists('taxonomy', $data) && in_array($data['taxonomy'], $this->inviteTypes[$role]['to_terms']??[])) ? $data['taxonomy'] : false;
-
- $args = [
- 'user' => (array_key_exists('user', $data)) ? (int)$data['user'] : false,
- 'role' => $role,
- 'to_term' => $toTerm,
- 'taxonomy' => $taxonomy,
- 'status' => array_key_exists('status', $data) && in_array($data['status'], ['all', 'pending', 'accepted', 'rejected', 'expired', 'revoked']) ? $data['status'] : 'all',
- 'page' => array_key_exists('page', $data) ? (int)$data['page'] : 1,
- ];
-
- return $args;
- }
- /**
- * @param object $request the request object
- *
- * @return WP_REST_Response
- */
- public function getInvitations(object $request): WP_REST_Response
- {
- $args = $this->buildParams($request);
- if ($args['user']) {
- if (!$this->userCheck($args['user'])) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Looks like you are not who you say you are'
- ]);
- }
- if (!$this->isVerifiedUser($args['user'])) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Sorry, you don\'t have permission to do this.',
- ]);
- }
- return $this->getUserInvitations($args);
- } elseif ($args['to_term']) {
- if (!$this->checkTerm($args)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Looks like this '.$args['taxonomy'].' does not exist'
- ]);
- }
- return $this->getTermInvitations($args);
- }
-
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Invalid request'
- ]);
- }
-
- public function getTermInvitations(array $args):WP_REST_Response
- {
- if (!$this->checkTerm($args)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Invalid shop'
- ]);
- }
-
- if (!user_can($args['user'], 'manage_'.$args['taxonomy'].'_'.$args['to_term'])) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'You do not have permission to view invitations for this '.$args['taxonomy']
- ]);
- }
-
- $key = $this->cache->generateKey($args);
-
- $cache = $this->cache->get($key);
- if ($cache) {
- return new WP_REST_Response($cache);
- }
-
- $per_page = 20;
-
- $conditions = [];
- $params = [];
-
- //Filter by term
- $conditions[] = "to_{$args['taxonomy']} = %d";
- $params[] = $args['to_term'];
-
- if ($args['status'] !== 'all') {
- $conditions[] = "status = %s";
- $params[] = $args['status'];
- }
-
- $where = !empty($conditions) ? " WHERE " .implode(' AND ', $conditions) : "";
-
- //Count total for pagination
- $count_query = "SELECT COUNT(*) FROM {$this->tableNames[$args['role']]} {$where}";
- $total = $this->wpdb->get_var($this->wpdb->prepare($count_query, $params));
-
- //Get paginated invitations
- $offset = ($args['page'] - 1) * $per_page;
- $query = $count_query." ORDER BY created_at DESC LIMIT %d OFFSET %d";
-
- //Add pagination
- $pagination = array_merge($params, [$per_page, $offset]);
- $invitations = $this->wpdb->get_results($this->wpdb->prepare($query, $pagination));
-
- $formatted = [];
- foreach ($invitations as $invitation) {
- $formatted[] = $this->formatInvitation($invitation);
- }
-
- $return = [
- 'invitations' => $formatted,
- 'total' => (int)$total,
- 'pages' => ceil($total /$per_page),
- 'page' => $args['page'],
- 'per_page' => $per_page
- ];
-
- $this->cache->set($key, $return);
- return new WP_REST_Response($return);
- }
-
- protected function buildInvitationArgs(object $request):array
- {
- $data = $request->get_params();
-
- $user = (array_key_exists('user', $data) && $this->userCheck($data['user'])) ? (int) $data['user'] : false;
- if (!$user) {
- return [];
- }
- $role = jvbUserRole($user);
- $args = [
- 'user' => $user,
- 'role' => $role,
- 'action' => (array_key_exists('action', $data) && in_array($data['action'], ['refresh', 'revoke', 'create'])) ? $data['action'] : false,
- 'inviteID' => (array_key_exists('refresh', $data)) ? (int) $data['refresh'] : false,
- ];
-
- $allowed = $this->inviteTypes[$role];
- if (count($allowed) > 1) {
- $inviteAs = (array_key_exists('type', $data) && in_array($data['type'], $allowed)) ? $data['type'] : false;
- } else {
- $invitedAs = $allowed[0];
- }
-
- if (array_key_exists('invites', $data)) {
- $invites = [];
- foreach ($data['invites'] as $invite) {
- $temp = [
- 'invited_id' => (array_key_exists('invited_id', $invite) && $this->userCheck($invite['invited_id'])) ? $invite['invited_id'] : false,
- 'to_term' => (array_key_exists('to_term', $invite)) ? (int) $invite['to_term'] : false,
- 'taxonomy' => (array_key_exists('taxonomy', $invite) && in_array($invite['taxonomy'], $this->inviteTypes[$role]['to_terms']??[])) ? $invite['taxonomy'] : false,
- 'invited_name' => (array_key_exists('name', $invite) && is_string($invite['name'])) ? sanitize_text_field($invite['name']) : false,
- 'invited_email' => (array_key_exists('email', $invite) && is_email($invite['email'])) ? sanitize_email($invite['email']) : false,
- ];
- if ($temp['invited_id'] || ($temp['invited_email'] && $temp['invited_name'])) {
- $invites[$invitedAs][] = $data;
- }
- }
- $args['invites'] = $invites;
- }
- if (!$invitedAs && !empty($args['invites'])) {
- unset($args['invites']);
- }
-
- return $args;
- }
- /**
- * @param object $request The Request Object
- *
- * @return WP_REST_Response
- */
- public function createInvitationRequest(object $request):WP_REST_Response
- {
- $args = $this->buildInvitationArgs($request);
-
- $error = '';
- if (!$args['user']) {
- $error = 'User ID doesn\'t match up.... are you a bot?';
- } elseif (Features::forMembership()->has('member_verified') && !user_can($args['user'], 'skip_moderation')) {
- $error = 'Only verified users can send invitations.';
- } elseif (!$args['role']) {
- $error = 'It doesn\'t look like you can invite users.';
- }
- if ($error !== '') {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => $error
- ]);
- }
-
- switch ($args['action']) {
- case 'revoke':
- return $this->revokeInvite($args);
- case 'refresh':
- return $this->resendInvite($args);
- }
-
- //Inviting to content taxonomy (ie: shop)
- $artist = jvbContentFromUser($args['user']);
- foreach ($args['invites'] as $index => $invite) {
- if ($invite['to_term'] && $invite['taxonomy']) {
- if (!$artist[$invite['taxonomy']] || $artist[$invite['taxonomy']['id'] !== $invite['term_id']]) {
- $args['invites'][$index]['to_term'] = false;
- $args['invites'][$index]['taxonomy'] = false;
- }
- }
- }
-
- if (!empty($args['invites']??[])) {
- JVB()->queue()->queueOperation(
- 'invitation_create',
- $args['user'],
- [
- 'invitations' => $args['invites'],
- ],
- [
- 'count' => count($args['invites']),
- 'priority' => 'high',
- 'chunk_size' => 20,
- 'chunk_key' => 'invitations'
- ]
- );
-
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'Processing ' . count($args['invites']) . ' invitations',
- ]);
- }
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'No invitations sent.'
- ]);
- }
-
- /**
- * Revoke an invitation
- *
- * @params array $args
- * @return array Response with success or error message
- */
- public function revokeInvite(array $args): array
- {
- $invitation = $this->getInvitationByUser($args);
-
- if (!$invitation || is_wp_error($invitation)) {
- return [
- 'success' => false,
- 'result' => 'Invitation not found'
- ];
- }
-
- // Check if invitation can be revoked (only pending invitations)
- if ($invitation['status'] !== 'pending' && $invitation['status'] !== 'expired') {
- return [
- 'success' => true,
- 'result' => 'Only pending or expired invitations can be revoked'
- ];
- }
-
- // Check if the user is one of the inviters
- $inviters = json_decode($invitation['inviters'], true);
- $user_is_inviter = false;
- $updated_inviters = [];
-
- foreach ($inviters as $inviter) {
- if (intval($inviter['user_id']) === $args['user']) {
- $user_is_inviter = true;
- } else {
- // Keep other inviters
- $updated_inviters[] = $inviter;
- }
- }
-
- if (!$user_is_inviter) {
- return [
- 'success' => false,
- 'return' => 'You are not authorized to revoke this invitation'
- ];
- }
-
- // If there are still other inviters, just update the inviters list
- if (!empty($updated_inviters)) {
- $this->wpdb->update(
- $this->tableNames[$args['role']],
- [
- 'inviters' => json_encode($updated_inviters),
- 'updated_at' => current_time('mysql')
- ],
- ['id' => $invitation['id']]
- );
-
- return [
- 'success' => true,
- 'result' => 'You have been removed from the inviters list but the invitation is still active with other inviters',
- ];
- }
-
- // If no inviters left, mark the invitation as revoked
- $this->wpdb->update(
- $this->tableNames[$args['role']],
- [
- 'status' => 'revoked',
- 'updated_at' => current_time('mysql')
- ],
- ['id' => $invitation['id'] ]
- );
-
- $this->sendRevocationEmail($invitation['email'], $invitation['name']);
-
- return [
- 'success' => true,
- 'result' => 'Invitation has been successfully revoked'
- ];
- }
-
- /**
- * Resend an expired invitation
- *
- * @param array $args Args, as defined in buildInvitationArgs())
- * @return WP_REST_Response Response with success or error message
- */
- public function resendInvite(array $args): WP_REST_Response
- {
- $invitation_id = isset($args['inviteID']) ? intval($args['inviteID']) : 0;
- $user_id = isset($args['user']) ? intval($args['user']) : 0;
-
- if (!$invitation_id || !$user_id) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Missing invitation ID or user ID'
- ]);
- }
-
- // Get the invitation
- $invitation = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT * FROM {$this->tableNames[$args['role']]} WHERE id = %d",
- $invitation_id
- ), ARRAY_A);
-
- if (!$invitation) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Invitation not found'
- ]);
- }
-
- // Check if the invitation is expired or pending
- if (!in_array($invitation['status'], ['expired', 'pending'])) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Only expired or pending invitations can be resent'
- ]);
- }
-
- // Check if the user is one of the inviters
- $inviters = json_decode($invitation['inviters'], true);
- $user_is_inviter = false;
-
- foreach ($inviters as &$inviter) {
- if (intval($inviter['user_id']) === $user_id) {
- $user_is_inviter = true;
- // Update the invited_at timestamp for this inviter
- $inviter['invited_at'] = current_time('mysql');
- break;
- }
- }
-
- if (!$user_is_inviter) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'You are not authorized to resend this invitation'
- ]);
- }
-
- // Generate a new token
- $token = wp_generate_password(32, false);
-
- // Set new expiration date
- $expires_at = date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days"));
-
- // Update the invitation
- $this->wpdb->update(
- $this->tableNames[$args['role']],
- [
- 'invitation_token' => $token,
- 'status' => 'pending',
- 'expires_at' => $expires_at,
- 'inviters' => json_encode($inviters),
- 'updated_at' => current_time('mysql')
- ],
- ['id' => $invitation_id]
- );
-
- // Send the invitation email again
- $name = $invitation['name'];
- $email = $invitation['email'];
- $role = $invitation['role'];
- $terms = $this->getInvitationTerms($invitation, $role);
-
-
- $result = $this->sendInvitationEmail($name, $email, $token, $user_id, $terms, $role);
-
- if (!$result) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Failed to send invitation email'
- ]);
- }
-
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'Invitation has been successfully resent',
- 'expires_at' => $expires_at
- ]);
- }
-
- protected function getInvitationTerms(object|array $invitation, string $role) {
- if (is_object($invitation)) {
- $invitation = json_decode(json_encode($invitation), true);
- }
- $terms = [];
- foreach ($this->inviteTypes[$role]['to_terms'] as $taxonomy) {
- $terms[$taxonomy] = $invitation['to_'.$taxonomy];
- }
- return $terms;
+ // Cache connections
+ $this->cache
+ ->connect('user')
+ ->connect('taxonomy');
}
- /**
- * Create or update an invitation
- * @param string $name Name of person being invited
- * @param string $email Email of person being invited
- * @param int $inviter_id User ID of the person inviting
- * @param string|false $role
- * @param int|false $termID Optional shop ID
- * @param string|false $taxonomy Optional taxonomy
- * @param bool $send_email whether to send email right away
- * @return WP_Error|array
- *
- */
- public function createInvitation(
- string $name,
- string $email,
- int $inviter_id,
- string|false $role = false,
- int|false $termID = false,
- string|false $taxonomy = false,
- bool $send_email = true
- ):WP_Error|array {
- error_log('Creating Invitation with data: '.print_r([
- 'name' => $name,
- 'email' => $email,
- 'inviter ID'=> $inviter_id,
- 'termID' => $termID,
- 'taxonomy' => $taxonomy,
- 'role' => $role
- ], true));
- // Sanitize and validate email
- $email = sanitize_email($email);
- if (!is_email($email)) {
- error_log('Invalid email');
- return new WP_Error('invalid_email', 'Invalid email address');
- }
+ public function registerRoutes(): void
+ {
+ Route::for('invitations')
+ ->get([$this, 'getInvitations'])
+ ->args([
+ 'user' => 'int|required',
+ 'to_term' => 'int',
+ 'taxonomy' => 'string',
+ 'status' => 'string|enum:all,pending,accepted,rejected,expired,revoked|default:all',
+ 'page' => 'int|default:1|min:1'
+ ])
+ ->auth('user')
+ ->rateLimit(20)
- // Check if inviter is verified
- if (Features::forMembership()->has('member_verified') && !$this->isVerifiedUser($inviter_id)) {
- error_log('Unverified Artist');
- return new WP_Error('unauthorized', 'Only verified artists can send invitations');
- }
+ ->post([$this, 'createInvitationRequest'])
+ ->args([
+ 'user' => 'int|required',
+ 'action' => 'string|enum:create,revoke,refresh|default:create',
+ 'invites' => 'array',
+ 'invitation_id' => 'int'
+ ])
+ ->auth('verified')
+ ->rateLimit(10, 300)
+ ->register();
+ }
- if ($termID) {
- // Check if shop exists if specified
- if ($this->checkTerm(['term_id' => $termID, 'taxonomy' => $taxonomy])) {
- error_log('Invalid Taxonomy');
- return new WP_Error('invalid_term', 'The specified term does not exist');
- }
- }
+ /**
+ * Get invitations for a user or term
+ */
+ public function getInvitations(WP_REST_Request $request): WP_REST_Response
+ {
+ $userID = $request->get_param('user');
+ $termID = $request->get_param('to_term');
+ $taxonomy = $request->get_param('taxonomy');
- if (!$role || !array_key_exists($role, $this->inviteTypes)) {
- return new WP_Error('invalid_role', 'No role was set');
- }
-
- // Check if user already exists
- $invite = !email_exists($email);
-
- // Get existing invitation if any
- $existing = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT * FROM {$this->tableNames[$role]} WHERE email = %s",
- $email
- ), ARRAY_A);
-
- // Generate token
- $token = wp_generate_password(32, false);
-
- // Set expiration date
- $expires_at = date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days"));
-
- if ($existing) {
- // Update existing invitation
- $inviters = json_decode($existing['inviters'], true);
-
- // Check if this inviter already invited
- $inviter_exists = false;
- foreach ($inviters as $inviter) {
- if ($inviter['user_id'] == $inviter_id) {
- $inviter_exists = true;
- // Update the invited_at timestamp
- $inviter['invited_at'] = current_time('mysql');
- break;
- }
- }
-
- if (!$inviter_exists) {
- // Add new inviter
- $inviters[] = [
- 'user_id' => $inviter_id,
- 'invited_at' => current_time('mysql')
- ];
- }
-
- // Prepare update data
- $update_data = [
- 'inviters' => json_encode($inviters),
- 'status' => 'pending',
- 'expires_at' => $expires_at,
- 'updated_at' => current_time('mysql'),
- ];
- // Set shop_id if provided and not already set
- $check = 'to_'.$taxonomy;
- if ($termID && $existing[$check] !== $termID) {
- $update_data[$check] = $termID;
- }
-
- // If invitation was expired, generate new token
- if ($existing['status'] === 'expired') {
- $update_data['invitation_token'] = $token;
- } else {
- $token = $existing['invitation_token'];
- }
-
- $this->wpdb->update(
- $this->tableNames[$role],
- $update_data,
- ['id' => $existing['id']]
- );
-
- $invitation_id = $existing['id'];
- } else {
- // Create new invitation
- $inviters = [[
- 'user_id' => $inviter_id,
- 'invited_at' => current_time('mysql')
- ]];
-
- $insert_data = [
- 'name' => sanitize_text_field($name),
- 'email' => $email,
- 'invitation_token' => $token,
- 'status' => 'pending',
- 'inviters' => json_encode($inviters),
- 'expires_at' => $expires_at,
- 'created_at' => current_time('mysql')
- ];
- // Add shop if provided
- if ($termID) {
- $insert_data['to_'.$taxonomy] = $termID;
- }
-
- $this->wpdb->insert(
- $this->tableNames[$role],
- $insert_data
- );
-
- $invitation_id = $this->wpdb->insert_id;
- }
-
- error_log('On to invitation email send:');
- // Send invitation email
- if ($invite && $send_email) {
- $this->sendInvitationEmail($name, $email, $token, $inviter_id, [$taxonomy => $termID], $role);
- }
-
- return [
- 'id' => $invitation_id,
- 'email' => $email,
- 'token' => $token,
- 'expires_at' => $expires_at
- ];
- }
-
- /**
- * Validate an invitation token
- * @param string $token the generated token
- * @param string $email the email of the invited person
- * @param string $role the role
- * @return object $invitation or error
- */
- public function validateInvitation(string $token, string $email, string $role):object
- {
- if (!array_key_exists($role, $this->inviteTypes)) {
- return new WP_Error('invalid_role', 'Invalid role type');
+ // Validate user
+ if (get_current_user_id() !== $userID) {
+ return $this->unauthorized('Invalid user');
}
- $table = $this->wpdb->prefix . $this->tableNames[$role];
- // Get invitation by token and email
- $invitation = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT * FROM $table
- WHERE invitation_token = %s
- AND email = %s
- AND status = 'pending'",
- $token,
- $email
- ));
+ $args = [
+ 'user' => $userID,
+ 'to_term' => $termID,
+ 'taxonomy' => $taxonomy ? jvbNoBase($taxonomy) : null,
+ 'status' => $request->get_param('status'),
+ 'page' => $request->get_param('page')
+ ];
- if (!$invitation) {
- return new WP_Error('invalid_invitation', 'Invalid invitation token or email');
- }
+ // Check cache
+ $key = $this->cache->generateKey($args);
+ if ($cached = $this->cache->get($key)) {
+ return $this->success($cached);
+ }
- // Check if expired
- if (strtotime($invitation->expires_at) < time()) {
- return new WP_Error('expired_invitation', 'This invitation has expired');
- }
+ // Get appropriate invitations
+ $result = ($args['to_term'] && $args['taxonomy'])
+ ? $this->getTermInvitations($args)
+ : $this->getUserInvitations($args);
- return $invitation;
- }
+ // Cache result
+ $this->cache->set($key, $result);
- /**
- * Send invitation email to the new artist
- * @param string $name The invited person's name
- * @param string $email The invited person's email
- * @param string $token The randomly generated password
- * @param int $inviter_id The User ID of the one inviting
- * @param int|null $shopID The optional shop ID to be invited to
- * @return bool Whether or not the invitation was sent successfully
- */
- protected function sendInvitationEmail(string $name, string $email, string $token, int $inviter_id, array $terms, string|null $role = null):bool
- {
- $inviter = get_userdata($inviter_id);
- $inviter_name = jvbGetUsername($inviter_id);
- $inviter_name = $inviter_name ?: $inviter->display_name;
+ return $this->success($result);
+ }
- $siteName = get_bloginfo('name');
+ /**
+ * Get invitations for a specific term
+ */
+ protected function getTermInvitations(array $args): array
+ {
+ // Check permission
+ if (!JVB()->roles()->isManager($args['user'], $args['to_term'])) {
+ return $this->forbidden('You cannot view invitations for this ' . $args['taxonomy'])->get_data();
+ }
- $subject = apply_filters('jvbInvitationSubject',
- sprintf(
- "%s invited you to join %s!",
- $inviter_name,
- $siteName
- ),
- $inviter_name
+ $perPage = 20;
+ $offset = ($args['page'] - 1) * $perPage;
+
+ // Build query
+ $where = ['to_' . $args['taxonomy'] => $args['to_term']];
+ if ($args['status'] !== 'all') {
+ $where['status'] = $args['status'];
+ }
+
+ // Fluent CustomTable usage
+ $total = $this->table
+ ->where($where)
+ ->countResults();
+
+ $invitations = $this->table
+ ->where($where)
+ ->orderBy('created_at')
+ ->limit($perPage, $offset)
+ ->getResults();
+
+ return [
+ 'invitations' => array_map([$this, 'formatInvitation'], $invitations),
+ 'total' => $total,
+ 'pages' => ceil($total / $perPage),
+ 'page' => $args['page'],
+ 'per_page' => $perPage
+ ];
+ }
+
+ /**
+ * Get invitations sent by a user
+ */
+ protected function getUserInvitations(array $args): array
+ {
+ $perPage = 20;
+ $offset = ($args['page'] - 1) * $perPage;
+
+ // Use raw query for JSON search
+ $query = "SELECT * FROM {$this->table->getFullTableName()}
+ WHERE JSON_SEARCH(inviters, 'one', %s, NULL, '$[*].user_id') IS NOT NULL";
+ $params = [$args['user']];
+
+ if ($args['status'] !== 'all') {
+ $query .= " AND status = %s";
+ $params[] = $args['status'];
+ }
+
+ $query .= " ORDER BY created_at DESC LIMIT %d OFFSET %d";
+ $params = array_merge($params, [$perPage, $offset]);
+
+ $invitations = $this->table->queryResults($query, $params);
+
+ // Get count
+ $countQuery = str_replace('SELECT *', 'SELECT COUNT(*)',
+ substr($query, 0, strrpos($query, 'ORDER BY')));
+ $countParams = array_slice($params, 0, -2);
+ $total = (int) $this->table->queryVar($countQuery, $countParams);
+
+ return [
+ 'invitations' => array_map([$this, 'formatInvitation'], $invitations),
+ 'total' => $total,
+ 'pages' => ceil($total / $perPage),
+ 'page' => $args['page'],
+ 'per_page' => $perPage
+ ];
+ }
+
+ /**
+ * Create invitation request - queues invitations for processing
+ */
+ public function createInvitationRequest(WP_REST_Request $request): WP_REST_Response
+ {
+ $userID = $request->get_param('user');
+ $action = $request->get_param('action');
+
+ // Validate user
+ if (get_current_user_id() !== $userID) {
+ return $this->unauthorized('Invalid user');
+ }
+
+ // Handle actions
+ return match($action) {
+ 'revoke' => $this->revokeInvite($userID, $request->get_params()),
+ 'refresh' => $this->resendInvite($userID, $request->get_params()),
+ default => $this->queueInvitations($userID, $request->get_param('invites'))
+ };
+ }
+
+ protected function queueInvitations(int $userID, array $invites): WP_REST_Response
+ {
+ if (empty($invites)) {
+ return $this->error('No invitations provided');
+ }
+
+ // Validate invitations
+ $validated = $this->validateInvitations($userID, $invites);
+
+ if (empty($validated)) {
+ return $this->error('No valid invitations to send');
+ }
+
+ // Queue using fluent interface
+ $op = JVB()->queue()->add(
+ 'invitation_create',
+ $userID,
+ ['invitations' => $validated],
+ [
+ 'priority' => 'high',
+ 'chunk_key' => 'invitations',
+ 'chunk_size' => 20
+ ]
+ );
+ return $this->queued($op['operation_id']);
+ }
+
+ /**
+ * Validate and sanitize invitation data
+ */
+ protected function validateInvitations(int $userID, array $invites): array
+ {
+ return JVB()->invitations()->validateInvitations($userID, $invites);
+ }
+
+
+
+
+ /**
+ * Revoke an invitation
+ */
+ protected function revokeInvite(int $userID, array $data): WP_REST_Response
+ {
+ $invitationID = (int) ($data['invitation_id'] ?? 0);
+
+ if (!$invitationID) {
+ return $this->error('Invitation ID required');
+ }
+
+ $op = JVB()->queue()->add(
+ 'invitation_revoke',
+ $userID,
+ ['invitation_id' => $invitationID],
+ ['priority' => 'high']
);
- $signup_url = add_query_arg([
- 'invite' => $token,
- 'email' => urlencode($email),
- 'name' => $name,
- 'role' => $role
- ], wp_registration_url());
+ return $this->queued($op['operation_id']);
+ }
+
+ /**
+ * Resend an invitation
+ */
+ protected function resendInvite(int $userID, array $data): WP_REST_Response
+ {
+ $invitationID = (int) ($data['invitation_id'] ?? 0);
+
+ if (!$invitationID) {
+ return $this->error('Invitation ID required');
+ }
+
+ $op = JVB()->queue()->add(
+ 'invitation_resend',
+ $userID,
+ ['invitation_id' => $invitationID],
+ ['priority' => 'high']
+ );
+
+ if (is_wp_error($op)) {
+ return $this->error($op->get_error_message());
+ }
+ return $this->queued($op['operation_id']);
+ }
- // Get shop name if applicable
- $toContentTax = [];
- if (!empty ($terms)) {
- foreach ($terms as $taxonomy => $termID) {
+
+ /**
+ * Format invitation for API response
+ */
+ protected function formatInvitation(object $invitation): array
+ {
+ $inviters = json_decode($invitation->inviters ?? '[]', true) ?: [];
+
+ $formatted = [
+ 'id' => (int) $invitation->id,
+ 'name' => $invitation->name,
+ 'email' => $invitation->email,
+ 'invited_role' => $invitation->invited_role,
+ 'status' => $invitation->status,
+ 'expires_at' => $invitation->expires_at,
+ 'accepted_at' => $invitation->accepted_at ?? null,
+ 'created_at' => $invitation->created_at,
+ 'inviters' => array_map(fn($inviter) => [
+ 'id' => (int) $inviter['user_id'],
+ 'name' => jvbGetUsername($inviter['user_id']),
+ 'invited_at' => $inviter['invited_at']
+ ], $inviters)
+ ];
+
+ // Add term information if present
+ foreach (JVB()->roles()->getInvitableTaxonomies() as $taxonomy) {
+ $column = 'to_' . $taxonomy;
+ if (isset($invitation->$column) && $invitation->$column) {
+ $termID = (int) $invitation->$column;
$term = get_term($termID, BASE . $taxonomy);
+
if ($term && !is_wp_error($term)) {
- $toContentTax[] = sprintf(
- "<p>%s has also invited you to join %s. You'll be automatically added to this %s when you register.</p>",
- $inviter_name,
- $term->name,
- $taxonomy
- );
+ $formatted['term'] = [
+ 'id' => $termID,
+ 'name' => $term->name,
+ 'taxonomy' => $taxonomy
+ ];
+ break; // Only show first term
}
}
}
- $toContentTax = implode(' ', $toContentTax);
- $button = JVB()->email()->button($signup_url, 'Join the Scene!');
- $link = JVB()->email()->link($signup_url);
- $signature = JVB()->email()->signature();
-
- $message = sprintf(
- '<p>Hi %s!</p>
- <p>%s has invited you to join them on %s.</p>
-
- <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>
- %s
- <p>This invitation expires in %d days.</p>
- <p>Ink on, %s</p>
- %s
- ',
- $name,
- $inviter_name,
- $siteName,
- $button,
- $link,
- $name,
- $toContentTax,
- $this->expiryDays,
- $signature
- );
- $message = apply_filters('jvbInvitationMessage',
- $message,
- $name,
- $inviter_name,
- $role,
- $termID,
- $taxonomy,
- $toContentTax,
- $this->expiryDays,
- $button,
- $link,
- $signature,
- );
-
-
- $success = JVB()->email()->sendEmail($email, $subject, $message);
-
-
- if (!$success) {
- // Log the invitation
- JVB()->error()->log(
- 'invitation_email',
- 'Invitation not sent',
- [
- 'email' => $email,
- 'inviter_id' => $inviter_id,
- 'token' => $token
- ],
- 'info'
- );
- }
-
- return $success;
- }
-
- /**
- * Send revocation email notification
- * @param string $email the invited person's email
- * @param string $name the invited person's name
- * @return bool Whether or not the email was sent
- */
- protected function sendRevocationEmail(string $email, string $name):bool
- {
- $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, the site admin, or try registering yourself.</p>',
- $name,
- $siteName
- ),
- $name
- );
-
- $success = JVB()->email()->sendEmail($email, $subject, $content, 'INVITATION REVOKED');
- if (!$success) {
- JVB()->error()->log(
- 'invitation_revoke_email',
- 'Invitation not sent',
- [
- 'email' => $email,
- 'name' => $name,
- ],
- 'info'
- );
- }
- return $success;
- }
-
- /**
- * Verify an invitation token
- * @param string $token The randomly generated token
- * @param string $email The invited person's email
- * @return bool|object False on failure. Invitation object if success
- */
- public function verifyInvitation(string $token, string $email, string $role):bool|object
- {
- $invitation = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT * FROM {$this->tableNames[$role]}
- WHERE invitation_token = %s AND email = %s AND status = 'pending' AND expires_at > NOW()",
- $token,
- $email
- ));
-
- if (!$invitation) {
- return false;
- }
-
- return $invitation;
- }
-
- /**
- * Mark an invitation as accepted
- * @param string $token The randomly generated token
- * @param string $email The invited person's email
- * @param int $user_id The invited person's user ID
- * @return bool whether or not it was successfully accepted
- */
- public function acceptInvitation(string $token, string $email, int $user_id):bool
- {
- $role = jvbUserRole($user_id);
- $invitation = $this->verifyInvitation($token, $email, $role);
-
- if (!$invitation) {
- return false;
- }
-
- // Update invitation status
- $this->wpdb->update(
- $this->tableNames[$role],
- [
- 'status' => 'accepted',
- 'new_user_id' => $user_id,
- 'accepted_at' => current_time('mysql'),
- 'updated_at' => current_time('mysql')
- ],
- ['id' => $invitation->id]
- );
-
- // Set user role to artist with can_publish=false (needs verification)
- $user = get_userdata($user_id);
- // Set the user's verification status
- $user->add_cap('skip_moderation', true);
-
- // If there's a shop to add the artist to, do that now
- if (!empty($invitation->to_shop)) {
- JVB()->routes('shopInvite')->addArtistToShop($user_id, $invitation->to_shop);
- }
-
- // Notify inviters
- $this->notifyInvitersOfAcceptance($invitation, $user_id);
-
- return true;
- }
-
- /**
- * Notify all inviters that the invitation was accepted
- * @param object $invitation The invitation object
- * @param int $user_id the newly added user id
- * @return void
- */
- protected function notifyInvitersOfAcceptance(object $invitation, int $user_id):void
- {
- $inviters = json_decode($invitation->inviters, true);
- $user_data = get_userdata($user_id);
-
- foreach ($inviters as $inviter) {
- JVB()->notification()->addNotification(
- $inviter['user_id'],
- 'artist_joined',
- [
- 'invited_email' => $invitation->email,
- 'user_id' => $user_id,
- 'display_name' => $user_data->display_name
- ]
- );
- }
- }
-
- /**
- * Check if a registered user has a pending invitation. Accept invitation if so
- * @param int $user_id The user ID to check
- * @return void
- */
- public function checkInvitation(int $user_id):void
- {
- $user = get_userdata($user_id);
-
- if (!$user) {
- return;
- }
-
- // Check if there's a token and email in the request
- $token = isset($_GET['invite']) ? sanitize_text_field($_GET['invite']) : '';
- $email = isset($_GET['email']) ? sanitize_email($_GET['email']) : '';
-
- if ($token && $email && $email === $user->user_email) {
- $this->acceptInvitation($token, $email, $user_id);
- }
- }
-
- /**
- * Clean up expired invitations
- * @return void
- */
- public function cleanupExpiredInvitations():void
- {
- global $wpdb;
-
- $wpdb->query($wpdb->prepare(
- "UPDATE {$this->tableName}
- SET status = 'expired', updated_at = %s
- WHERE status = 'pending' AND expires_at < NOW()",
- current_time('mysql')
- ));
- }
-
- /**
- * Get invitations sent by a specific user
- * @param array $args built by buildParams()
- * @return WP_REST_Response
- */
- public function getUserInvitations(array $args):WP_REST_Response
- {
- if (!$this->checkUser($args['user'])) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Invalid user'
- ]);
- }
-
- $key = $this->cache->generateKey($args);
- $cache = $this->cache->get($key);
- if ($cache) {
- return new WP_REST_Response($cache);
- }
-
- $per_page = 20;
-
- // Build query conditions
- $conditions = [];
- $params = [];
-
- $conditions[] = "inviters LIKE %s";
- $params[] = '%"'.$args['user'].'"%';
-
- // Filter by status
- if ($args['status'] !== 'all') {
- $conditions[] = "status = %s";
- $params[] = $args['status'];
- }
-
- $where = !empty($conditions) ? "WHERE " . implode(' AND ', $conditions) : "";
-
- // Count total invitations for pagination
- $count_query = "SELECT COUNT(*) FROM {$this->tableNames[$args['role']]} {$where}";
- $total = $this->wpdb->get_var($this->wpdb->prepare($count_query, $params));
-
- // Get paginated invitations
- $offset = ($args['page'] - 1) * $per_page;
- $query = "SELECT * FROM {$this->tableNames[$args['role']]} {$where} ORDER BY created_at DESC LIMIT %d OFFSET %d";
-
- // Add pagination parameters
- $pagination_params = array_merge($params, [$per_page, $offset]);
- $invitations = $this->wpdb->get_results($this->wpdb->prepare($query, $pagination_params));
-
- // Format invitations for response
- $formatted = [];
- foreach ($invitations as $invitation) {
- $formatted[] = $this->formatInvitation($invitation);
- }
-
- $return = [
- 'invitations' => $formatted,
- 'total' => (int)$total,
- 'pages' => ceil($total / $per_page),
- 'page' => $args['page'],
- 'per_page' => $per_page
- ];
-
- $this->cache->set($key, $return);
-
- return new WP_REST_Response($return);
- }
-
- /**
- * Get a specific invitation by its ID
- *
- * @param int $invitationID The invitation ID to fetch
- * @param string $role
- * @return array|WP_Error The formatted invitation or an error
- */
- protected function getInvitation(int $invitationID, string $role):array|WP_Error
- {
- // Validate invitation ID
- $invitationID = intval($invitationID);
- if (!$invitationID) {
- return new WP_Error('invalid_id', 'Invalid invitation ID');
- }
-
- // Try to get from cache first
- $cached = $this->cache->get($invitationID);
- if ($cached) {
- return $cached;
- }
-
- // Query the database
- $invitation = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT * FROM {$this->tableNames[$role]} WHERE id = %d",
- $invitationID
- ));
-
- // Return error if not found
- if (!$invitation) {
- return new WP_Error('not_found', 'Invitation not found');
- }
-
- // Format the invitation for response
- $formatted = $this->formatInvitation($invitation);
-
- // Cache the result
- $this->cache->set($invitationID, $formatted);
-
- return $formatted;
- }
-
- /**
- * Get invitations for a specific user by their email or user ID
- *
- * @param int|string $identifier Either user ID or email of the invited person
- * @param bool $include_token Whether to include the token in the response
- * @return array|WP_Error The formatted invitations or an error
- */
- public function getInvitationByUser(int|string $identifier):array|WP_Error
- {
- // Try to get from cache first
- $cached = $this->cache->get($identifier);
- if ($cached) {
- return $cached;
- }
- global $wpdb;
-
- // Determine if we have a user ID or email
- if (is_numeric($identifier)) {
- // We have a user ID
- $userID = intval($identifier);
-
- // Query by user ID
- $invitation = $wpdb->get_row($wpdb->prepare(
- "SELECT * FROM {$this->tableName} WHERE new_user_id = %d",
- $userID
- ));
- } else {
- // We have an email
- $email = sanitize_email($identifier);
- if (!is_email($email)) {
- return new WP_Error('invalid_email', 'Invalid email address');
- }
-
- // Query by email
- $invitation = $wpdb->get_row($wpdb->prepare(
- "SELECT * FROM {$this->tableName} WHERE email = %s",
- $email
- ));
- }
-
- // Return error if not found
- if (!$invitation) {
- return new WP_Error('not_found', 'No invitations found for this user');
- }
-
- // Format the invitation for response
- $formattedInvitation = $this->formatInvitation($invitation);
-
- $this->cache->set($identifier, $formattedInvitation);
-
- return $formattedInvitation;
- }
-
- /**
- * Format invitation for API response
- * @param object $invitation The invitation object
- * @param bool $include_token whether or not to include the token in response
- * @return array The formatted invitation
- */
- protected function formatInvitation(object $invitation, bool $include_token = false):array
- {
- // Parse inviters JSON
- $inviters = json_decode($invitation->inviters ?? '[]', true) ?: [];
-
- // Format inviters with names
- $inviter_details = [];
- foreach ($inviters as $inviter_id) {
- $inviter_details[] = [
- 'id' => $inviter_id,
- 'name' => jvbGetUsername($inviter_id)
- ];
- }
-
- // Build formatted invitation
- $formatted = [
- 'id' => $invitation->id,
- 'name' => $invitation->name,
- 'email' => $invitation->email,
- 'status' => $invitation->status,
- 'expires_at' => $invitation->expires_at,
- 'accepted_at' => $invitation->accepted_at,
- 'created_at' => $invitation->created_at,
- 'updated_at' => $invitation->updated_at,
- 'inviters' => $inviters
- ];
-
- // Include shop if assigned
- if (!empty($invitation->to_shop)) {
- $shop = get_term($invitation->to_shop, BASE . 'shop');
- if ($shop && !is_wp_error($shop)) {
- $formatted['shop'] = [
- 'id' => $shop->term_id,
- 'name' => $shop->name
- ];
- }
- }
-
- // Include token if needed (only for validation)
- if ($include_token) {
- $formatted['token'] = $invitation->invitation_token;
- }
-
- // Add registration URL for convenience
- $formatted['registration_url'] = add_query_arg([
- 'token' => $invitation->invitation_token,
- 'email' => urlencode($invitation->email)
- ], home_url('/register/'));
-
- return $formatted;
- }
-
- /**
- * @param WP_Error|array $result The WP_Error to replace, if this is the operation type we're looking for
- * @param object $operation The operation object
- * @param array $data The data to process
- * @return WP_Error|array WP_Error or array of processed data
- *
- */
- public function processOperation(WP_Error|array $result, object $operation, array $data):array|WP_Error
- {
- switch ($operation->type) {
- case 'invitation_create':
- return $this->processInvitations($data, $operation->user_id);
- case 'invitation_revoke':
- return $this->revokeInvite(
- $data['invited']
- );
- }
- return $result;
- }
-
- /**
- * Process a batch of invitations with transaction support
- *
- * @param array $data Array of invitation data ['role' => $invites ]
- * @param int $user_id User ID of the inviter
- * @return array Result data with success/failure information
- */
- public function processInvitations(array $data, int $user_id):array
- {
- if (!$this->checkUser($user_id)) {
- return [
- 'success' => false,
- 'result' => 'Invalid User',
- ];
- }
-
- // Start transaction
- $this->wpdb->query('START TRANSACTION');
-
- $results = [
- 'success' => [],
- 'failed' => []
- ];
-
- try {
- foreach ($data as $role => $invitations) {
- foreach ($invitations as $invite) {
- if (!$invite['invited_name'] || !$invite['invited_email']) {
- $results['failed'][] = [
- 'email' => $invite['invited_email'],
- 'name' => $invite['invited_name'],
- 'reason' => 'Invalid name or email'
- ];
- continue;
- }
-
- if ($invite['to_term'] && !$this->checkTerm($invite)) {
- $results['failed'][] = [
- 'email' => $invite['invited_email'],
- 'name' => $invite['invited_name'],
- 'reason' => 'Invalid taxonomy to add to'
- ];
- }
-
- // Create invitation (modify your existing method to avoid sending emails yet)
- $result = $this->createInvitation($invite['invited_name'], $invite['invited_email'], $user_id, $role, $invite['to_term'], $invite['taxonomy'], false);
-
- if (is_wp_error($result)) {
- $results['failed'][] = [
- 'email' => $invite['invited_email'],
- 'name' => $invite['invited_name'],
- 'reason' => $result->get_error_message()
- ];
- } else {
- $results['success'][] = [
- 'email' => $invite['invited_email'],
- 'name' => $invite['invited_name'],
- 'id' => $result['id'],
- 'to_term' => $invite['to_term'],
- 'taxonomy' => $invite['taxonomy'],
- 'role' => $role,
- 'expires_at' => $result['expires_at']
- ];
- }
- }
- }
-
- // If we've processed at least one invitation successfully, commit
- if (!empty($results['success'])) {
- $this->wpdb->query('COMMIT');
-
- // Now send emails for successful invitations
- foreach ($results['success'] as $invitation) {
- $this->sendInvitationEmail(
- $invitation['name'],
- $invitation['email'],
- $invitation['token'],
- $user_id,
- [$invitation['taxonomy'] => $invitation['to_term']],
- $invitation['role']
- );
- }
- } else {
- // No successful invitations, roll back
- $this->wpdb->query('ROLLBACK');
- }
-
- return [
- 'success' => count($results['success']) > count($results['failed']),
- 'results' => $results
- ];
-
- } catch (Exception $e) {
- // Handle error and roll back transaction
- $this->wpdb->query('ROLLBACK');
-
- JVB()->error()->log(
- 'invitation_create',
- 'Error processing batch invitations: ' . $e->getMessage(),
- [
- 'user_id' => $user_id,
- 'error' => $e->getMessage()
- ],
- 'error'
- );
-
- return [
- 'success' => false,
- 'result' => [
- 'failed' => $invitations,
- 'error' => $e->getMessage()
- ]
- ];
- }
- }
-
- public function modifyLoginLabels(array $labels, array $get_params): array
- {
- // Only modify if invitation params present
- if (!array_key_exists('invite', $get_params) || !array_key_exists('email', $get_params)) {
- return $labels;
- }
- $email = sanitize_email($get_params['email']);
- $token = sanitize_text_field($get_params['invite']);
- $user = email_exists($email);
- if (!$user) {
- return $labels;
- }
- $role = jvbUserRole($user);
- // Get invitation data
- $data = $this->verifyInvitation(
- $token,
- $email,
- $role,
- );
-
- if (!$data) {
- return $labels;
- }
-
- // Build custom message
- $inviters = json_decode($data->inviters, true);
- $name = $data->name;
- $names = array_map(function($inviter) {
- $artist = jvbContentFromUser((int)$inviter['user_id']);
- return $artist['name'] ?: $artist['display_name'];
- }, $inviters);
-
- $message = count($names) > 1
- ? 'are already here, and have invited you to join in!'
- : ' is already here, and invited you to join in!';
-
- // Modify labels
- $labels['title'] = 'Join the Scene, ' . $data->name;
- $labels['description'] = [jvbCommaList($names) . ' ' . $message];
-
- return $labels;
+ return $formatted;
}
+
}
--
Gitblit v1.10.0