Deploy: 2026-07-15 05:08:07
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Models\Company;
|
||||
|
||||
/**
|
||||
* Handles WhatsApp Cloud API (Meta Official) routes
|
||||
*/
|
||||
class CloudApiController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Get the Cloud API connection status for the company
|
||||
*/
|
||||
public function status(Request $request, Response $response)
|
||||
{
|
||||
$companyId = $request->company_id;
|
||||
|
||||
$session = \App\Core\Database::selectOne(
|
||||
"SELECT * FROM meta_cloud_sessions WHERE company_id = ? LIMIT 1",
|
||||
[$companyId]
|
||||
);
|
||||
|
||||
if (!$session) {
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'data' => null
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide token from frontend
|
||||
unset($session['access_token']);
|
||||
unset($session['verify_token']);
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'data' => $session
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect or update a Cloud API session
|
||||
*/
|
||||
public function connect(Request $request, Response $response)
|
||||
{
|
||||
$companyId = $request->company_id;
|
||||
$body = $request->getBody();
|
||||
|
||||
if (empty($body['phone_number_id']) || empty($body['waba_id']) || empty($body['access_token'])) {
|
||||
$response->status(400)->json(['error' => 'Missing required fields']);
|
||||
return;
|
||||
}
|
||||
|
||||
$session = \App\Core\Database::selectOne(
|
||||
"SELECT id FROM meta_cloud_sessions WHERE company_id = ? LIMIT 1",
|
||||
[$companyId]
|
||||
);
|
||||
|
||||
$verifyToken = 'nabeh_cloud_' . bin2hex(random_bytes(8));
|
||||
|
||||
if ($session) {
|
||||
\App\Core\Database::execute(
|
||||
"UPDATE meta_cloud_sessions SET phone_number_id = ?, waba_id = ?, access_token = ?, status = 'active' WHERE id = ?",
|
||||
[$body['phone_number_id'], $body['waba_id'], $body['access_token'], $session['id']]
|
||||
);
|
||||
} else {
|
||||
\App\Core\Database::execute(
|
||||
"INSERT INTO meta_cloud_sessions (company_id, phone_number_id, waba_id, access_token, verify_token, status) VALUES (?, ?, ?, ?, ?, 'active')",
|
||||
[$companyId, $body['phone_number_id'], $body['waba_id'], $body['access_token'], $verifyToken]
|
||||
);
|
||||
}
|
||||
|
||||
$updated = \App\Core\Database::selectOne(
|
||||
"SELECT * FROM meta_cloud_sessions WHERE company_id = ? LIMIT 1",
|
||||
[$companyId]
|
||||
);
|
||||
|
||||
unset($updated['access_token']);
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'message' => 'WhatsApp Cloud API connected successfully',
|
||||
'data' => $updated
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect Cloud API
|
||||
*/
|
||||
public function disconnect(Request $request, Response $response)
|
||||
{
|
||||
$companyId = $request->company_id;
|
||||
|
||||
\App\Core\Database::execute(
|
||||
"DELETE FROM meta_cloud_sessions WHERE company_id = ?",
|
||||
[$companyId]
|
||||
);
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'message' => 'Disconnected successfully'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch templates from Meta and sync them locally
|
||||
*/
|
||||
public function syncTemplates(Request $request, Response $response)
|
||||
{
|
||||
$companyId = $request->company_id;
|
||||
|
||||
$session = \App\Core\Database::selectOne(
|
||||
"SELECT * FROM meta_cloud_sessions WHERE company_id = ? LIMIT 1",
|
||||
[$companyId]
|
||||
);
|
||||
|
||||
if (!$session || $session['status'] !== 'active') {
|
||||
$response->status(400)->json(['error' => 'Cloud API not connected']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Call the Node.js Cloud API Service via Gateway
|
||||
$gatewayUrl = rtrim(getenv('WHATSAPP_GATEWAY_URL') ?: 'http://localhost:3722', '/');
|
||||
$syncUrl = $gatewayUrl . '/api/cloud/templates/sync';
|
||||
|
||||
$payload = json_encode([
|
||||
'waba_id' => $session['waba_id'],
|
||||
'access_token' => $session['access_token']
|
||||
]);
|
||||
|
||||
$ch = curl_init($syncUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'X-Webhook-Secret: ' . getenv('WEBHOOK_SECRET')
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||
$res = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200) {
|
||||
$resData = json_decode($res, true);
|
||||
$templates = $resData['data'] ?? [];
|
||||
|
||||
// Clear old templates and insert new ones
|
||||
\App\Core\Database::execute("DELETE FROM message_templates WHERE company_id = ?", [$companyId]);
|
||||
|
||||
foreach ($templates as $tpl) {
|
||||
\App\Core\Database::execute(
|
||||
"INSERT INTO message_templates (company_id, meta_template_id, name, language, category, status, body_text) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
[$companyId, $tpl['id'], $tpl['name'], $tpl['language'], strtolower($tpl['category']), strtolower($tpl['status']), 'Synced from Meta']
|
||||
);
|
||||
}
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'message' => 'Templates synced successfully',
|
||||
'count' => count($templates)
|
||||
]);
|
||||
} else {
|
||||
$response->status($httpCode)->json(['error' => 'Failed to sync templates from Meta']);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$response->status(500)->json(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook to receive incoming messages from Meta
|
||||
*/
|
||||
public function webhook(Request $request, Response $response)
|
||||
{
|
||||
// Meta GET verification request
|
||||
if ($request->getMethod() === 'GET') {
|
||||
$mode = $request->get('hub_mode');
|
||||
$token = $request->get('hub_verify_token');
|
||||
$challenge = $request->get('hub_challenge');
|
||||
|
||||
if ($mode === 'subscribe' && $token) {
|
||||
$session = \App\Core\Database::selectOne(
|
||||
"SELECT * FROM meta_cloud_sessions WHERE verify_token = ? LIMIT 1",
|
||||
[$token]
|
||||
);
|
||||
|
||||
if ($session) {
|
||||
echo $challenge;
|
||||
exit;
|
||||
}
|
||||
}
|
||||
$response->status(403)->send('Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle POST incoming messages
|
||||
$body = $request->getBody();
|
||||
if (isset($body['entry'][0]['changes'][0]['value']['messages'])) {
|
||||
$metaMessages = $body['entry'][0]['changes'][0]['value']['messages'];
|
||||
$metadata = $body['entry'][0]['changes'][0]['value']['metadata'];
|
||||
$phoneNumberId = $metadata['phone_number_id'];
|
||||
|
||||
$session = \App\Core\Database::selectOne(
|
||||
"SELECT * FROM meta_cloud_sessions WHERE phone_number_id = ? AND status = 'active' LIMIT 1",
|
||||
[$phoneNumberId]
|
||||
);
|
||||
|
||||
if ($session) {
|
||||
foreach ($metaMessages as $msg) {
|
||||
// Log incoming message
|
||||
$msgText = $msg['text']['body'] ?? '[Media/Other]';
|
||||
\App\Models\MessageLog::logMessage([
|
||||
'company_id' => $session['company_id'],
|
||||
'contact_phone' => $msg['from'],
|
||||
'direction' => 'inbound',
|
||||
'message_type' => $msg['type'],
|
||||
'message_body' => $msgText,
|
||||
'whatsapp_message_id' => $msg['id'],
|
||||
'status' => 'read'
|
||||
]);
|
||||
|
||||
// Note: Here you can integrate Gemini AI for auto-replies over Cloud API
|
||||
// Similar to triggerAutoReply() in WhatsAppController
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$response->status(200)->send('EVENT_RECEIVED');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
http_response_code(403);
|
||||
exit('Access denied.');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/app/bootstrap.php';
|
||||
|
||||
use App\Core\Database;
|
||||
|
||||
try {
|
||||
$pdo = Database::getConnection();
|
||||
|
||||
echo "=== Running Database Migrations: WhatsApp Cloud API Integration ===\n";
|
||||
|
||||
// 1. Create meta_cloud_sessions table
|
||||
$createSessionsTableSql = "
|
||||
CREATE TABLE IF NOT EXISTS `meta_cloud_sessions` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`company_id` INT NOT NULL,
|
||||
`phone_number_id` VARCHAR(50) NOT NULL COMMENT 'Meta Phone Number ID',
|
||||
`waba_id` VARCHAR(50) NOT NULL COMMENT 'WhatsApp Business Account ID',
|
||||
`access_token` VARCHAR(1024) NOT NULL COMMENT 'Encrypted Meta Access Token',
|
||||
`display_phone` VARCHAR(20) DEFAULT NULL COMMENT 'Display phone number',
|
||||
`status` ENUM('active', 'inactive', 'pending') DEFAULT 'pending',
|
||||
`verify_token` VARCHAR(100) DEFAULT NULL COMMENT 'Webhook verification token',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE,
|
||||
INDEX `idx_meta_company` (`company_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
";
|
||||
Database::execute($createSessionsTableSql);
|
||||
echo "✅ Table 'meta_cloud_sessions' verified/created.\n";
|
||||
|
||||
// 2. Create message_templates table
|
||||
$createTemplatesTableSql = "
|
||||
CREATE TABLE IF NOT EXISTS `message_templates` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`company_id` INT NOT NULL,
|
||||
`meta_template_id` VARCHAR(100) DEFAULT NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`language` VARCHAR(10) DEFAULT 'ar',
|
||||
`category` ENUM('marketing', 'utility', 'authentication') DEFAULT 'utility',
|
||||
`header_type` ENUM('none', 'text', 'image', 'video', 'document') DEFAULT 'none',
|
||||
`header_content` TEXT DEFAULT NULL,
|
||||
`body_text` TEXT NOT NULL,
|
||||
`footer_text` VARCHAR(60) DEFAULT NULL,
|
||||
`buttons` JSON DEFAULT NULL,
|
||||
`status` ENUM('draft', 'pending', 'approved', 'rejected') DEFAULT 'draft',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE,
|
||||
INDEX `idx_template_company` (`company_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
";
|
||||
Database::execute($createTemplatesTableSql);
|
||||
echo "✅ Table 'message_templates' verified/created.\n";
|
||||
|
||||
echo "Migration completed successfully!\n";
|
||||
} catch (\Exception $e) {
|
||||
echo "❌ Migration error: " . $e->getMessage() . "\n";
|
||||
}
|
||||
@@ -901,6 +901,7 @@
|
||||
<!-- Sub-tabs for Channels -->
|
||||
<div style="display: flex; gap: 1rem; border-bottom: 1px solid var(--card-border); margin-bottom: 1.5rem;" :style="lang === 'ar' ? 'flex-direction: row-reverse' : ''">
|
||||
<button class="tab-btn" :class="{ 'active': channelTab === 'whatsapp' }" @click="channelTab = 'whatsapp'" x-text="lang === 'ar' ? 'واتساب' : 'WhatsApp'"></button>
|
||||
<button class="tab-btn" :class="{ 'active': channelTab === 'whatsapp_cloud' }" @click="channelTab = 'whatsapp_cloud'; fetchCloudSession()" x-text="lang === 'ar' ? 'واتساب بزنس API (رسمي)' : 'WhatsApp Business API'"></button>
|
||||
<button class="tab-btn" :class="{ 'active': channelTab === 'messenger' }" @click="channelTab = 'messenger'; fetchMetaSessions()" x-text="lang === 'ar' ? 'فيسبوك ماسنجر' : 'Facebook Messenger'"></button>
|
||||
<button class="tab-btn" :class="{ 'active': channelTab === 'instagram' }" @click="channelTab = 'instagram'; fetchMetaSessions()" x-text="lang === 'ar' ? 'إنستغرام' : 'Instagram'"></button>
|
||||
</div>
|
||||
@@ -978,6 +979,52 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- WhatsApp Cloud API Tab Content -->
|
||||
<div x-show="channelTab === 'whatsapp_cloud'">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;" :style="lang === 'ar' ? 'flex-direction: row-reverse' : ''">
|
||||
<h2 style="font-size: 1.4rem; margin: 0;" x-text="lang === 'ar' ? 'واتساب بزنس API (الربط الرسمي بـ Meta)' : 'WhatsApp Business API Integration'"></h2>
|
||||
</div>
|
||||
<p class="text-muted" style="margin-bottom: 1.5rem; font-size: 0.9rem;" x-text="lang === 'ar' ? 'اربط حساب شركتك الرسمي مع Meta مباشرة. هذا الخيار مثالي لإرسال حملات دعائية ضخمة وقوالب معتمدة بدون خطر حظر الرقم.' : 'Connect your official WhatsApp Business account with Meta. Best for massive broadcast campaigns using approved templates without ban risks.'"></p>
|
||||
|
||||
<div class="card" style="margin-bottom: 2rem;">
|
||||
<template x-if="!cloudSession">
|
||||
<div style="display: flex; flex-direction: column; gap: 1.5rem;" :style="lang === 'ar' ? 'align-items: flex-end;' : 'align-items: flex-start;'">
|
||||
<div style="width: 100%; max-width: 500px;">
|
||||
<label class="form-label" x-text="lang === 'ar' ? 'رقم الهاتف (Phone Number ID)' : 'Phone Number ID'"></label>
|
||||
<input type="text" x-model="newCloudSession.phone_number_id" class="form-input" placeholder="e.g. 123456789012345">
|
||||
</div>
|
||||
<div style="width: 100%; max-width: 500px;">
|
||||
<label class="form-label" x-text="lang === 'ar' ? 'رقم حساب الأعمال (WABA ID)' : 'WhatsApp Business Account ID'"></label>
|
||||
<input type="text" x-model="newCloudSession.waba_id" class="form-input" placeholder="e.g. 987654321098765">
|
||||
</div>
|
||||
<div style="width: 100%; max-width: 500px;">
|
||||
<label class="form-label" x-text="lang === 'ar' ? 'رمز الوصول الدائم (Access Token)' : 'Permanent Access Token'"></label>
|
||||
<input type="password" x-model="newCloudSession.access_token" class="form-input" placeholder="EA...XXXX">
|
||||
</div>
|
||||
<button @click="connectCloudSession()" class="btn btn-primary" :disabled="actionLoading" style="align-self: flex-start;">
|
||||
<span x-text="lang === 'ar' ? 'ربط حساب Meta' : 'Connect Meta Account'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="cloudSession">
|
||||
<div style="display: flex; flex-direction: column; gap: 1rem;" :style="lang === 'ar' ? 'align-items: flex-end;' : 'align-items: flex-start;'">
|
||||
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||
<div style="font-size: 2rem; color: var(--success);">✓</div>
|
||||
<div>
|
||||
<h3 style="margin: 0; color: var(--success);" x-text="lang === 'ar' ? 'الحساب مربوط بنجاح' : 'Account Connected Successfully'"></h3>
|
||||
<p class="text-muted" style="margin: 0.5rem 0 0 0;" x-text="lang === 'ar' ? 'رقم الهاتف ID: ' + cloudSession.phone_number_id : 'Phone ID: ' + cloudSession.phone_number_id"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 1rem; margin-top: 1rem;">
|
||||
<button @click="disconnectCloudSession()" class="btn btn-danger" :disabled="actionLoading">
|
||||
<span x-text="lang === 'ar' ? 'قطع الاتصال وإزالة الحساب' : 'Disconnect Account'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-two" style="align-items: start;">
|
||||
<!-- QR Display Card (Active selected session) -->
|
||||
<div class="card" style="margin: 0;" x-show="whatsappSession && (whatsappSession.status === 'connecting' || whatsappSession.status === 'waiting_qr' || whatsappSession.status === 'connected')">
|
||||
@@ -1946,6 +1993,12 @@
|
||||
whatsappMaxSessions: 1,
|
||||
newSessionName: '',
|
||||
metaSessions: [],
|
||||
cloudSession: null,
|
||||
newCloudSession: {
|
||||
phone_number_id: '',
|
||||
waba_id: '',
|
||||
access_token: ''
|
||||
},
|
||||
newMetaSession: {
|
||||
channel_type: 'messenger',
|
||||
page_id: '',
|
||||
@@ -2375,6 +2428,68 @@
|
||||
this.actionLoading = false;
|
||||
}
|
||||
},
|
||||
async fetchCloudSession() {
|
||||
if (!this.token) return;
|
||||
try {
|
||||
const response = await fetch('/api/cloud/status', {
|
||||
headers: { 'Authorization': `Bearer ${this.token}` }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok && data.status === 'success') {
|
||||
this.cloudSession = data.data;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch cloud session', err);
|
||||
}
|
||||
},
|
||||
async connectCloudSession() {
|
||||
if (!this.newCloudSession.phone_number_id || !this.newCloudSession.waba_id || !this.newCloudSession.access_token) {
|
||||
alert(this.lang === 'ar' ? 'الرجاء تعبئة جميع الحقول' : 'Please fill all fields');
|
||||
return;
|
||||
}
|
||||
this.actionLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/cloud/connect', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.token}`
|
||||
},
|
||||
body: JSON.stringify(this.newCloudSession)
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok && data.status === 'success') {
|
||||
this.cloudSession = data.data;
|
||||
this.newCloudSession = { phone_number_id: '', waba_id: '', access_token: '' };
|
||||
} else {
|
||||
alert(data.message || data.error || 'Connection failed');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Network error connecting Cloud API');
|
||||
} finally {
|
||||
this.actionLoading = false;
|
||||
}
|
||||
},
|
||||
async disconnectCloudSession() {
|
||||
if (!confirm(this.lang === 'ar' ? 'هل أنت متأكد من قطع الاتصال بحساب Meta؟' : 'Are you sure you want to disconnect Meta Account?')) {
|
||||
return;
|
||||
}
|
||||
this.actionLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/cloud/disconnect', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${this.token}` }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok && data.status === 'success') {
|
||||
this.cloudSession = null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to disconnect cloud session', err);
|
||||
} finally {
|
||||
this.actionLoading = false;
|
||||
}
|
||||
},
|
||||
async deleteMetaSession(id) {
|
||||
if (!confirm(this.lang === 'ar' ? 'هل أنت متأكد من حذف هذه القناة؟' : 'Are you sure you want to disconnect this channel?')) {
|
||||
return;
|
||||
|
||||
@@ -103,6 +103,14 @@ $router->delete('/api/meta/sessions', [\App\Controllers\MetaWebhookContr
|
||||
$router->get('/api/webhooks/meta', [\App\Controllers\MetaWebhookController::class, 'verify']);
|
||||
$router->post('/api/webhooks/meta', [\App\Controllers\MetaWebhookController::class, 'webhook']);
|
||||
|
||||
// WhatsApp Cloud API (Meta Official) Routes
|
||||
$router->get('/api/cloud/status', [\App\Controllers\CloudApiController::class, 'status'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->post('/api/cloud/connect', [\App\Controllers\CloudApiController::class, 'connect'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
|
||||
$router->post('/api/cloud/disconnect', [\App\Controllers\CloudApiController::class, 'disconnect'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->post('/api/cloud/templates/sync', [\App\Controllers\CloudApiController::class, 'syncTemplates'],[\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->get('/api/cloud/webhook', [\App\Controllers\CloudApiController::class, 'webhook']); // GET verify
|
||||
$router->post('/api/cloud/webhook', [\App\Controllers\CloudApiController::class, 'webhook']); // POST incoming messages
|
||||
|
||||
// Customer Service Agents (Staff) Routes
|
||||
$router->get('/api/staff', [\App\Controllers\StaffController::class, 'index'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->post('/api/staff', [\App\Controllers\StaffController::class, 'store'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
|
||||
|
||||
Reference in New Issue
Block a user