<?php
|
|
namespace JVBase\rest\routes;
|
|
use JVBase\managers\CustomTable;
|
use JVBase\registrar\Registrar;
|
use JVBase\rest\PermissionHandler;
|
use JVBase\rest\Rest;
|
use JVBase\rest\Route;
|
use JVBase\rest\Response;
|
use JVBase\base\Site;
|
use WP_REST_Request;
|
use WP_REST_Response;
|
use Exception;
|
|
if (!defined('ABSPATH')) {
|
exit; // Exit if accessed directly
|
}
|
|
class ApprovalRoutes extends Rest
|
{
|
protected array $userTypes;
|
protected array $termTypes;
|
protected array $allTypes;
|
|
protected int $expiryDays = 7;
|
protected bool $hasMemberApproval = false;
|
|
public function __construct()
|
{
|
$this->cacheName = 'approvals';
|
$this->hasMemberApproval = Site::membership() && Site::membership()->has('member_verified');
|
parent::__construct();
|
|
$this->initTypes();
|
|
if ($this->hasMemberApproval) {
|
add_action('user_register', [$this, 'handleNewUserRegistration'], 10, 2);
|
}
|
|
add_action('jvb_cleanup_expired_approvals', [$this, 'cleanupExpiredApprovals']);
|
}
|
|
protected function initTypes():void
|
{
|
$this->userTypes = [];
|
$this->termTypes = [];
|
if ($this->hasMemberApproval) {
|
$this->userTypes = Registrar::getFeatured('approve_new', 'user');
|
$this->allTypes = $this->userTypes;
|
}
|
if (Site::has('term_approval')) {
|
$this->termTypes = Registrar::getFeatured('approve_new', 'term');
|
$this->allTypes[] = 'term';
|
}
|
}
|
|
public function registerRoutes():void
|
{
|
Route::for('approvals')
|
->get([$this, 'getApprovals'])
|
->args([
|
'user' => 'integer|required',
|
'type' => 'string',
|
'status' => 'string|enum:pending,approved,rejected,expired',
|
])
|
->auth(PermissionHandler::combine(['user', 'verified']))
|
->rateLimit(30)
|
->post([$this, 'handleAction'])
|
->args([
|
'user' => 'integer|required',
|
'request_id' => 'integer|required',
|
'action' => 'string|required|enum:approve,reject',
|
'type' => 'string|required',
|
'notes' => 'string',
|
])
|
->auth(PermissionHandler::combine(['user', 'verified']))
|
->rateLimit(3)
|
->register();
|
}
|
|
/**
|
* Handler for user registration
|
*
|
* @param int $user_id New user ID
|
* @param object $user the new user object
|
*
|
* @return void
|
*/
|
public function handleNewUserRegistration(int $user_id, object $user): void
|
{
|
$intersect = array_intersect(
|
array_map(fn($role) => BASE.$role, $this->userTypes),
|
(array) $user->roles
|
);
|
|
if (!empty($intersect)) {
|
$user->add_cap('skip_moderation', false);
|
$this->createArtistApprovalRequest($user_id);
|
}
|
}
|
|
|
/**
|
* @param WP_REST_Request $request
|
*
|
* @return WP_REST_Response
|
*/
|
public function handleAction(WP_REST_Request $request):WP_REST_Response
|
{
|
$data = $request->get_params();
|
$request_id = absint($data['request_id']);
|
$user_id = absint($data['user']);
|
$action = sanitize_text_field($data['action']);
|
$type = sanitize_text_field($data['type']);
|
$notes = sanitize_text_field($data['notes'] ?? '');
|
|
if (!in_array($type, $this->allTypes)) {
|
return Response::validationError(['message' => 'Invalid type']);
|
}
|
|
$result = $this->handleVote($type, $action, $request_id, $user_id, $notes);
|
|
return $result
|
? Response::success(['message' => 'Vote recorded successfully'])
|
: Response::error('Failed to record vote');
|
}
|
|
/**
|
* Artist and Term Approvals
|
*/
|
protected function handleVote(string $type, string $vote, int $request_id, int $user_id, string $notes = ''): bool
|
{
|
if (!in_array($vote, ['approve', 'reject'])) {
|
return false;
|
}
|
|
$requestTable = $this->getTableName($type, 'requests');
|
$voteTable = $this->getTableName($type, 'votes');
|
|
$requests = CustomTable::for($requestTable);
|
$votes = CustomTable::for($voteTable);
|
|
try {
|
return $requests->transaction(function($requests) use ($votes, $request_id, $user_id, $vote, $notes, $type) {
|
// Get the approval request
|
$request = $requests->where(['id' => $request_id])->first();
|
|
if (!$request || $request->status !== 'pending') {
|
throw new Exception("Invalid approval request");
|
}
|
|
// Check if user already voted
|
$existingVote = $votes->where([
|
'request_id' => $request_id,
|
'user_id' => $user_id
|
])->first();
|
|
if ($existingVote) {
|
if ($existingVote->vote !== $vote) {
|
// Update vote
|
$votes->where(['id' => $existingVote->id])
|
->updateResults(['vote' => $vote]);
|
return true;
|
}
|
throw new Exception("User has already voted on this request");
|
}
|
|
// Insert new vote
|
$votes->create([
|
'request_id' => $request_id,
|
'user_id' => $user_id,
|
'vote' => $vote,
|
'notes' => $notes,
|
]);
|
|
// Update request based on vote type
|
$user = get_userdata($user_id);
|
|
if ($vote === 'approve') {
|
$this->handleApproval($requests, $request, $request_id, $user, $type);
|
} else {
|
$this->handleRejection($requests, $request, $request_id, $user, $type);
|
}
|
|
return true;
|
});
|
} catch (Exception $e) {
|
$this->logError('handleVote', [
|
'error' => $e->getMessage(),
|
'user_id' => $user_id,
|
'request_id' => $request_id,
|
'vote' => $vote
|
]);
|
return false;
|
}
|
}
|
|
/**
|
* Handle approval vote logic
|
*/
|
protected function handleApproval(CustomTable $table, object $request, int $request_id, $user, string $type): void
|
{
|
$approvers = json_decode($request->approved_by, true) ?: [];
|
$approvers[$user->ID] = [
|
'name' => $user->display_name,
|
'voted' => current_time('mysql')
|
];
|
|
$table->where(['id' => $request_id])->updateResults([
|
'current_approvals' => $request->current_approvals + 1,
|
'approved_by' => json_encode($approvers),
|
'expires_at' => $this->rebuildExpiryDate()
|
]);
|
|
// Check if threshold met
|
if ($request->current_approvals + 1 >= $request->required_approvals) {
|
match ($type) {
|
'term' => $this->makeTermLive($request),
|
default => $this->completeVerification($request_id, $type),
|
};
|
}
|
}
|
|
/**
|
* Handle rejection vote logic
|
*/
|
protected function handleRejection(CustomTable $table, object $request, int $request_id, $user, string $type): void
|
{
|
$rejecters = json_decode($request->rejected_by, true) ?: [];
|
$rejecters[$user->ID] = [
|
'name' => $user->display_name,
|
'voted' => current_time('mysql')
|
];
|
|
$table->where(['id' => $request_id])->updateResults([
|
'current_rejections' => $request->current_rejections + 1,
|
'rejected_by' => json_encode($rejecters),
|
'expires_at' => $this->rebuildExpiryDate()
|
]);
|
|
// Check if threshold met
|
if ($request->current_rejections + 1 >= $request->required_approvals) {
|
match ($type) {
|
'term' => $this->makeTermUnalive($request),
|
default => $this->denyVerification($request_id, $type),
|
};
|
}
|
}
|
protected function rebuildExpiryDate()
|
{
|
return date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days", time()));
|
}
|
|
/**
|
* @param string $type user/artist or term
|
* @param array $request
|
*
|
* @return bool|int
|
*/
|
protected function createApprovalRequest(string $type, array $request): int
|
{
|
$tableName = $this->getTableName($type, 'requests');
|
|
$id = CustomTable::for($tableName)->create($request);
|
|
if (!$id) {
|
throw new Exception('Failed to create approval request');
|
}
|
|
return $id;
|
}
|
|
/*************
|
* Artist Approvals
|
************/
|
/**
|
* Create artist approval request
|
*
|
* @param int $user_id User ID to be approved
|
*
|
* @return int|false Request ID or false on failure
|
*/
|
/**
|
* Create artist approval request - REFACTORED
|
*/
|
public function createArtistApprovalRequest(int $user_id): int|false
|
{
|
$userRole = jvbUserRole($user_id);
|
$tableName = $this->getTableName($userRole, 'requests');
|
$table = CustomTable::for($tableName);
|
|
try {
|
return $table->transaction(function($table) use ($user_id) {
|
// Check for existing request
|
$existing = $table->where(['user_id' => $user_id])->first();
|
|
if ($existing) {
|
return $existing->id;
|
}
|
|
$user_data = get_userdata($user_id);
|
|
return $table->create([
|
'user_id' => $user_id,
|
'status' => 'pending',
|
'expires_at' => date('Y-m-d H:i:s', strtotime('+30 days')),
|
'current_approvals' => 0,
|
'current_rejections' => 0,
|
'required_approvals' => 3, // From config
|
'approved_by' => json_encode([]),
|
'rejected_by' => json_encode([]),
|
]);
|
});
|
} catch (Exception $e) {
|
$this->logError('createArtistApprovalRequest', [
|
'error' => $e->getMessage(),
|
'user_id' => $user_id
|
]);
|
return false;
|
}
|
}
|
|
/**
|
* Mark an artist as verified
|
*
|
* @param int $user_id The user to verify
|
* @param int $verified_by ID of user who verified them (optional)
|
*
|
* @return bool Success status
|
*/
|
public function verifyArtist(int $user_id, int $verified_by = 0):bool
|
{
|
$user = get_userdata($user_id);
|
|
// Check if user has the artist role
|
if (!array_intersect(array_map(function ($role) { return BASE.$role; }, $this->userTypes), $user->roles)) {
|
return false;
|
}
|
|
// Add the capability
|
$user->add_cap('skip_moderation', true);
|
|
// Store verification metadata
|
update_user_meta($user_id, BASE . 'verification_date', current_time('mysql'));
|
if ($verified_by) {
|
update_user_meta($user_id, BASE . 'verified_by', $verified_by);
|
}
|
|
return true;
|
}
|
|
/**
|
* Mark an artist as verified
|
*
|
* @param int $user_id The user to verify
|
* @param int $verified_by ID of user who verified them (optional)
|
*
|
* @return bool Success status
|
*/
|
public function unverifyArtist(int $user_id, int $verified_by = 0):bool
|
{
|
$user = get_userdata($user_id);
|
|
// Check if user has the artist role
|
if (!array_intersect(array_map(function ($role) { return BASE.$role; }, $this->userTypes), $user->roles)) {
|
return false;
|
}
|
|
// Add the capability
|
$user->add_cap('skip_moderation', false);
|
|
// Store verification metadata
|
update_user_meta($user_id, BASE . 'unverification_date', current_time('mysql'));
|
if ($verified_by) {
|
update_user_meta($user_id, BASE . 'unverified_by', $verified_by);
|
}
|
|
return true;
|
}
|
|
/**
|
* Record an approval vote for an artist
|
*
|
* @param int $user_id User casting the approval vote
|
* @param int $request_id The approval request ID
|
* @param string $vote 'approve' or 'reject'
|
* @param string $notes Optional notes for the vote
|
*
|
* @return bool Success status
|
*/
|
public function voteForArtist(int $user_id, int $request_id, string $vote, string $notes = ''):bool
|
{
|
return $this->handleVote(jvbUserRole($user_id), $vote, $request_id, $user_id, $notes);
|
}
|
|
/**
|
* Complete verification - REFACTORED
|
*/
|
protected function completeVerification(int $request_id, string $type = 'artist'): void
|
{
|
$tableName = $this->getTableName($type, 'requests');
|
$table = CustomTable::for($tableName);
|
|
$table->where(['id' => $request_id])->updateResults([
|
'status' => 'approved',
|
'approved_at' => current_time('mysql')
|
]);
|
|
$request = $table->where(['id' => $request_id])->first();
|
|
if ($request && $request->user_id) {
|
$user = new \WP_User($request->user_id);
|
$user->add_cap('skip_moderation', true);
|
|
JVB()->notification()->addNotification(
|
$request->user_id,
|
'approval_granted',
|
['message' => 'Your account has been verified!']
|
);
|
}
|
|
$this->cache->flush();
|
}
|
|
protected function denyVerification(int $request_id, string $type = 'artist'): void
|
{
|
$tableName = $this->getTableName($type, 'requests');
|
$table = CustomTable::for($tableName);
|
|
$table->where(['id' => $request_id])->updateResults([
|
'status' => 'rejected',
|
'rejected_at' => current_time('mysql')
|
]);
|
|
$request = $table->where(['id' => $request_id])->first();
|
|
if ($request && $request->user_id) {
|
JVB()->notification()->addNotification(
|
$request->user_id,
|
'approval_denied',
|
['message' => 'Your verification request was not approved.']
|
);
|
}
|
|
$this->cache->flush();
|
}
|
|
/**
|
* Get verification details for a request
|
*
|
* @param int $requestID the request ID
|
* @param string $type Type
|
*
|
* @return array|false Verification details or false if not verified
|
*/
|
public function getVerificationDetails(int $requestID, string $type): array|false
|
{
|
$requestTable = CustomTable::for($this->getTableName($type, 'requests'));
|
$voteTable = CustomTable::for($this->getTableName($type, 'votes'));
|
|
$request = $requestTable->where(['id' => $requestID])->first(ARRAY_A);
|
|
if (!$request) {
|
return false;
|
}
|
|
// Get the votes for this request
|
$votes = $voteTable
|
->where(['request_id' => $request['id']])
|
->orderBy('created_at', 'ASC')
|
->getResults(ARRAY_A);
|
|
// Join with user data for display names
|
foreach ($votes as &$vote) {
|
$user = get_userdata($vote['user_id']);
|
$vote['approver_name'] = $user ? $user->display_name : 'Unknown';
|
}
|
|
return [
|
'request' => $request,
|
'votes' => $votes,
|
'verification_date' => $request['updated_at'],
|
];
|
}
|
|
/*************
|
* Term Approvals
|
************/
|
public function voteForTerm(int $user_id, int $request_id, string $vote, string $notes = ''):bool
|
{
|
return $this->handleVote('term', $vote, $user_id, $request_id, $notes);
|
}
|
|
/**
|
* Publish an approved term
|
*
|
* @param object $request Approval request object
|
*
|
* @return boolean Success or failure
|
*/
|
protected function makeTermLive(object $request): bool
|
{
|
try {
|
$taxonomy = $request->taxonomy;
|
$term_name = $request->name;
|
$parent = $request->parent;
|
|
$result = wp_insert_term($term_name, $taxonomy, [
|
'parent' => $parent
|
]);
|
|
if (is_wp_error($result)) {
|
throw new Exception($result->get_error_message());
|
}
|
|
$term_id = $result['term_id'];
|
|
// Update request status
|
CustomTable::for($this->getTableName('term', 'requests'))
|
->where(['id' => $request->id])
|
->updateResults([
|
'status' => 'approved',
|
'created_term' => $term_id
|
]);
|
|
$userIDs = [];
|
$approvedBy = [];
|
$approvers = json_decode($request->approved_by, true) ?: [];
|
$requesters = json_decode($request->requested_by, true) ?: [];
|
$rejectors = json_decode($request->rejected_by, true) ?: [];
|
|
foreach (array_merge($requesters, $approvers, $rejectors) as $user_id => $info) {
|
$userIDs[] = $user_id;
|
}
|
foreach ($approvers as $user_id => $info) {
|
$approvedBy[] = $info['name'];
|
}
|
|
$approvedBy = jvbCommaList($approvedBy);
|
|
JVB()->notification()->addNotification(
|
$userIDs,
|
'term_approved',
|
[
|
'term_id' => $term_id,
|
'term_name' => $term_name,
|
'taxonomy' => $taxonomy,
|
'approved_by' => $approvedBy
|
]
|
);
|
|
return true;
|
} catch (Exception $e) {
|
$this->logError('makeTermLive', [
|
'error' => $e->getMessage(),
|
'request_id' => $request->id,
|
'term_name' => $term_name ?? '',
|
'taxonomy' => $taxonomy ?? ''
|
]);
|
|
return false;
|
}
|
}
|
/**
|
* Reject a proposed term
|
*
|
* @param object $request request object
|
*
|
* @return boolean Success or failure
|
*/
|
protected function makeTermUnalive(object $request): bool
|
{
|
try {
|
// Update request status
|
CustomTable::for($this->getTableName('term', 'requests'))
|
->where(['id' => $request->id])
|
->updateResults([
|
'status' => 'rejected'
|
]);
|
|
$userIDs = [];
|
$rejectedBy = [];
|
|
$approvers = json_decode($request->approved_by, true) ?: [];
|
$requesters = json_decode($request->requested_by, true) ?: [];
|
$rejectors = json_decode($request->rejected_by, true) ?: [];
|
|
foreach (array_merge($requesters, $approvers, $rejectors) as $user_id => $info) {
|
$userIDs[] = $user_id;
|
}
|
foreach ($rejectors as $user_id => $info) {
|
$rejectedBy[] = $info['name'];
|
}
|
|
$rejectedBy = jvbCommaList($rejectedBy);
|
|
JVB()->notification()->addNotification(
|
$userIDs,
|
'term_rejected',
|
[
|
'term_name' => $request->name,
|
'taxonomy' => $request->taxonomy,
|
'rejected_by' => $rejectedBy
|
]
|
);
|
|
return true;
|
} catch (Exception $e) {
|
$this->logError('makeTermUnalive', [
|
'error' => $e->getMessage(),
|
'request_id' => $request->id,
|
'term_name' => $request->name ?? '',
|
'taxonomy' => $request->taxonomy ?? ''
|
]);
|
|
return false;
|
}
|
}
|
|
/**
|
* Create a new term approval request
|
*
|
* @param int $user_id User requesting approval
|
* @param string $taxonomy Taxonomy
|
* @param string $name New Term Name
|
* @param int $parent Parent Term ID
|
* @param int $required_approvals Number of approvals required
|
*
|
* @return int|false Request ID or false on failure
|
*/
|
public function createTermApprovalRequest(
|
int $user_id,
|
string $taxonomy,
|
string $name,
|
int $parent = 0,
|
int $required_approvals = 3
|
): int|false {
|
$table = CustomTable::for($this->getTableName('term', 'requests'));
|
|
try {
|
return $table->transaction(function($table) use ($user_id, $taxonomy, $name, $parent, $required_approvals) {
|
// Check for existing request
|
$existing = $table->where([
|
'name' => $name,
|
'taxonomy' => $taxonomy,
|
'parent' => $parent,
|
'status' => 'pending'
|
])->first();
|
|
if ($existing) {
|
$requestedBy = json_decode($existing->requested_by, true) ?: [];
|
|
if (isset($requestedBy[$user_id])) {
|
return (int)$existing->id;
|
}
|
|
$requestedBy[$user_id] = get_userdata($user_id)->display_name;
|
|
$table->where(['id' => $existing->id])->updateResults([
|
'requested_by' => json_encode($requestedBy)
|
]);
|
|
return (int)$existing->id;
|
}
|
|
// Create new request
|
return $this->createApprovalRequest('term', [
|
'taxonomy' => $taxonomy,
|
'name' => $name,
|
'parent' => $parent ?: null,
|
'status' => 'pending',
|
'required_approvals' => $required_approvals,
|
'current_approvals' => 0,
|
'current_rejections' => 0,
|
'requested_by' => json_encode([$user_id => get_userdata($user_id)->display_name]),
|
'expires_at' => date('Y-m-d H:i:s', strtotime('+30 days')),
|
]);
|
});
|
} catch (Exception $e) {
|
$this->logError('createTermApprovalRequest', [
|
'error' => $e->getMessage(),
|
'user_id' => $user_id,
|
'taxonomy' => $taxonomy,
|
'name' => $name
|
]);
|
|
return false;
|
}
|
}
|
/**
|
* Clean up expired approval requests and notify admin
|
*
|
* @return void
|
*/
|
public function cleanupExpiredApprovals(): void
|
{
|
$now = current_time('mysql');
|
|
foreach ($this->allTypes as $type) {
|
$tableName = $this->getTableName($type, 'requests');
|
|
CustomTable::for($tableName)->query(
|
"UPDATE {table}
|
SET status = 'expired'
|
WHERE status = 'pending'
|
AND expires_at < %s",
|
[$now]
|
);
|
}
|
|
$this->cache->flush();
|
}
|
|
protected function getTableName(string $type, string $suffix): string
|
{
|
return match ($type) {
|
'term' => "approval_term_{$suffix}",
|
default => "approval_{$type}_{$suffix}",
|
};
|
}
|
|
public function getApprovals(WP_REST_Request $request): WP_REST_Response
|
{
|
$user_id = absint($request->get_param('user'));
|
$type = sanitize_text_field($request->get_param('type') ?? 'all');
|
$status = sanitize_text_field($request->get_param('status') ?? 'pending');
|
|
if (!$this->checkUser($user_id)) {
|
return $this->unauthorized();
|
}
|
|
$cacheKey = compact('user_id', 'type', 'status');
|
|
$result = $this->cache->remember($cacheKey, function() use ($type, $status) {
|
$data = [];
|
|
if ($type === 'user' || $type === 'all') {
|
$data['user_approvals'] = $this->getUserApprovals($status);
|
}
|
|
if ($type === 'term' || $type === 'all') {
|
$data['term_approvals'] = $this->getTermApprovals($status);
|
}
|
|
return $data;
|
});
|
|
return $this->success($result);
|
}
|
|
private function getUserApprovals(string $status = 'pending'): array
|
{
|
$table = CustomTable::for($this->getTableName('artist', 'requests'));
|
|
$query = $table;
|
|
if ($status !== 'all') {
|
$query = $query->where(['status' => $status]);
|
}
|
|
return $query->orderBy('created_at', 'DESC')->getResults(ARRAY_A);
|
}
|
|
private function getTermApprovals(string $status = 'pending'): array
|
{
|
$table = CustomTable::for($this->getTableName('term', 'requests'));
|
|
if ($status === 'all') {
|
return $table->orderBy('created_at', 'DESC')->getResults(ARRAY_A);
|
}
|
|
return $table
|
->where(['status' => $status])
|
->orderBy('created_at', 'DESC')
|
->getResults(ARRAY_A);
|
}
|
}
|