Commit fb97035e authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(orgs): applications decide through decide_application; org staff stay in their org

org-applications: approve and reject are one call each to
decide_application() (El3ab docs/redeem-contract.md 7.5), which adds or
upgrades the membership, runs any grants a code application was holding and
records the staff reviewer in reviewed_by_admin, all in one transaction. The
player is notified only after it answers ok, with the org's name from that
answer in title_ar/body_ar and the reviewer's note or reason in the body.
The old code wrote missing columns and an admin id into a profiles key, so
nothing was ever approved, and then told the player they were in. Bulk
approve and reject go through the same path (up to 50) and say how many
failed and why. The list and show pages read el3ab_organizations (the org
column was blank for all 13), with per-status counts and the cancelled
status. The proof is shown through a ten-minute signed link, from the
private org-proofs bucket when the file is there (core/ProofDocument.php),
and a proof still sitting in the old public bucket is flagged. Signed links
now carry the apikey Kong requires.

Org scoping: core/OrgScope.php, called by the Router before every route,
keeps an org-scoped staff account (admin_users.org_id) inside its own org:
the org in organizations/{id}/... and {orgId}, the owner org of any record
named in the path (application, code, event, channel, frame, transfer, ...),
and org ids in the query or form are all checked with Auth::canAccessOrg;
lists are pinned to the account's org, the org directory goes straight to
it, and modules the role has no permission for at all are refused.
OrgPermissions uses player_id and status='active', ranks moderator with
arbiter, and treats an org-scoped staff account as the owner of its own org
only, as the database functions do.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 9679dd19
<?php <?php
/**
* Org roles and what each may do.
*
* Two kinds of actor:
* - a manager session (admin_users). Platform staff (superadmin, admin, or no
* org_id) may do everything everywhere. An org-scoped staff account acts as
* the owner of its own org and of no other (El3ab docs/redeem-contract.md
* 6.2), which is also how the database functions treat it.
* - a player, by their org_members row: player_id, and only status 'active'
* counts. This class used to query org_members.user_id and .status (only
* the first did not exist, but the query failed either way), so it never
* granted anything to anyone but the superadmin.
*
* Rank order matches the player app (includes/org-perms.php): owner 4, admin 3,
* arbiter and moderator 2, member 1; an unknown role ranks as member.
*/
class OrgPermissions class OrgPermissions
{ {
private static array $roleHierarchy = [ private static array $roleHierarchy = [
'owner' => 4, 'owner' => 4,
'admin' => 3, 'admin' => 3,
'arbiter' => 2, 'arbiter' => 2,
'moderator' => 2,
'member' => 1, 'member' => 1,
]; ];
...@@ -17,13 +34,13 @@ class OrgPermissions ...@@ -17,13 +34,13 @@ class OrgPermissions
'treasury.view', 'treasury.deposit', 'treasury.withdraw', 'treasury.view', 'treasury.deposit', 'treasury.withdraw',
'announcements.manage', 'events.manage', 'content.manage', 'announcements.manage', 'events.manage', 'content.manage',
'rosters.manage', 'training.manage', 'media.manage', 'rosters.manage', 'training.manage', 'media.manage',
'invites.manage', 'applications.review', 'invites.manage', 'applications.review', 'proofs.view',
'achievements.manage', 'spotlights.manage', 'achievements.manage', 'spotlights.manage',
'recruitment.manage', 'loyalty.manage', 'recruitment.manage', 'loyalty.manage',
'settings.view', 'settings.view', 'settings.manage',
], ],
'arbiter' => [ 'arbiter' => [
'members.view', 'members.invite', 'members.view',
'chat.moderate', 'chat.moderate',
'treasury.view', 'treasury.view',
'announcements.view', 'events.view', 'events.create', 'announcements.view', 'events.view', 'events.create',
...@@ -31,8 +48,13 @@ class OrgPermissions ...@@ -31,8 +48,13 @@ class OrgPermissions
'rosters.view', 'rosters.view',
'training.view', 'training.create', 'training.view', 'training.create',
'media.view', 'media.upload', 'media.view', 'media.upload',
'applications.view',
'spotlights.view', 'spotlights.view',
'tournaments.manage',
],
'moderator' => [
'members.view', 'members.mute', 'members.kick',
'chat.view', 'chat.send', 'chat.moderate',
'announcements.view', 'events.view', 'content.view', 'media.view',
], ],
'member' => [ 'member' => [
'members.view', 'members.view',
...@@ -45,53 +67,29 @@ class OrgPermissions ...@@ -45,53 +67,29 @@ class OrgPermissions
], ],
]; ];
/** May the signed-in manager account do $permission in this org? */
public static function check(string $orgId, string $permission): bool public static function check(string $orgId, string $permission): bool
{ {
$session = $_SESSION['user'] ?? null; $role = self::sessionRole($orgId);
if (!$session) { return $role !== null && self::roleHasPermission($role, $permission);
return false;
}
if (($session['role'] ?? '') === 'superadmin') {
return true;
}
$db = Database::getInstance();
$member = $db->selectOne('org_members', [
'org_id' => "eq.{$orgId}",
'user_id' => "eq.{$session['id']}",
'status' => 'eq.active',
]);
if (!$member) {
return false;
}
return self::roleHasPermission($member['role'], $permission);
} }
public static function requireAccess(string $orgId): void /** The role a manager session holds in this org, or null for none. */
public static function sessionRole(string $orgId): ?string
{ {
$session = $_SESSION['user'] ?? null; $session = $_SESSION['user'] ?? null;
if (!$session) { if (!$session) return null;
http_response_code(403); if (in_array($session['role'] ?? '', ['superadmin', 'admin'], true)) return 'owner';
exit('Access denied'); if (empty($session['org_id'])) return null;
} return $session['org_id'] === $orgId ? 'owner' : null;
}
if (($session['role'] ?? '') === 'superadmin') {
return;
}
$db = Database::getInstance();
$member = $db->selectOne('org_members', [
'org_id' => "eq.{$orgId}",
'user_id' => "eq.{$session['id']}",
'status' => 'eq.active',
]);
if (!$member) { public static function requireAccess(string $orgId): void
{
if (self::sessionRole($orgId) === null) {
http_response_code(403); http_response_code(403);
exit('Access denied'); View::render('errors/403');
exit;
} }
} }
...@@ -99,35 +97,29 @@ class OrgPermissions ...@@ -99,35 +97,29 @@ class OrgPermissions
{ {
if (!self::check($orgId, $permission)) { if (!self::check($orgId, $permission)) {
http_response_code(403); http_response_code(403);
exit('Insufficient permissions'); View::render('errors/403');
exit;
} }
} }
public static function getRolePermissions(string $role): array public static function getRolePermissions(string $role): array
{ {
return self::$rolePermissions[$role] ?? []; return self::$rolePermissions[$role] ?? self::$rolePermissions['member'];
} }
/** May this player (an active member) do $permission in the org? */
public static function memberCan(string $orgId, string $playerId, string $permission): bool public static function memberCan(string $orgId, string $playerId, string $permission): bool
{ {
$db = Database::getInstance(); $member = self::activeMember($orgId, $playerId);
$member = $db->selectOne('org_members', [ if (!$member) return false;
'org_id' => "eq.{$orgId}",
'user_id' => "eq.{$playerId}", $custom = $member['permissions'] ?? [];
'status' => 'eq.active', if (is_string($custom)) $custom = json_decode($custom, true) ?? [];
]); // Per-member overrides are an object {"kick": true}; older rows hold a list.
if (is_array($custom)) {
if (!$member) { if (array_is_list($custom) && in_array($permission, $custom, true)) return true;
return false; $short = explode('.', $permission)[0];
} if (!array_is_list($custom) && (($custom[$permission] ?? null) === true || ($custom[$short] ?? null) === true)) return true;
$customPerms = $member['permissions'] ?? [];
if (is_string($customPerms)) {
$customPerms = json_decode($customPerms, true) ?? [];
}
if (in_array($permission, $customPerms, true)) {
return true;
} }
return self::roleHasPermission($member['role'], $permission); return self::roleHasPermission($member['role'], $permission);
...@@ -135,35 +127,32 @@ class OrgPermissions ...@@ -135,35 +127,32 @@ class OrgPermissions
public static function getMemberRole(string $orgId, string $playerId): ?string public static function getMemberRole(string $orgId, string $playerId): ?string
{ {
$db = Database::getInstance(); return self::activeMember($orgId, $playerId)['role'] ?? null;
$member = $db->selectOne('org_members', [ }
'org_id' => "eq.{$orgId}",
'user_id' => "eq.{$playerId}",
'status' => 'eq.active',
]);
return $member['role'] ?? null; public static function rank(?string $role): int
{
return self::$roleHierarchy[$role ?? ''] ?? 1;
} }
public static function isRoleHigherOrEqual(string $role, string $targetRole): bool public static function isRoleHigherOrEqual(string $role, string $targetRole): bool
{ {
$roleLevel = self::$roleHierarchy[$role] ?? 0; return self::rank($role) >= self::rank($targetRole);
$targetLevel = self::$roleHierarchy[$targetRole] ?? 0;
return $roleLevel >= $targetLevel;
} }
private static function roleHasPermission(string $role, string $permission): bool private static function activeMember(string $orgId, string $playerId): ?array
{ {
$perms = self::$rolePermissions[$role] ?? []; return Database::getInstance()->selectOne('org_members', [
'org_id' => "eq.{$orgId}",
if (in_array('*', $perms, true)) { 'player_id' => "eq.{$playerId}",
return true; 'status' => 'eq.active',
} ]);
}
if (in_array($permission, $perms, true)) {
return true;
}
private static function roleHasPermission(string $role, string $permission): bool
{
$perms = self::$rolePermissions[$role] ?? self::$rolePermissions['member'];
if (in_array('*', $perms, true) || in_array($permission, $perms, true)) return true;
$domain = explode('.', $permission)[0] ?? ''; $domain = explode('.', $permission)[0] ?? '';
return in_array("{$domain}.*", $perms, true); return in_array("{$domain}.*", $perms, true);
} }
......
<?php
/**
* Keeps an org-scoped staff account (admin_users.org_id set, roles org_admin /
* org_manager) inside its own organisation.
*
* Auth::canAccessOrg() existed but nothing called it, so such an account could
* open any other org's pages, or its members, codes, chat and treasury, by
* editing the id in the URL. The Router now calls OrgScope::enforce() before
* every org route, which:
*
* - checks the org named in the path (organizations/{id}/..., {orgId});
* - checks the org that owns any other record named in the path (an
* application, a code, an event, a frame, a transfer...), looked up in its
* table, so a foreign record cannot be reached through one's own org id;
* - checks org ids posted or passed in the query (org_id, to_org_id, ...);
* - pins list pages to the account's org ($_GET['org_id']), and sends the
* org directory straight to the account's own org;
* - refuses, for these accounts, every module their role has no permission
* for at all in config/permissions.php (settings, users, branding, ...).
*
* Platform staff (superadmin, admin, and any account without org_id) are not
* affected.
*/
class OrgScope
{
/** Path parameter => [table, org columns]. 'id' depends on the module. */
private const PARAM_OWNERS = [
'memberId' => ['org_members', ['org_id']],
'inviteId' => ['redeem_codes', ['org_id']],
'codeId' => ['redeem_codes', ['org_id']],
'eventId' => ['org_events', ['org_id']],
'annId' => ['org_announcements', ['org_id']],
'channelId' => ['org_chat_channels', ['org_id']],
'messageId' => ['org_chat_messages', ['org_id']],
'contentId' => ['org_content', ['org_id']],
'rewardId' => ['org_loyalty_rewards', ['org_id']],
'mediaId' => ['org_media', ['org_id']],
'rosterId' => ['org_rosters', ['org_id']],
'spotId' => ['org_member_spotlights', ['org_id']],
'sessionId' => ['org_training_sessions', ['org_id']],
'redemptionId' => ['redeem_redemptions', ['code_id']],
];
/** Modules whose {id} is a record of their own table, not an org. */
private const ID_OWNERS = [
'org-applications' => ['org_membership_applications', ['org_id']],
'org-achievements' => ['org_achievements', ['org_id']],
'org-frames' => ['profile_frames', ['org_id']],
'org-challenges' => ['org_challenges', ['challenger_org_id', 'challenged_org_id']],
'org-transfers' => ['org_player_transfers', ['from_org_id', 'to_org_id']],
'org-recruitment' => ['org_recruitment_posts', ['org_id']],
'org-partnerships' => ['org_partnerships', ['org_a_id', 'org_b_id']],
];
/** Request fields that name an org. */
private const ORG_FIELDS = ['org_id', 'org', 'to_org_id', 'from_org_id', 'challenged_org_id', 'challenger_org_id', 'org_a_id', 'org_b_id', 'partner_org_id'];
public static function isOrgModule(string $module): bool
{
return $module === 'organizations' || str_starts_with($module, 'org-');
}
public static function enforce(string $module, string $pattern, array $params): void
{
if (!Auth::isOrgScoped() || in_array(Auth::role(), ['superadmin', 'admin'], true)) return;
$own = Auth::orgId();
if (!self::roleTouchesModule($module)) self::deny();
if (!self::isOrgModule($module)) return;
// The org directory: an org account has exactly one org.
if ($module === 'organizations' && $pattern === 'organizations') {
header('Location: /organizations/' . rawurlencode($own));
exit;
}
if ($module === 'organizations' && in_array($pattern, ['organizations/create', 'organizations/store'], true)) self::deny();
$orgs = [];
foreach ($params as $name => $value) {
if ($name === 'id' && str_starts_with($pattern, 'organizations/{id}')) {
$orgs[] = $value;
} elseif ($name === 'orgId') {
$orgs[] = $value;
} elseif ($name === 'id' && isset(self::ID_OWNERS[$module])) {
$owners = self::owners(self::ID_OWNERS[$module], $value);
if ($owners === null) continue; // not found: the controller says so
if (!in_array($own, $owners, true)) self::deny();
} elseif (isset(self::PARAM_OWNERS[$name])) {
[$table, $cols] = self::PARAM_OWNERS[$name];
if ($table === 'redeem_redemptions') {
$codeIds = self::owners(self::PARAM_OWNERS[$name], $value);
$owners = $codeIds ? self::owners(['redeem_codes', ['org_id']], $codeIds[0]) : null;
} else {
$owners = self::owners([$table, $cols], $value);
}
if ($owners === null) continue;
if (!in_array($own, $owners, true)) self::deny();
}
}
foreach (self::ORG_FIELDS as $f) {
foreach ([$_GET[$f] ?? null, $_POST[$f] ?? null] as $v) {
if (is_string($v) && $v !== '' && $f !== 'to_org_id' && $f !== 'challenged_org_id' && $f !== 'org_b_id' && $f !== 'partner_org_id') {
$orgs[] = $v;
}
}
}
foreach ($orgs as $o) {
if (!Auth::canAccessOrg((string)$o)) self::deny();
}
// Lists without an org in the path show the account's org only.
$_GET['org_id'] = $own;
$_GET['org'] = $own;
}
/**
* Add the account's org to a list query; $cols are the org columns (any
* of them may match). A no-op for platform staff.
*/
public static function filter(array &$query, array $cols): void
{
if (!Auth::isOrgScoped() || in_array(Auth::role(), ['superadmin', 'admin'], true)) return;
$own = Auth::orgId();
if (count($cols) === 1) {
$query[$cols[0]] = 'eq.' . $own;
} else {
$query['or'] = '(' . implode(',', array_map(fn($c) => "{$c}.eq.{$own}", $cols)) . ')';
}
}
/** Org ids that own a record, or null when the record does not exist. */
private static function owners(array $spec, string $id): ?array
{
[$table, $cols] = $spec;
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $id)) return null;
$row = Database::getInstance()->selectOne($table, ['id' => 'eq.' . $id, 'select' => implode(',', $cols)]);
if (!$row) return null;
return array_values(array_filter(array_map(fn($c) => $row[$c] ?? null, $cols)));
}
/** Has the role any permission at all on this module? */
private static function roleTouchesModule(string $module): bool
{
if (in_array($module, ['dashboard'], true)) return true;
$permissions = require BASE_PATH . '/config/permissions.php';
foreach ($permissions[Auth::role()] ?? [] as $p) {
if ($p === '*' || $p === $module || str_starts_with($p, $module . '.')) return true;
}
return false;
}
private static function deny(): void
{
http_response_code(403);
View::render('errors/403');
exit;
}
}
<?php
/**
* Membership proofs (ID cards, receipts) attached to org applications.
*
* The old player app stored them in the PUBLIC `assets` bucket under
* `org-proofs/...` and saved a public URL in org_membership_applications
* .document_url, so anyone with the link could read a national ID. New proofs
* go to the private `org-proofs` bucket (El3ab docs/redeem-api.md 4.2, 6.5),
* and only staff see them, through a link signed for ten minutes.
*
* document_url may therefore hold:
* - a storage path in the private bucket ("org-proofs/<uid>/<file>" or "<uid>/<file>"),
* - a storage URL (public, sign or authenticated form) in any bucket,
* - anything else (an external link), shown as it is.
* The manager signs whatever it can with the service key, preferring the
* private bucket, and only falls back to the stored URL when nothing signs.
*/
class ProofDocument
{
public const PRIVATE_BUCKET = 'org-proofs';
public const TTL = 600;
/**
* @return array{url:?string, expires_at:?string, signed:bool, private:bool, legacy_public:bool}
*/
public static function resolve(?string $documentUrl, int $ttl = self::TTL): array
{
$none = ['url' => null, 'expires_at' => null, 'signed' => false, 'private' => false, 'legacy_public' => false];
$raw = trim((string)$documentUrl);
if ($raw === '') return $none;
$candidates = [];
$legacyPublic = false;
if (preg_match('#/storage/v1/object/(?:public/|sign/|authenticated/)?([^/?]+)/([^?]+)#', $raw, $m)) {
$bucket = $m[1];
$path = rawurldecode($m[2]);
$legacyPublic = str_contains($raw, '/object/public/');
// A file moved into the private bucket keeps its name below org-proofs/.
if (($pos = strpos($path, self::PRIVATE_BUCKET . '/')) !== false) {
$candidates[] = [self::PRIVATE_BUCKET, substr($path, $pos + strlen(self::PRIVATE_BUCKET) + 1)];
}
$candidates[] = [$bucket, $path];
} elseif (!preg_match('#^https?://#i', $raw)) {
$path = ltrim($raw, '/');
if (str_starts_with($path, self::PRIVATE_BUCKET . '/')) $path = substr($path, strlen(self::PRIVATE_BUCKET) + 1);
$candidates[] = [self::PRIVATE_BUCKET, $path];
}
$storage = SupabaseStorage::getInstance();
foreach ($candidates as [$bucket, $path]) {
try {
$url = $storage->getSignedUrl($bucket, self::encodePath($path), $ttl);
} catch (RuntimeException $e) {
continue;
}
return [
'url' => $url,
'expires_at' => gmdate('c', time() + $ttl),
'signed' => true,
'private' => $bucket === self::PRIVATE_BUCKET,
'legacy_public' => $legacyPublic && $bucket !== self::PRIVATE_BUCKET,
];
}
if (preg_match('#^https?://#i', $raw)) {
return ['url' => $raw, 'expires_at' => null, 'signed' => false, 'private' => false, 'legacy_public' => $legacyPublic];
}
return $none;
}
private static function encodePath(string $path): string
{
return implode('/', array_map('rawurlencode', explode('/', $path)));
}
}
...@@ -21,6 +21,7 @@ class Router ...@@ -21,6 +21,7 @@ class Router
$config = $match['config']; $config = $match['config'];
$params = $match['params']; $params = $match['params'];
$pattern = $match['pattern'];
$module = $config['module']; $module = $config['module'];
$action = $config['action']; $action = $config['action'];
...@@ -49,6 +50,9 @@ class Router ...@@ -49,6 +50,9 @@ class Router
Auth::requireAuth(); Auth::requireAuth();
// An org-scoped staff account stays inside its own org, whatever the URL says.
OrgScope::enforce($module, $pattern, $params);
$controllerPath = MODULES_PATH . "/{$module}/controller.php"; $controllerPath = MODULES_PATH . "/{$module}/controller.php";
if (!file_exists($controllerPath)) { if (!file_exists($controllerPath)) {
http_response_code(404); http_response_code(404);
...@@ -78,7 +82,7 @@ class Router ...@@ -78,7 +82,7 @@ class Router
private function matchRoute(string $route): ?array private function matchRoute(string $route): ?array
{ {
if (isset($this->routes[$route])) { if (isset($this->routes[$route])) {
return ['config' => $this->routes[$route], 'params' => []]; return ['config' => $this->routes[$route], 'params' => [], 'pattern' => $route];
} }
foreach ($this->routes as $pattern => $config) { foreach ($this->routes as $pattern => $config) {
...@@ -89,7 +93,7 @@ class Router ...@@ -89,7 +93,7 @@ class Router
if (preg_match($regex, $route, $matches)) { if (preg_match($regex, $route, $matches)) {
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY); $params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
return ['config' => $config, 'params' => $params]; return ['config' => $config, 'params' => $params, 'pattern' => $pattern];
} }
} }
......
...@@ -104,7 +104,13 @@ class SupabaseStorage ...@@ -104,7 +104,13 @@ class SupabaseStorage
} }
$data = json_decode($response, true); $data = json_decode($response, true);
return SUPABASE_URL . '/storage/v1' . ($data['signedURL'] ?? ''); $signed = (string)($data['signedURL'] ?? $data['signedUrl'] ?? '');
if ($signed === '') {
throw new RuntimeException('Failed to generate signed URL');
}
// Kong in front of storage refuses any request without an apikey, the
// token in the signed URL included; the anon key is public by design.
return SUPABASE_URL . '/storage/v1' . $signed . (str_contains($signed, '?') ? '&' : '?') . 'apikey=' . SUPABASE_ANON_KEY;
} }
public function validateFile(array $file, array $allowedMimes, int $maxSizeBytes): ?string public function validateFile(array $file, array $allowedMimes, int $maxSizeBytes): ?string
......
...@@ -16,7 +16,9 @@ require_once __DIR__ . '/core/Response.php'; ...@@ -16,7 +16,9 @@ require_once __DIR__ . '/core/Response.php';
require_once __DIR__ . '/core/ThemeService.php'; require_once __DIR__ . '/core/ThemeService.php';
require_once __DIR__ . '/core/SupabaseStorage.php'; require_once __DIR__ . '/core/SupabaseStorage.php';
require_once __DIR__ . '/core/OrgPermissions.php'; require_once __DIR__ . '/core/OrgPermissions.php';
require_once __DIR__ . '/core/OrgScope.php';
require_once __DIR__ . '/core/EconomyService.php'; require_once __DIR__ . '/core/EconomyService.php';
require_once __DIR__ . '/core/ProofDocument.php';
$route = trim($_GET['route'] ?? '', '/'); $route = trim($_GET['route'] ?? '', '/');
$method = $_SERVER['REQUEST_METHOD']; $method = $_SERVER['REQUEST_METHOD'];
......
<?php <?php
/**
* Membership applications (org_membership_applications).
*
* Every decision is one call to decide_application() (El3ab
* docs/redeem-contract.md 7.5), which in a single transaction adds or upgrades
* the membership, runs any grants a code application was holding, and records
* the staff reviewer in reviewed_by_admin. The old version wrote columns that
* did not exist and an admin id into a profiles foreign key, so every approval
* failed, and it then told the player they had been accepted anyway. The
* player is now notified only after the function answers ok, with the org's
* name from that answer.
*/
class OrgApplicationsController class OrgApplicationsController
{ {
private Database $db; private Database $db;
public const STATUS = [
'pending' => ['معلق', 'warning'],
'approved' => ['مقبول', 'success'],
'rejected' => ['مرفوض', 'danger'],
'cancelled' => ['ملغى', 'default'],
];
public const DOC_TYPES = [
'membership_card' => 'كارنيه عضوية',
'id_card' => 'بطاقة هوية',
'receipt' => 'إيصال',
'invite_code' => 'كود دعوة',
'other' => 'أخرى',
];
/** Answers of decide_application, in staff Arabic. */
private const ERRORS = [
'not_found' => 'الطلب غير موجود',
'already_decided' => 'تمت مراجعة هذا الطلب من قبل',
'forbidden' => 'لا تملك صلاحية على هذه المنظمة',
'reviewer_required' => 'هذا الحساب لا يمكنه المراجعة؛ ادخل بحساب من حسابات الإدارة',
'invalid_decision' => 'قرار غير صالح',
'org_not_found' => 'المنظمة غير موجودة',
'org_inactive' => 'المنظمة موقوفة أو محذوفة',
'org_full' => 'المنظمة وصلت للحد الأقصى من الأعضاء',
'org_limit' => 'اللاعب عضو بالفعل في 5 منظمات (الحد الأقصى)',
'banned_from_org' => 'اللاعب محظور من هذه المنظمة',
'membership_suspended' => 'عضوية اللاعب في هذه المنظمة معلقة',
];
public function __construct() public function __construct()
{ {
$this->db = Database::getInstance(); $this->db = Database::getInstance();
...@@ -11,18 +53,14 @@ class OrgApplicationsController ...@@ -11,18 +53,14 @@ class OrgApplicationsController
public function list(array $params, string $method): void public function list(array $params, string $method): void
{ {
$status = $_GET['status'] ?? ''; $status = $_GET['status'] ?? 'pending';
if ($status !== '' && !isset(self::STATUS[$status])) $status = '';
$orgId = $_GET['org'] ?? ''; $orgId = $_GET['org'] ?? '';
if (Auth::isOrgScoped()) $orgId = Auth::orgId();
$queryParams = ['select' => '*', 'order' => 'created_at.desc']; $queryParams = ['select' => '*', 'order' => 'created_at.desc'];
if ($status) $queryParams['status'] = "eq.{$status}";
if ($status) { if ($orgId) $queryParams['org_id'] = "eq.{$orgId}";
$queryParams['status'] = "eq.{$status}";
}
if ($orgId) {
$queryParams['org_id'] = "eq.{$orgId}";
}
$countParams = array_diff_key($queryParams, ['select' => 1, 'order' => 1]); $countParams = array_diff_key($queryParams, ['select' => 1, 'order' => 1]);
$total = $this->db->count('org_membership_applications', $countParams); $total = $this->db->count('org_membership_applications', $countParams);
...@@ -32,27 +70,30 @@ class OrgApplicationsController ...@@ -32,27 +70,30 @@ class OrgApplicationsController
$applications = $this->db->select('org_membership_applications', $queryParams); $applications = $this->db->select('org_membership_applications', $queryParams);
// Fetch related player and org data $players = $this->byId('profiles', array_column($applications, 'player_id'), 'id,username,display_name,avatar_url,level');
$orgs = $this->byId('el3ab_organizations', array_column($applications, 'org_id'), 'id,name,name_ar,logo_url,is_active');
foreach ($applications as &$app) { foreach ($applications as &$app) {
$app['player'] = $this->db->selectOne('profiles', ['id' => "eq.{$app['player_id']}"]); $app['player'] = $players[$app['player_id']] ?? null;
$app['organization'] = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$app['org_id']}"]); $app['organization'] = $orgs[$app['org_id']] ?? null;
} }
unset($app); unset($app);
$pendingCount = $this->db->count('org_membership_applications', ['status' => 'eq.pending']); $countBase = $orgId ? ['org_id' => "eq.{$orgId}"] : [];
$counts = [];
foreach (array_keys(self::STATUS) as $s) {
$counts[$s] = $this->db->count('org_membership_applications', $countBase + ['status' => "eq.{$s}"]);
}
$pendingCount = $counts['pending'];
// Fetch organizations for filter dropdown $orgQuery = ['select' => 'id,name,name_ar', 'order' => 'name.asc'];
$organizations = $this->db->select('el3ab_organizations', [ if (Auth::isOrgScoped()) $orgQuery['id'] = 'eq.' . Auth::orgId();
'select' => 'id,name,name_ar', $organizations = $this->db->select('el3ab_organizations', $orgQuery);
'is_active' => 'eq.true',
'order' => 'name.asc',
]);
$pageTitle = 'طلبات العضوية'; $pageTitle = 'طلبات العضوية';
$moduleCSS = 'org-applications'; $moduleCSS = 'org-applications';
$moduleJS = 'org-applications'; $moduleJS = 'org-applications';
View::render('org-applications/list', compact( View::render('org-applications/list', compact(
'applications', 'pagination', 'status', 'orgId', 'applications', 'pagination', 'status', 'orgId', 'counts',
'pendingCount', 'organizations', 'pageTitle', 'moduleCSS', 'moduleJS' 'pendingCount', 'organizations', 'pageTitle', 'moduleCSS', 'moduleJS'
)); ));
} }
...@@ -71,240 +112,207 @@ class OrgApplicationsController ...@@ -71,240 +112,207 @@ class OrgApplicationsController
$player = $this->db->selectOne('profiles', ['id' => "eq.{$application['player_id']}"]); $player = $this->db->selectOne('profiles', ['id' => "eq.{$application['player_id']}"]);
$organization = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$application['org_id']}"]); $organization = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$application['org_id']}"]);
// Generate signed URLs for proof documents // The proof, through a ten-minute signed link (private bucket first).
$proofDocuments = []; $proof = ProofDocument::resolve($application['document_url'] ?? null);
$docs = $application['proof_documents'] ?? [];
if (is_string($docs)) {
$docs = json_decode($docs, true) ?? [];
}
$storage = SupabaseStorage::getInstance();
foreach ($docs as $doc) {
$signedUrl = $storage->getSignedUrl('org-documents', $doc['path'], 3600);
$proofDocuments[] = [
'name' => $doc['name'] ?? basename($doc['path']),
'path' => $doc['path'],
'type' => $doc['type'] ?? 'file',
'url' => $signedUrl,
];
}
// Get player's current org memberships
$playerOrgs = $this->db->select('org_members', [ $playerOrgs = $this->db->select('org_members', [
'select' => '*', 'select' => 'org_id,role,status,joined_at',
'player_id' => "eq.{$application['player_id']}", 'player_id' => "eq.{$application['player_id']}",
'order' => 'joined_at.desc',
]); ]);
$memberOrgs = $this->byId('el3ab_organizations', array_column($playerOrgs, 'org_id'), 'id,name,name_ar');
foreach ($playerOrgs as &$membership) { foreach ($playerOrgs as &$membership) {
$membership['organization'] = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$membership['org_id']}"]); $membership['organization'] = $memberOrgs[$membership['org_id']] ?? null;
} }
unset($membership); unset($membership);
$activeOrgCount = count(array_filter($playerOrgs, fn($m) => $m['status'] === 'active'));
// Get reviewer info if already reviewed // Staff reviewer (reviewed_by_admin) or an org owner/admin in the app (reviewed_by).
$reviewer = null; $reviewer = null;
if (!empty($application['reviewed_by'])) { if (!empty($application['reviewed_by_admin'])) {
$reviewer = $this->db->selectOne('profiles', ['id' => "eq.{$application['reviewed_by']}"]); $a = $this->db->selectOne('admin_users', ['id' => "eq.{$application['reviewed_by_admin']}", 'select' => 'username,display_name']);
if ($a) $reviewer = ['name' => $a['display_name'] ?: $a['username'], 'kind' => 'staff'];
} elseif (!empty($application['reviewed_by'])) {
$p = $this->db->selectOne('profiles', ['id' => "eq.{$application['reviewed_by']}", 'select' => 'username,display_name']);
if ($p) $reviewer = ['name' => $p['display_name'] ?: $p['username'], 'kind' => 'player'];
}
// The code it came through, when it came through one.
$code = null;
if (!empty($application['invite_code_id'])) {
$code = $this->db->selectOne('redeem_codes', ['id' => "eq.{$application['invite_code_id']}", 'select' => 'id,code,code_display,label,campaign,org_id,grants']);
} }
$pageTitle = 'تفاصيل طلب العضوية'; $pageTitle = 'تفاصيل طلب العضوية';
$moduleCSS = 'org-applications'; $moduleCSS = 'org-applications';
$moduleJS = 'org-applications'; $moduleJS = 'org-applications';
View::render('org-applications/show', compact( View::render('org-applications/show', compact(
'application', 'player', 'organization', 'proofDocuments', 'application', 'player', 'organization', 'proof', 'code',
'playerOrgs', 'reviewer', 'pageTitle', 'moduleCSS', 'moduleJS' 'playerOrgs', 'activeOrgCount', 'reviewer', 'pageTitle', 'moduleCSS', 'moduleJS'
)); ));
} }
public function approve(array $params, string $method): void public function approve(array $params, string $method): void
{ {
Auth::requireCsrf(); Auth::requireCsrf();
$id = $params['id']; $this->decideOne($params['id'], 'approved', trim($_POST['review_note'] ?? ''));
}
$application = $this->db->selectOne('org_membership_applications', ['id' => "eq.{$id}"]);
if (!$application) {
Response::error('الطلب غير موجود', '/org-applications');
return;
}
if ($application['status'] !== 'pending') { public function reject(array $params, string $method): void
Response::error('هذا الطلب تم مراجعته مسبقاً', "/org-applications/{$id}"); {
Auth::requireCsrf();
$note = trim($_POST['review_note'] ?? '');
if ($note === '') {
Response::error('يجب كتابة سبب الرفض', "/org-applications/{$params['id']}");
return; return;
} }
$this->decideOne($params['id'], 'rejected', $note);
$reviewNote = trim($_POST['review_note'] ?? '');
$user = Auth::user();
// Update application status
$old = $application;
$this->db->update('org_membership_applications', ['id' => "eq.{$id}"], [
'status' => 'approved',
'reviewed_by' => $user['id'],
'review_note' => $reviewNote,
'reviewed_at' => date('c'),
]);
// Add to org_members
$this->db->insert('org_members', [
'org_id' => $application['org_id'],
'player_id' => $application['player_id'],
'role' => $application['target_role'] ?? 'member',
'joined_at' => date('c'),
]);
// Send notification
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$application['org_id']}"]);
$orgName = $org['name_ar'] ?? $org['name'] ?? '';
$this->db->insert('notifications', [
'user_id' => $application['player_id'],
'title' => 'تم قبول طلب العضوية',
'body' => "تم قبولك في {$orgName}",
'type' => 'social',
]);
AuditLog::log('approve', 'org_application', $id, $old, [
'status' => 'approved',
'reviewed_by' => $user['id'],
'review_note' => $reviewNote,
]);
Response::success('تم قبول الطلب بنجاح', '/org-applications');
} }
public function reject(array $params, string $method): void public function bulkApprove(array $params, string $method): void
{ {
Auth::requireCsrf(); Auth::requireCsrf();
$id = $params['id']; $this->decideMany('approved', trim($_POST['review_note'] ?? ''));
}
$application = $this->db->selectOne('org_membership_applications', ['id' => "eq.{$id}"]); public function bulkReject(array $params, string $method): void
if (!$application) { {
Response::error('الطلب غير موجود', '/org-applications'); Auth::requireCsrf();
$note = trim($_POST['review_note'] ?? '');
if ($note === '') {
Response::error('يجب كتابة سبب الرفض', '/org-applications');
return; return;
} }
$this->decideMany('rejected', $note);
}
if ($application['status'] !== 'pending') { // ─── Decisions ──────────────────────────────────────────────────────
Response::error('هذا الطلب تم مراجعته مسبقاً', "/org-applications/{$id}");
return;
}
$reviewNote = trim($_POST['review_note'] ?? ''); private function decideOne(string $id, string $decision, string $note): void
if (empty($reviewNote)) { {
Response::error('يجب كتابة سبب الرفض', "/org-applications/{$id}"); $back = "/org-applications/{$id}";
$r = $this->decide($id, $decision, $note);
if (!$r['ok']) {
Response::error($r['message'], $back);
return; return;
} }
$msg = $decision === 'approved' ? 'تم قبول الطلب وإضافة اللاعب للمنظمة' : 'تم رفض الطلب';
$user = Auth::user(); if (!$r['notified']) $msg .= ' (تعذر إرسال الإشعار للاعب)';
$old = $application; Response::success($msg, $back);
$this->db->update('org_membership_applications', ['id' => "eq.{$id}"], [
'status' => 'rejected',
'reviewed_by' => $user['id'],
'review_note' => $reviewNote,
'reviewed_at' => date('c'),
]);
AuditLog::log('reject', 'org_application', $id, $old, [
'status' => 'rejected',
'reviewed_by' => $user['id'],
'review_note' => $reviewNote,
]);
Response::success('تم رفض الطلب', '/org-applications');
} }
public function bulkApprove(array $params, string $method): void private function decideMany(string $decision, string $note): void
{ {
Auth::requireCsrf();
$ids = $_POST['ids'] ?? []; $ids = $_POST['ids'] ?? [];
if (is_string($ids)) { if (is_string($ids)) $ids = explode(',', $ids);
$ids = array_filter(explode(',', $ids)); $ids = array_values(array_unique(array_filter(array_map('trim', (array)$ids), fn($v) => preg_match('/^[0-9a-f-]{36}$/i', $v))));
} if (!$ids) {
if (empty($ids)) {
Response::error('يجب اختيار طلب واحد على الأقل', '/org-applications'); Response::error('يجب اختيار طلب واحد على الأقل', '/org-applications');
return; return;
} }
if (count($ids) > 50) {
Response::error('الحد الأقصى 50 طلبًا في المرة الواحدة', '/org-applications');
return;
}
$user = Auth::user(); $done = 0;
$approvedCount = 0; $failed = [];
foreach ($ids as $id) { foreach ($ids as $id) {
$application = $this->db->selectOne('org_membership_applications', ['id' => "eq.{$id}"]); $r = $this->decide($id, $decision, $note);
if (!$application || $application['status'] !== 'pending') { if ($r['ok']) { $done++; } else { $failed[$r['message']] = ($failed[$r['message']] ?? 0) + 1; }
continue;
}
$this->db->update('org_membership_applications', ['id' => "eq.{$id}"], [
'status' => 'approved',
'reviewed_by' => $user['id'],
'review_note' => 'موافقة جماعية',
'reviewed_at' => date('c'),
]);
$this->db->insert('org_members', [
'org_id' => $application['org_id'],
'player_id' => $application['player_id'],
'role' => $application['target_role'] ?? 'member',
'joined_at' => date('c'),
]);
// Send notification
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$application['org_id']}"]);
$orgName = $org['name_ar'] ?? $org['name'] ?? '';
$this->db->insert('notifications', [
'user_id' => $application['player_id'],
'title' => 'تم قبول طلب العضوية',
'body' => "تم قبولك في {$orgName}",
'type' => 'social',
]);
$approvedCount++;
} }
AuditLog::log('bulk_approve', 'org_application', null, null, ['count' => $approvedCount]); AuditLog::log('bulk_' . ($decision === 'approved' ? 'approve' : 'reject'), 'org_application', null, null, [
Response::success("تم قبول {$approvedCount} طلب بنجاح", '/org-applications'); 'done' => $done, 'failed' => array_sum($failed), 'review_note' => $note,
]);
$verb = $decision === 'approved' ? 'قبول' : 'رفض';
$msg = "تم {$verb} {$done} طلب";
if ($failed) {
$parts = [];
foreach ($failed as $why => $n) $parts[] = "{$n}: {$why}";
$msg .= ' — لم يتم ' . array_sum($failed) . ' (' . implode('، ', $parts) . ')';
}
if ($done === 0) {
Response::error($msg, '/org-applications');
return;
}
Response::success($msg, '/org-applications');
} }
public function bulkReject(array $params, string $method): void /**
* One decision through decide_application, then the player's notice.
*
* @return array{ok:bool, message:string, notified:bool}
*/
private function decide(string $id, string $decision, string $note): array
{ {
Auth::requireCsrf(); $adminId = Auth::user()['id'] ?? null;
$ids = $_POST['ids'] ?? []; if (!$adminId) {
if (is_string($ids)) { return ['ok' => false, 'message' => self::ERRORS['reviewer_required'], 'notified' => false];
$ids = array_filter(explode(',', $ids));
} }
$reviewNote = trim($_POST['review_note'] ?? ''); $r = $this->db->rpc('decide_application', [
'p_app' => $id,
'p_decision' => $decision,
'p_reviewer_admin' => $adminId,
'p_reason' => $note !== '' ? $note : null,
]);
if (empty($ids)) { if (!is_array($r) || !array_key_exists('ok', $r)) {
Response::error('يجب اختيار طلب واحد على الأقل', '/org-applications'); $why = $this->db->lastErrorMessage();
return; $this->db->clearWriteErrors();
return ['ok' => false, 'message' => 'خطأ في قاعدة البيانات: ' . $why, 'notified' => false];
} }
if (!$r['ok']) {
if (empty($reviewNote)) { $key = (string)($r['error'] ?? '');
Response::error('يجب كتابة سبب الرفض', '/org-applications'); return ['ok' => false, 'message' => self::ERRORS[$key] ?? ('تعذر القرار: ' . $key), 'notified' => false];
return;
} }
$user = Auth::user(); $org = $r['org'] ?? [];
$rejectedCount = 0; $orgName = trim((string)($org['name_ar'] ?? '')) ?: trim((string)($org['name'] ?? '')) ?: 'المنظمة';
$orgNameEn = trim((string)($org['name'] ?? '')) ?: $orgName;
foreach ($ids as $id) { if ($decision === 'approved') {
$application = $this->db->selectOne('org_membership_applications', ['id' => "eq.{$id}"]); $row = [
if (!$application || $application['status'] !== 'pending') { 'title' => "You joined {$orgNameEn}",
continue; 'title_ar' => "تم قبولك في {$orgName}",
} 'body' => "Your membership request was approved. Welcome to {$orgNameEn}." . ($note !== '' ? " {$note}" : ''),
'body_ar' => "وافقت إدارة {$orgName} على طلب عضويتك. أهلًا بيك!" . ($note !== '' ? " {$note}" : ''),
];
} else {
$row = [
'title' => "Request to {$orgNameEn} declined",
'title_ar' => "لم يُقبل طلبك في {$orgName}",
'body' => "Reason: {$note}",
'body_ar' => "السبب: {$note}",
];
}
$notified = $this->db->soft(fn(Database $d) => $d->insert('notifications', $row + [
'user_id' => $r['player_id'] ?? null,
'type' => 'org_application',
'data' => ['org_id' => $org['id'] ?? null, 'application_id' => $id, 'status' => $decision],
])) !== null;
AuditLog::log($decision === 'approved' ? 'approve' : 'reject', 'org_application', $id, null, [
'org_id' => $org['id'] ?? null, 'player_id' => $r['player_id'] ?? null, 'review_note' => $note,
'member' => $r['member'] ?? null, 'redemption' => $r['redemption']['status'] ?? null,
]);
$this->db->update('org_membership_applications', ['id' => "eq.{$id}"], [ return ['ok' => true, 'message' => '', 'notified' => $notified];
'status' => 'rejected', }
'reviewed_by' => $user['id'],
'review_note' => $reviewNote,
'reviewed_at' => date('c'),
]);
$rejectedCount++; /** Rows of $table keyed by id, for the given ids. */
private function byId(string $table, array $ids, string $select): array
{
$ids = array_values(array_unique(array_filter($ids)));
if (!$ids) return [];
$out = [];
foreach (array_chunk($ids, 150) as $chunk) {
foreach ($this->db->select($table, ['select' => $select, 'id' => 'in.(' . implode(',', $chunk) . ')']) as $row) {
$out[$row['id']] = $row;
}
} }
return $out;
AuditLog::log('bulk_reject', 'org_application', null, null, ['count' => $rejectedCount, 'review_note' => $reviewNote]);
Response::success("تم رفض {$rejectedCount} طلب", '/org-applications');
} }
} }
...@@ -10,17 +10,17 @@ ...@@ -10,17 +10,17 @@
<!-- Filters --> <!-- Filters -->
<div class="flex items-center gap-4 mb-5"> <div class="flex items-center gap-4 mb-5">
<div class="filter-pills"> <div class="filter-pills">
<a href="/org-applications" class="filter-pill <?= empty($status) ? 'active' : '' ?>">الكل</a> <a href="/org-applications?status=<?= $orgId ? "&org={$orgId}" : '' ?>" class="filter-pill <?= empty($status) ? 'active' : '' ?>">الكل</a>
<a href="/org-applications?status=pending<?= $orgId ? "&org={$orgId}" : '' ?>" class="filter-pill <?= $status === 'pending' ? 'active' : '' ?>">معلق</a> <?php foreach (OrgApplicationsController::STATUS as $key => [$label]): ?>
<a href="/org-applications?status=approved<?= $orgId ? "&org={$orgId}" : '' ?>" class="filter-pill <?= $status === 'approved' ? 'active' : '' ?>">مقبول</a> <a href="/org-applications?status=<?= $key ?><?= $orgId ? "&org={$orgId}" : '' ?>" class="filter-pill <?= $status === $key ? 'active' : '' ?>"><?= $label ?> <span class="tabular-nums">(<?= (int)($counts[$key] ?? 0) ?>)</span></a>
<a href="/org-applications?status=rejected<?= $orgId ? "&org={$orgId}" : '' ?>" class="filter-pill <?= $status === 'rejected' ? 'active' : '' ?>">مرفوض</a> <?php endforeach; ?>
</div> </div>
<select class="form-select" style="width: auto; padding: var(--space-2) var(--space-4);" onchange="location.href='/org-applications?status=<?= urlencode($status) ?>&org='+this.value"> <select class="form-select" style="width: auto; padding: var(--space-2) var(--space-4);" onchange="location.href='/org-applications?status=<?= urlencode($status) ?>&org='+this.value">
<option value="">كل المنظمات</option> <option value="">كل المنظمات</option>
<?php foreach ($organizations as $org): ?> <?php foreach ($organizations as $org): ?>
<option value="<?= $org['id'] ?>" <?= $orgId === $org['id'] ? 'selected' : '' ?>> <option value="<?= $org['id'] ?>" <?= $orgId === $org['id'] ? 'selected' : '' ?>>
<?= View::e($org['name_ar'] ?? $org['name']) ?> <?= View::e(($org['name_ar'] ?? '') ?: $org['name']) ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
...@@ -57,7 +57,7 @@ ...@@ -57,7 +57,7 @@
<th>المنظمة</th> <th>المنظمة</th>
<th>الحالة</th> <th>الحالة</th>
<th>تاريخ التقديم</th> <th>تاريخ التقديم</th>
<th>المستندات</th> <th>المستند</th>
<th>الإجراءات</th> <th>الإجراءات</th>
</tr> </tr>
</thead> </thead>
...@@ -85,30 +85,18 @@ ...@@ -85,30 +85,18 @@
</div> </div>
</td> </td>
<td> <td>
<div class="font-medium"><?= View::e($app['organization']['name_ar'] ?? $app['organization']['name'] ?? '-') ?></div> <?php $o = $app['organization']; ?>
<div class="font-medium"><?= View::e($o ? ((($o['name_ar'] ?? '') ?: $o['name'])) : '-') ?></div>
</td> </td>
<td> <td>
<?php <?php [$sl, $sb] = OrgApplicationsController::STATUS[$app['status']] ?? [$app['status'], 'default']; ?>
$statusBadges = ['pending' => 'warning', 'approved' => 'success', 'rejected' => 'danger']; <span class="badge badge-<?= $sb ?> badge-dot"><?= View::e($sl) ?></span>
$statusLabels = ['pending' => 'معلق', 'approved' => 'مقبول', 'rejected' => 'مرفوض'];
?>
<span class="badge badge-<?= $statusBadges[$app['status']] ?? 'default' ?> badge-dot">
<?= $statusLabels[$app['status']] ?? $app['status'] ?>
</span>
</td> </td>
<td class="text-xs tabular-nums"><?= date('Y-m-d H:i', strtotime($app['created_at'])) ?></td> <td class="text-xs tabular-nums"><?= Time::show($app['created_at'] ?? null) ?></td>
<td> <td>
<?php <span class="text-xs"><?= View::e(OrgApplicationsController::DOC_TYPES[$app['document_type'] ?? ''] ?? ($app['document_type'] ?: '-')) ?></span>
$docs = $app['proof_documents'] ?? []; <?php if (!empty($app['document_url'])): ?>
if (is_string($docs)) { <span class="badge badge-info badge-sm">مرفق</span>
$docs = json_decode($docs, true) ?? [];
}
$docCount = count($docs);
?>
<?php if ($docCount > 0): ?>
<span class="badge badge-info"><?= $docCount ?> مستند</span>
<?php else: ?>
<span class="text-muted text-xs">-</span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td>
...@@ -117,7 +105,7 @@ ...@@ -117,7 +105,7 @@
<?php if (($app['status'] ?? '') === 'pending'): ?> <?php if (($app['status'] ?? '') === 'pending'): ?>
<form method="POST" action="/org-applications/<?= $app['id'] ?>/approve" style="display:inline;"> <form method="POST" action="/org-applications/<?= $app['id'] ?>/approve" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>"> <input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-success btn-sm" title="قبول"> <button type="submit" class="btn btn-success btn-sm" title="قبول" aria-label="قبول" onclick="return confirm('قبول هذا الطلب وإضافة اللاعب للمنظمة؟')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
</button> </button>
</form> </form>
...@@ -155,7 +143,7 @@ ...@@ -155,7 +143,7 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3>رفض الطلب</h3> <h3>رفض الطلب</h3>
<button class="btn btn-icon btn-ghost" onclick="closeRejectModal()">&times;</button> <button class="btn btn-icon btn-ghost" onclick="closeRejectModal()" aria-label="إغلاق"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div> </div>
<form method="POST" id="rejectForm"> <form method="POST" id="rejectForm">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>"> <input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
...@@ -177,7 +165,7 @@ ...@@ -177,7 +165,7 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3>رفض الطلبات المحددة</h3> <h3>رفض الطلبات المحددة</h3>
<button class="btn btn-icon btn-ghost" onclick="closeBulkRejectModal()">&times;</button> <button class="btn btn-icon btn-ghost" onclick="closeBulkRejectModal()" aria-label="إغلاق"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div> </div>
<form method="POST" action="/org-applications/bulk-reject" id="bulkRejectForm"> <form method="POST" action="/org-applications/bulk-reject" id="bulkRejectForm">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>"> <input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
......
<?php
[$statusLabel, $statusBadge] = OrgApplicationsController::STATUS[$application['status']] ?? [$application['status'], 'default'];
$orgName = $organization ? ((($organization['name_ar'] ?? '') ?: $organization['name'])) : null;
$isImage = !empty($proof['url']) && preg_match('/\.(jpe?g|png|webp|gif)(\?|$)/i', (string)($application['document_url'] ?? ''));
$fileIcon = '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity: 0.6;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
?>
<div class="content-header"> <div class="content-header">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<a href="/org-applications" class="btn btn-icon btn-ghost"> <a href="/org-applications" class="btn btn-icon btn-ghost" aria-label="رجوع">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a> </a>
<h1>تفاصيل طلب العضوية</h1> <h1>تفاصيل طلب العضوية</h1>
<?php <span class="badge badge-<?= $statusBadge ?>"><?= View::e($statusLabel) ?></span>
$statusBadges = ['pending' => 'warning', 'approved' => 'success', 'rejected' => 'danger'];
$statusLabels = ['pending' => 'معلق', 'approved' => 'مقبول', 'rejected' => 'مرفوض'];
?>
<span class="badge badge-<?= $statusBadges[$application['status']] ?? 'default' ?>">
<?= $statusLabels[$application['status']] ?? $application['status'] ?>
</span>
</div> </div>
</div> </div>
<div class="grid grid-2 mb-6"> <div class="grid grid-2 mb-6">
<!-- Left Column: Player Card -->
<div class="card"> <div class="card">
<div class="card-header"><h3 class="card-title">معلومات اللاعب</h3></div> <div class="card-header"><h3 class="card-title">معلومات اللاعب</h3></div>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
...@@ -24,12 +23,12 @@ ...@@ -24,12 +23,12 @@
<?php if (!empty($player['avatar_url'])): ?> <?php if (!empty($player['avatar_url'])): ?>
<img src="<?= View::e($player['avatar_url']) ?>" alt=""> <img src="<?= View::e($player['avatar_url']) ?>" alt="">
<?php else: ?> <?php else: ?>
<?= mb_substr($player['username'] ?? '?', 0, 1) ?> <?= View::e(mb_substr($player['username'] ?? '?', 0, 1)) ?>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div> <div>
<div class="font-medium text-lg"><?= View::e($player['display_name'] ?? $player['username'] ?? 'غير معروف') ?></div> <div class="font-medium text-lg"><?= View::e($player['display_name'] ?? $player['username'] ?? 'غير معروف') ?></div>
<div class="text-muted">@<?= View::e($player['username'] ?? '') ?></div> <div class="text-muted" dir="ltr">@<?= View::e($player['username'] ?? '') ?></div>
<?php if ($player): ?> <?php if ($player): ?>
<a href="/players/<?= $player['id'] ?>" class="text-xs text-blue mt-1">عرض الملف الشخصي</a> <a href="/players/<?= $player['id'] ?>" class="text-xs text-blue mt-1">عرض الملف الشخصي</a>
<?php endif; ?> <?php endif; ?>
...@@ -37,35 +36,37 @@ ...@@ -37,35 +36,37 @@
</div> </div>
<div class="flex flex-col gap-2 text-sm"> <div class="flex flex-col gap-2 text-sm">
<?php if (!empty($player['level'])): ?>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-secondary">المستوى</span> <span class="text-secondary">المستوى</span>
<span class="badge badge-info"><?= View::e($player['level']) ?></span> <span class="badge badge-info"><?= View::e($player['level'] ?? 1) ?></span>
</div>
<div class="flex justify-between">
<span class="text-secondary">عمر الحساب</span>
<span class="tabular-nums"><?= !empty($player['created_at']) ? max(0, (int)floor((time() - strtotime($player['created_at'])) / 86400)) . ' يوم' : '-' ?></span>
</div>
<div class="flex justify-between">
<span class="text-secondary">العضويات النشطة</span>
<span class="tabular-nums <?= $activeOrgCount >= 5 ? 'text-danger' : '' ?>"><?= $activeOrgCount ?> من 5</span>
</div> </div>
<?php endif; ?>
</div> </div>
<?php if (!empty($playerOrgs)): ?> <?php if (!empty($playerOrgs)): ?>
<div class="mt-3"> <div>
<span class="text-xs text-muted">العضويات الحالية:</span> <span class="text-xs text-muted">عضوياته:</span>
<div class="flex flex-col gap-2 mt-2"> <div class="flex flex-col gap-2 mt-2">
<?php foreach ($playerOrgs as $membership): ?> <?php foreach ($playerOrgs as $membership): ?>
<?php $mo = $membership['organization']; ?>
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
<span class="badge badge-default"><?= View::e($membership['organization']['name_ar'] ?? $membership['organization']['name'] ?? '-') ?></span> <span class="badge badge-default"><?= View::e($mo ? ((($mo['name_ar'] ?? '') ?: $mo['name'])) : '-') ?></span>
<span class="text-xs text-muted">(<?= View::e($membership['role'] ?? 'member') ?>)</span> <span class="text-xs text-muted"><?= View::e($membership['role'] ?? 'member') ?> — <?= View::e($membership['status'] ?? '') ?></span>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</div> </div>
<?php else: ?>
<div class="mt-3">
<span class="text-xs text-muted">لا يوجد عضويات حالية</span>
</div>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
<!-- Right Column: Application Details -->
<div class="card"> <div class="card">
<div class="card-header"><h3 class="card-title">تفاصيل الطلب</h3></div> <div class="card-header"><h3 class="card-title">تفاصيل الطلب</h3></div>
<div class="flex flex-col gap-3 text-sm"> <div class="flex flex-col gap-3 text-sm">
...@@ -73,89 +74,74 @@ ...@@ -73,89 +74,74 @@
<span class="text-secondary">المنظمة</span> <span class="text-secondary">المنظمة</span>
<span class="font-medium"> <span class="font-medium">
<?php if ($organization): ?> <?php if ($organization): ?>
<a href="/organizations/<?= $organization['id'] ?>" class="text-blue"> <a href="/organizations/<?= $organization['id'] ?>" class="text-blue"><?= View::e($orgName) ?></a>
<?= View::e($organization['name_ar'] ?? $organization['name']) ?> <?php if (empty($organization['is_active'])): ?><span class="badge badge-danger badge-sm">موقوفة</span><?php endif; ?>
</a>
<?php else: ?> <?php else: ?>
غير معروفة غير معروفة
<?php endif; ?> <?php endif; ?>
</span> </span>
</div> </div>
<?php if ($organization): ?>
<div class="flex justify-between">
<span class="text-secondary">الأعضاء</span>
<span class="tabular-nums"><?= (int)($organization['member_count'] ?? 0) ?> / <?= $organization['max_members'] === null ? 'بلا حد' : (int)$organization['max_members'] ?></span>
</div>
<?php endif; ?>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-secondary">تاريخ التقديم</span> <span class="text-secondary">تاريخ التقديم</span>
<span class="tabular-nums"><?= date('Y-m-d H:i', strtotime($application['created_at'])) ?></span> <span class="tabular-nums"><?= Time::show($application['created_at'] ?? null) ?></span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-secondary">الدور المطلوب</span> <span class="text-secondary">نوع المستند</span>
<span class="badge badge-default"><?= View::e($application['target_role'] ?? 'member') ?></span> <span><?= View::e(OrgApplicationsController::DOC_TYPES[$application['document_type'] ?? ''] ?? ($application['document_type'] ?: '-')) ?></span>
</div> </div>
<?php if ($code): ?>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-secondary">الحالة</span> <span class="text-secondary">عبر كود</span>
<span class="badge badge-<?= $statusBadges[$application['status']] ?? 'default' ?> badge-dot"> <span><span dir="ltr" class="font-medium"><?= View::e($code['code_display']) ?></span><?= $code['label'] ? ' — ' . View::e($code['label']) : '' ?></span>
<?= $statusLabels[$application['status']] ?? $application['status'] ?>
</span>
</div> </div>
<?php endif; ?>
<?php if (!empty($application['application_text'])): ?> <?php if (!empty($application['notes'])): ?>
<div class="mt-3"> <div class="mt-2">
<span class="text-secondary">نص الطلب:</span> <span class="text-secondary">ملاحظات اللاعب:</span>
<p class="mt-2 p-3" style="background: var(--bg-secondary); border-radius: var(--radius-md);"> <p class="mt-2 p-3" style="background: var(--bg-secondary); border-radius: var(--radius-md);"><?= nl2br(View::e($application['notes'])) ?></p>
<?= nl2br(View::e($application['application_text'])) ?>
</p>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
</div> </div>
<!-- Proof Documents Section -->
<div class="card mb-6"> <div class="card mb-6">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">المستندات المرفقة</h3> <h3 class="card-title">المستند المرفق</h3>
<?php if (!empty($proofDocuments)): ?> <?php if (!empty($proof['url'])): ?>
<span class="badge badge-info"><?= count($proofDocuments) ?> مستند</span> <?php if ($proof['signed']): ?>
<span class="text-xs text-muted">رابط مؤقت يعمل 10 دقائق؛ حدّث الصفحة لرابط جديد</span>
<?php endif; ?>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php if (empty($proofDocuments)): ?> <?php if (empty($proof['url'])): ?>
<div class="text-center text-muted py-4"> <div class="text-center text-muted py-4">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="margin: 0 auto; opacity: 0.5;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> <?= $fileIcon ?>
<p class="mt-2 text-sm">لا توجد مستندات مرفقة</p> <p class="mt-2 text-sm"><?= !empty($application['document_url']) ? 'يوجد مستند لكن تعذر فتحه من المخزن الآن' : 'لا يوجد مستند مرفق' ?></p>
</div> </div>
<?php else: ?> <?php else: ?>
<div class="grid grid-3 gap-4"> <?php if ($proof['legacy_public']): ?>
<?php foreach ($proofDocuments as $doc): ?> <p class="text-xs text-warning mb-3">هذا المستند محفوظ في مخزن عام قديم يمكن لأي شخص معه الرابط فتحه. يجب نقله للمخزن الخاص.</p>
<?php <?php endif; ?>
$isImage = in_array($doc['type'], ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image']) <div style="max-width: 420px;">
|| preg_match('/\.(jpg|jpeg|png|webp|gif)$/i', $doc['name']); <a href="<?= View::e($proof['url']) ?>" target="_blank" rel="noopener noreferrer" style="display:block; border: 1px solid var(--border-color); border-radius: var(--radius-md); overflow: hidden;">
$isPdf = $doc['type'] === 'application/pdf' || str_ends_with(strtolower($doc['name']), '.pdf'); <?php if ($isImage): ?>
?> <img src="<?= View::e($proof['url']) ?>" alt="مستند الطلب" referrerpolicy="no-referrer" style="width: 100%; max-height: 360px; object-fit: contain; display: block; background: var(--bg-secondary);">
<div class="proof-doc-card" style="border: 1px solid var(--border-color); border-radius: var(--radius-md); overflow: hidden;"> <?php else: ?>
<?php if ($isImage): ?> <div style="display: flex; align-items: center; justify-content: center; height: 160px; background: var(--bg-secondary);"><?= $fileIcon ?></div>
<a href="<?= View::e($doc['url']) ?>" target="_blank" class="proof-doc-preview"> <?php endif; ?>
<img src="<?= View::e($doc['url']) ?>" alt="<?= View::e($doc['name']) ?>" style="width: 100%; height: 160px; object-fit: cover; display: block;"> </a>
</a> <a href="<?= View::e($proof['url']) ?>" target="_blank" rel="noopener noreferrer" class="btn btn-ghost btn-sm mt-2">فتح المستند</a>
<?php elseif ($isPdf): ?>
<a href="<?= View::e($doc['url']) ?>" target="_blank" class="proof-doc-preview" style="display: flex; align-items: center; justify-content: center; height: 160px; background: var(--bg-secondary);">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity: 0.6;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
</a>
<?php else: ?>
<a href="<?= View::e($doc['url']) ?>" target="_blank" class="proof-doc-preview" style="display: flex; align-items: center; justify-content: center; height: 160px; background: var(--bg-secondary);">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity: 0.6;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
</a>
<?php endif; ?>
<div class="p-3 flex items-center justify-between" style="border-top: 1px solid var(--border-color);">
<span class="text-xs truncate" style="max-width: 150px;"><?= View::e($doc['name']) ?></span>
<a href="<?= View::e($doc['url']) ?>" target="_blank" class="btn btn-ghost btn-sm" title="تحميل">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</a>
</div>
</div>
<?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<!-- Action / Review Section -->
<?php if ($application['status'] === 'pending'): ?> <?php if ($application['status'] === 'pending'): ?>
<div class="grid grid-2"> <div class="grid grid-2">
<div class="card"> <div class="card">
...@@ -163,10 +149,10 @@ ...@@ -163,10 +149,10 @@
<form method="POST" action="/org-applications/<?= $application['id'] ?>/approve"> <form method="POST" action="/org-applications/<?= $application['id'] ?>/approve">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>"> <input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<div class="form-group"> <div class="form-group">
<label class="form-label">ملاحظة (اختياري)</label> <label class="form-label">ملاحظة للاعب (اختياري)</label>
<textarea name="review_note" class="form-input" placeholder="أضف ملاحظة على القبول..."></textarea> <textarea name="review_note" class="form-input" placeholder="تظهر للاعب في الإشعار"></textarea>
</div> </div>
<button type="submit" class="btn btn-success w-full"> <button type="submit" class="btn btn-success w-full" onclick="return confirm('قبول الطلب وإضافة اللاعب للمنظمة؟')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
قبول الطلب قبول الطلب
</button> </button>
...@@ -179,7 +165,7 @@ ...@@ -179,7 +165,7 @@
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>"> <input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<div class="form-group"> <div class="form-group">
<label class="form-label">سبب الرفض *</label> <label class="form-label">سبب الرفض *</label>
<textarea name="review_note" class="form-input" required placeholder="اكتب سبب رفض الطلب..."></textarea> <textarea name="review_note" class="form-input" required placeholder="يظهر للاعب في الإشعار"></textarea>
</div> </div>
<button type="submit" class="btn btn-danger w-full"> <button type="submit" class="btn btn-danger w-full">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
...@@ -194,30 +180,21 @@ ...@@ -194,30 +180,21 @@
<div class="flex flex-col gap-3 text-sm"> <div class="flex flex-col gap-3 text-sm">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-secondary">القرار</span> <span class="text-secondary">القرار</span>
<span class="badge badge-<?= $statusBadges[$application['status']] ?? 'default' ?>"> <span class="badge badge-<?= $statusBadge ?>"><?= View::e($statusLabel) ?></span>
<?= $statusLabels[$application['status']] ?? $application['status'] ?>
</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-secondary">المراجع</span> <span class="text-secondary">المراجع</span>
<span> <span><?= $reviewer ? View::e($reviewer['name']) . ($reviewer['kind'] === 'staff' ? ' (الإدارة)' : ' (إدارة المنظمة)') : 'غير معروف' ?></span>
<?php if ($reviewer): ?>
<?= View::e($reviewer['display_name'] ?? $reviewer['username'] ?? 'غير معروف') ?>
<?php else: ?>
غير معروف
<?php endif; ?>
</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-secondary">تاريخ المراجعة</span> <span class="text-secondary">تاريخ المراجعة</span>
<span class="tabular-nums"><?= $application['reviewed_at'] ? date('Y-m-d H:i', strtotime($application['reviewed_at'])) : '-' ?></span> <span class="tabular-nums"><?= Time::show($application['reviewed_at'] ?? null) ?></span>
</div> </div>
<?php if (!empty($application['review_note'])): ?> <?php $note = ($application['review_note'] ?? '') ?: ($application['rejection_reason'] ?? ''); ?>
<?php if ($note !== ''): ?>
<div class="mt-3"> <div class="mt-3">
<span class="text-secondary">ملاحظة المراجع:</span> <span class="text-secondary">ملاحظة المراجع:</span>
<p class="mt-2 p-3" style="background: var(--bg-secondary); border-radius: var(--radius-md);"> <p class="mt-2 p-3" style="background: var(--bg-secondary); border-radius: var(--radius-md);"><?= nl2br(View::e($note)) ?></p>
<?= nl2br(View::e($application['review_note'])) ?>
</p>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
......
...@@ -18,6 +18,7 @@ class OrgChallengesController ...@@ -18,6 +18,7 @@ class OrgChallengesController
$queryParams['status'] = "eq.{$status}"; $queryParams['status'] = "eq.{$status}";
} }
OrgScope::filter($queryParams, ['challenger_org_id', 'challenged_org_id']);
$countParams = $queryParams; $countParams = $queryParams;
unset($countParams['select'], $countParams['order']); unset($countParams['select'], $countParams['order']);
$total = $this->db->count('org_challenges', $countParams); $total = $this->db->count('org_challenges', $countParams);
......
...@@ -20,6 +20,7 @@ class OrgPartnershipsController ...@@ -20,6 +20,7 @@ class OrgPartnershipsController
$queryParams['status'] = 'eq.' . $_GET['status']; $queryParams['status'] = 'eq.' . $_GET['status'];
} }
OrgScope::filter($queryParams, ['org_a_id', 'org_b_id']);
$countParams = $queryParams; $countParams = $queryParams;
unset($countParams['select'], $countParams['order']); unset($countParams['select'], $countParams['order']);
$total = $this->db->count('org_partnerships', $countParams); $total = $this->db->count('org_partnerships', $countParams);
......
...@@ -18,6 +18,7 @@ class OrgRecruitmentController ...@@ -18,6 +18,7 @@ class OrgRecruitmentController
$queryParams['status'] = "eq.{$status}"; $queryParams['status'] = "eq.{$status}";
} }
OrgScope::filter($queryParams, ['org_id']);
$countParams = $queryParams; $countParams = $queryParams;
unset($countParams['select'], $countParams['order']); unset($countParams['select'], $countParams['order']);
$total = $this->db->count('org_recruitment_posts', $countParams); $total = $this->db->count('org_recruitment_posts', $countParams);
......
...@@ -18,6 +18,7 @@ class OrgTransfersController ...@@ -18,6 +18,7 @@ class OrgTransfersController
$queryParams['status'] = "eq.{$status}"; $queryParams['status'] = "eq.{$status}";
} }
OrgScope::filter($queryParams, ['from_org_id', 'to_org_id']);
$countParams = $queryParams; $countParams = $queryParams;
unset($countParams['select'], $countParams['order']); unset($countParams['select'], $countParams['order']);
$total = $this->db->count('org_player_transfers', $countParams); $total = $this->db->count('org_player_transfers', $countParams);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment