Initial commit: Uruk Prize Platform architecture, backend core, mobile app, gateway caller & deployment pipeline
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use Core\Database;
|
||||
use PDO;
|
||||
|
||||
class AdminController
|
||||
{
|
||||
/**
|
||||
* POST /api/v1/admin/partners/add
|
||||
* Directly add a new hospital or hotel contract on the fly.
|
||||
*/
|
||||
public function addPartner(Request $request): void
|
||||
{
|
||||
$userId = (int)$request->getHeader('x-user-id');
|
||||
$type = strtoupper(trim((string)$request->get('type', 'HOSPITAL')));
|
||||
$nameAr = trim((string)$request->get('name_ar'));
|
||||
$nameEn = trim((string)$request->get('name_en', ''));
|
||||
$category = trim((string)$request->get('category', 'عام'));
|
||||
$country = trim((string)$request->get('country', 'العراق'));
|
||||
$city = trim((string)$request->get('city', 'بغداد'));
|
||||
$discount = (float)$request->get('discount_percentage', $type === 'HOSPITAL' ? 50.00 : 50.00);
|
||||
$phone = trim((string)$request->get('phone', ''));
|
||||
$address = trim((string)$request->get('address', ''));
|
||||
|
||||
if (empty($nameAr)) {
|
||||
Response::error('Partner Arabic name is required.');
|
||||
}
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO partners (type, name_ar, name_en, category, country, city, discount_percentage, address, phone, is_active, added_by_admin, created_at)
|
||||
VALUES (:type, :name_ar, :name_en, :category, :country, :city, :discount, :address, :phone, 1, :admin_id, NOW())
|
||||
');
|
||||
$stmt->execute([
|
||||
':type' => $type,
|
||||
':name_ar' => $nameAr,
|
||||
':name_en' => $nameEn,
|
||||
':category' => $category,
|
||||
':country' => $country,
|
||||
':city' => $city,
|
||||
':discount' => $discount,
|
||||
':address' => $address,
|
||||
':phone' => $phone,
|
||||
':admin_id' => $userId,
|
||||
]);
|
||||
|
||||
$newId = (int)$pdo->lastInsertId();
|
||||
|
||||
Response::success([
|
||||
'partner_id' => $newId,
|
||||
'name_ar' => $nameAr,
|
||||
'discount' => $discount,
|
||||
'message' => 'تمت إضافة الشريك بنجاح ومتاح فورياً لجميع المشتركين.',
|
||||
], 'Partner created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/payments/pending
|
||||
*/
|
||||
public function listPendingPayments(Request $request): void
|
||||
{
|
||||
$pdo = Database::getConnection();
|
||||
$stmt = $pdo->query('
|
||||
SELECT t.id, t.subscription_id, t.reference_number, t.amount, t.currency, t.method, t.receipt_image_url, t.created_at,
|
||||
u.id AS user_id, u.full_name, u.phone, s.membership_number
|
||||
FROM transactions t
|
||||
JOIN subscriptions s ON s.id = t.subscription_id
|
||||
JOIN users u ON u.id = t.user_id
|
||||
WHERE t.status = "SUBMITTED"
|
||||
ORDER BY t.id DESC
|
||||
LIMIT 50
|
||||
');
|
||||
$pending = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
Response::success($pending, 'Pending payments list retrieved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/payments/approve
|
||||
*/
|
||||
public function approvePayment(Request $request): void
|
||||
{
|
||||
$adminId = (int)$request->getHeader('x-user-id');
|
||||
$transactionId = (int)$request->get('transaction_id');
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
$pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare('SELECT id, subscription_id, user_id FROM transactions WHERE id = :id LIMIT 1');
|
||||
$stmt->execute([':id' => $transactionId]);
|
||||
$trans = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$trans) {
|
||||
Response::notFound('Transaction not found.');
|
||||
}
|
||||
|
||||
// Update transaction
|
||||
$updTrans = $pdo->prepare('UPDATE transactions SET status = "VERIFIED_ADMIN", verified_at = NOW() WHERE id = :id');
|
||||
$updTrans->execute([':id' => $transactionId]);
|
||||
|
||||
// Activate subscription
|
||||
$updSub = $pdo->prepare('
|
||||
UPDATE subscriptions
|
||||
SET status = "ACTIVE", starts_at = CURDATE(), expires_at = DATE_ADD(CURDATE(), INTERVAL 2 YEAR), activated_by = :admin_id
|
||||
WHERE id = :sub_id
|
||||
');
|
||||
$updSub->execute([
|
||||
':admin_id' => $adminId,
|
||||
':sub_id' => $trans['subscription_id'],
|
||||
]);
|
||||
|
||||
// Activate user
|
||||
$updUser = $pdo->prepare('UPDATE users SET status = "ACTIVE" WHERE id = :uid');
|
||||
$updUser->execute([':uid' => $trans['user_id']]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
Response::success([
|
||||
'transaction_id' => $transactionId,
|
||||
'subscription_id' => $trans['subscription_id'],
|
||||
'status' => 'ACTIVE',
|
||||
], 'Payment approved and membership activated successfully.');
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
Response::error('Approval failed: ' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/stats
|
||||
*/
|
||||
public function getStats(Request $request): void
|
||||
{
|
||||
$pdo = Database::getConnection();
|
||||
|
||||
$membersCount = (int)$pdo->query('SELECT COUNT(*) FROM subscriptions WHERE status = "ACTIVE"')->fetchColumn();
|
||||
$pendingCount = (int)$pdo->query('SELECT COUNT(*) FROM subscriptions WHERE status = "PENDING_PAYMENT"')->fetchColumn();
|
||||
$totalRevenue = (float)$pdo->query('SELECT COALESCE(SUM(amount), 0) FROM transactions WHERE status IN ("VERIFIED_AUTO", "VERIFIED_ADMIN")')->fetchColumn();
|
||||
$hospitalsCount = (int)$pdo->query('SELECT COUNT(*) FROM partners WHERE type = "HOSPITAL" AND is_active = 1')->fetchColumn();
|
||||
$hotelsCount = (int)$pdo->query('SELECT COUNT(*) FROM partners WHERE type = "HOTEL" AND is_active = 1')->fetchColumn();
|
||||
|
||||
Response::success([
|
||||
'active_members' => $membersCount,
|
||||
'pending_verifications' => $pendingCount,
|
||||
'total_revenue_iqd' => $totalRevenue,
|
||||
'total_revenue_usd' => round($totalRevenue / 1320, 2),
|
||||
'active_hospitals' => $hospitalsCount,
|
||||
'active_hotels' => $hotelsCount,
|
||||
], 'System statistics retrieved.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use Core\Security;
|
||||
use Core\Database;
|
||||
use App\Services\OtpIqService;
|
||||
use PDO;
|
||||
|
||||
class AuthController
|
||||
{
|
||||
/**
|
||||
* POST /api/v1/auth/request-otp
|
||||
*/
|
||||
public function requestOtp(Request $request): void
|
||||
{
|
||||
$phone = trim((string)$request->get('phone'));
|
||||
if (empty($phone) || strlen($phone) < 8) {
|
||||
Response::error('Phone number is required.');
|
||||
}
|
||||
|
||||
// Generate 6-digit cryptographic OTP code
|
||||
$otp = (string)random_int(100000, 999999);
|
||||
|
||||
// Store OTP temporarily in DB or cache
|
||||
$pdo = Database::getConnection();
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO audit_logs (user_id, action, endpoint, method, ip_address, response_code, payload_snippet, created_at)
|
||||
VALUES (0, "OTP_REQUESTED", "/api/v1/auth/request-otp", "POST", :ip, 200, :payload, NOW())
|
||||
');
|
||||
$stmt->execute([
|
||||
':ip' => $request->getIp(),
|
||||
':payload' => json_encode(['phone' => $phone, 'otp' => $otp]),
|
||||
]);
|
||||
|
||||
$otpService = new OtpIqService();
|
||||
$res = $otpService->sendOtp($phone, $otp);
|
||||
|
||||
Response::success([
|
||||
'phone' => $phone,
|
||||
'channels' => ['whatsapp', 'sms'],
|
||||
'mock_otp' => getenv('APP_ENV') === 'development' ? $otp : null,
|
||||
], 'Verification OTP dispatched via OTPIQ Smart Fallback.');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/verify-otp
|
||||
* Receives phone, otp, device_fingerprint, device_model, and optional full_name.
|
||||
*/
|
||||
public function verifyOtp(Request $request): void
|
||||
{
|
||||
$phone = trim((string)$request->get('phone'));
|
||||
$otp = trim((string)$request->get('otp'));
|
||||
$fingerprint = trim((string)$request->get('device_fingerprint'));
|
||||
$model = trim((string)$request->get('device_model', 'Unknown Device'));
|
||||
$fullName = trim((string)$request->get('full_name', 'عضو جائزة أوروك'));
|
||||
|
||||
if (empty($phone) || empty($otp) || empty($fingerprint)) {
|
||||
Response::error('Phone, OTP, and Device Fingerprint are mandatory.');
|
||||
}
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
|
||||
// Check or find user
|
||||
$stmt = $pdo->prepare('SELECT id, phone, full_name, role, status FROM users WHERE phone = :phone LIMIT 1');
|
||||
$stmt->execute([':phone' => $phone]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$user) {
|
||||
// Register new user
|
||||
$ins = $pdo->prepare('
|
||||
INSERT INTO users (phone, full_name, role, status, created_at)
|
||||
VALUES (:phone, :name, "MEMBER", "ACTIVE", NOW())
|
||||
');
|
||||
$ins->execute([':phone' => $phone, ':name' => $fullName]);
|
||||
$userId = (int)$pdo->lastInsertId();
|
||||
$role = 'MEMBER';
|
||||
} else {
|
||||
$userId = (int)$user['id'];
|
||||
$role = $user['role'];
|
||||
}
|
||||
|
||||
// Generate per-device secret key and session token
|
||||
$deviceSecret = Security::generateRandomHex(32);
|
||||
$sessionToken = Security::generateRandomHex(32);
|
||||
|
||||
// Bind device fingerprint and store secret in user_security table
|
||||
$secStmt = $pdo->prepare('
|
||||
INSERT INTO user_security (user_id, device_fingerprint, device_secret, device_model, last_token, last_ip, last_active_at)
|
||||
VALUES (:uid, :fp, :secret, :model, :token, :ip, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
device_secret = VALUES(device_secret),
|
||||
device_model = VALUES(device_model),
|
||||
last_token = VALUES(last_token),
|
||||
last_ip = VALUES(last_ip),
|
||||
last_active_at = NOW(),
|
||||
is_locked = 0
|
||||
');
|
||||
$secStmt->execute([
|
||||
':uid' => $userId,
|
||||
':fp' => $fingerprint,
|
||||
':secret' => $deviceSecret,
|
||||
':model' => $model,
|
||||
':token' => $sessionToken,
|
||||
':ip' => $request->getIp(),
|
||||
]);
|
||||
|
||||
Response::success([
|
||||
'user' => [
|
||||
'id' => $userId,
|
||||
'phone' => $phone,
|
||||
'full_name' => $user['full_name'] ?? $fullName,
|
||||
'role' => $role,
|
||||
],
|
||||
'token' => $sessionToken,
|
||||
'device_secret' => $deviceSecret,
|
||||
'device_fingerprint' => $fingerprint,
|
||||
], 'Authentication successful. Device bound and secured.');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/auth/profile
|
||||
*/
|
||||
public function getProfile(Request $request): void
|
||||
{
|
||||
$userId = (int)$request->getHeader('x-user-id');
|
||||
$pdo = Database::getConnection();
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT u.id, u.phone, u.full_name, u.role, u.status, u.created_at,
|
||||
s.membership_number, s.status AS subscription_status, s.expires_at
|
||||
FROM users u
|
||||
LEFT JOIN subscriptions s ON s.user_id = u.id AND s.status = "ACTIVE"
|
||||
WHERE u.id = :uid
|
||||
ORDER BY s.id DESC
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([':uid' => $userId]);
|
||||
$profile = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
Response::success($profile, 'User profile retrieved.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use Core\Database;
|
||||
use PDO;
|
||||
|
||||
class GatewayController
|
||||
{
|
||||
private string $configuredAppKey;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->configuredAppKey = getenv('GATEWAY_APP_KEY') ?: 'uruk_gateway_secret_2026';
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate gateway device app_key
|
||||
*/
|
||||
private function authenticate(string $appKey): bool
|
||||
{
|
||||
return hash_equals($this->configuredAppKey, $appKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/gateway/pending-call or /pending-call.php
|
||||
*/
|
||||
public function pendingCall(Request $request): void
|
||||
{
|
||||
$deviceId = (string)$request->get('device_id');
|
||||
$appKey = (string)$request->get('app_key');
|
||||
|
||||
if (!$this->authenticate($appKey) || empty($deviceId)) {
|
||||
Response::json([
|
||||
'task_id' => null,
|
||||
'phone' => null,
|
||||
'caller_id' => null,
|
||||
'otp' => null,
|
||||
'timeout_seconds' => null,
|
||||
'error' => 'Unauthorized or missing device_id'
|
||||
], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
|
||||
// Update device heartbeat
|
||||
$this->heartbeat($pdo, $deviceId);
|
||||
|
||||
// Fetch oldest pending FLASH_CALL task
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT id, target_phone, otp_code, timeout_seconds
|
||||
FROM gateway_tasks
|
||||
WHERE task_type = "FLASH_CALL"
|
||||
AND (assigned_device_id = :dev OR assigned_device_id IS NULL)
|
||||
AND status = "PENDING"
|
||||
AND created_at >= NOW() - INTERVAL 60 SECOND
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([':dev' => $deviceId]);
|
||||
$task = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($task) {
|
||||
// Lock task to this device
|
||||
$upd = $pdo->prepare('UPDATE gateway_tasks SET status = "PROCESSING", assigned_device_id = :dev WHERE id = :id');
|
||||
$upd->execute([':dev' => $deviceId, ':id' => $task['id']]);
|
||||
|
||||
Response::json([
|
||||
'task_id' => (int)$task['id'],
|
||||
'phone' => $task['target_phone'],
|
||||
'caller_id' => null,
|
||||
'otp' => $task['otp_code'],
|
||||
'timeout_seconds' => (int)($task['timeout_seconds'] ?: 25),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
Response::json([
|
||||
'task_id' => null,
|
||||
'phone' => null,
|
||||
'caller_id' => null,
|
||||
'otp' => null,
|
||||
'timeout_seconds' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/gateway/pending-sms or /pending-sms.php
|
||||
*/
|
||||
public function pendingSms(Request $request): void
|
||||
{
|
||||
$deviceId = (string)$request->get('device_id');
|
||||
$appKey = (string)$request->get('app_key');
|
||||
|
||||
if (!$this->authenticate($appKey) || empty($deviceId)) {
|
||||
Response::json([
|
||||
'task_id' => null,
|
||||
'phone' => null,
|
||||
'caller_id' => null,
|
||||
'otp' => null,
|
||||
'timeout_seconds' => null,
|
||||
'error' => 'Unauthorized or missing device_id'
|
||||
], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
$this->heartbeat($pdo, $deviceId);
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT id, target_phone, otp_code, timeout_seconds
|
||||
FROM gateway_tasks
|
||||
WHERE task_type = "SMS"
|
||||
AND (assigned_device_id = :dev OR assigned_device_id IS NULL)
|
||||
AND status = "PENDING"
|
||||
AND created_at >= NOW() - INTERVAL 120 SECOND
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([':dev' => $deviceId]);
|
||||
$task = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($task) {
|
||||
$upd = $pdo->prepare('UPDATE gateway_tasks SET status = "PROCESSING", assigned_device_id = :dev WHERE id = :id');
|
||||
$upd->execute([':dev' => $deviceId, ':id' => $task['id']]);
|
||||
|
||||
Response::json([
|
||||
'task_id' => (int)$task['id'],
|
||||
'phone' => $task['target_phone'],
|
||||
'caller_id' => null,
|
||||
'otp' => $task['otp_code'],
|
||||
'timeout_seconds' => (int)($task['timeout_seconds'] ?: 30),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
Response::json([
|
||||
'task_id' => null,
|
||||
'phone' => null,
|
||||
'caller_id' => null,
|
||||
'otp' => null,
|
||||
'timeout_seconds' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/gateway/call-done or /call-done.php
|
||||
*/
|
||||
public function callDone(Request $request): void
|
||||
{
|
||||
$taskId = (int)$request->get('task_id');
|
||||
$deviceId = (string)$request->get('device_id');
|
||||
$appKey = (string)$request->get('app_key');
|
||||
$result = (string)$request->get('result');
|
||||
|
||||
if (!$this->authenticate($appKey)) {
|
||||
Response::json(['success' => false, 'message' => 'Unauthorized'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
$newStatus = (strtoupper($result) === 'SUCCESS' || strtoupper($result) === 'DONE') ? 'COMPLETED' : 'FAILED';
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE gateway_tasks
|
||||
SET status = :st, completed_at = NOW()
|
||||
WHERE id = :id AND assigned_device_id = :dev
|
||||
');
|
||||
$stmt->execute([':st' => $newStatus, ':id' => $taskId, ':dev' => $deviceId]);
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'message' => "Call task {$taskId} recorded as {$newStatus}.",
|
||||
'device_id' => $deviceId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/gateway/sms-done or /sms-done.php
|
||||
*/
|
||||
public function smsDone(Request $request): void
|
||||
{
|
||||
$taskId = (int)$request->get('task_id');
|
||||
$deviceId = (string)$request->get('device_id');
|
||||
$appKey = (string)$request->get('app_key');
|
||||
$result = (string)$request->get('result');
|
||||
|
||||
if (!$this->authenticate($appKey)) {
|
||||
Response::json(['success' => false, 'message' => 'Unauthorized'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
$newStatus = (strtoupper($result) === 'SUCCESS' || strtoupper($result) === 'SENT') ? 'COMPLETED' : 'FAILED';
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE gateway_tasks
|
||||
SET status = :st, completed_at = NOW()
|
||||
WHERE id = :id AND assigned_device_id = :dev
|
||||
');
|
||||
$stmt->execute([':st' => $newStatus, ':id' => $taskId, ':dev' => $deviceId]);
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'message' => "SMS task {$taskId} recorded as {$newStatus}.",
|
||||
'device_id' => $deviceId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/gateway/register-device or /register-device.php
|
||||
*/
|
||||
public function registerDevice(Request $request): void
|
||||
{
|
||||
$deviceId = (string)$request->get('device_id');
|
||||
$phoneNumber = (string)$request->get('phone_number');
|
||||
$simSlot = (int)$request->get('sim_slot', 1);
|
||||
$appKey = (string)$request->get('app_key');
|
||||
|
||||
if (!$this->authenticate($appKey) || empty($deviceId)) {
|
||||
Response::json(['success' => false, 'message' => 'Unauthorized or missing device_id'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO gateway_devices (device_id, phone_number, sim_slot, status, last_heartbeat)
|
||||
VALUES (:did, :phone, :slot, "ACTIVE", NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
phone_number = VALUES(phone_number),
|
||||
sim_slot = VALUES(sim_slot),
|
||||
status = "ACTIVE",
|
||||
last_heartbeat = NOW()
|
||||
');
|
||||
$stmt->execute([
|
||||
':did' => $deviceId,
|
||||
':phone' => $phoneNumber,
|
||||
':slot' => $simSlot,
|
||||
]);
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'message' => 'Gateway device registered and active.',
|
||||
'device_id' => $deviceId,
|
||||
]);
|
||||
}
|
||||
|
||||
private function heartbeat(PDO $pdo, string $deviceId): void
|
||||
{
|
||||
try {
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO gateway_devices (device_id, status, last_heartbeat)
|
||||
VALUES (:did, "ACTIVE", NOW())
|
||||
ON DUPLICATE KEY UPDATE status = "ACTIVE", last_heartbeat = NOW()
|
||||
');
|
||||
$stmt->execute([':did' => $deviceId]);
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore heartbeat failures if table schema is pending
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use Core\Database;
|
||||
use PDO;
|
||||
|
||||
class PartnerController
|
||||
{
|
||||
/**
|
||||
* GET /api/v1/partners
|
||||
* Query params: type (HOSPITAL, HOTEL), country, city, search
|
||||
*/
|
||||
public function listPartners(Request $request): void
|
||||
{
|
||||
$type = strtoupper(trim((string)$request->getQuery('type', '')));
|
||||
$country = trim((string)$request->getQuery('country', ''));
|
||||
$city = trim((string)$request->getQuery('city', ''));
|
||||
$search = trim((string)$request->getQuery('search', ''));
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
|
||||
$sql = 'SELECT id, type, name_ar, name_en, category, country, city, discount_percentage, address, phone, latitude, longitude
|
||||
FROM partners
|
||||
WHERE is_active = 1';
|
||||
$params = [];
|
||||
|
||||
if (!empty($type)) {
|
||||
$sql .= ' AND type = :type';
|
||||
$params[':type'] = $type;
|
||||
}
|
||||
|
||||
if (!empty($country)) {
|
||||
$sql .= ' AND country = :country';
|
||||
$params[':country'] = $country;
|
||||
}
|
||||
|
||||
if (!empty($city)) {
|
||||
$sql .= ' AND city = :city';
|
||||
$params[':city'] = $city;
|
||||
}
|
||||
|
||||
if (!empty($search)) {
|
||||
$sql .= ' AND (name_ar LIKE :search OR name_en LIKE :search OR category LIKE :search)';
|
||||
$params[':search'] = '%' . $search . '%';
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY discount_percentage DESC, id DESC';
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$partners = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
Response::success($partners, 'Partners directory retrieved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/partners/{id}
|
||||
*/
|
||||
public function getPartner(Request $request, array $params): void
|
||||
{
|
||||
$partnerId = (int)($params['id'] ?? 0);
|
||||
$pdo = Database::getConnection();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM partners WHERE id = :id AND is_active = 1 LIMIT 1');
|
||||
$stmt->execute([':id' => $partnerId]);
|
||||
$partner = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$partner) {
|
||||
Response::notFound('Partner not found.');
|
||||
}
|
||||
|
||||
Response::success($partner, 'Partner details retrieved.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use App\Services\SuperQiParserService;
|
||||
|
||||
class PaymentController
|
||||
{
|
||||
/**
|
||||
* POST /api/v1/payments/ingest-notification
|
||||
* Invoked securely by the Android Bridge Listener app when a SuperQi/ZainCash notification arrives.
|
||||
*/
|
||||
public function ingestNotification(Request $request): void
|
||||
{
|
||||
$secret = $request->getHeader('x-listener-secret');
|
||||
$expectedSecret = getenv('NOTIFICATION_INGEST_SECRET') ?: 'secure_ingest_key_for_android_bridge_listener';
|
||||
|
||||
if ($secret !== $expectedSecret) {
|
||||
Response::forbidden('Invalid listener secret.');
|
||||
}
|
||||
|
||||
$body = $request->getBody();
|
||||
$result = SuperQiParserService::processIngestedNotification($body);
|
||||
|
||||
if (!$result['success']) {
|
||||
Response::error($result['message'] ?? 'Ingestion failed', 400);
|
||||
}
|
||||
|
||||
Response::success($result, 'Notification ingested and processed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/payments/swiftpay-webhook
|
||||
*/
|
||||
public function swiftPayWebhook(Request $request): void
|
||||
{
|
||||
// Handle SwiftPayIQ transaction webhook
|
||||
$payload = $request->getBody();
|
||||
Response::success(['status' => 'received'], 'Webhook processed.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use App\Services\QrTokenService;
|
||||
|
||||
class QrController
|
||||
{
|
||||
/**
|
||||
* GET /api/v1/qr/dynamic-token
|
||||
* User's Flutter app requests a rotating QR token.
|
||||
*/
|
||||
public function getDynamicToken(Request $request): void
|
||||
{
|
||||
$userId = (int)$request->getHeader('x-user-id');
|
||||
$res = QrTokenService::generateToken($userId);
|
||||
|
||||
if (!$res['success']) {
|
||||
Response::forbidden($res['message']);
|
||||
}
|
||||
|
||||
Response::success($res, 'Dynamic QR token generated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/qr/verify-token
|
||||
* Scanned by hospital/hotel front desk or verification portal.
|
||||
*/
|
||||
public function verifyToken(Request $request): void
|
||||
{
|
||||
$token = trim((string)$request->get('token'));
|
||||
$partnerId = (int)$request->get('partner_id', 1);
|
||||
$staffName = trim((string)$request->get('staff_name', 'موظف الاستقبال'));
|
||||
|
||||
if (empty($token)) {
|
||||
Response::error('QR token is required for verification.');
|
||||
}
|
||||
|
||||
$res = QrTokenService::verifyScannedToken($token, $partnerId, $staffName);
|
||||
|
||||
if (!$res['success']) {
|
||||
Response::error($res['message'], 400);
|
||||
}
|
||||
|
||||
Response::success($res, 'Member verified successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use Core\Security;
|
||||
use Core\Database;
|
||||
use App\Services\SuperQiParserService;
|
||||
use PDO;
|
||||
|
||||
class SubscriptionController
|
||||
{
|
||||
/**
|
||||
* GET /api/v1/subscription/plans
|
||||
*/
|
||||
public function getPlans(Request $request): void
|
||||
{
|
||||
$appConfig = require __DIR__ . '/../../config/app.php';
|
||||
|
||||
$plans = [
|
||||
[
|
||||
'id' => 'uruk-2year-standard',
|
||||
'name' => 'عضوية جائزة أوروك التنفيذية (سنتان)',
|
||||
'duration_years' => 2,
|
||||
'price_usd' => (float)$appConfig['membership_price_usd'],
|
||||
'price_iqd' => (float)$appConfig['membership_price_iqd'],
|
||||
'benefits' => [
|
||||
'خصم 50% في شبكة المستشفيات والمراكز التخصصية في العراق والأردن ومصر ولبنان',
|
||||
'خصومات فندقية وسياحية تتراوح بين 40% و60% في أرقى الفنادق الشريكة',
|
||||
'وصول مجاني لأكثر من 30 تخصصاً تدريبياً معتمداً حضورياً وعبر المنصة الرقمية',
|
||||
'شهادات معتمدة ومسجلة رسمياً باسم المشترك',
|
||||
'هوية رقمية ذكية مع رمز QR مشفر للتحقق الفوري',
|
||||
],
|
||||
'official_activation_date' => '2026-01-01',
|
||||
'early_registration_active' => true,
|
||||
],
|
||||
];
|
||||
|
||||
Response::success($plans, 'Available subscription plans retrieved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/subscription/submit
|
||||
* User submits payment details (Method, Ref Number, Receipt).
|
||||
*/
|
||||
public function submitSubscription(Request $request): void
|
||||
{
|
||||
$userId = (int)$request->getHeader('x-user-id');
|
||||
$method = strtoupper(trim((string)$request->get('method', 'SUPER_QI')));
|
||||
$refNumber = trim((string)$request->get('reference_number'));
|
||||
$receiptUrl = trim((string)$request->get('receipt_url', ''));
|
||||
|
||||
if (empty($refNumber)) {
|
||||
Response::error('Payment transaction reference number is required.');
|
||||
}
|
||||
|
||||
$appConfig = require __DIR__ . '/../../config/app.php';
|
||||
$pdo = Database::getConnection();
|
||||
|
||||
// 1. Generate unique membership number (e.g. URUK-2026-XXXX)
|
||||
$memNumber = 'URUK-' . date('Y') . '-' . strtoupper(substr(uniqid(), -5));
|
||||
$qrSeed = Security::generateRandomHex(16);
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// Create subscription record with PENDING_PAYMENT
|
||||
$subStmt = $pdo->prepare('
|
||||
INSERT INTO subscriptions (user_id, membership_number, plan_name, price_usd, price_local, currency, starts_at, expires_at, status, qr_seed, created_at)
|
||||
VALUES (:uid, :mem, "2-Year Executive Membership", :usd, :iqd, "IQD", CURDATE(), DATE_ADD(CURDATE(), INTERVAL 2 YEAR), "PENDING_PAYMENT", :seed, NOW())
|
||||
');
|
||||
$subStmt->execute([
|
||||
':uid' => $userId,
|
||||
':mem' => $memNumber,
|
||||
':usd' => $appConfig['membership_price_usd'],
|
||||
':iqd' => $appConfig['membership_price_iqd'],
|
||||
':seed' => $qrSeed,
|
||||
]);
|
||||
$subscriptionId = (int)$pdo->lastInsertId();
|
||||
|
||||
// Create transaction record
|
||||
$transStmt = $pdo->prepare('
|
||||
INSERT INTO transactions (subscription_id, user_id, method, reference_number, amount, currency, status, receipt_image_url, created_at)
|
||||
VALUES (:sub_id, :uid, :method, :ref, :amount, "IQD", "SUBMITTED", :receipt, NOW())
|
||||
');
|
||||
$transStmt->execute([
|
||||
':sub_id' => $subscriptionId,
|
||||
':uid' => $userId,
|
||||
':method' => $method,
|
||||
':ref' => $refNumber,
|
||||
':amount' => $appConfig['membership_price_iqd'],
|
||||
':receipt' => $receiptUrl,
|
||||
]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
// 2. Check if a matching SuperQi / ZainCash notification was already ingested by the Android listener
|
||||
$checkStmt = $pdo->prepare('
|
||||
SELECT id, amount, status FROM transactions
|
||||
WHERE reference_number = :ref AND id != :current_id AND status = "VERIFIED_AUTO"
|
||||
LIMIT 1
|
||||
');
|
||||
$checkStmt->execute([':ref' => $refNumber, ':current_id' => (int)$pdo->lastInsertId()]);
|
||||
$preExistingPayment = $checkStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($preExistingPayment) {
|
||||
// Instantly activate!
|
||||
$actSub = $pdo->prepare('UPDATE subscriptions SET status = "ACTIVE" WHERE id = :id');
|
||||
$actSub->execute([':id' => $subscriptionId]);
|
||||
|
||||
$actTrans = $pdo->prepare('UPDATE transactions SET status = "VERIFIED_AUTO", verified_at = NOW() WHERE subscription_id = :id');
|
||||
$actTrans->execute([':id' => $subscriptionId]);
|
||||
|
||||
Response::success([
|
||||
'subscription_id' => $subscriptionId,
|
||||
'membership_number' => $memNumber,
|
||||
'status' => 'ACTIVE',
|
||||
'message' => 'تم التحقق من الحوالة آلياً بنجاح وتفعيل العضوية فورياً!',
|
||||
], 'Membership activated automatically via SuperQi verification.');
|
||||
}
|
||||
|
||||
Response::success([
|
||||
'subscription_id' => $subscriptionId,
|
||||
'membership_number' => $memNumber,
|
||||
'status' => 'PENDING_PAYMENT',
|
||||
'message' => 'تم استلام طلبك بنجاح وجارٍ التحقق من الحوالة وتفعيل بطاقتك.',
|
||||
], 'Subscription request registered. Verification in progress.');
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
Response::error('Failed to submit subscription: ' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/subscription/my-card
|
||||
*/
|
||||
public function getMyCard(Request $request): void
|
||||
{
|
||||
$userId = (int)$request->getHeader('x-user-id');
|
||||
$pdo = Database::getConnection();
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT s.id, s.membership_number, s.plan_name, s.status, s.starts_at, s.expires_at,
|
||||
u.full_name, u.phone, u.avatar_url
|
||||
FROM subscriptions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.user_id = :uid
|
||||
ORDER BY s.id DESC
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([':uid' => $userId]);
|
||||
$card = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$card) {
|
||||
Response::notFound('No subscription card found for this user.');
|
||||
}
|
||||
|
||||
Response::success($card, 'Digital card details retrieved.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user