Commit f4eec81f authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(security): close coin minting and open write policies

Found while building the org/redeem layer; all exploitable with the public
anon key that ships in realtime.js, or with a player's own session.

- complete_match, award_coins and the match/queue functions were executable
  by anon and players: one REST call paid win coins to anyone. Service role
  only now; every caller is PHP with the service key.
- battlepass.php credited whatever "coins" the request carried, to anyone,
  as often as asked. The server now decides the reward, needs an active
  season and an unlocked tier, and pays each tier once.
- The legacy api/ludo.php let any player end any ludo match with any winners
  and get paid. It, api/domino.php and api/multiplayer.php were unused by the
  app and are removed.
- Shop, challenges and daily reward wrote balances with the player's token,
  which only worked because players could PATCH their own coins over REST.
  They write with the service key now; the shop deducts only if the balance
  is unchanged (a double tap cannot pay twice), refunds if the grant fails,
  and records its transaction (the old insert used a missing column).
- Policies: anyone could delete any ludo/domino/backgammon match, rewrite
  queues, groups and the theme, insert an "accepted" friendship with anyone
  and post into any chat. Writes are service-role only; every read and
  Realtime feed is unchanged. Group members read only their own memberships.
- profiles-guard.sql (applied after this deploy): a player's token may change
  only cosmetic profile fields.

Also the org-unify / redeem SQL layer and its contract (docs/redeem-contract.md).
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 0b076f00
......@@ -110,15 +110,29 @@ function generateTierReward(int $tier): array {
}
function claimTier($db, string $userId, array $input): void {
// The reward is the server's to decide. This used to credit whatever
// "coins" the request carried, to anyone, as often as they liked.
$tier = intval($input['tier'] ?? 0);
$coins = intval($input['coins'] ?? 0);
if ($tier < 1) jsonError('Invalid tier');
$profiles = $db->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'coins', 'limit' => 1]);
$profile = is_array($profiles) && !empty($profiles) ? $profiles[0] : ['coins' => 0];
$newCoins = ($profile['coins'] ?? 0) + $coins;
$db->update('profiles', ['coins' => $newCoins], ['id' => 'eq.' . $userId]);
jsonResponse(['success' => true, 'coins' => $newCoins]);
$sdb = supabaseService();
$seasons = $sdb->get('seasons', ['is_active' => 'eq.true', 'select' => '*', 'limit' => 1]);
$s = (is_array($seasons) && !isset($seasons['error']) && !empty($seasons)) ? $seasons[0] : null;
if (!$s || (!empty($s['ends_at']) && strtotime($s['ends_at']) < time())) jsonError('No active season', 409);
$totalTiers = intval($s['total_tiers'] ?? 30);
$xpPerTier = max(1, intval($s['xp_per_tier'] ?? 100));
if ($tier > $totalTiers) jsonError('Invalid tier');
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'xp', 'limit' => 1]);
$xp = intval($profiles[0]['xp'] ?? 0);
if ($tier > intdiv($xp, $xpPerTier)) jsonError('Tier not unlocked yet', 403);
$reason = 'Battle pass ' . $s['id'] . ' tier ' . $tier;
$claimed = $sdb->get('economy_transactions', ['player_id' => 'eq.' . $userId, 'reason' => 'eq.' . $reason, 'select' => 'id', 'limit' => 1]);
if (is_array($claimed) && !isset($claimed['error']) && !empty($claimed)) jsonError('Already claimed', 409);
$reward = generateTierReward($tier)['free'];
if (($reward['type'] ?? '') !== 'coins') jsonResponse(['success' => true, 'reward' => $reward]);
$balance = supabaseRpc('award_coins', ['p_player_id' => $userId, 'p_amount' => intval($reward['amount']), 'p_reason' => $reason]);
jsonResponse(['success' => true, 'reward' => $reward, 'coins' => is_numeric($balance) ? (int)$balance : null]);
}
......@@ -170,13 +170,14 @@ function claimChallenge($db, string $userId, array $input): void {
}
// Grant rewards
$profiles = $db->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'coins,xp', 'limit' => 1]);
// Balances are written with the service key; players cannot write them.
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'coins,xp', 'limit' => 1]);
$profile = is_array($profiles) && !empty($profiles) ? $profiles[0] : ['coins' => 0, 'xp' => 0];
$newCoins = ($profile['coins'] ?? 0) + $coins;
$newXp = ($profile['xp'] ?? 0) + $xp;
$db->update('profiles', ['coins' => $newCoins, 'xp' => $newXp], ['id' => 'eq.' . $userId]);
$sdb->update('profiles', ['coins' => $newCoins, 'xp' => $newXp], ['id' => 'eq.' . $userId]);
// Record transaction
$sdb->insert('economy_transactions', [
......
......@@ -94,7 +94,7 @@ if ($method === 'POST') {
$newCoins = $currentCoins + $reward;
// Update profile: coins, streak, last_daily_reward
$db->update('profiles', [
$sdb->update('profiles', [
'coins' => $newCoins,
'daily_streak' => $streak,
'last_daily_reward' => $now
......
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/cors.php';
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
$token = requireAuth();
$userId = getUserId($token);
$input = getInput();
$action = $input['action'] ?? '';
$db = supabase($token);
switch ($action) {
case 'create':
$room = $db->insert('domino_matches', [
'room_code' => generateRoomCode(),
'status' => 'waiting',
'players' => json_encode([$userId]),
'board' => '[]',
'hands' => '{}',
'scores' => '{}',
'created_by' => $userId
]);
jsonResponse($room[0] ?? $room);
break;
case 'join':
$roomCode = $input['room_code'] ?? '';
if (!$roomCode) jsonError('room_code required');
$match = $db->getOne('domino_matches', ['room_code' => 'eq.' . $roomCode, 'status' => 'eq.waiting']);
if (!$match) jsonError('Room not found', 404);
$players = json_decode($match['players'], true) ?: [];
if (in_array($userId, $players)) jsonError('Already in room');
$players[] = $userId;
$db->update('domino_matches', ['players' => json_encode($players)], ['id' => 'eq.' . $match['id']]);
jsonResponse(['success' => true, 'match_id' => $match['id']]);
break;
case 'play':
$matchId = $input['match_id'] ?? '';
$board = $input['board'] ?? null;
$hands = $input['hands'] ?? null;
if (!$matchId) jsonError('match_id required');
$update = [];
if ($board !== null) $update['board'] = json_encode($board);
if ($hands !== null) $update['hands'] = json_encode($hands);
if (!empty($update)) $db->update('domino_matches', $update, ['id' => 'eq.' . $matchId]);
jsonResponse(['success' => true]);
break;
case 'end':
$matchId = $input['match_id'] ?? '';
$scores = $input['scores'] ?? [];
if (!$matchId) jsonError('match_id required');
$db->update('domino_matches', [
'status' => 'completed',
'scores' => json_encode($scores)
], ['id' => 'eq.' . $matchId]);
jsonResponse(['success' => true]);
break;
default:
jsonError('Invalid action');
}
function generateRoomCode(): string {
return strtoupper(substr(bin2hex(random_bytes(3)), 0, 6));
}
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/cors.php';
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
$token = requireAuth();
$userId = getUserId($token);
$input = getInput();
$action = $input['action'] ?? '';
$db = supabase($token);
switch ($action) {
case 'create':
$match = $db->insert('ludo_matches', [
'room_code' => strtoupper(substr(bin2hex(random_bytes(3)), 0, 6)),
'status' => 'waiting',
'players' => json_encode([$userId]),
'positions' => '{}',
'dice_value' => null,
'current_turn' => 0,
'winners' => '[]',
'created_by' => $userId
]);
jsonResponse($match[0] ?? $match);
break;
case 'join':
$roomCode = $input['room_code'] ?? '';
if (!$roomCode) jsonError('room_code required');
$match = $db->getOne('ludo_matches', ['room_code' => 'eq.' . $roomCode, 'status' => 'eq.waiting']);
if (!$match) jsonError('Room not found', 404);
$players = json_decode($match['players'], true) ?: [];
$players[] = $userId;
$db->update('ludo_matches', ['players' => json_encode($players)], ['id' => 'eq.' . $match['id']]);
jsonResponse(['success' => true, 'match_id' => $match['id']]);
break;
case 'roll':
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$dice = random_int(1, 6);
$db->update('ludo_matches', ['dice_value' => $dice], ['id' => 'eq.' . $matchId]);
jsonResponse(['dice' => $dice]);
break;
case 'move':
$matchId = $input['match_id'] ?? '';
$positions = $input['positions'] ?? null;
$currentTurn = $input['current_turn'] ?? null;
if (!$matchId) jsonError('match_id required');
$update = ['last_activity' => date('c')];
if ($positions !== null) $update['positions'] = json_encode($positions);
if ($currentTurn !== null) $update['current_turn'] = $currentTurn;
if (!empty($update)) $db->update('ludo_matches', $update, ['id' => 'eq.' . $matchId]);
jsonResponse(['success' => true]);
break;
case 'end':
$matchId = $input['match_id'] ?? '';
$winners = $input['winners'] ?? [];
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
$sdb->update('ludo_matches', [
'status' => 'completed',
'winners' => json_encode($winners),
'completed_at' => date('c')
], ['id' => 'eq.' . $matchId]);
supabaseRpc('complete_match', [
'p_game_key' => 'ludo',
'p_match_id' => $matchId,
'p_winners' => json_encode($winners),
'p_reason' => 'normal'
]);
jsonResponse(['success' => true]);
break;
default:
jsonError('Invalid action');
}
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/cors.php';
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
$token = requireAuth();
$userId = getUserId($token);
$input = getInput();
$action = $input['action'] ?? '';
$gameKey = $input['game_key'] ?? '';
$validGames = ['chess', 'ludo', 'domino', 'backgammon'];
if ($gameKey && !in_array($gameKey, $validGames)) { jsonError('Invalid game_key', 400); }
function resolveTable(string $gameKey): string {
return match($gameKey) {
'chess' => 'matches',
'domino' => 'domino_matches',
'backgammon' => 'backgammon_matches',
default => 'ludo_matches',
};
}
switch ($action) {
case 'create_room':
$db = supabase($token);
$table = resolveTable($gameKey);
$result = null;
$code = '';
for ($attempt = 0; $attempt < 5; $attempt++) {
$code = strtoupper(substr(bin2hex(random_bytes(3)), 0, 6));
$existing = $db->get($table, ['room_code' => 'eq.' . $code, 'status' => 'eq.waiting', 'limit' => 1]);
if (!empty($existing) && !isset($existing['error'])) continue;
$result = $db->insert($table, [
'room_code' => $code,
'status' => 'waiting',
'players' => json_encode([$userId]),
'created_by' => $userId
]);
break;
}
if (!$result) jsonError('Failed to generate unique room code', 500);
jsonResponse(['room_code' => $code, 'match' => $result[0] ?? $result]);
break;
case 'join_room':
$roomCode = $input['room_code'] ?? '';
if (!$roomCode) jsonError('room_code required');
$sdb = supabaseService();
$table = resolveTable($gameKey);
$match = $sdb->get($table, ['room_code' => 'eq.' . $roomCode, 'status' => 'eq.waiting', 'select' => 'id,players,player_count', 'limit' => 1]);
if (!is_array($match) || empty($match) || isset($match['error'])) jsonError('Room not found', 404);
$match = $match[0];
$players = is_array($match['players']) ? $match['players'] : json_decode($match['players'] ?? '[]', true);
if (in_array($userId, $players)) {
jsonResponse(['success' => true, 'match_id' => $match['id']]);
break;
}
$maxPlayers = $match['player_count'] ?? ($gameKey === 'domino' ? 2 : 4);
if (count($players) >= $maxPlayers) jsonError('Room is full', 409);
$players[] = $userId;
$newStatus = count($players) >= $maxPlayers ? 'in_progress' : 'waiting';
$sdb->update($table, ['players' => $players, 'status' => $newStatus], [
'id' => 'eq.' . $match['id'],
'status' => 'eq.waiting'
]);
// Verify join succeeded (re-read to detect race)
$verify = $sdb->get($table, ['id' => 'eq.' . $match['id'], 'select' => 'players', 'limit' => 1]);
if (is_array($verify) && !empty($verify) && !isset($verify['error'])) {
$finalPlayers = is_array($verify[0]['players']) ? $verify[0]['players'] : json_decode($verify[0]['players'] ?? '[]', true);
if (!in_array($userId, $finalPlayers)) {
jsonError('Room is full', 409);
}
}
jsonResponse(['success' => true, 'match_id' => $match['id']]);
break;
default:
jsonError('Invalid action');
}
......@@ -26,43 +26,51 @@ if ($action === 'purchase') {
$cosmeticId = $input['cosmetic_id'] ?? '';
if (!$cosmeticId) jsonError('cosmetic_id required');
$items = $db->get('cosmetics', ['id' => 'eq.' . $cosmeticId, 'limit' => 1]);
// Money and ownership are written with the service key: players have no
// write rights on profiles' balances or on player_cosmetics.
$sdb = supabaseService();
$items = $sdb->get('cosmetics', ['id' => 'eq.' . $cosmeticId, 'limit' => 1]);
$item = is_array($items) && !empty($items) && !isset($items['error']) ? $items[0] : null;
if (!$item) jsonError('Item not found', 404);
$profiles = $db->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'coins,gems', 'limit' => 1]);
$profile = is_array($profiles) && !empty($profiles) ? $profiles[0] : ['coins' => 0, 'gems' => 0];
$coins = $profile['coins'] ?? 0;
$gems = $profile['gems'] ?? 0;
$priceCoin = $item['price_coins'] ?? 0;
$priceGem = $item['price_gems'] ?? 0;
if ($priceCoin > 0 && $coins < $priceCoin) jsonError('Insufficient coins');
if ($priceGem > 0 && $gems < $priceGem) jsonError('Insufficient gems');
$owned = $db->get('player_cosmetics', ['player_id' => 'eq.' . $userId, 'cosmetic_id' => 'eq.' . $cosmeticId, 'select' => 'id']);
$owned = $sdb->get('player_cosmetics', ['player_id' => 'eq.' . $userId, 'cosmetic_id' => 'eq.' . $cosmeticId, 'select' => 'id']);
if (!empty($owned) && !isset($owned['error'])) jsonError('Already owned');
if ($priceCoin > 0) {
$db->update('profiles', ['coins' => $coins - $priceCoin], ['id' => 'eq.' . $userId]);
}
if ($priceGem > 0) {
$db->update('profiles', ['gems' => $gems - $priceGem], ['id' => 'eq.' . $userId]);
$priceCoin = (int)($item['price_coins'] ?? 0);
$priceGem = (int)($item['price_gems'] ?? 0);
$currency = $priceCoin > 0 ? 'coins' : ($priceGem > 0 ? 'gems' : null);
$price = $currency === 'coins' ? $priceCoin : $priceGem;
$balanceAfter = null;
if ($currency) {
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'coins,gems', 'limit' => 1]);
$have = (int)($profiles[0][$currency] ?? 0);
if ($have < $price) jsonError($currency === 'coins' ? 'Insufficient coins' : 'Insufficient gems');
// Only if the balance is still what we read: a double tap cannot pay twice.
$paid = $sdb->update('profiles', [$currency => $have - $price], ['id' => 'eq.' . $userId, $currency => 'eq.' . $have]);
if (!is_array($paid) || isset($paid['error']) || empty($paid)) jsonError('Balance changed, try again', 409);
$balanceAfter = $have - $price;
}
$db->insert('player_cosmetics', ['player_id' => $userId, 'cosmetic_id' => $cosmeticId, 'is_equipped' => false]);
$grant = $sdb->insert('player_cosmetics', ['player_id' => $userId, 'cosmetic_id' => $cosmeticId, 'is_equipped' => false, 'acquired_via' => 'purchase']);
if (!is_array($grant) || isset($grant['error'])) {
// Give the money back rather than keep it for nothing.
if ($currency) $sdb->update('profiles', [$currency => $balanceAfter + $price], ['id' => 'eq.' . $userId]);
jsonError('Purchase failed', 500);
}
$sdb = supabaseService();
if ($currency) {
$sdb->insert('economy_transactions', [
'player_id' => $userId,
'type' => 'purchase',
'currency' => $priceCoin > 0 ? 'coins' : 'gems',
'amount' => -($priceCoin > 0 ? $priceCoin : $priceGem),
'balance_after' => $priceCoin > 0 ? ($coins - $priceCoin) : ($gems - $priceGem),
'description' => 'Purchase: ' . ($item['name'] ?? $cosmeticId)
'currency' => $currency,
'amount' => -$price,
'balance_after' => $balanceAfter,
'reason' => 'Purchase: ' . ($item['name'] ?? $cosmeticId),
]);
}
jsonResponse(['success' => true, 'item' => $item]);
jsonResponse(['success' => true, 'item' => $item, 'balance' => $balanceAfter]);
}
if ($action === 'equip') {
......@@ -72,8 +80,11 @@ if ($action === 'equip') {
$item = $db->getOne('cosmetics', ['id' => 'eq.' . $cosmeticId, 'select' => 'type']);
if (!$item) jsonError('Item not found', 404);
$db->update('player_cosmetics', ['is_equipped' => false], ['player_id' => 'eq.' . $userId, 'cosmetic_id' => "not.eq.{$cosmeticId}"]);
$db->update('player_cosmetics', ['is_equipped' => true], ['player_id' => 'eq.' . $userId, 'cosmetic_id' => 'eq.' . $cosmeticId]);
$sdb = supabaseService();
$mine = $sdb->get('player_cosmetics', ['player_id' => 'eq.' . $userId, 'cosmetic_id' => 'eq.' . $cosmeticId, 'select' => 'id', 'limit' => 1]);
if (!is_array($mine) || isset($mine['error']) || empty($mine)) jsonError('Not owned', 403);
$sdb->update('player_cosmetics', ['is_equipped' => false], ['player_id' => 'eq.' . $userId, 'cosmetic_id' => "not.eq.{$cosmeticId}"]);
$sdb->update('player_cosmetics', ['is_equipped' => true], ['player_id' => 'eq.' . $userId, 'cosmetic_id' => 'eq.' . $cosmeticId]);
jsonResponse(['success' => true]);
}
......
# Redeem codes and the org model: database contract
This is the contract that the player API (`api/redeem.php`, `api/orgs.php`, `api/auth.php`) and the manager (`El3ab-management`) code against. It describes what is live in `safe-supabase-db` as of 2026-10-25, about 21:05 UTC (00:05 on 2026-10-26, Cairo).
- **Design:** `docs/org-invites-and-events-plan.md`, sections 2.1 to 2.10.
- **Migrations,** applied in this order, each in its own transaction:
1. `migrations/2026-10-26-org-unify.sql`
2. `migrations/2026-10-26-redeem.sql`
3. `migrations/2026-10-26-redeem-functions.sql`
4. `migrations/2026-10-26-tournament-rounds-rls.sql`
All four are idempotent; running them again changes nothing.
Every table below is reached with the **service key only**. Every function except `redeem_canonical` and `redeem_fold` can be executed only by `service_role`. Over HTTP, anon and authenticated callers get `401` with code `42501`.
---
## 1. Calling convention
- Call the functions through PostgREST as `POST /rest/v1/rpc/<name>` with a JSON body of named arguments, using the service key. PHP: `supabaseRpc('redeem_code', [...])`. Manager: `$db->rpc('redeem_code', [...])`.
- **Always use POST.** These functions write, and PostgREST runs a GET RPC in a read-only transaction.
- Every function returns one JSON object with an `ok` boolean.
- When `ok` is `false`, `error` holds a stable key from section 8.
- Business refusals never raise. A PostgREST error object (with `code` and `message` and no `ok`) means a bug or a schema problem, never a user outcome.
- **Canonicalise before calling.** `redeem_code` folds the code again itself, which is idempotent. Passing the display form ("K7QM-4WXP") also works.
---
## 2. The org model
### 2.1 `el3ab_organizations` is the only org table El3ab uses
The Swiss service's `organizations` and `org_memberships` tables are untouched and stay the Swiss API's own. Their two test orgs were copied in with the same ids, and `swiss_api_org_id = id` is the bridge back.
New and changed columns:
| Column | Type | Default | Notes |
|---|---|---|---|
| `tag` | text | null | Clan tag, 2 to 5 characters with no whitespace. Unique ignoring case (`el3ab_org_tag_uq` on `upper(tag)`). |
| `join_policy` | text not null | `'approval'` | `open`, `approval`, `code_only` or `closed`. A code's `org_join` bypasses the policy, because the code is the invitation. The exception is `closed`, which refuses code joins with `org_closed` (staff approvals still work). |
| `games` | text[] not null | `{chess}` | A subset of `chess`, `ludo`, `domino`, `backgammon`. |
| `hue` | text not null | `'purple'` | One of the sticker families: `gold silver red green blue purple orange cream teal wood pink`. Never a hex value. |
| `max_members` | int | **500** (was 50) | Null means unlimited. |
| `primary_color` | text | **no default** (was `#6366f1`) | `secondary_color` still defaults to `#8b5cf6`. |
| `member_count` | int | | **Maintained by a trigger.** Never write it yourself. |
Two other columns matter here:
- `is_active = false` means the org is soft-deleted. Its codes then answer `revoked`, and joins return `org_inactive`.
- `features` (jsonb): `features->>'member_invites' = 'true'` lets ordinary members create personal referral codes.
### 2.2 `org_members`
There is one row per (org, player). `org_id` and `player_id` are now `NOT NULL`. New columns:
| Column | Type | Notes |
|---|---|---|
| `status` | text not null, default `'active'` | `active`, `suspended`, `banned` or `left`. Only `active` counts towards membership, `member_count`, the 5-org limit and `primary_org_id`. |
| `left_at` | timestamptz | Set when a member leaves or a redemption is reversed. |
| `ban_reason` | text | |
| `redemption_id` | uuid, FK to `redeem_redemptions`, deferrable | The redemption that created this membership. A role upgrade does not overwrite it. |
Existing columns that are now in use:
- `role`: `owner`, `admin`, `arbiter` or `member`. Rank order is owner 4, admin 3, arbiter 2 (the manager's `moderator` also ranks 2), member 1. Unknown roles rank as member.
- `joined_via`: the redeem `source` (`redeem_screen`, `link`, `qr`, ...), `application` or `migrated`.
- `invite_link_id`: the `redeem_codes.id` used. It has no FK.
- `invited_by`: the code's `owner_player_id`.
- `application_id`: the application, when the member joined through one.
**Trigger `org_members_sync`** (after insert, delete, or update of status, org_id or player_id):
- keeps `el3ab_organizations.member_count` equal to the number of active rows;
- sets `profiles.primary_org_id` on a player's first active membership;
- moves `primary_org_id` to another active org, or clears it, when that membership stops being active.
This covers every writer, including the manager. **To remove a member, set `status='left', left_at=now()`, don't delete the row.** Deleting still keeps the count right, but loses the history.
**Limit:** a player may be an active member of at most **5** orgs (`_redeem_org_limit()`). `redeem_code` and `decide_application` enforce it. `orgs.php join` must enforce it too.
### 2.3 `org_membership_applications`
- `org_id` now references `el3ab_organizations` (on delete cascade). The 13 pending applications still point at the 2 migrated orgs.
- `invite_code_id` now references `redeem_codes`. The old `org_invite_codes` table is left in place, unused.
- New columns:
- `review_note` text;
- `reviewed_by_admin` uuid, FK to `admin_users` (on delete set null), for staff reviewers from the manager;
- `redemption_id` uuid, FK to `redeem_redemptions` (deferrable).
- `reviewed_by` is still the FK to `profiles`. It holds **player** reviewers only (org owners or admins in the app). Never write an `admin_users` id into it.
- `status` values in use: `pending`, `approved`, `rejected`, and `cancelled` (a pending code application whose redemption was reversed). There is no check constraint.
- Applications created by a code have `document_type = 'invite_code'` and `notes = <code_display>`.
**PostgREST shim.** `api/profile.php` `my-applications` embeds `organizations(id,name,slug,logo_url)` through the FK that moved. The computed relationship `public.organizations(org_membership_applications)` keeps that embed working, now answered from `el3ab_organizations`; I checked it over HTTP. Drop it when `profile.php` stops embedding `organizations`.
### 2.4 `profiles.primary_org_id`
uuid, FK to `el3ab_organizations`, on delete set null. The clan tag shown next to a name comes from this org. The `org_members` trigger maintains it. The app may also let the player choose it, but only among their active orgs.
---
## 3. Redeem tables
RLS is on for all four tables, with no policies. `service_role` has select, insert, update and delete; nobody else has anything.
### 3.1 `redeem_codes`
| Column | Type | Notes |
|---|---|---|
| `id` | uuid pk | |
| `code` | text, unique | The canonical form (section 5). This is the lookup key. |
| `code_display` | text | As printed: `K7QM-4WXP`, `123-456-7890`, `Zamalek-26`, `نادي-الاختبار`. |
| `kind` | text | `generated`, `vanity` or `numeric`. |
| `scope` | text | `org`, `tournament`, `group` or `platform`. Scope `org` requires `org_id`. |
| `org_id` | uuid, FK `el3ab_organizations` (cascade) | The org that owns the code. A treasury-funded grant pays from this org. |
| `grants` | jsonb array, 1 to 6 items | The normalised grants (section 6). |
| `label` | text | For example "Poster at the venue". At most 120 characters. |
| `campaign` | text | Groups codes for statistics. At most 64 characters. |
| `max_uses` | int, null or above 0 | Null means unlimited. |
| `uses` | int | Seats used. |
| `reserved` | int | Seats held by pending approvals. The check `uses + reserved <= max_uses` is enforced. |
| `starts_at`, `expires_at` | timestamptz | `starts_at < expires_at` is enforced. |
| `revoked_at`, `revoked_by`, `revoke_reason` | | Codes are revoked, never hard-deleted. `revoked_by` is an admin id or a profile id, with no FK. |
| `requires_approval` | bool | |
| `eligibility` | jsonb object | See 6.3. |
| `owner_player_id` | uuid, FK `profiles` | A member's personal referral code. Shown as `inviter`. |
| `created_by_player` / `created_by_admin` | uuid, FKs to `profiles` / `admin_users` | At least one is set. |
| `welcome_message`, `welcome_message_ar` | text | At most 1000 characters each. |
| `link_opens`, `previews` | int | Funnel counters, incremented by `redeem_code` dry runs. |
| `created_at`, `updated_at` | timestamptz | `updated_at` changes only when an editable field changes, not on a use or a preview. |
**Trigger `redeem_codes_guard`** (before insert or update) re-checks the hard limits in 6.2 whenever `grants`, `kind`, `max_uses`, `expires_at` or `requires_approval` change. A direct REST insert or PATCH that breaks a limit fails with Postgres `23514`, the message `redeem code limit: <key>`, and `hint = <key>`.
**Editing and revoking** are plain PATCHes on `redeem_codes`. There is no function for them.
- Fields you may patch: `label`, `campaign`, `max_uses`, `starts_at`, `expires_at`, `welcome_message*`, and `revoked_at`/`revoked_by`/`revoke_reason`.
- Don't change the `grants` or `kind` of a code that has been used.
- Lowering `max_uses` below `uses + reserved` fails the cap check (`23514`).
- Write the `org_activity_log` rows `invite.revoke` and `invite.edit` from the API.
### 3.2 `redeem_redemptions`
| Column | Notes |
|---|---|
| `id`, `code_id` (FK, no cascade), `player_id` (FK `profiles`, cascade) | `unique (code_id, player_id)`: one redemption per player per code, ever. |
| `status` | `applied`, `partial`, `pending_approval`, `rejected` or `reversed`. |
| `source` | `signup`, `link`, `qr`, `redeem_screen`, `org_page`, `guest_upgrade` or `admin`. |
| `was_new_signup` | True when the source is `signup` or `guest_upgrade`, or the profile is less than 10 minutes old. |
| `results` | One entry per grant, in grant order (section 7.4). |
| `ip_hash`, `device_id` | |
| `reviewed_by`, `reviewed_at` | For approval codes. `reviewed_by` is the admin id or the profile id; the application row holds the typed columns. |
| `reversed_by`, `reversed_at`, `reverse_reason`, `reversal` | `reversal` = `{previous_status, effects[], by_admin}`. |
| `created_at`, `updated_at` | |
A rejected or reversed player can't redeem **the same code** again (the unique key). They need a different code.
### 3.3 `redeem_attempts`
This is the rate-limit and abuse log. Columns: `id` (bigserial), `player_id`, `ip_hash` (`'none'` when no hash was given), `device_id`, `code_tried` (raw input, first 64 characters), `code_id` (set when the code exists), `source`, `outcome`, `is_preview`, `no_account`, `created_at`.
- **Rows written:** every failure, every successful preview (`outcome = 'preview'`), every `account_required` and every `rate_limited` refusal. Successful applies are never logged here.
- **Rows counted for limits:** every outcome except `preview`, `account_required` and `rate_limited`.
- **Pruning is not automatic.** `cron.php` should run `delete from redeem_attempts where created_at < now() - interval '30 days'`.
### 3.4 `redeem_intents`
`(player_id, code_id)` primary key, plus `source` and `created_at`. A guest (a GoTrue anonymous user) holding a code.
- **Written** when a guest previews or applies a valid code.
- **Deleted automatically** when the same player later gets `applied`, `partial` or `pending_approval` on that code.
After `upgrade-guest`, PHP selects the user's intents, calls `redeem_code(..., p_source => 'guest_upgrade')` for each, and deletes any intent whose code answered a terminal error (`expired`, `revoked`, `exhausted`, `not_found`, `banned_from_org`, `not_eligible`).
### 3.5 Other tables the functions write
| Table | What is written |
|---|---|
| `economy_transactions` | `type 'redeem_grant'`, currency `coins` or `gems`, positive amount, `balance_after`, `reason 'redeem <code_display>'`, `source_id = <redemption id>`. A reversal writes `type 'debit'` with a negative amount. The type check now also allows `redeem_grant`, `tournament_entry` and `tournament_prize`; every earlier type is kept. |
| `org_treasury` / `org_treasury_transactions` | The grant writes `type 'redeem_grant'` and increases `total_withdrawn_*`. A reversal writes `type 'redeem_refund'`. The type check gained `redeem_grant` and `redeem_refund`. |
| `player_frames` | `acquisition_type 'redeem'` (newly allowed), `source_id = <redemption id>`. `profile_frames.current_supply` is increased by 1. |
| `player_cosmetics` | `acquired_via 'redeem'`. |
| `player_achievements` | `completed = true`, `completed_at = now()`, `progress = condition.count`. The achievement's own coin or XP reward is **not** paid. |
| `tournament_registrations` | `status 'registered'`. A withdrawn or cancelled row is revived instead of inserting a second one. |
| `group_members` / `groups.member_count` | `role 'member'`. The count is recounted from the rows. |
| `org_activity_log` | `member.joined_via_code`, `member.joined_via_application`, `member.role_upgraded_via_code`, `member.role_upgraded_via_application`, `member.applied_via_code`, `application.approved`, `application.rejected`, `redemption.reversed`, `invite.create`, `invite.bulk_create`. `actor_id` is the player for redemptions, and the reviewer or creator otherwise. |
| `org_referrals` | When a personal code (`owner_player_id`) brings in a new member: `status 'pending'`, `invite_link_id = code id`. That FK now points at `redeem_codes`. |
---
## 4. Restore and undo
**Backup** (taken before anything was altered): `/root/backups/2026-10-26-org-redeem/` on the server. Read it with `sudo`.
| File | sha256 | Contents |
|---|---|---|
| `org-tables-data.sql` | `1305c717c3b1470be7d6bb7b3771ab013688065fe0b41326588b3f71b461d6b7` | `pg_dump` (schema and data) of `organizations`, `org_memberships`, `el3ab_organizations`, `org_members`, `org_membership_applications` |
| `altered-tables-schema.sql` | `51ad1e9d7c985bf8390f562b21bdbda06431f10a7f6089e0f1d7268d4ac6382f` | Schema-only dump of `economy_transactions`, `profiles`, `player_frames`, `org_treasury_transactions`, `org_referrals`, `groups`, `el3ab_tournament_rounds` |
| `constraints-before.txt` | `bd9bcdbf2948b35d5b6c04bd3a48d6c8b2a1ee8d31753cbe5ee254d564ec3fba` | Every constraint definition on the tables whose constraints changed |
| `rounds-policies-before.txt` | `7b34baaa64659e09e2891c7423ed71caa89c531ac241bfeb995500eeb8d7fb96` | The rounds policies, captured after the parallel `2026-10-26-manager-columns.sql` had already dropped the write policy |
**Undo the schema.** I tested this against the live database inside `BEGIN ... ROLLBACK`. It puts back the exact pre-migration constraints, FKs, columns and defaults.
It fails loudly, by design, when live data already depends on the new model:
- economy or treasury rows of the new types;
- members of El3ab-only orgs;
- applications to El3ab-only orgs.
Decide what to do with those rows first.
Save the block below as `down.sql`, then run:
```bash
ssh server "sudo docker exec -i -e PGOPTIONS='-c lock_timeout=5s' safe-supabase-db psql -U postgres -d postgres -v ON_ERROR_STOP=1" < down.sql
```
```sql
begin;
drop index if exists public.groups_invite_code_canon_idx;
do $$
declare f regprocedure;
begin
for f in select p.oid::regprocedure from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and (p.proname like '\_redeem\_%' or p.proname in ('redeem_code','decide_application','redeem_reverse',
'redeem_create','redeem_create_batch','redeem_canonical','redeem_fold','redeem_blocklist'))
loop execute format('drop function %s cascade', f); end loop;
end $$;
alter table public.org_members drop constraint if exists org_members_redemption_id_fkey;
alter table public.org_membership_applications drop constraint if exists org_membership_applications_redemption_id_fkey;
alter table public.org_membership_applications drop constraint if exists org_membership_applications_invite_code_id_fkey;
update public.org_membership_applications set invite_code_id = null
where invite_code_id is not null and invite_code_id not in (select id from public.org_invite_codes);
alter table public.org_membership_applications add constraint org_membership_applications_invite_code_id_fkey
foreign key (invite_code_id) references public.org_invite_codes(id);
alter table public.org_referrals drop constraint if exists org_referrals_invite_link_id_fkey;
update public.org_referrals set invite_link_id = null
where invite_link_id is not null and invite_link_id not in (select id from public.org_invite_links);
alter table public.org_referrals add constraint org_referrals_invite_link_id_fkey
foreign key (invite_link_id) references public.org_invite_links(id);
drop table if exists public.redeem_intents, public.redeem_attempts, public.redeem_redemptions, public.redeem_codes;
alter table public.economy_transactions drop constraint economy_transactions_type_check;
alter table public.economy_transactions add constraint economy_transactions_type_check check (type = any (array[
'credit','debit','game_reward','level_up_reward','daily_reward','purchase','refund','admin_grant']::text[]));
alter table public.player_frames drop constraint player_frames_acquisition_type_check;
alter table public.player_frames add constraint player_frames_acquisition_type_check check (acquisition_type = any (array[
'purchase','reward','gift','achievement','org_membership','admin_grant']::text[]));
alter table public.org_treasury_transactions drop constraint org_treasury_transactions_type_check;
alter table public.org_treasury_transactions add constraint org_treasury_transactions_type_check check (type = any (array[
'deposit','withdrawal','reward','tax','admin_grant','admin_revoke']::text[]));
drop function if exists public.organizations(public.org_membership_applications);
drop trigger if exists org_members_sync on public.org_members;
drop function if exists public.org_members_sync();
alter table public.profiles drop column if exists primary_org_id;
alter table public.org_membership_applications drop constraint if exists org_membership_applications_org_id_fkey;
alter table public.org_membership_applications add constraint org_membership_applications_org_id_fkey
foreign key (org_id) references public.organizations(id);
drop index if exists public.org_apps_org_status_idx;
drop index if exists public.org_apps_player_idx;
alter table public.org_membership_applications
drop column if exists review_note, drop column if exists reviewed_by_admin, drop column if exists redemption_id;
delete from public.org_members m where m.joined_via = 'migrated' and m.id in (select id from public.org_memberships);
drop index if exists public.org_members_player_active_idx;
drop index if exists public.org_members_org_status_idx;
alter table public.org_members drop constraint if exists org_members_status_check;
alter table public.org_members
drop column if exists status, drop column if exists left_at, drop column if exists ban_reason, drop column if exists redemption_id,
alter column org_id drop not null, alter column player_id drop not null;
delete from public.el3ab_organizations e where e.id in (select id from public.organizations);
drop index if exists public.el3ab_org_tag_uq;
alter table public.el3ab_organizations
drop constraint if exists el3ab_org_join_policy_check, drop constraint if exists el3ab_org_tag_check,
drop constraint if exists el3ab_org_hue_check, drop constraint if exists el3ab_org_games_check,
drop column if exists tag, drop column if exists join_policy, drop column if exists games, drop column if exists hue,
alter column max_members set default 50,
alter column primary_color set default '#6366f1';
-- The rounds write policy is deliberately not recreated (it was a hole).
grant insert, update, delete, truncate on public.el3ab_tournament_rounds to anon, authenticated;
commit;
notify pgrst, 'reload schema';
```
**Restore the 13 application rows exactly as they were.** For example, if decisions made after the migration have to be undone. This replaces the table's rows with the dump's COPY block; I tested it inside `BEGIN ... ROLLBACK`, and it restored 13 rows, all `pending`:
```bash
ssh server '{ echo "begin;"; echo "delete from public.org_membership_applications;";
sudo sed -n "/^COPY public.org_membership_applications /,/^\\\\\\.\$/p" /root/backups/2026-10-26-org-redeem/org-tables-data.sql;
echo "commit;"; } | sudo docker exec -i safe-supabase-db psql -U postgres -d postgres -v ON_ERROR_STOP=1'
```
`el3ab_organizations` and `org_members` were empty before the migration, and the Swiss tables were never written, so the undo above restores those exactly.
---
## 5. Code format
### 5.1 Kinds and printed form
| Kind | Canonical | Printed | Notes |
|---|---|---|---|
| `generated` (default) | 8 symbols from `23456789ACDEFHJKMNPQRTVWXY` (26 symbols) | `XXXX-XXXX` | 26^8 is about 2.1e11. Rejection sampling over `gen_random_bytes`, so every symbol is equally likely. Blocklist hits are regenerated. |
| `numeric` | 10 digits | `XXX-XXX-XXXX` | Scratch cards and SMS. |
| `vanity` | 4 to 16 characters of `[0-9A-Z]` and Arabic U+0621 to U+064A, after folding | As typed: trimmed, with bidi marks removed | Must not collide with any `redeem_codes.code` or any folded `groups.invite_code`. Checked against the blocklist. |
Group invite codes (`groups.invite_code`, 6 hex characters) are a **legacy namespace**. When `redeem_codes` has no match, `redeem_code` falls back to a group whose folded invite code matches (indexed by `groups_invite_code_canon_idx`). New group codes are random hex and could in theory equal an existing vanity code; `redeem_codes` is looked up first and wins.
### 5.2 Canonicalisation: `redeem_fold(text)` and `redeem_canonical(text)`
`redeem_canonical(x)` returns the folded form, or **null** when the result isn't a valid shape (that is `invalid_format`). `redeem_fold(x)` is the same folding without the shape check. PHP (`redeemCanonical()`) and JS (`core/redeem-code.js`) must implement exactly these steps and pass the vectors in 5.3. Both SQL functions are callable by anyone, for example `POST /rpc/redeem_canonical {"p_input": "..."}`; use that when in doubt, such as for the manager's vanity check.
1. Unicode NFKC.
2. Map Arabic-Indic digits U+0660 to U+0669 and Persian digits U+06F0 to U+06F9 to `0` to `9`.
3. Remove every character in this set:
- ASCII spaces and controls: space, `\t \n \r \v \f`;
- other spaces and bidi or invisible marks: U+0085, U+00A0, U+1680, U+2000 to U+200F, U+2028 to U+202F, U+205F, U+2060, U+2066 to U+2069, U+3000, U+FEFF, U+061C;
- tatweel U+0640;
- dashes U+2010 to U+2015 and U+2212;
- `_`, `.` and `-`.
4. Uppercase ASCII `a` to `z`, then fold `O->0`, `I->1`, `L->1`, `S->5`, `Z->2`, `B->8`, `G->6`, `U->V`.
5. Remove the harakat U+064B to U+0652 and U+0670. Then fold `أ إ آ ٱ` to `ا`, `ة` to `ه`, `ى` to `ي`, `ؤ` to `و`, `ئ` to `ي`, and (beyond 2.4) Persian `ی` to `ي` and `ک` to `ك`.
6. The result is valid only if it matches `^[0-9A-Zء-ي]{4,16}$`. Generated and numeric codes are subsets of this shape.
**Additions beyond design 2.4** (mirror them in PHP and JS):
- In step 3: U+0085, U+00A0, U+1680, U+2000 to U+200A, U+2028 to U+202F (this includes the bidi embeddings U+202A to U+202E), U+205F, U+2060, U+3000, U+FEFF, the dashes U+2010 to U+2015, and U+2212.
- In step 5: the two Persian letters.
These additions only remove invisible or look-alike characters, so pasted codes match.
### 5.3 Test vectors
These are all verified against the live function.
| Input | `redeem_canonical` |
|---|---|
| `k7qm-4wxp` | `K7QM4WXP` |
| ` K7QM 4WXP ` | `K7QM4WXP` |
| U+200F + `K7QM-4WXP` + U+200E | `K7QM4WXP` |
| U+2066 + `K7QM` + U+2014 + U+2069 + `4WXP` | `K7QM4WXP` |
| `KQ7M-4WXP` (fullwidth) | `KQ7M4WXP` |
| `k7qm_4wxp.` | `K7QM4WXP` |
| `٠١٢-٣٤٥-٦٧٨٩` | `0123456789` |
| `۱۲۳۴۵۶۷۸۹۰` | `1234567890` |
| `zamalek` | `2AMA1EK` |
| `ZAMALEK-26` | `2AMA1EK26` |
| `AHLY26` | `AH1Y26` |
| `Sobhy` | `508HY` |
| `bugs` | `8V65` |
| `b042ec` (a group code) | `8042EC` |
| `نادي-الزمالك` | `ناديالزمالك` |
| `نَادِي الزَّمالِك` | `ناديالزمالك` |
| `نـــادي` | `نادي` |
| `أهلي` | `اهلي` |
| `إمبابة` | `امبابه` |
| `مصرى` | `مصري` |
| `abc` | null (too short) |
| `K7QM4WXP!` | null |
| `ÉCOLE12` | null |
| `ABCDEFGHJKMNPQRTV` (17 characters) | null |
| empty string, or null | null |
### 5.4 Blocklist
`redeem_blocklist()` returns `text[]`: English, Franco-Arabic and Arabic entries, stored as typed and compared after folding.
- **Generated codes** are regenerated on any substring hit.
- **Vanity codes** are refused with `code_blocked` when the code contains an entry of 4 or more folded characters, or exactly equals a shorter entry. This keeps "MARS2026" (which contains "ARS") usable.
To change the list, `create or replace` that one function.
---
## 6. Grants
### 6.1 Stored shapes
`redeem_create` normalises the grants: it fills `org_id` from the code, sets `role` and `source`, and validates every id. Stored grants are always complete:
```json
{"type": "org_join", "org_id": "<uuid>", "role": "member|arbiter|admin"}
{"type": "tournament_register", "tournament_id": "<uuid>", "waive_fee": false}
{"type": "group_join", "group_id": "<uuid>"}
{"type": "coins", "amount": 100, "source": "treasury|platform"}
{"type": "gems", "amount": 5, "source": "treasury|platform"}
{"type": "frame", "frame_id": "<uuid>"}
{"type": "cosmetic", "cosmetic_id": "<cosmetics.id text>"}
{"type": "badge", "achievement_id": "<achievements.id text>"}
```
- `source: 'treasury'` debits the `org_treasury` of the code's `org_id`. The grant fails with `treasury_short` when the treasury holds less than the amount.
- `source: 'platform'` mints the coins. Only platform staff can create such a code.
- When a grant has no `source` (a row inserted directly), the default is `treasury` for scope `org` and `platform` otherwise.
- `badge` uses **platform achievements** (`achievements.id`). `player_achievements` can't reference `org_achievements`, so org badges aren't grantable yet.
### 6.2 Who may create what (`redeem_create`), and the hard limits
The creator is either `p_created_by_admin` or `p_created_by_player`.
**Platform staff** is an `admin_users` row that is active, not deleted, and has `org_id` null. Staff may create any grant, at any scope.
**Org-scoped staff** (`admin_users.org_id` set) act as the **owner** of that org, and only that org.
**A player** acts with their active `org_members.role` in `p_org_id`:
| Grant | owner | admin | arbiter / member |
|---|---|---|---|
| `org_join` | member, arbiter or admin | member or arbiter | member only, as their own personal code (`p_owner_player` = themselves), only when the org's `features.member_invites = true` |
| `tournament_register` | the org's own tournaments | the org's own tournaments | no |
| `group_join` | a group the player owns or administers (groups have no org link yet) | same | same |
| `coins` / `gems` | yes, always from the treasury (owner decision 2026-10-26) | no | no |
| `frame` | frames whose `profile_frames.org_id` is this org | same | no |
| `cosmetic`, `badge` | no | no | no |
Further rules:
- A player who isn't an active member of `p_org_id` may create only `scope 'group'` codes.
- `owner` is never grantable, by anyone.
- Every org-referencing grant in a player's code must use the code's org.
**Hard limits.** `redeem_create` enforces these, and so does the trigger, on any insert or edit. Each failure has its own error key:
| Rule | Error |
|---|---|
| A code granting `arbiter` or `admin` must be `generated`... | `staff_code_must_be_generated` |
| ...and have `max_uses` of 5 or fewer... | `staff_code_max_uses` |
| ...and an `expires_at` within 7 days | `staff_code_expiry` |
| Coins, gems or `waive_fee` need `max_uses`... | `value_code_needs_max_uses` |
| ...and can't be on a vanity code | `vanity_not_allowed` |
| A vanity code may only carry `org_join` as member, `group_join`, or `tournament_register` for a tournament with no fee | `vanity_not_allowed` |
| `requires_approval` needs exactly one `org_join` | `approval_needs_one_org_join` |
| 1 to 6 grants of a known type | `invalid_grants` / `invalid_grant` |
| `role` must be member, arbiter or admin | `invalid_role` |
`redeem_create` also checks:
- `max_uses` is 1 to 1,000,000;
- `expires_at` is in the future and after `starts_at`;
- amounts are 1 to 1,000,000;
- text lengths (`invalid_text`);
- eligibility keys and types;
- the owner of a personal code is an active member (`invalid_owner`).
### 6.3 Eligibility (`redeem_codes.eligibility`)
| Key | Type | The player qualifies when | `rule` in `not_eligible` |
|---|---|---|---|
| `new_accounts_only` | bool | `profiles.created_at >= redeem_codes.created_at` (the account was made after the code). A guest upgraded in place keeps the guest's `created_at`. | `new_accounts_only` |
| `min_level` | int | `profiles.level >= n` | `min_level` |
| `min_games` | int | `greatest(total_games_played, games_played) >= n` | `min_games` |
| `account_age_days` | int | the account is at least `n` days old (anti-farming) | `account_age_days` |
| `countries` | text[] | `trim(profiles.country_code)` is in the list, ignoring case | `countries` |
Other keys are refused at creation (`invalid_eligibility`).
---
## 7. Functions
### 7.1 `redeem_code`
```
redeem_code(p_player uuid, p_code text, p_source text default 'redeem_screen',
p_ip_hash text default null, p_device text default null, p_dry_run boolean default false) returns jsonb
```
The function is `SECURITY DEFINER` and runs as one transaction.
**Arguments:**
- `p_player`: the caller's profile id, or null when there is no session (a link opened while logged out).
- `p_source`: one of the seven sources. **PHP must whitelist it; never pass the client's `admin`.**
- `p_ip_hash`: `substr(sha256(ip . REDEEM_SALT), 0, 16)`. **Always send it from the player API.** Null means `'none'` and skips the IP limits; that's only for trusted server callers such as the manager.
- `p_device`: up to 128 characters.
- `p_dry_run = true`: a preview. It never uses a seat or grants anything.
**Check order.** The first failure is returned.
| # | Check | Outcome | Notes |
|---|---|---|---|
| 0 | source valid; player exists | `invalid_request` / `player_not_found` | Not logged |
| 1 | rate limits (below) | `rate_limited` | |
| 2 | shape (`redeem_canonical` returns null) | `invalid_format` | |
| 3 | code lookup (FOR UPDATE when applying), then the legacy group lookup | `not_found` | |
| 4 | the player already has a redemption for this code | `already_redeemed` (ok) | Checked before the code's own state, so a retry after the last seat went, or after expiry, still gets its stored result. **This order differs from 2.9 on purpose.** |
| 5 | revoked, or the code's org (or any `org_join` org) is soft-deleted | `revoked` | |
| | `starts_at > now()` | `not_started` | |
| | `expires_at <= now()` | `expired` | |
| | `max_uses` reached (`uses + reserved >= max_uses`) | `exhausted` | |
| 6 | no account (`p_player` null) or `auth.users.is_anonymous` | `account_required` | A guest's intent is saved. In a dry run, the preview is returned with `state.account_required = true` instead. |
| 7 | banned in an `org_join` org | `banned_from_org` | |
| | eligibility | `not_eligible` | |
| | every grant is an `org_join` the player already holds at the same or a higher role | `already_member` (ok) | In a dry run: `state.already_member = true` |
| | a new membership would exceed 5 active orgs | `org_limit` | |
| | a new membership into a full org (codes without approval only) | `org_full` | |
| 8 | dry run | preview payload | `previews + 1`, plus `link_opens + 1` when the source is `link` or `qr` |
| 9 | `requires_approval` | `pending_approval` (ok) | An application is created (or an existing pending one is reused), `reserved + 1`, and **every** grant waits for `decide_application` |
| | otherwise each grant runs in order | `applied` / `partial` (ok) | A seat is used only if at least one grant took effect. If none did and one failed, the result is the first failure's reason as an error, with `results`. If none did and none failed, the result is `already_member` (when there was an `org_join`) or `already_owned`. |
**Rate limits.** Only failures count (see 3.3). The window is rolling.
| `limit` | Who | Threshold |
|---|---|---|
| `player_hour` | `p_player` (a permanent account or a guest) | 8 counted rows in 1 hour |
| `player_day` | `p_player` | 25 in 24 hours |
| `ip_hour` | callers with an account, per `ip_hash` | 40 in 1 hour (all counted rows from that IP) |
| `ip_preview_hour` | callers without an account or guests, per `ip_hash` | 15 in 1 hour (counting only that IP's no-account rows) |
The response is `{"ok":false,"error":"rate_limited","limit":"player_hour","retry_after":<seconds>}`. `retry_after` is the time until the count drops below the threshold. Send it as `Retry-After`.
Other failure responses carry `recent_failures`, the number of counted failures in the last hour including this one. **The API adds the 2-second delay when `recent_failures >= 5`** (design 2.8).
**Concurrency.** The row lock on the code serialises every apply of one code. I tested it live with two sessions:
- two players racing for the last seat: the second waits about 3 s for the first to commit, then gets `exhausted`;
- the same player applying twice at once: the second gets `already_redeemed`.
A player's applies across different codes are serialised by a lock on their own profile row (`FOR NO KEY UPDATE`), so the 5-org limit can't be raced.
### 7.2 Preview payload (dry run, and inside `account_required` / `not_eligible`)
```json
{
"ok": true, "dry_run": true,
"code": {"id": "uuid|null", "display": "VFN8-97VM", "label": "Poster at the venue", "kind": "generated|vanity|numeric|group",
"scope": "org", "org_id": "uuid|null", "starts_at": null, "expires_at": "2026-11-24T21:07:25Z",
"seats_left": 64, "requires_approval": false},
"org": {"id": "...", "name": "Cairo Chess Club", "name_ar": "...", "tag": "CCC", "logo_url": null,
"member_count": 1, "max_members": 500, "is_verified": true, "hue": "green", "join_policy": "approval"},
"inviter": {"id": "...", "name": "<display_name or username>", "username": "...", "avatar_url": null},
"grants": [
{"type": "org_join", "role": "member", "org": { /* same shape as "org" */ }},
{"type": "tournament_register", "tournament": {"id": "...", "name": "...", "name_ar": "...", "game_key": "chess",
"format": "swiss", "starts_at": "...", "status": "registration", "max_players": 64, "entry_fee_coins": 0, "fee_waived": false}},
{"type": "group_join", "group": {"id": "...", "name": "...", "avatar_url": null, "member_count": 3, "max_members": 20}},
{"type": "coins", "amount": 100},
{"type": "frame", "frame": {"id": "...", "name": "...", "name_ar": "...", "image_url": "...", "thumbnail_url": null, "rarity": "common"}},
{"type": "cosmetic", "cosmetic": {"id": "board_ocean_blue", "name": "...", "name_ar": "...", "type": "board_theme", "rarity": "rare", "preview_url": null}},
{"type": "badge", "badge": {"id": "first_win", "name": "...", "name_ar": "...", "icon_url": null}}
],
"state": {"account_required": false, "already_redeemed": false, "already_member": false},
"requires_approval": false,
"welcome_message": null, "welcome_message_ar": "..."
}
```
- `inviter` and `org` are null when they don't apply.
- `seats_left` is null for unlimited codes.
- A grant whose target was deleted previews as `{"type": "...", "missing": true}`.
- The extra keys present in some cases:
- a guest's preview adds `"intent_saved": true`;
- an already-redeemed player's preview adds `"redemption": {"id","status","created_at","results"}`;
- a legacy group code has `code.kind = "group"`, `code.id = null`, and one `group_join` grant.
### 7.3 Apply responses (`ok: true`)
```json
{"ok": true, "status": "applied|partial", "redemption_id": "uuid", "code": { /* as in the preview */ },
"results": [ /* 7.4 */ ], "welcome_message": null, "welcome_message_ar": "...", "was_new_signup": false}
{"ok": true, "status": "pending_approval", "redemption_id": "uuid", "application_id": "uuid",
"org": { /* org brief */ }, "code": { }, "results": [ /* org_join pending, the others deferred */ ],
"welcome_message": null, "welcome_message_ar": null, "was_new_signup": false}
{"ok": true, "status": "already_redeemed", "already": true, "redemption_id": "uuid",
"redemption_status": "applied|partial|pending_approval|rejected|reversed", "results": [ ], "created_at": "...",
"code": { }, "welcome_message": null, "welcome_message_ar": "..."}
{"ok": true, "status": "already_member", "org": { /* org brief */ }, "code": { }}
{"ok": true, "status": "already_owned", "results": [ ], "code": { }}
```
- A legacy group code that applies returns `status 'applied'` with `redemption_id: null` and no redemption row. When the player is already in the group, it returns `status 'already_member'` with `group` instead of `org`.
- Suggested HTTP status: 200 for everything here, except 202 for `pending_approval`.
### 7.4 Result entries (`results[]`, one per grant, in grant order)
Every entry has `type`, `ok` and `applied`:
- `ok` means the end state holds: it was applied now, or it was already true.
- `applied` means this call changed something.
- A failed entry has `ok: false`, `applied: false` and a `reason`; an unexpected error also has `detail`.
| type | Success fields | `already` case | Failure `reason` values |
|---|---|---|---|
| `org_join` | `org_id, role, member_id, upgraded, rejoined`. An upgrade adds `from_role`. | `already: true, role` (the current role), `member_id` | `invalid_role, org_not_found, org_inactive, banned_from_org, membership_suspended, org_closed, org_full, org_limit` |
| `org_join`, pending | `pending: true, role, application_id` (`ok: true, applied: false`) | | |
| any type, deferred | `deferred: true` (`ok: null, applied: false`), until `decide_application` runs it | | |
| `tournament_register` | `tournament_id, registration_id, revived, player_count, fee_waived, waitlisted: false` | `already: true, registration_id` | `tournament_not_found, registration_closed, tournament_full` |
| `group_join` | `group_id, member_count` | `already: true` | `group_not_found, blocked, group_full` |
| `coins` / `gems` | `amount, source, balance` (the player's new balance), and `treasury_balance` when the treasury paid | | `invalid_amount, invalid_grant, treasury_short` (with `treasury_balance`) |
| `frame` | `frame_id` | `already: true` | `frame_not_found, frame_sold_out` |
| `cosmetic` | `cosmetic_id` | `already: true` | `cosmetic_not_found` |
| `badge` | `achievement_id, inserted` | `already: true` | `badge_not_found` |
| any | | | `invalid_grant`, `grant_error` (+ `detail`). An unexpected error inside one handler rolls back only that handler. |
**Tournament registration** applies exactly the checks in `api/tournaments.php` `register`:
- the status is `draft` or `registration`;
- `registration_closes_at` hasn't passed;
- `max_players` counted among `registered` rows, where 0 or null means unlimited;
- a withdrawn or cancelled row is revived instead of inserting a new one.
Three differences and gaps to know:
- **No waitlist status exists**, so a full tournament is `tournament_full` and `waitlisted` is always false.
- **Entry fees aren't charged**, because `tournaments.php` doesn't charge them either; `waive_fee` is only recorded. When registration starts charging fees (type `tournament_entry`), change `_redeem_tournament` in the same release.
- min and max rating aren't enforced, matching `tournaments.php`.
**Group join** applies the checks in `groups.php` `join`: already a member, a block in either direction with the owner, and `max_members`. **PHP must still post the chat system line and notify the owner** for every applied `group_join` result, as `groups.php` does.
### 7.5 `decide_application`
```
decide_application(p_app uuid, p_decision text, p_reviewer_admin uuid default null,
p_reviewer_player uuid default null, p_reason text default null) returns jsonb
```
**Arguments:**
- `p_decision` is `'approved'` or `'rejected'`.
- Pass the staff account as `p_reviewer_admin` (from the manager), or the player as `p_reviewer_player` (an org owner or admin in the app).
**Authorisation:**
- Staff must be active and not deleted. An org-scoped staff account may decide only for its own org.
- A player must be an active `owner` or `admin` of the application's org.
**Approval,** in one transaction:
1. Inserts the membership, reactivates a `left` row, or upgrades the role. It never downgrades, and records `joined_via 'application'` and `application_id`. The role comes from the code's `org_join` grant when the application came from a code, otherwise `member`.
2. The trigger bumps `member_count`.
3. For a code application, runs the grants that waited, updates the redemption to `applied` or `partial`, and turns the reserved seat into a used one (or releases it if nothing took effect).
4. Sets `status`, `review_note = p_reason`, the reviewer columns and `reviewed_at`.
If the membership is refused (`banned_from_org`, `membership_suspended`, `org_full`, `org_limit`, `org_inactive`), **nothing changes**: the application stays `pending` and the error is returned.
**Rejection** sets the status, `rejection_reason` and `review_note` (both `p_reason`), the reviewer and `reviewed_at`. A code application's redemption becomes `rejected` and its seat is released.
```json
{"ok": true, "status": "approved", "org": {"id": "...", "name": "...", "name_ar": "..."}, "player_id": "uuid",
"application_id": "uuid", "member": {"id": "uuid", "role": "member", "upgraded": false, "already": false},
"redemption": {"id": "uuid", "status": "applied", "results": [ ]} }
{"ok": true, "status": "rejected", "org": { }, "player_id": "uuid", "application_id": "uuid", "member": null,
"redemption": {"id": "uuid", "status": "rejected"} }
```
`redemption` is null for legacy applications (no code).
Errors: `invalid_decision`, `reviewer_required`, `not_found`, `already_decided` (with `status`), `org_not_found`, `forbidden`. The membership refusals above come with `org` and `player_id`.
**Notify the player only after `ok: true`.** Use `org.name_ar`, falling back to `org.name`.
### 7.6 `redeem_reverse`
```
redeem_reverse(p_redemption uuid, p_actor uuid, p_reason text default null) returns jsonb
```
**Who may reverse:** `p_actor` is an `admin_users` id (staff, with org-scoped staff limited to their own org) or a profile id that is an active **owner** of the code's org.
**What it does.** It undoes what the redemption did, as far as that still stands:
| Grant | Effect |
|---|---|
| A join made by this redemption | Becomes `left`; `member_count` drops |
| A role upgrade | Goes back to `from_role`, if the role is unchanged since |
| A registration | `cancelled`, or `withdrawn` once the tournament is in progress |
| A group join | The member row is deleted and the count recounted |
| A frame, a cosmetic, a badge it inserted | Removed |
| Coins or gems | Takes back what the player still holds, never below 0, as a `debit` ledger row. When the treasury paid, refunds exactly that amount to it (`redeem_refund`). |
Then it frees the seat (`uses - 1`) and marks the redemption `reversed` with `reversal = {previous_status, effects, by_admin}`.
A `pending_approval` redemption is reversed by cancelling its application (status `cancelled`) and releasing the reserved seat.
```json
{"ok": true, "status": "reversed", "redemption_id": "uuid", "player_id": "uuid", "code_id": "uuid", "previous_status": "applied",
"effects": [{"type": "org_join", "action": "membership_removed", "org_id": "..."},
{"type": "tournament_register", "action": "registration_cancelled", "tournament_id": "..."},
{"type": "coins", "action": "clawed_back", "granted": 100, "amount": 100, "balance": 0, "refunded_to_treasury": 100}]}
```
- `action` values: `membership_removed`, `role_restored`, `registration_cancelled`, `left_group`, `clawed_back`, `removed`, `application_cancelled` (with `count`), and `unchanged` (when the state had already moved on).
- Errors: `actor_required`, `not_found`, `already_reversed`, `not_reversible` (a rejected redemption), `forbidden`.
### 7.7 `redeem_create` and `redeem_create_batch`
```
redeem_create(p_grants jsonb, p_scope text default 'org', p_org_id uuid default null, p_kind text default 'generated',
p_vanity text default null, p_label text default null, p_campaign text default null,
p_max_uses int default null, p_starts_at timestamptz default null, p_expires_at timestamptz default null,
p_requires_approval boolean default false, p_eligibility jsonb default '{}', p_owner_player uuid default null,
p_welcome_message text default null, p_welcome_message_ar text default null,
p_created_by_admin uuid default null, p_created_by_player uuid default null) returns jsonb
```
**Success** returns `{"ok": true, "code": <the full redeem_codes row as JSON>}` and logs `invite.create` when the code has an org.
**The links are built by the API:**
- link: `https://el3ab-player.caprover.al-arcade.com/r/<code>`;
- QR payload: the same link;
- display: `code.code_display`.
**For an org-scoped staff account,** `p_org_id` defaults to its own org.
**Errors:**
- creator: `creator_required`, `forbidden` (with `detail`), `player_not_found`, `account_required`;
- scope and org: `invalid_scope`, `org_required`, `org_not_found`, `org_inactive`;
- fields: `invalid_kind`, `invalid_max_uses`, `invalid_expiry`, `invalid_window`, `invalid_text`, `invalid_eligibility` (with `detail`), `invalid_owner`;
- grants: `invalid_grants`, `invalid_grant` (with `detail`), `invalid_role`, `invalid_amount`;
- the hard-limit keys in 6.2;
- vanity: `invalid_format`, `code_blocked`, `code_taken` (with `canonical`);
- generation: `code_space_exhausted`.
**Live vanity availability check:** `POST /rpc/redeem_canonical`, then select `redeem_codes` by `code`, and check `groups` too. Or simply attempt the create; `code_taken` returns the canonical form.
```
redeem_create_batch(p_count int, p_grants jsonb, p_scope text default 'org', p_org_id uuid default null,
p_kind text default 'generated', p_label text default null, p_campaign text default null,
p_max_uses int default 1, p_starts_at timestamptz default null, p_expires_at timestamptz default null,
p_requires_approval boolean default false, p_eligibility jsonb default '{}',
p_welcome_message text default null, p_welcome_message_ar text default null,
p_created_by_admin uuid default null, p_created_by_player uuid default null) returns jsonb
```
- Creates 1 to 1000 codes (`generated` or `numeric`, one use each by default), all or nothing.
- Logs one `invite.bulk_create` row.
- Returns `{"ok": true, "count": n, "codes": [{"id", "code", "display"}]}`. That's enough for the CSV of printed cards.
- Errors: `invalid_count`, `invalid_kind`, plus every `redeem_create` error.
### 7.8 Pure helpers
`redeem_canonical(text) -> text|null` and `redeem_fold(text) -> text` are immutable and callable by anyone. `redeem_blocklist() -> text[]` is callable by `service_role`.
---
## 8. Error keys
The `message_key` naming follows 2.11 (`redeem.err_<key>`). These are the suggested HTTP statuses for `api/redeem.php`.
| Key | HTTP | Seat used? | Source |
|---|---|---|---|
| `invalid_request` | 400 | no | redeem_code (bad `source`) |
| `player_not_found` | 404 | no | redeem_code, redeem_create |
| `invalid_format` | 422 | no | redeem_code, redeem_create (vanity) |
| `rate_limited` | 429 + `Retry-After` | no | redeem_code |
| `not_found` | 404 | no | redeem_code, decide_application, redeem_reverse |
| `revoked` | 410 | no | redeem_code |
| `not_started` | 409 | no | redeem_code (`starts_at`) |
| `expired` | 410 | no | redeem_code (`expires_at`) |
| `exhausted` | 410 | no | redeem_code |
| `account_required` | 401 | no | redeem_code (`intent_saved`, `preview`), redeem_create |
| `banned_from_org` | 403 | no | redeem_code (`org_id`), grant reason, decide_application |
| `not_eligible` | 403 | no | redeem_code (`rule`, `value`, `preview`) |
| `org_limit` | 409 | no | redeem_code (`limit`, `orgs[]`), grant reason, decide_application |
| `org_full` | 409 | no | redeem_code (`org`), grant reason, decide_application |
| `org_closed`, `org_inactive`, `org_not_found`, `membership_suspended`, `invalid_role` | 409 / 410 / 404 / 403 / 422 | no | grant reason (an error when it is the only grant) |
| `tournament_full`, `registration_closed` | 409 | no | grant reason |
| `tournament_not_found`, `group_not_found`, `frame_not_found`, `cosmetic_not_found`, `badge_not_found` | 404 | no | grant reason |
| `group_full`, `frame_sold_out`, `treasury_short` | 409 | no | grant reason |
| `blocked` | 403 | no | grant reason (group owner block) |
| `invalid_amount`, `invalid_grant` | 422 | no | grant reason, redeem_create |
| `grant_error` | 500 | no | grant reason (`detail`): a bug, report it |
| `invalid_decision`, `reviewer_required`, `already_decided` | 422 / 422 / 409 | | decide_application |
| `actor_required`, `already_reversed`, `not_reversible` | 422 / 409 / 409 | | redeem_reverse |
| `forbidden` | 403 | | decide_application, redeem_reverse, redeem_create |
| `creator_required`, `invalid_scope`, `org_required`, `invalid_kind`, `invalid_max_uses`, `invalid_expiry`, `invalid_window`, `invalid_text`, `invalid_eligibility`, `invalid_owner`, `invalid_grants`, `invalid_count` | 422 | | redeem_create, redeem_create_batch |
| `staff_code_must_be_generated`, `staff_code_max_uses`, `staff_code_expiry`, `value_code_needs_max_uses`, `vanity_not_allowed`, `approval_needs_one_org_join` | 422 | | redeem_create, and the trigger (`23514`) |
| `code_blocked`, `code_taken`, `code_space_exhausted` | 422 / 409 / 503 | | redeem_create |
These success statuses are not errors: `applied`, `partial`, `pending_approval` (202), `already_redeemed`, `already_member`, `already_owned`.
---
## 9. Notes for the API and manager engineers
**The manager**
- **`org-applications`** `approve()` and `reject()` should be one call each: `decide_application(id, 'approved'|'rejected', <admin id>, null, <note>)`. Then insert the notification only when `ok` is true, with the org name from the response. Don't write `reviewed_by` with an admin id; the function writes `reviewed_by_admin`.
- **`organizations` `removeMember`** hard-deletes. Prefer `status = 'left'`. Either way, `member_count` stays right.
- **`org-invites`** still writes `org_invite_links`. That table is retired: rebuild the module on `redeem_create` and PATCHes to `redeem_codes`.
**Notifications and side effects stay in PHP:**
- the player's in-app notification;
- the org admins' daily digest;
- the group chat system line and the owner's notification for `group_join`.
**Wrap everything that writes these tables in the functions.** Don't PATCH `uses`, `reserved` or `member_count`.
**`orgs.php join` (open policy) and `apply`:**
- They aren't covered by a function yet.
- They must check: `join_policy`, an existing `banned` row, `max_members` against active members, and the 5-org limit (active rows only).
- They must also write `org_activity_log`.
**Statuses:**
- Members: query `status = 'active'` everywhere. `banned`, `suspended` and `left` rows exist now.
- Applications: the new `cancelled` status exists.
**`profile.php`:**
- `get-orgs` and `my-memberships` still read the Swiss tables (`organizations`, `org_memberships`). New El3ab orgs won't show there until those actions move to `el3ab_organizations` and `org_members`.
- The apply flow now references `el3ab_organizations`, so applying to a new El3ab org works. But its "already a member" check still reads `org_memberships`.
**Index page and link opens:**
- `index.php /r/CODE` should call `redeem_code(null or uid, code, 'link' or 'qr', ip_hash, null, true)` once, for the OG tags. This counts the link open and logs unknown codes, as 2.5 requires.
- The client's own previews should pass a non-link source (`redeem_screen`, `signup`, `org_page`) so opens aren't counted twice. When applying, pass the original source (`link` or `qr`) so it's recorded on the redemption and the membership.
**`was_new_signup`** is computed in SQL. Pass `p_source => 'signup'` from `auth.php` signup and `'guest_upgrade'` after an upgrade.
**Personal referral codes** create `org_referrals` rows with status `pending`. Activating them after 3 rated games, and paying from the treasury (2.8), is still to be built.
**Already applied in the database:**
- the `el3ab_tournament_rounds` write policy is gone;
- `anon` and `authenticated` hold only SELECT on that table.
This affects no code: the client only subscribes over realtime, and every PHP writer uses the service key.
-- Security lockdown, part 2 of 2. Apply only after the PHP that writes balances
-- with the service key is deployed (shop.php, challenges.php, battlepass.php,
-- daily-reward.php): until then those endpoints still write with the player's
-- token and would be refused.
--
-- "Users can update their own profile" has no column limits, so a player could
-- PATCH their own coins, gems, XP, ratings or ban flag over REST with the anon
-- key and their session. Policies cannot limit columns, so a trigger does: a
-- player's own token may change only the fields below. Everything else (and
-- any column added later) belongs to the server, which writes as service_role
-- or through SECURITY DEFINER functions, where current_user is not a client role.
BEGIN;
SET LOCAL lock_timeout = '5s';
CREATE OR REPLACE FUNCTION public.profiles_guard()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
editable text[] := ARRAY[
'username', 'display_name', 'display_name_ar', 'avatar_url', 'banner_url', 'bio', 'bio_ar',
'country_code', 'city', 'preferred_language', 'fide_id', 'fide_rating_standard',
'fide_rating_rapid', 'fide_rating_blitz', 'fide_title', 'avatar_border_color',
'is_online', 'last_seen_at', 'current_game', 'updated_at'];
BEGIN
IF current_user IN ('anon', 'authenticated')
AND (to_jsonb(NEW) - editable) IS DISTINCT FROM (to_jsonb(OLD) - editable) THEN
RAISE EXCEPTION 'Only the server can change these profile fields' USING ERRCODE = '42501';
END IF;
RETURN NEW;
END $$;
REVOKE EXECUTE ON FUNCTION public.profiles_guard() FROM PUBLIC, anon, authenticated;
DROP TRIGGER IF EXISTS profiles_guard ON public.profiles;
CREATE TRIGGER profiles_guard BEFORE UPDATE ON public.profiles
FOR EACH ROW EXECUTE FUNCTION public.profiles_guard();
COMMIT;
-- 2026-10-26 Redeem functions. Contract: docs/redeem-contract.md.
-- Spec: docs/org-invites-and-events-plan.md 2.3, 2.4, 2.7-2.10.
--
-- Public entry points (service_role only, except the two pure helpers):
-- redeem_code, decide_application, redeem_reverse, redeem_create,
-- redeem_create_batch, redeem_canonical (public), redeem_fold (public).
-- Everything prefixed _redeem_ is internal: no EXECUTE for any API role.
--
-- Lock order everywhere: redeem_codes row, then application/redemption row,
-- then the player's profiles row, then the org / tournament / group row.
--
-- Idempotent (create or replace). Requires 2026-10-26-redeem.sql.
begin;
-- 1. Code text ------------------------------------------------------------------
-- Steps 1-5 of design 2.4, without the shape check.
create or replace function public.redeem_fold(p_input text)
returns text
language plpgsql
immutable parallel safe
as $$
declare
s text;
begin
if p_input is null then
return null;
end if;
s := normalize(p_input, NFKC);
s := translate(s, '٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹', '01234567890123456789');
s := regexp_replace(s,
'[ \t\n\r\v\f\u0085   -‏
-  ⁠⁦-⁩ ؜ـ‐-―−_.-]',
'', 'g');
s := translate(s, 'abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ');
s := translate(s, 'OILSZBGU', '0115286V');
s := regexp_replace(s, '[ً-ْٰ]', '', 'g');
s := translate(s, 'أإآٱةىؤئیک', 'ااااهيوييك');
return s;
end $$;
-- The canonical form, or null when the result is not a valid code shape
-- (invalid_format). Generated (8 of the alphabet) and numeric (10 digits)
-- codes are both inside the vanity shape, so one pattern covers all three.
create or replace function public.redeem_canonical(p_input text)
returns text
language sql
immutable parallel safe
as $$
select case when f ~ '^[0-9A-Zء-ي]{4,16}$' then f end
from (select public.redeem_fold(p_input) as f) x
$$;
-- Rude substrings, as typed; compared after folding. Generated codes are
-- rejected on any substring hit; vanity codes on a substring of 4+ folded
-- characters, or an exact match for shorter entries.
create or replace function public.redeem_blocklist()
returns text[]
language sql
immutable
as $$
select array[
'SEX','FUCK','FUK','FCK','SHIT','CUNT','DICK','COCK','PUSSY','ASS','PORN','PENIS','NAZI',
'HITLER','KKK','RAPE','SLUT','WHORE','BITCH','FAG','NIGGA','NIGGER','TITS','BOOB','ANAL',
'CUM','WTF','XXX','ISIS','KILL',
'KOS','KOSS','KOSOMAK','KOSOM','A7A','ZEB','ZOBR','TEZ','TIZ','5WAL','KHAWAL','KHWAL',
'METNAK','MNYK','NEEK','SHARMOTA','SHRMT','ARS','3ARS','ERS','7MAR','HMAR','KALB','MANYAK',
'WES5','KHARA','5ARA','LBWA','MOMS',
'شرموط','متناك','منيوك','عرص','طيز','كسمك','كسامك','زبي','خول','منيك'
]::text[]
$$;
create or replace function public._redeem_blocked(p_code text, p_vanity boolean)
returns boolean
language sql
stable
as $$
select exists (
select 1
from unnest(public.redeem_blocklist()) w
cross join lateral (select public.redeem_fold(w) as f) x
where x.f <> ''
and case when p_vanity and char_length(x.f) < 4 then p_code = x.f
else position(x.f in p_code) > 0 end)
$$;
-- 8 symbols of 23456789ACDEFHJKMNPQRTVWXY, or 10 digits. Rejection sampling,
-- so every symbol is equally likely.
create or replace function public._redeem_random_code(p_kind text)
returns text
language plpgsql
volatile
as $$
declare
c_alpha constant text := '23456789ACDEFHJKMNPQRTVWXY';
v_len int := case when p_kind = 'numeric' then 10 else 8 end;
v_lim int := case when p_kind = 'numeric' then 250 else 234 end;
v_out text := '';
v_bytes bytea;
v_b int;
begin
while char_length(v_out) < v_len loop
v_bytes := extensions.gen_random_bytes(32);
for i in 0..31 loop
v_b := get_byte(v_bytes, i);
if v_b < v_lim then
v_out := v_out || case when p_kind = 'numeric' then (v_b % 10)::text
else substr(c_alpha, (v_b % 26) + 1, 1) end;
exit when char_length(v_out) = v_len;
end if;
end loop;
end loop;
return v_out;
end $$;
-- 2. Small helpers --------------------------------------------------------------
create or replace function public._redeem_uuid(p text)
returns uuid
language plpgsql
immutable
as $$
begin
return p::uuid;
exception when others then
return null;
end $$;
create or replace function public._redeem_int(p text)
returns int
language plpgsql
immutable
as $$
begin
return p::int;
exception when others then
return null;
end $$;
-- Org roles, low to high. Unknown roles rank as member.
create or replace function public._redeem_role_rank(p_role text)
returns int
language sql
immutable
as $$
select case p_role when 'owner' then 4 when 'admin' then 3 when 'arbiter' then 2
when 'moderator' then 2 else 1 end
$$;
-- Owner decision 2026-10-26: a player belongs to at most 5 orgs.
create or replace function public._redeem_org_limit()
returns int
language sql
immutable
as $$ select 5 $$;
create or replace function public._redeem_log(
p_player uuid, p_ip text, p_device text, p_code text, p_code_id uuid,
p_source text, p_outcome text, p_preview boolean, p_no_account boolean)
returns void
language sql
as $$
insert into public.redeem_attempts
(player_id, ip_hash, device_id, code_tried, code_id, source, outcome, is_preview, no_account)
values (p_player, coalesce(p_ip, 'none'), p_device, left(p_code, 64), p_code_id, p_source,
p_outcome, coalesce(p_preview, false), coalesce(p_no_account, false))
$$;
create or replace function public._redeem_org_brief(p_org uuid)
returns jsonb
language sql
stable
set search_path = public, pg_temp
as $$
select jsonb_build_object(
'id', o.id, 'name', o.name, 'name_ar', o.name_ar, 'tag', o.tag, 'logo_url', o.logo_url,
'member_count', o.member_count, 'max_members', o.max_members,
'is_verified', coalesce(o.is_verified, false), 'hue', o.hue, 'join_policy', o.join_policy)
from el3ab_organizations o where o.id = p_org
$$;
create or replace function public._redeem_code_brief(p_c public.redeem_codes)
returns jsonb
language sql
stable
as $$
select jsonb_build_object(
'id', p_c.id, 'display', p_c.code_display, 'label', p_c.label, 'kind', p_c.kind,
'scope', p_c.scope, 'org_id', p_c.org_id, 'starts_at', p_c.starts_at, 'expires_at', p_c.expires_at,
'seats_left', case when p_c.max_uses is null then null
else greatest(p_c.max_uses - p_c.uses - p_c.reserved, 0) end,
'requires_approval', p_c.requires_approval)
$$;
create or replace function public._redeem_grant_preview(p_g jsonb)
returns jsonb
language plpgsql
stable
set search_path = public, pg_temp
as $$
declare
t text := p_g->>'type';
r jsonb;
begin
if t = 'org_join' then
r := jsonb_build_object('type', t, 'role', coalesce(p_g->>'role', 'member'),
'org', _redeem_org_brief(_redeem_uuid(p_g->>'org_id')));
elsif t = 'tournament_register' then
select jsonb_build_object('type', t, 'tournament', jsonb_build_object(
'id', x.id, 'name', x.name, 'name_ar', x.name_ar, 'game_key', x.game_key,
'format', x.format, 'starts_at', x.starts_at, 'status', x.status,
'max_players', x.max_players, 'entry_fee_coins', x.entry_fee_coins,
'fee_waived', coalesce((p_g->>'waive_fee')::boolean, false)))
into r from el3ab_tournaments x where x.id = _redeem_uuid(p_g->>'tournament_id');
elsif t = 'group_join' then
select jsonb_build_object('type', t, 'group', jsonb_build_object(
'id', g.id, 'name', g.name, 'avatar_url', g.avatar_url,
'member_count', g.member_count, 'max_members', g.max_members))
into r from groups g where g.id = _redeem_uuid(p_g->>'group_id');
elsif t in ('coins', 'gems') then
r := jsonb_build_object('type', t, 'amount', _redeem_int(p_g->>'amount'));
elsif t = 'frame' then
select jsonb_build_object('type', t, 'frame', jsonb_build_object(
'id', f.id, 'name', f.name, 'name_ar', f.name_ar, 'image_url', f.image_url,
'thumbnail_url', f.thumbnail_url, 'rarity', f.rarity))
into r from profile_frames f where f.id = _redeem_uuid(p_g->>'frame_id');
elsif t = 'cosmetic' then
select jsonb_build_object('type', t, 'cosmetic', jsonb_build_object(
'id', c.id, 'name', c.name, 'name_ar', c.name_ar, 'type', c.type,
'rarity', c.rarity, 'preview_url', c.preview_url))
into r from cosmetics c where c.id = p_g->>'cosmetic_id';
elsif t = 'badge' then
select jsonb_build_object('type', t, 'badge', jsonb_build_object(
'id', a.id, 'name', a.name, 'name_ar', a.name_ar, 'icon_url', a.icon_url))
into r from achievements a where a.id = p_g->>'achievement_id';
end if;
return coalesce(r, jsonb_build_object('type', t, 'missing', true));
end $$;
create or replace function public._redeem_preview(
p_c public.redeem_codes, p_account_required boolean, p_already_redeemed boolean, p_already_member boolean)
returns jsonb
language sql
stable
set search_path = public, pg_temp
as $$
select jsonb_build_object(
'ok', true,
'dry_run', true,
'code', _redeem_code_brief(p_c),
'inviter', (select jsonb_build_object('id', p.id, 'name', coalesce(nullif(p.display_name, ''), p.username),
'username', p.username, 'avatar_url', p.avatar_url)
from profiles p where p.id = p_c.owner_player_id),
'org', case when p_c.org_id is not null then _redeem_org_brief(p_c.org_id) end,
'grants', (select jsonb_agg(_redeem_grant_preview(x.g) order by x.o)
from jsonb_array_elements(p_c.grants) with ordinality x(g, o)),
'state', jsonb_build_object('account_required', p_account_required,
'already_redeemed', p_already_redeemed,
'already_member', p_already_member),
'requires_approval', p_c.requires_approval,
'welcome_message', p_c.welcome_message,
'welcome_message_ar', p_c.welcome_message_ar)
$$;
-- Logs the refusal and answers rate_limited with the seconds until the
-- window drops below the limit again.
create or replace function public._redeem_limited(
p_scope text, p_player uuid, p_ip text, p_window interval, p_limit int,
p_device text, p_code text, p_source text, p_dry boolean, p_noacct boolean)
returns jsonb
language plpgsql
set search_path = public, pg_temp
as $$
declare
c_skip constant text[] := array['preview', 'account_required', 'rate_limited'];
v_at timestamptz;
begin
if p_scope in ('player_hour', 'player_day') then
select a.created_at into v_at from redeem_attempts a
where a.player_id = p_player and a.created_at > now() - p_window and a.outcome <> all (c_skip)
order by a.created_at desc offset p_limit - 1 limit 1;
elsif p_scope = 'ip_preview_hour' then
select a.created_at into v_at from redeem_attempts a
where a.ip_hash = p_ip and a.no_account and a.created_at > now() - p_window and a.outcome <> all (c_skip)
order by a.created_at desc offset p_limit - 1 limit 1;
else
select a.created_at into v_at from redeem_attempts a
where a.ip_hash = p_ip and a.created_at > now() - p_window and a.outcome <> all (c_skip)
order by a.created_at desc offset p_limit - 1 limit 1;
end if;
perform _redeem_log(p_player, p_ip, p_device, p_code, null, p_source, 'rate_limited', p_dry, p_noacct);
return jsonb_build_object('ok', false, 'error', 'rate_limited', 'limit', p_scope,
'retry_after', greatest(1, ceil(extract(epoch from (coalesce(v_at, now()) + p_window - now())))::int));
end $$;
-- 3. Creation-time hard limits (design 2.3) --------------------------------------
-- Returns an error key, or null when the combination is allowed. Used by
-- redeem_create and by the redeem_codes trigger, so a direct REST insert or
-- edit cannot step around them.
create or replace function public._redeem_check_limits(
p_kind text, p_grants jsonb, p_max_uses int, p_expires_at timestamptz, p_requires_approval boolean)
returns text
language plpgsql
stable
set search_path = public, pg_temp
as $$
declare
g jsonb;
t text;
v_role text;
n_org int := 0;
begin
if jsonb_typeof(p_grants) is distinct from 'array'
or jsonb_array_length(p_grants) not between 1 and 6 then
return 'invalid_grants';
end if;
for g in select value from jsonb_array_elements(p_grants) loop
t := g->>'type';
if t is null or t not in ('org_join','tournament_register','group_join','coins','gems','frame','cosmetic','badge') then
return 'invalid_grant';
end if;
if t = 'org_join' then
n_org := n_org + 1;
v_role := coalesce(g->>'role', 'member');
if v_role not in ('member', 'arbiter', 'admin') then
return 'invalid_role';
end if;
if v_role in ('arbiter', 'admin') then
if p_kind <> 'generated' then return 'staff_code_must_be_generated'; end if;
if p_max_uses is null or p_max_uses > 5 then return 'staff_code_max_uses'; end if;
if p_expires_at is null or p_expires_at > now() + interval '7 days' then return 'staff_code_expiry'; end if;
end if;
end if;
if t in ('coins', 'gems')
or (t = 'tournament_register' and coalesce((g->>'waive_fee')::boolean, false)) then
if p_kind = 'vanity' then return 'vanity_not_allowed'; end if;
if p_max_uses is null then return 'value_code_needs_max_uses'; end if;
end if;
if p_kind = 'vanity' then
if t in ('frame', 'cosmetic', 'badge') then
return 'vanity_not_allowed';
end if;
if t = 'tournament_register' and exists (
select 1 from el3ab_tournaments x where x.id = _redeem_uuid(g->>'tournament_id')
and (coalesce(x.entry_fee_coins, 0) > 0 or coalesce(x.entry_fee_gems, 0) > 0)) then
return 'vanity_not_allowed';
end if;
end if;
end loop;
if coalesce(p_requires_approval, false) and n_org <> 1 then
return 'approval_needs_one_org_join';
end if;
return null;
end $$;
create or replace function public._redeem_codes_guard()
returns trigger
language plpgsql
security definer
set search_path = public, pg_temp
as $$
declare
v_err text;
begin
if tg_op = 'INSERT'
or new.grants is distinct from old.grants or new.kind is distinct from old.kind
or new.max_uses is distinct from old.max_uses or new.expires_at is distinct from old.expires_at
or new.requires_approval is distinct from old.requires_approval then
v_err := _redeem_check_limits(new.kind, new.grants, new.max_uses, new.expires_at, new.requires_approval);
if v_err is not null then
raise exception 'redeem code limit: %', v_err using errcode = '23514', hint = v_err;
end if;
end if;
if tg_op = 'UPDATE' and (
new.label, new.campaign, new.max_uses, new.starts_at, new.expires_at, new.revoked_at,
new.grants, new.eligibility, new.requires_approval, new.welcome_message, new.welcome_message_ar)
is distinct from (
old.label, old.campaign, old.max_uses, old.starts_at, old.expires_at, old.revoked_at,
old.grants, old.eligibility, old.requires_approval, old.welcome_message, old.welcome_message_ar) then
new.updated_at := now();
end if;
return new;
end $$;
drop trigger if exists redeem_codes_guard on public.redeem_codes;
create trigger redeem_codes_guard
before insert or update on public.redeem_codes
for each row execute function public._redeem_codes_guard();
-- 4. Grant handlers -------------------------------------------------------------
-- Each returns {type, ok, applied, reason?, ...}. ok means the end state holds
-- (applied now, or already true); applied means this call changed something.
-- A handler that refuses changes nothing.
create or replace function public._redeem_org_join(
p_player uuid, p_org uuid, p_role text, p_joined_via text, p_code_id uuid, p_code_display text,
p_inviter uuid, p_redemption uuid, p_application uuid, p_actor uuid)
returns jsonb
language plpgsql
set search_path = public, pg_temp
as $$
declare
v_o el3ab_organizations%rowtype;
v_m org_members%rowtype;
v_has boolean;
v_n int;
v_mid uuid;
v_via text := case when p_application is not null then 'application' else 'code' end;
base jsonb := jsonb_build_object('type', 'org_join', 'org_id', p_org);
begin
if p_role is null or p_role not in ('member', 'arbiter', 'admin') then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'invalid_role');
end if;
select * into v_o from el3ab_organizations where id = p_org for update;
if not found then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'org_not_found');
end if;
if not coalesce(v_o.is_active, true) then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'org_inactive');
end if;
select * into v_m from org_members where org_id = p_org and player_id = p_player for update;
v_has := found;
if v_has and v_m.status = 'banned' then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'banned_from_org');
end if;
if v_has and v_m.status = 'suspended' then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'membership_suspended');
end if;
if v_has and v_m.status = 'active' then
if _redeem_role_rank(p_role) > _redeem_role_rank(v_m.role) then
update org_members set role = p_role where id = v_m.id;
insert into org_activity_log (org_id, actor_id, action, resource_type, resource_id, details)
values (p_org, p_actor, 'member.role_upgraded_via_' || v_via, 'org_member', v_m.id::text,
jsonb_build_object('player_id', p_player, 'from', v_m.role, 'to', p_role,
'code_id', p_code_id, 'code', p_code_display, 'application_id', p_application));
return base || jsonb_build_object('ok', true, 'applied', true, 'upgraded', true,
'from_role', v_m.role, 'role', p_role, 'member_id', v_m.id);
end if;
return base || jsonb_build_object('ok', true, 'applied', false, 'already', true,
'role', v_m.role, 'member_id', v_m.id);
end if;
-- A new member, or one who left and rejoins as a fresh membership.
if v_o.join_policy = 'closed' and p_application is null then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'org_closed');
end if;
if v_o.max_members is not null then
select count(*) into v_n from org_members where org_id = p_org and status = 'active';
if v_n >= v_o.max_members then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'org_full');
end if;
end if;
select count(*) into v_n from org_members where player_id = p_player and status = 'active';
if v_n >= _redeem_org_limit() then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'org_limit');
end if;
if v_has then
update org_members
set status = 'active', role = p_role, joined_at = now(), left_at = null, ban_reason = null,
joined_via = p_joined_via, invited_by = p_inviter, invite_link_id = p_code_id,
redemption_id = p_redemption, application_id = p_application, custom_title = null,
permissions = '{}', is_muted = false, muted_until = null, contribution_points = 0
where id = v_m.id;
v_mid := v_m.id;
else
insert into org_members (org_id, player_id, role, status, joined_via, invited_by,
invite_link_id, redemption_id, application_id)
values (p_org, p_player, p_role, 'active', p_joined_via, p_inviter,
p_code_id, p_redemption, p_application)
returning id into v_mid;
end if;
insert into org_activity_log (org_id, actor_id, action, resource_type, resource_id, details)
values (p_org, p_actor, 'member.joined_via_' || v_via, 'org_member', v_mid::text,
jsonb_build_object('player_id', p_player, 'role', p_role, 'code_id', p_code_id,
'code', p_code_display, 'source', p_joined_via,
'application_id', p_application, 'rejoined', v_has));
if p_inviter is not null and p_inviter <> p_player then
insert into org_referrals (org_id, referrer_id, referred_id, invite_link_id, status)
values (p_org, p_inviter, p_player, p_code_id, 'pending')
on conflict (org_id, referred_id) do nothing;
end if;
return base || jsonb_build_object('ok', true, 'applied', true, 'upgraded', false,
'role', p_role, 'member_id', v_mid, 'rejoined', v_has);
end $$;
-- Same checks as api/tournaments.php `register`: draft or registration status,
-- registration_closes_at, max_players among 'registered' rows, revive a
-- withdrawn/cancelled row. There is no waitlist status, so full is a failure.
-- Entry fees are not charged by tournaments.php yet; waive_fee is recorded.
create or replace function public._redeem_tournament(p_player uuid, p_tid uuid, p_waive boolean)
returns jsonb
language plpgsql
set search_path = public, pg_temp
as $$
declare
v_t el3ab_tournaments%rowtype;
v_rid uuid;
v_rst text;
v_found boolean;
v_n int;
base jsonb := jsonb_build_object('type', 'tournament_register', 'tournament_id', p_tid,
'fee_waived', coalesce(p_waive, false), 'waitlisted', false);
begin
select * into v_t from el3ab_tournaments where id = p_tid for update;
if not found then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'tournament_not_found');
end if;
if v_t.status is null or v_t.status not in ('registration', 'draft')
or (v_t.registration_closes_at is not null and v_t.registration_closes_at < now()) then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'registration_closed');
end if;
select id, status into v_rid, v_rst from tournament_registrations
where tournament_id = p_tid and player_id = p_player for update;
v_found := found;
if v_found and v_rst = 'registered' then
return base || jsonb_build_object('ok', true, 'applied', false, 'already', true, 'registration_id', v_rid);
end if;
select count(*) into v_n from tournament_registrations where tournament_id = p_tid and status = 'registered';
if coalesce(v_t.max_players, 0) > 0 and v_n >= v_t.max_players then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'tournament_full');
end if;
if v_found then
update tournament_registrations set status = 'registered', withdrawn_at = null where id = v_rid;
else
insert into tournament_registrations (tournament_id, player_id, status)
values (p_tid, p_player, 'registered') returning id into v_rid;
end if;
return base || jsonb_build_object('ok', true, 'applied', true, 'registration_id', v_rid,
'revived', v_found, 'player_count', v_n + 1);
end $$;
-- Same checks as api/groups.php `join`: member already, a block either way
-- with the owner, max_members. The chat system line and the owner's
-- notification stay in PHP.
create or replace function public._redeem_group(p_player uuid, p_gid uuid)
returns jsonb
language plpgsql
set search_path = public, pg_temp
as $$
declare
v_g groups%rowtype;
v_n int;
base jsonb := jsonb_build_object('type', 'group_join', 'group_id', p_gid);
begin
select * into v_g from groups where id = p_gid for update;
if not found then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'group_not_found');
end if;
if exists (select 1 from group_members where group_id = p_gid and user_id = p_player) then
return base || jsonb_build_object('ok', true, 'applied', false, 'already', true);
end if;
if exists (select 1 from player_blocks b
where b.type = 'block'
and ((b.player_id = p_player and b.blocked_id = v_g.owner_id)
or (b.player_id = v_g.owner_id and b.blocked_id = p_player))) then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'blocked');
end if;
select count(*) into v_n from group_members where group_id = p_gid;
if v_n >= v_g.max_members then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'group_full');
end if;
insert into group_members (group_id, user_id, role) values (p_gid, p_player, 'member');
update groups set member_count = greatest(v_n + 1, 1) where id = p_gid;
return base || jsonb_build_object('ok', true, 'applied', true, 'member_count', v_n + 1);
end $$;
-- Coins or gems. source 'treasury' debits the org treasury first and fails
-- when it is short; 'platform' mints (staff codes only, enforced at creation).
-- Ledger rows match award_coins: profiles balance, then economy_transactions
-- with balance_after.
create or replace function public._redeem_currency(
p_player uuid, p_currency text, p_amount int, p_source text, p_org uuid,
p_code_display text, p_redemption uuid)
returns jsonb
language plpgsql
set search_path = public, pg_temp
as $$
declare
v_tb bigint;
v_bal int;
base jsonb := jsonb_build_object('type', p_currency, 'amount', p_amount, 'source', p_source);
begin
if p_amount is null or p_amount <= 0 then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'invalid_amount');
end if;
if p_source not in ('treasury', 'platform') then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'invalid_grant');
end if;
if p_source = 'treasury' then
if p_org is null then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'treasury_short');
end if;
if p_currency = 'coins' then
update org_treasury
set balance_coins = coalesce(balance_coins, 0) - p_amount,
total_withdrawn_coins = coalesce(total_withdrawn_coins, 0) + p_amount,
updated_at = now()
where org_id = p_org and coalesce(balance_coins, 0) >= p_amount
returning balance_coins into v_tb;
else
update org_treasury
set balance_gems = coalesce(balance_gems, 0) - p_amount,
total_withdrawn_gems = coalesce(total_withdrawn_gems, 0) + p_amount,
updated_at = now()
where org_id = p_org and coalesce(balance_gems, 0) >= p_amount
returning balance_gems into v_tb;
end if;
if not found then
select case when p_currency = 'coins' then balance_coins else balance_gems end
into v_tb from org_treasury where org_id = p_org;
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'treasury_short',
'treasury_balance', coalesce(v_tb, 0));
end if;
insert into org_treasury_transactions (org_id, type, currency, amount, balance_after, player_id, reason)
values (p_org, 'redeem_grant', p_currency, p_amount, v_tb, p_player, 'redeem ' || p_code_display);
end if;
if p_currency = 'coins' then
update profiles set coins = coalesce(coins, 0) + p_amount where id = p_player returning coins into v_bal;
else
update profiles set gems = coalesce(gems, 0) + p_amount where id = p_player returning gems into v_bal;
end if;
insert into economy_transactions (player_id, type, currency, amount, balance_after, reason, source_id)
values (p_player, 'redeem_grant', p_currency, p_amount, v_bal, 'redeem ' || p_code_display, p_redemption::text);
return base || jsonb_build_object('ok', true, 'applied', true, 'balance', v_bal)
|| case when p_source = 'treasury' then jsonb_build_object('treasury_balance', v_tb) else '{}'::jsonb end;
end $$;
create or replace function public._redeem_frame(p_player uuid, p_fid uuid, p_redemption uuid)
returns jsonb
language plpgsql
set search_path = public, pg_temp
as $$
declare
v_f profile_frames%rowtype;
base jsonb := jsonb_build_object('type', 'frame', 'frame_id', p_fid);
begin
select * into v_f from profile_frames where id = p_fid for update;
if not found or not coalesce(v_f.is_active, true) then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'frame_not_found');
end if;
if exists (select 1 from player_frames where player_id = p_player and frame_id = p_fid) then
return base || jsonb_build_object('ok', true, 'applied', false, 'already', true);
end if;
if v_f.max_supply is not null and coalesce(v_f.current_supply, 0) >= v_f.max_supply then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'frame_sold_out');
end if;
insert into player_frames (player_id, frame_id, acquisition_type, source_id)
values (p_player, p_fid, 'redeem', p_redemption::text);
update profile_frames set current_supply = coalesce(current_supply, 0) + 1 where id = p_fid;
return base || jsonb_build_object('ok', true, 'applied', true);
end $$;
create or replace function public._redeem_cosmetic(p_player uuid, p_cid text)
returns jsonb
language plpgsql
set search_path = public, pg_temp
as $$
declare
v_id uuid;
base jsonb := jsonb_build_object('type', 'cosmetic', 'cosmetic_id', p_cid);
begin
if not exists (select 1 from cosmetics where id = p_cid) then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'cosmetic_not_found');
end if;
insert into player_cosmetics (player_id, cosmetic_id, acquired_via, is_equipped)
values (p_player, p_cid, 'redeem', false)
on conflict (player_id, cosmetic_id) do nothing
returning id into v_id;
if v_id is null then
return base || jsonb_build_object('ok', true, 'applied', false, 'already', true);
end if;
return base || jsonb_build_object('ok', true, 'applied', true);
end $$;
-- Platform achievements (player_achievements.achievement_id references
-- achievements.id). The achievement's own coin/XP reward is not paid here.
create or replace function public._redeem_badge(p_player uuid, p_aid text)
returns jsonb
language plpgsql
set search_path = public, pg_temp
as $$
declare
v_target int;
v_done boolean;
v_found boolean;
base jsonb := jsonb_build_object('type', 'badge', 'achievement_id', p_aid);
begin
select coalesce(_redeem_int(a.condition->>'count'), 1) into v_target from achievements a where a.id = p_aid;
if not found then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'badge_not_found');
end if;
select coalesce(completed, false) into v_done from player_achievements
where player_id = p_player and achievement_id = p_aid for update;
v_found := found;
if v_found and v_done then
return base || jsonb_build_object('ok', true, 'applied', false, 'already', true);
end if;
if v_found then
update player_achievements
set completed = true, completed_at = now(), progress = greatest(coalesce(progress, 0), v_target)
where player_id = p_player and achievement_id = p_aid;
else
insert into player_achievements (player_id, achievement_id, progress, completed, completed_at)
values (p_player, p_aid, v_target, true, now());
end if;
return base || jsonb_build_object('ok', true, 'applied', true, 'inserted', not v_found);
end $$;
-- Runs one grant. An unexpected error inside a handler rolls back only that
-- handler (sub-transaction) and becomes reason 'grant_error'.
create or replace function public._redeem_apply_grant(
p_player uuid, p_g jsonb, p_code_id uuid, p_code_display text, p_code_org uuid, p_scope text,
p_inviter uuid, p_joined_via text, p_redemption uuid, p_application uuid, p_actor uuid)
returns jsonb
language plpgsql
set search_path = public, pg_temp
as $$
declare
t text := p_g->>'type';
r jsonb;
begin
begin
if t = 'org_join' then
r := _redeem_org_join(p_player, _redeem_uuid(p_g->>'org_id'), coalesce(p_g->>'role', 'member'),
p_joined_via, p_code_id, p_code_display, p_inviter, p_redemption,
p_application, p_actor);
elsif t = 'tournament_register' then
r := _redeem_tournament(p_player, _redeem_uuid(p_g->>'tournament_id'),
coalesce((p_g->>'waive_fee')::boolean, false));
elsif t = 'group_join' then
r := _redeem_group(p_player, _redeem_uuid(p_g->>'group_id'));
elsif t in ('coins', 'gems') then
r := _redeem_currency(p_player, t, _redeem_int(p_g->>'amount'),
coalesce(p_g->>'source', case when p_scope = 'org' then 'treasury' else 'platform' end),
p_code_org, p_code_display, p_redemption);
elsif t = 'frame' then
r := _redeem_frame(p_player, _redeem_uuid(p_g->>'frame_id'), p_redemption);
elsif t = 'cosmetic' then
r := _redeem_cosmetic(p_player, p_g->>'cosmetic_id');
elsif t = 'badge' then
r := _redeem_badge(p_player, p_g->>'achievement_id');
else
r := jsonb_build_object('type', t, 'ok', false, 'applied', false, 'reason', 'invalid_grant');
end if;
exception when others then
r := jsonb_build_object('type', t, 'ok', false, 'applied', false, 'reason', 'grant_error', 'detail', sqlerrm);
end;
return r;
end $$;
-- 5. redeem_code ------------------------------------------------------------------
create or replace function public.redeem_code(
p_player uuid,
p_code text,
p_source text default 'redeem_screen',
p_ip_hash text default null,
p_device text default null,
p_dry_run boolean default false)
returns jsonb
language plpgsql
security definer
set search_path = public, extensions, pg_temp
as $$
declare
c_skip constant text[] := array['preview', 'account_required', 'rate_limited'];
v_ip text := coalesce(nullif(btrim(p_ip_hash), ''), 'none');
v_device text := left(nullif(btrim(p_device), ''), 128);
v_dry boolean := coalesce(p_dry_run, false);
v_prof profiles%rowtype;
v_player boolean := false;
v_anon boolean := false;
v_noacct boolean;
v_n int;
v_fail int := 0;
v_canon text;
v_c redeem_codes%rowtype;
v_grp groups%rowtype;
v_red redeem_redemptions%rowtype;
v_g jsonb;
v_org uuid;
v_m org_members%rowtype;
v_has_m boolean;
v_banned uuid;
v_all_mem boolean := true;
v_new_orgs uuid[] := '{}';
v_full uuid;
v_elig jsonb;
v_rule text;
v_rval jsonb;
v_preview jsonb;
v_r jsonb;
v_results jsonb := '[]';
v_any_ok boolean := false;
v_any_fail boolean := false;
v_first text;
v_status text;
v_rid uuid;
v_app uuid;
v_newsign boolean;
v_org_join boolean;
begin
if p_source is null or p_source not in ('signup','link','qr','redeem_screen','org_page','guest_upgrade','admin') then
return jsonb_build_object('ok', false, 'error', 'invalid_request', 'detail', 'source');
end if;
if p_player is not null then
select * into v_prof from profiles where id = p_player;
if not found then
return jsonb_build_object('ok', false, 'error', 'player_not_found');
end if;
v_player := true;
select coalesce(u.is_anonymous, false) into v_anon from auth.users u where u.id = p_player;
v_anon := coalesce(v_anon, false);
end if;
v_noacct := not v_player or v_anon;
-- 1. Rate limits. Only failures count: previews that found a code,
-- account_required and rate_limited refusals never do.
if v_player then
select count(*) into v_n from redeem_attempts a
where a.player_id = p_player and a.created_at > now() - interval '1 hour' and a.outcome <> all (c_skip);
v_fail := v_n;
if v_n >= 8 then
return _redeem_limited('player_hour', p_player, v_ip, interval '1 hour', 8, v_device, p_code, p_source, v_dry, v_noacct);
end if;
select count(*) into v_n from redeem_attempts a
where a.player_id = p_player and a.created_at > now() - interval '24 hours' and a.outcome <> all (c_skip);
if v_n >= 25 then
return _redeem_limited('player_day', p_player, v_ip, interval '24 hours', 25, v_device, p_code, p_source, v_dry, v_noacct);
end if;
end if;
if v_ip <> 'none' then
if v_noacct then
select count(*) into v_n from redeem_attempts a
where a.ip_hash = v_ip and a.no_account and a.created_at > now() - interval '1 hour' and a.outcome <> all (c_skip);
if v_n >= 15 then
return _redeem_limited('ip_preview_hour', p_player, v_ip, interval '1 hour', 15, v_device, p_code, p_source, v_dry, v_noacct);
end if;
else
select count(*) into v_n from redeem_attempts a
where a.ip_hash = v_ip and a.created_at > now() - interval '1 hour' and a.outcome <> all (c_skip);
if v_n >= 40 then
return _redeem_limited('ip_hour', p_player, v_ip, interval '1 hour', 40, v_device, p_code, p_source, v_dry, v_noacct);
end if;
end if;
if not v_player then
v_fail := v_n;
end if;
end if;
-- 2. Shape (PHP/JS already canonicalised; folding again is idempotent).
v_canon := redeem_canonical(p_code);
if v_canon is null then
perform _redeem_log(p_player, v_ip, v_device, p_code, null, p_source, 'invalid_format', v_dry, v_noacct);
return jsonb_build_object('ok', false, 'error', 'invalid_format', 'recent_failures', v_fail + 1);
end if;
-- 3. Lookup. Applying locks the code row: this serialises a hall of players
-- on one poster and makes the seat check below exact.
if v_dry then
select * into v_c from redeem_codes where code = v_canon;
else
select * into v_c from redeem_codes where code = v_canon for update;
end if;
if not found then
-- Legacy namespace: a group's own invite code (groups.invite_code).
if v_dry then
select * into v_grp from groups g where redeem_canonical(g.invite_code) = v_canon limit 1;
else
select * into v_grp from groups g where redeem_canonical(g.invite_code) = v_canon limit 1 for update;
end if;
if not found then
perform _redeem_log(p_player, v_ip, v_device, p_code, null, p_source, 'not_found', v_dry, v_noacct);
return jsonb_build_object('ok', false, 'error', 'not_found', 'recent_failures', v_fail + 1);
end if;
v_preview := jsonb_build_object(
'ok', true, 'dry_run', true,
'code', jsonb_build_object('id', null, 'display', v_grp.invite_code, 'label', null, 'kind', 'group',
'scope', 'group', 'org_id', null, 'starts_at', null, 'expires_at', null,
'seats_left', greatest(v_grp.max_members - v_grp.member_count, 0),
'requires_approval', false),
'inviter', null, 'org', null,
'grants', jsonb_build_array(_redeem_grant_preview(jsonb_build_object('type', 'group_join', 'group_id', v_grp.id))),
'state', jsonb_build_object('account_required', v_noacct, 'already_redeemed', false,
'already_member', v_player and exists (
select 1 from group_members where group_id = v_grp.id and user_id = p_player)),
'requires_approval', false, 'welcome_message', null, 'welcome_message_ar', null);
if v_dry then
perform _redeem_log(p_player, v_ip, v_device, p_code, null, p_source, 'preview', true, v_noacct);
return v_preview;
end if;
if v_noacct then
perform _redeem_log(p_player, v_ip, v_device, p_code, null, p_source, 'account_required', false, v_noacct);
return jsonb_build_object('ok', false, 'error', 'account_required', 'intent_saved', false, 'preview', v_preview);
end if;
perform 1 from profiles where id = p_player for no key update;
v_r := _redeem_group(p_player, v_grp.id);
if coalesce((v_r->>'applied')::boolean, false) then
return jsonb_build_object('ok', true, 'status', 'applied', 'redemption_id', null,
'code', v_preview->'code', 'results', jsonb_build_array(v_r),
'welcome_message', null, 'welcome_message_ar', null, 'was_new_signup', false);
end if;
if (v_r->>'ok')::boolean then
return jsonb_build_object('ok', true, 'status', 'already_member', 'group', v_preview->'grants'->0->'group',
'results', jsonb_build_array(v_r));
end if;
perform _redeem_log(p_player, v_ip, v_device, p_code, null, p_source, v_r->>'reason', false, v_noacct);
return jsonb_build_object('ok', false, 'error', v_r->>'reason', 'results', jsonb_build_array(v_r),
'recent_failures', v_fail + 1);
end if;
-- 4. Already redeemed. Checked before the code's own state so that a retry
-- after the last seat went, or after expiry, still gets its result.
if v_player then
select * into v_red from redeem_redemptions where code_id = v_c.id and player_id = p_player;
if found then
if v_dry then
return _redeem_preview(v_c, v_noacct, true, false)
|| jsonb_build_object('redemption', jsonb_build_object(
'id', v_red.id, 'status', v_red.status, 'created_at', v_red.created_at, 'results', v_red.results));
end if;
return jsonb_build_object('ok', true, 'status', 'already_redeemed', 'already', true,
'redemption_id', v_red.id, 'redemption_status', v_red.status,
'results', v_red.results, 'created_at', v_red.created_at,
'code', _redeem_code_brief(v_c),
'welcome_message', v_c.welcome_message, 'welcome_message_ar', v_c.welcome_message_ar);
end if;
end if;
-- 5. The code's own state. A soft-deleted org's codes answer revoked.
if v_c.revoked_at is not null
or exists (select 1 from el3ab_organizations o where o.id = v_c.org_id and not coalesce(o.is_active, true))
or exists (select 1 from jsonb_array_elements(v_c.grants) g
join el3ab_organizations o on o.id = _redeem_uuid(g->>'org_id')
where g->>'type' = 'org_join' and not coalesce(o.is_active, true)) then
perform _redeem_log(p_player, v_ip, v_device, p_code, v_c.id, p_source, 'revoked', v_dry, v_noacct);
return jsonb_build_object('ok', false, 'error', 'revoked', 'recent_failures', v_fail + 1);
end if;
if v_c.starts_at is not null and v_c.starts_at > now() then
perform _redeem_log(p_player, v_ip, v_device, p_code, v_c.id, p_source, 'not_started', v_dry, v_noacct);
return jsonb_build_object('ok', false, 'error', 'not_started', 'starts_at', v_c.starts_at,
'recent_failures', v_fail + 1);
end if;
if v_c.expires_at is not null and v_c.expires_at <= now() then
perform _redeem_log(p_player, v_ip, v_device, p_code, v_c.id, p_source, 'expired', v_dry, v_noacct);
return jsonb_build_object('ok', false, 'error', 'expired', 'expires_at', v_c.expires_at,
'recent_failures', v_fail + 1);
end if;
if v_c.max_uses is not null and v_c.uses + v_c.reserved >= v_c.max_uses then
perform _redeem_log(p_player, v_ip, v_device, p_code, v_c.id, p_source, 'exhausted', v_dry, v_noacct);
return jsonb_build_object('ok', false, 'error', 'exhausted', 'recent_failures', v_fail + 1);
end if;
-- 6. Accounts. A guest (GoTrue anonymous user) holds the code as an intent,
-- on preview as well as on apply, until the account is upgraded in place.
if v_noacct then
if v_anon then
insert into redeem_intents (player_id, code_id, source) values (p_player, v_c.id, p_source)
on conflict (player_id, code_id) do nothing;
end if;
v_preview := _redeem_preview(v_c, true, false, false);
if v_dry then
update redeem_codes
set previews = previews + 1,
link_opens = link_opens + case when p_source in ('link', 'qr') then 1 else 0 end
where id = v_c.id;
perform _redeem_log(p_player, v_ip, v_device, p_code, v_c.id, p_source, 'preview', true, v_noacct);
return v_preview || jsonb_build_object('intent_saved', v_anon);
end if;
perform _redeem_log(p_player, v_ip, v_device, p_code, v_c.id, p_source, 'account_required', false, v_noacct);
return jsonb_build_object('ok', false, 'error', 'account_required', 'intent_saved', v_anon, 'preview', v_preview);
end if;
-- 7. The player against this code: ban, eligibility, already a member,
-- the org limit, org capacity (in that order, design 2.7).
for v_g in select value from jsonb_array_elements(v_c.grants) loop
if v_g->>'type' <> 'org_join' then
v_all_mem := false;
continue;
end if;
v_org := _redeem_uuid(v_g->>'org_id');
select * into v_m from org_members where org_id = v_org and player_id = p_player;
v_has_m := found;
if v_has_m and v_m.status = 'banned' and v_banned is null then
v_banned := v_org;
end if;
if v_has_m and v_m.status = 'active'
and _redeem_role_rank(v_m.role) >= _redeem_role_rank(coalesce(v_g->>'role', 'member')) then
continue;
end if;
v_all_mem := false;
if not v_has_m or v_m.status = 'left' then
v_new_orgs := v_new_orgs || v_org;
if not v_c.requires_approval and v_full is null and exists (
select 1 from el3ab_organizations o
where o.id = v_org and o.max_members is not null
and (select count(*) from org_members m where m.org_id = v_org and m.status = 'active') >= o.max_members) then
v_full := v_org;
end if;
end if;
end loop;
if v_banned is not null then
perform _redeem_log(p_player, v_ip, v_device, p_code, v_c.id, p_source, 'banned_from_org', v_dry, v_noacct);
return jsonb_build_object('ok', false, 'error', 'banned_from_org', 'org_id', v_banned,
'recent_failures', v_fail + 1);
end if;
v_elig := v_c.eligibility;
if coalesce((v_elig->>'new_accounts_only')::boolean, false) and v_prof.created_at < v_c.created_at then
v_rule := 'new_accounts_only'; v_rval := to_jsonb(true);
elsif v_elig ? 'min_level' and coalesce(v_prof.level, 1) < _redeem_int(v_elig->>'min_level') then
v_rule := 'min_level'; v_rval := v_elig->'min_level';
elsif v_elig ? 'min_games'
and greatest(coalesce(v_prof.total_games_played, 0), coalesce(v_prof.games_played, 0)) < _redeem_int(v_elig->>'min_games') then
v_rule := 'min_games'; v_rval := v_elig->'min_games';
elsif v_elig ? 'account_age_days'
and v_prof.created_at > now() - make_interval(days => coalesce(_redeem_int(v_elig->>'account_age_days'), 0)) then
v_rule := 'account_age_days'; v_rval := v_elig->'account_age_days';
elsif jsonb_typeof(v_elig->'countries') = 'array' and jsonb_array_length(v_elig->'countries') > 0
and not exists (select 1 from jsonb_array_elements_text(v_elig->'countries') c
where upper(btrim(c)) = upper(btrim(coalesce(v_prof.country_code, '')))) then
v_rule := 'countries'; v_rval := v_elig->'countries';
end if;
if v_rule is not null then
perform _redeem_log(p_player, v_ip, v_device, p_code, v_c.id, p_source, 'not_eligible', v_dry, v_noacct);
return jsonb_build_object('ok', false, 'error', 'not_eligible', 'rule', v_rule, 'value', v_rval,
'recent_failures', v_fail + 1, 'preview', _redeem_preview(v_c, false, false, false));
end if;
if v_all_mem then
v_org := _redeem_uuid((select g->>'org_id' from jsonb_array_elements(v_c.grants) g limit 1));
if v_dry then
update redeem_codes
set previews = previews + 1,
link_opens = link_opens + case when p_source in ('link', 'qr') then 1 else 0 end
where id = v_c.id;
perform _redeem_log(p_player, v_ip, v_device, p_code, v_c.id, p_source, 'preview', true, false);
return _redeem_preview(v_c, false, false, true);
end if;
return jsonb_build_object('ok', true, 'status', 'already_member', 'org', _redeem_org_brief(v_org),
'code', _redeem_code_brief(v_c));
end if;
if array_length(v_new_orgs, 1) > 0 then
select count(*) into v_n from org_members where player_id = p_player and status = 'active';
if v_n >= _redeem_org_limit() then
perform _redeem_log(p_player, v_ip, v_device, p_code, v_c.id, p_source, 'org_limit', v_dry, v_noacct);
return jsonb_build_object('ok', false, 'error', 'org_limit', 'limit', _redeem_org_limit(),
'orgs', (select jsonb_agg(_redeem_org_brief(m.org_id) || jsonb_build_object('role', m.role) order by m.joined_at)
from org_members m where m.player_id = p_player and m.status = 'active'),
'recent_failures', v_fail + 1);
end if;
end if;
if v_full is not null then
perform _redeem_log(p_player, v_ip, v_device, p_code, v_c.id, p_source, 'org_full', v_dry, v_noacct);
return jsonb_build_object('ok', false, 'error', 'org_full', 'org', _redeem_org_brief(v_full),
'recent_failures', v_fail + 1);
end if;
-- 8. Preview.
if v_dry then
update redeem_codes
set previews = previews + 1,
link_opens = link_opens + case when p_source in ('link', 'qr') then 1 else 0 end
where id = v_c.id;
perform _redeem_log(p_player, v_ip, v_device, p_code, v_c.id, p_source, 'preview', true, false);
return _redeem_preview(v_c, false, false, false);
end if;
-- 9. Apply.
perform 1 from profiles where id = p_player for no key update;
v_rid := gen_random_uuid();
v_newsign := p_source in ('signup', 'guest_upgrade') or v_prof.created_at > now() - interval '10 minutes';
if v_c.requires_approval then
-- The seat is held (reserved) and every grant waits for decide_application().
v_g := (select g from jsonb_array_elements(v_c.grants) g where g->>'type' = 'org_join' limit 1);
v_org := _redeem_uuid(v_g->>'org_id');
select a.id into v_app from org_membership_applications a
where a.org_id = v_org and a.player_id = p_player and a.status = 'pending' and a.redemption_id is null
order by a.created_at desc limit 1
for update;
if found then
update org_membership_applications
set invite_code_id = coalesce(invite_code_id, v_c.id), redemption_id = v_rid, updated_at = now()
where id = v_app;
else
insert into org_membership_applications (org_id, player_id, invite_code_id, redemption_id, status, document_type, notes)
values (v_org, p_player, v_c.id, v_rid, 'pending', 'invite_code', v_c.code_display)
returning id into v_app;
end if;
update redeem_codes set reserved = reserved + 1 where id = v_c.id;
select jsonb_agg(case when x.g->>'type' = 'org_join'
then jsonb_build_object('type', 'org_join', 'ok', true, 'applied', false, 'pending', true,
'org_id', v_org, 'role', coalesce(x.g->>'role', 'member'),
'application_id', v_app)
else jsonb_build_object('type', x.g->>'type', 'ok', null, 'applied', false, 'deferred', true)
end order by x.o)
into v_results
from jsonb_array_elements(v_c.grants) with ordinality x(g, o);
insert into redeem_redemptions (id, code_id, player_id, status, source, was_new_signup, results, ip_hash, device_id)
values (v_rid, v_c.id, p_player, 'pending_approval', p_source, v_newsign, v_results,
nullif(v_ip, 'none'), v_device);
delete from redeem_intents where player_id = p_player and code_id = v_c.id;
insert into org_activity_log (org_id, actor_id, action, resource_type, resource_id, details)
values (v_org, p_player, 'member.applied_via_code', 'org_membership_application', v_app::text,
jsonb_build_object('player_id', p_player, 'code_id', v_c.id, 'code', v_c.code_display,
'source', p_source, 'redemption_id', v_rid));
return jsonb_build_object('ok', true, 'status', 'pending_approval', 'redemption_id', v_rid,
'application_id', v_app, 'org', _redeem_org_brief(v_org),
'code', _redeem_code_brief(v_c), 'results', v_results,
'welcome_message', v_c.welcome_message, 'welcome_message_ar', v_c.welcome_message_ar,
'was_new_signup', v_newsign);
end if;
for v_g in select value from jsonb_array_elements(v_c.grants) loop
v_r := _redeem_apply_grant(p_player, v_g, v_c.id, v_c.code_display, v_c.org_id, v_c.scope,
v_c.owner_player_id, p_source, v_rid, null, p_player);
v_results := v_results || jsonb_build_array(v_r);
if coalesce((v_r->>'applied')::boolean, false) then
v_any_ok := true;
end if;
if not coalesce((v_r->>'ok')::boolean, false) then
v_any_fail := true;
v_first := coalesce(v_first, v_r->>'reason');
end if;
end loop;
-- A seat is used only when at least one grant took effect.
if not v_any_ok then
if v_any_fail then
perform _redeem_log(p_player, v_ip, v_device, p_code, v_c.id, p_source, v_first, false, false);
return jsonb_build_object('ok', false, 'error', v_first, 'results', v_results,
'recent_failures', v_fail + 1);
end if;
v_org_join := exists (select 1 from jsonb_array_elements(v_c.grants) g where g->>'type' = 'org_join');
return jsonb_build_object('ok', true, 'status', case when v_org_join then 'already_member' else 'already_owned' end,
'results', v_results, 'code', _redeem_code_brief(v_c));
end if;
v_status := case when v_any_fail then 'partial' else 'applied' end;
update redeem_codes set uses = uses + 1 where id = v_c.id;
insert into redeem_redemptions (id, code_id, player_id, status, source, was_new_signup, results, ip_hash, device_id)
values (v_rid, v_c.id, p_player, v_status, p_source, v_newsign, v_results, nullif(v_ip, 'none'), v_device);
delete from redeem_intents where player_id = p_player and code_id = v_c.id;
return jsonb_build_object('ok', true, 'status', v_status, 'redemption_id', v_rid,
'code', _redeem_code_brief(v_c), 'results', v_results,
'welcome_message', v_c.welcome_message, 'welcome_message_ar', v_c.welcome_message_ar,
'was_new_signup', v_newsign);
end $$;
-- 6. decide_application ---------------------------------------------------------
create or replace function public.decide_application(
p_app uuid,
p_decision text,
p_reviewer_admin uuid default null,
p_reviewer_player uuid default null,
p_reason text default null)
returns jsonb
language plpgsql
security definer
set search_path = public, extensions, pg_temp
as $$
declare
v_a org_membership_applications%rowtype;
v_code_id uuid;
v_c redeem_codes%rowtype;
v_has_c boolean := false;
v_red redeem_redemptions%rowtype;
v_has_r boolean := false;
v_adm admin_users%rowtype;
v_org el3ab_organizations%rowtype;
v_brief jsonb;
v_role text := 'member';
v_actor uuid := coalesce(p_reviewer_admin, p_reviewer_player);
v_join jsonb;
v_r jsonb;
v_results jsonb := '[]';
v_any_ok boolean := false;
v_any_fail boolean := false;
v_status text;
v_x record;
v_g jsonb;
begin
if p_decision is null or p_decision not in ('approved', 'rejected') then
return jsonb_build_object('ok', false, 'error', 'invalid_decision');
end if;
if v_actor is null then
return jsonb_build_object('ok', false, 'error', 'reviewer_required');
end if;
-- Same lock order as redeem_code: the code row before the application.
select invite_code_id into v_code_id from org_membership_applications where id = p_app;
if not found then
return jsonb_build_object('ok', false, 'error', 'not_found');
end if;
if v_code_id is not null then
select * into v_c from redeem_codes where id = v_code_id for update;
v_has_c := found;
end if;
select * into v_a from org_membership_applications where id = p_app for update;
if v_a.status is distinct from 'pending' then
return jsonb_build_object('ok', false, 'error', 'already_decided', 'status', v_a.status);
end if;
select * into v_org from el3ab_organizations where id = v_a.org_id;
if not found then
return jsonb_build_object('ok', false, 'error', 'org_not_found');
end if;
v_brief := jsonb_build_object('id', v_org.id, 'name', v_org.name, 'name_ar', v_org.name_ar);
-- Staff (org-scoped staff only for their own org), or an owner/admin of the org.
if p_reviewer_admin is not null then
select * into v_adm from admin_users
where id = p_reviewer_admin and coalesce(is_active, true) and deleted_at is null;
if not found or (v_adm.org_id is not null and v_adm.org_id <> v_a.org_id) then
return jsonb_build_object('ok', false, 'error', 'forbidden');
end if;
elsif not exists (select 1 from org_members m
where m.org_id = v_a.org_id and m.player_id = p_reviewer_player
and m.status = 'active' and m.role in ('owner', 'admin')) then
return jsonb_build_object('ok', false, 'error', 'forbidden');
end if;
if v_a.redemption_id is not null then
select * into v_red from redeem_redemptions where id = v_a.redemption_id for update;
v_has_r := found and v_red.status = 'pending_approval';
end if;
if v_has_c then
v_join := (select g from jsonb_array_elements(v_c.grants) g
where g->>'type' = 'org_join' and _redeem_uuid(g->>'org_id') = v_a.org_id limit 1);
v_role := coalesce(v_join->>'role', 'member');
end if;
if p_decision = 'rejected' then
update org_membership_applications
set status = 'rejected', rejection_reason = p_reason, review_note = p_reason,
reviewed_by = p_reviewer_player, reviewed_by_admin = p_reviewer_admin,
reviewed_at = now(), updated_at = now()
where id = p_app;
if v_has_r then
update redeem_redemptions set status = 'rejected', reviewed_by = v_actor, reviewed_at = now(), updated_at = now()
where id = v_red.id;
update redeem_codes set reserved = greatest(reserved - 1, 0) where id = v_red.code_id;
end if;
insert into org_activity_log (org_id, actor_id, action, resource_type, resource_id, details)
values (v_a.org_id, v_actor, 'application.rejected', 'org_membership_application', p_app::text,
jsonb_build_object('player_id', v_a.player_id, 'reason', p_reason, 'by_admin', p_reviewer_admin is not null));
return jsonb_build_object('ok', true, 'status', 'rejected', 'org', v_brief, 'player_id', v_a.player_id,
'application_id', p_app, 'member', null,
'redemption', case when v_has_r then jsonb_build_object('id', v_red.id, 'status', 'rejected') end);
end if;
-- Approve: the membership first; a refusal leaves the application pending.
perform 1 from profiles where id = v_a.player_id for no key update;
v_r := _redeem_org_join(v_a.player_id, v_a.org_id, v_role, 'application',
case when v_has_c then v_c.id end, case when v_has_c then v_c.code_display end,
case when v_has_c then v_c.owner_player_id end,
case when v_has_r then v_red.id end, p_app, v_actor);
if not coalesce((v_r->>'ok')::boolean, false) then
return jsonb_build_object('ok', false, 'error', v_r->>'reason', 'org', v_brief, 'player_id', v_a.player_id);
end if;
v_any_ok := coalesce((v_r->>'applied')::boolean, false);
-- A code application: run the grants that waited for approval.
if v_has_r then
for v_x in select g, o from jsonb_array_elements(v_c.grants) with ordinality t(g, o) order by o loop
if v_x.g->>'type' = 'org_join' and _redeem_uuid(v_x.g->>'org_id') = v_a.org_id then
v_results := v_results || jsonb_build_array(v_r || jsonb_build_object('application_id', p_app));
continue;
end if;
v_g := _redeem_apply_grant(v_a.player_id, v_x.g, v_c.id, v_c.code_display, v_c.org_id, v_c.scope,
v_c.owner_player_id, 'application', v_red.id, p_app, v_actor);
v_results := v_results || jsonb_build_array(v_g);
if coalesce((v_g->>'applied')::boolean, false) then v_any_ok := true; end if;
if not coalesce((v_g->>'ok')::boolean, false) then v_any_fail := true; end if;
end loop;
v_status := case when v_any_fail then 'partial' else 'applied' end;
update redeem_redemptions
set status = v_status, results = v_results, reviewed_by = v_actor, reviewed_at = now(), updated_at = now()
where id = v_red.id;
-- The held seat is used if anything took effect, released otherwise.
update redeem_codes
set uses = uses + case when v_any_ok then 1 else 0 end,
reserved = greatest(reserved - 1, 0)
where id = v_red.code_id;
end if;
update org_membership_applications
set status = 'approved', review_note = p_reason,
reviewed_by = p_reviewer_player, reviewed_by_admin = p_reviewer_admin,
reviewed_at = now(), updated_at = now()
where id = p_app;
insert into org_activity_log (org_id, actor_id, action, resource_type, resource_id, details)
values (v_a.org_id, v_actor, 'application.approved', 'org_membership_application', p_app::text,
jsonb_build_object('player_id', v_a.player_id, 'role', v_r->>'role', 'note', p_reason,
'by_admin', p_reviewer_admin is not null));
return jsonb_build_object(
'ok', true, 'status', 'approved', 'org', v_brief, 'player_id', v_a.player_id, 'application_id', p_app,
'member', jsonb_build_object('id', v_r->'member_id', 'role', v_r->'role',
'upgraded', coalesce(v_r->'upgraded', 'false'::jsonb),
'already', coalesce(v_r->'already', 'false'::jsonb)),
'redemption', case when v_has_r then jsonb_build_object('id', v_red.id, 'status', v_status, 'results', v_results) end);
end $$;
-- 7. redeem_reverse (design 2.10) ----------------------------------------------
-- Undoes what one redemption did, as far as it still stands, frees its seat
-- and marks it reversed. Coins: takes back what the player still holds (never
-- below zero) and returns exactly that to the treasury when the treasury paid.
create or replace function public.redeem_reverse(
p_redemption uuid,
p_actor uuid,
p_reason text default null)
returns jsonb
language plpgsql
security definer
set search_path = public, extensions, pg_temp
as $$
declare
v_code_id uuid;
v_c redeem_codes%rowtype;
v_red redeem_redemptions%rowtype;
v_adm admin_users%rowtype;
v_is_adm boolean := false;
v_r jsonb;
v_effects jsonb := '[]';
v_amt int;
v_take int;
v_bal int;
v_tb bigint;
v_n int;
begin
if p_actor is null then
return jsonb_build_object('ok', false, 'error', 'actor_required');
end if;
select code_id into v_code_id from redeem_redemptions where id = p_redemption;
if not found then
return jsonb_build_object('ok', false, 'error', 'not_found');
end if;
select * into v_c from redeem_codes where id = v_code_id for update;
select * into v_red from redeem_redemptions where id = p_redemption for update;
if v_red.status = 'reversed' then
return jsonb_build_object('ok', false, 'error', 'already_reversed');
end if;
if v_red.status = 'rejected' then
return jsonb_build_object('ok', false, 'error', 'not_reversible');
end if;
-- Staff (org-scoped staff for their own org only) or the owner of the code's org.
select * into v_adm from admin_users where id = p_actor and coalesce(is_active, true) and deleted_at is null;
if found then
if v_adm.org_id is not null and v_adm.org_id is distinct from v_c.org_id then
return jsonb_build_object('ok', false, 'error', 'forbidden');
end if;
v_is_adm := true;
elsif v_c.org_id is null or not exists (
select 1 from org_members m where m.org_id = v_c.org_id and m.player_id = p_actor
and m.status = 'active' and m.role = 'owner') then
return jsonb_build_object('ok', false, 'error', 'forbidden');
end if;
perform 1 from profiles where id = v_red.player_id for no key update;
if v_red.status = 'pending_approval' then
update org_membership_applications
set status = 'cancelled', review_note = p_reason, reviewed_at = now(), updated_at = now(),
reviewed_by_admin = case when v_is_adm then p_actor end,
reviewed_by = case when v_is_adm then null else p_actor end
where redemption_id = v_red.id and status = 'pending';
get diagnostics v_n = row_count;
v_effects := v_effects || jsonb_build_array(jsonb_build_object('type', 'org_join', 'action', 'application_cancelled', 'count', v_n));
update redeem_codes set reserved = greatest(reserved - 1, 0) where id = v_c.id;
else
for v_r in select value from jsonb_array_elements(v_red.results) loop
continue when not coalesce((v_r->>'applied')::boolean, false);
if v_r->>'type' = 'org_join' then
if coalesce((v_r->>'upgraded')::boolean, false) then
update org_members set role = v_r->>'from_role'
where id = _redeem_uuid(v_r->>'member_id') and status = 'active' and role = v_r->>'role';
get diagnostics v_n = row_count;
v_effects := v_effects || jsonb_build_array(jsonb_build_object('type', 'org_join',
'action', case when v_n > 0 then 'role_restored' else 'unchanged' end,
'org_id', v_r->'org_id', 'role', v_r->'from_role'));
else
update org_members set status = 'left', left_at = now()
where id = _redeem_uuid(v_r->>'member_id') and status = 'active' and redemption_id = v_red.id;
get diagnostics v_n = row_count;
v_effects := v_effects || jsonb_build_array(jsonb_build_object('type', 'org_join',
'action', case when v_n > 0 then 'membership_removed' else 'unchanged' end,
'org_id', v_r->'org_id'));
end if;
elsif v_r->>'type' = 'tournament_register' then
update tournament_registrations tr
set status = case when t.status = 'in_progress' then 'withdrawn' else 'cancelled' end,
withdrawn_at = now()
from el3ab_tournaments t
where tr.id = _redeem_uuid(v_r->>'registration_id') and t.id = tr.tournament_id
and tr.status = 'registered' and t.status not in ('completed', 'cancelled');
get diagnostics v_n = row_count;
v_effects := v_effects || jsonb_build_array(jsonb_build_object('type', 'tournament_register',
'action', case when v_n > 0 then 'registration_cancelled' else 'unchanged' end,
'tournament_id', v_r->'tournament_id'));
elsif v_r->>'type' = 'group_join' then
delete from group_members where group_id = _redeem_uuid(v_r->>'group_id') and user_id = v_red.player_id
and role = 'member';
get diagnostics v_n = row_count;
if v_n > 0 then
update groups g set member_count = greatest((select count(*) from group_members m where m.group_id = g.id), 1)
where g.id = _redeem_uuid(v_r->>'group_id');
end if;
v_effects := v_effects || jsonb_build_array(jsonb_build_object('type', 'group_join',
'action', case when v_n > 0 then 'left_group' else 'unchanged' end,
'group_id', v_r->'group_id'));
elsif v_r->>'type' in ('coins', 'gems') then
v_amt := coalesce(_redeem_int(v_r->>'amount'), 0);
if v_r->>'type' = 'coins' then
select least(v_amt, greatest(coalesce(coins, 0), 0)) into v_take from profiles where id = v_red.player_id;
update profiles set coins = coalesce(coins, 0) - v_take where id = v_red.player_id returning coins into v_bal;
else
select least(v_amt, greatest(coalesce(gems, 0), 0)) into v_take from profiles where id = v_red.player_id;
update profiles set gems = coalesce(gems, 0) - v_take where id = v_red.player_id returning gems into v_bal;
end if;
if v_take > 0 then
insert into economy_transactions (player_id, type, currency, amount, balance_after, reason, source_id)
values (v_red.player_id, 'debit', v_r->>'type', -v_take, v_bal,
'redeem reversal ' || v_c.code_display, v_red.id::text);
if v_r->>'source' = 'treasury' and v_c.org_id is not null then
if v_r->>'type' = 'coins' then
update org_treasury
set balance_coins = coalesce(balance_coins, 0) + v_take,
total_withdrawn_coins = greatest(coalesce(total_withdrawn_coins, 0) - v_take, 0),
updated_at = now()
where org_id = v_c.org_id returning balance_coins into v_tb;
else
update org_treasury
set balance_gems = coalesce(balance_gems, 0) + v_take,
total_withdrawn_gems = greatest(coalesce(total_withdrawn_gems, 0) - v_take, 0),
updated_at = now()
where org_id = v_c.org_id returning balance_gems into v_tb;
end if;
if found then
insert into org_treasury_transactions (org_id, type, currency, amount, balance_after, player_id, reason, approved_by)
values (v_c.org_id, 'redeem_refund', v_r->>'type', v_take, v_tb, v_red.player_id,
'redeem reversal ' || v_c.code_display, p_actor);
end if;
end if;
end if;
v_effects := v_effects || jsonb_build_array(jsonb_build_object('type', v_r->>'type',
'action', 'clawed_back', 'granted', v_amt, 'amount', v_take, 'balance', v_bal,
'refunded_to_treasury', case when v_r->>'source' = 'treasury' then v_take else 0 end));
elsif v_r->>'type' = 'frame' then
delete from player_frames where player_id = v_red.player_id and frame_id = _redeem_uuid(v_r->>'frame_id')
and acquisition_type = 'redeem' and source_id = v_red.id::text;
get diagnostics v_n = row_count;
if v_n > 0 then
update profile_frames set current_supply = greatest(coalesce(current_supply, 0) - 1, 0)
where id = _redeem_uuid(v_r->>'frame_id');
end if;
v_effects := v_effects || jsonb_build_array(jsonb_build_object('type', 'frame',
'action', case when v_n > 0 then 'removed' else 'unchanged' end, 'frame_id', v_r->'frame_id'));
elsif v_r->>'type' = 'cosmetic' then
delete from player_cosmetics where player_id = v_red.player_id and cosmetic_id = v_r->>'cosmetic_id'
and acquired_via = 'redeem';
get diagnostics v_n = row_count;
v_effects := v_effects || jsonb_build_array(jsonb_build_object('type', 'cosmetic',
'action', case when v_n > 0 then 'removed' else 'unchanged' end, 'cosmetic_id', v_r->'cosmetic_id'));
elsif v_r->>'type' = 'badge' then
v_n := 0;
if coalesce((v_r->>'inserted')::boolean, false) then
delete from player_achievements where player_id = v_red.player_id and achievement_id = v_r->>'achievement_id';
get diagnostics v_n = row_count;
end if;
v_effects := v_effects || jsonb_build_array(jsonb_build_object('type', 'badge',
'action', case when v_n > 0 then 'removed' else 'unchanged' end, 'achievement_id', v_r->'achievement_id'));
end if;
end loop;
update redeem_codes set uses = greatest(uses - 1, 0) where id = v_c.id;
end if;
update redeem_redemptions
set status = 'reversed', reversed_by = p_actor, reversed_at = now(), reverse_reason = p_reason,
reversal = jsonb_build_object('previous_status', v_red.status, 'effects', v_effects, 'by_admin', v_is_adm),
updated_at = now()
where id = v_red.id;
if v_c.org_id is not null then
insert into org_activity_log (org_id, actor_id, action, resource_type, resource_id, details)
values (v_c.org_id, p_actor, 'redemption.reversed', 'redeem_redemption', v_red.id::text,
jsonb_build_object('player_id', v_red.player_id, 'code_id', v_c.id, 'code', v_c.code_display,
'reason', p_reason, 'effects', v_effects, 'by_admin', v_is_adm));
end if;
return jsonb_build_object('ok', true, 'status', 'reversed', 'redemption_id', v_red.id,
'player_id', v_red.player_id, 'code_id', v_c.id,
'previous_status', v_red.status, 'effects', v_effects);
end $$;
-- 8. Creating codes ---------------------------------------------------------------
-- One validated path for the player API (org staff) and the manager (platform
-- staff). Normalises the grants (fills org_id, role, source), checks the
-- creator's authority per grant (design 2.3 table) and the hard limits.
create or replace function public._redeem_create(
p_grants jsonb, p_scope text, p_org_id uuid, p_kind text, p_vanity text, p_label text,
p_campaign text, p_max_uses int, p_starts_at timestamptz, p_expires_at timestamptz,
p_requires_approval boolean, p_eligibility jsonb, p_owner_player uuid,
p_welcome_message text, p_welcome_message_ar text, p_created_by_admin uuid, p_created_by_player uuid,
p_log boolean)
returns jsonb
language plpgsql
set search_path = public, extensions, pg_temp
as $$
declare
v_adm admin_users%rowtype;
v_platform boolean := false;
v_role text;
v_org_id uuid := p_org_id;
v_org el3ab_organizations%rowtype;
v_g jsonb;
v_t text;
v_ng jsonb;
v_grants jsonb := '[]';
v_id uuid;
v_role_g text;
v_src text;
v_amt int;
v_tour el3ab_tournaments%rowtype;
v_grp groups%rowtype;
v_frame profile_frames%rowtype;
v_err text;
v_code text;
v_display text;
v_try int := 0;
v_row redeem_codes%rowtype;
v_k text;
v_scope text := coalesce(p_scope, 'org');
v_kind text := coalesce(p_kind, 'generated');
v_elig jsonb := coalesce(p_eligibility, '{}'::jsonb);
begin
-- Creator.
if p_created_by_admin is null and p_created_by_player is null then
return jsonb_build_object('ok', false, 'error', 'creator_required');
end if;
if p_created_by_admin is not null then
select * into v_adm from admin_users where id = p_created_by_admin and coalesce(is_active, true) and deleted_at is null;
if not found then
return jsonb_build_object('ok', false, 'error', 'forbidden', 'detail', 'admin');
end if;
v_platform := v_adm.org_id is null;
if not v_platform then
v_org_id := coalesce(v_org_id, v_adm.org_id);
if v_org_id <> v_adm.org_id then
return jsonb_build_object('ok', false, 'error', 'forbidden', 'detail', 'org');
end if;
v_role := 'owner';
end if;
else
if not exists (select 1 from profiles where id = p_created_by_player) then
return jsonb_build_object('ok', false, 'error', 'player_not_found');
end if;
if exists (select 1 from auth.users u where u.id = p_created_by_player and coalesce(u.is_anonymous, false)) then
return jsonb_build_object('ok', false, 'error', 'account_required');
end if;
end if;
-- Scope, org, kind, numbers, dates, text.
if v_scope not in ('org', 'tournament', 'group', 'platform') then
return jsonb_build_object('ok', false, 'error', 'invalid_scope');
end if;
if v_scope = 'platform' and not v_platform then
return jsonb_build_object('ok', false, 'error', 'forbidden', 'detail', 'scope');
end if;
if v_scope = 'org' and v_org_id is null then
return jsonb_build_object('ok', false, 'error', 'org_required');
end if;
if v_org_id is not null then
select * into v_org from el3ab_organizations where id = v_org_id;
if not found then
return jsonb_build_object('ok', false, 'error', 'org_not_found');
end if;
if not coalesce(v_org.is_active, true) then
return jsonb_build_object('ok', false, 'error', 'org_inactive');
end if;
if p_created_by_admin is null then
select m.role into v_role from org_members m
where m.org_id = v_org_id and m.player_id = p_created_by_player and m.status = 'active';
end if;
end if;
v_role := coalesce(v_role, 'none');
if not v_platform and p_created_by_admin is null and v_role = 'none' and v_scope <> 'group' then
return jsonb_build_object('ok', false, 'error', 'forbidden', 'detail', 'not_a_member');
end if;
if v_kind not in ('generated', 'vanity', 'numeric') then
return jsonb_build_object('ok', false, 'error', 'invalid_kind');
end if;
if p_max_uses is not null and (p_max_uses < 1 or p_max_uses > 1000000) then
return jsonb_build_object('ok', false, 'error', 'invalid_max_uses');
end if;
if p_expires_at is not null and p_expires_at <= now() then
return jsonb_build_object('ok', false, 'error', 'invalid_expiry');
end if;
if p_starts_at is not null and p_expires_at is not null and p_starts_at >= p_expires_at then
return jsonb_build_object('ok', false, 'error', 'invalid_window');
end if;
if char_length(coalesce(p_label, '')) > 120 or char_length(coalesce(p_campaign, '')) > 64
or char_length(coalesce(p_welcome_message, '')) > 1000 or char_length(coalesce(p_welcome_message_ar, '')) > 1000 then
return jsonb_build_object('ok', false, 'error', 'invalid_text');
end if;
-- Eligibility: {new_accounts_only, min_level, min_games, account_age_days, countries[]}.
if jsonb_typeof(v_elig) <> 'object' then
return jsonb_build_object('ok', false, 'error', 'invalid_eligibility');
end if;
for v_k in select jsonb_object_keys(v_elig) loop
if v_k not in ('new_accounts_only', 'min_level', 'min_games', 'account_age_days', 'countries')
or (v_k = 'new_accounts_only' and jsonb_typeof(v_elig->v_k) <> 'boolean')
or (v_k in ('min_level', 'min_games', 'account_age_days')
and (jsonb_typeof(v_elig->v_k) <> 'number' or _redeem_int(v_elig->>v_k) is null or _redeem_int(v_elig->>v_k) < 0))
or (v_k = 'countries' and jsonb_typeof(v_elig->v_k) <> 'array') then
return jsonb_build_object('ok', false, 'error', 'invalid_eligibility', 'detail', v_k);
end if;
end loop;
-- A personal (referral) code belongs to an active member of the org.
if p_owner_player is not null then
if v_org_id is null or not exists (select 1 from org_members m where m.org_id = v_org_id
and m.player_id = p_owner_player and m.status = 'active') then
return jsonb_build_object('ok', false, 'error', 'invalid_owner');
end if;
end if;
-- Grants: normalise, then check the creator may give each one.
if jsonb_typeof(p_grants) is distinct from 'array' or jsonb_array_length(p_grants) not between 1 and 6 then
return jsonb_build_object('ok', false, 'error', 'invalid_grants');
end if;
for v_g in select value from jsonb_array_elements(p_grants) loop
v_t := v_g->>'type';
if v_t = 'org_join' then
v_id := coalesce(_redeem_uuid(v_g->>'org_id'), v_org_id);
v_role_g := coalesce(v_g->>'role', 'member');
if v_id is null or not exists (select 1 from el3ab_organizations o where o.id = v_id and coalesce(o.is_active, true)) then
return jsonb_build_object('ok', false, 'error', 'invalid_grant', 'detail', 'org_join.org_id');
end if;
if v_role_g not in ('member', 'arbiter', 'admin') then
return jsonb_build_object('ok', false, 'error', 'invalid_role');
end if;
if not v_platform then
if v_id is distinct from v_org_id then
return jsonb_build_object('ok', false, 'error', 'forbidden', 'detail', 'org_join.org_id');
end if;
if not (v_role = 'owner'
or (v_role = 'admin' and v_role_g in ('member', 'arbiter'))
or (v_role in ('arbiter', 'member') and v_role_g = 'member'
and p_owner_player is not distinct from p_created_by_player
and coalesce(v_org.features->>'member_invites', 'false') = 'true')) then
return jsonb_build_object('ok', false, 'error', 'forbidden', 'detail', 'org_join.role');
end if;
end if;
v_ng := jsonb_build_object('type', v_t, 'org_id', v_id, 'role', v_role_g);
elsif v_t = 'tournament_register' then
select * into v_tour from el3ab_tournaments where id = _redeem_uuid(v_g->>'tournament_id');
if not found then
return jsonb_build_object('ok', false, 'error', 'invalid_grant', 'detail', 'tournament_id');
end if;
if not v_platform and (v_tour.org_id is distinct from v_org_id or v_role not in ('owner', 'admin')) then
return jsonb_build_object('ok', false, 'error', 'forbidden', 'detail', 'tournament_register');
end if;
v_ng := jsonb_build_object('type', v_t, 'tournament_id', v_tour.id,
'waive_fee', coalesce((v_g->>'waive_fee')::boolean, false));
elsif v_t = 'group_join' then
select * into v_grp from groups where id = _redeem_uuid(v_g->>'group_id');
if not found then
return jsonb_build_object('ok', false, 'error', 'invalid_grant', 'detail', 'group_id');
end if;
-- Groups have no org link yet, so a player may attach only a group they run.
if not v_platform and (p_created_by_player is null or not (
v_grp.owner_id = p_created_by_player
or exists (select 1 from group_members gm where gm.group_id = v_grp.id
and gm.user_id = p_created_by_player and gm.role in ('owner', 'admin')))) then
return jsonb_build_object('ok', false, 'error', 'forbidden', 'detail', 'group_join');
end if;
v_ng := jsonb_build_object('type', v_t, 'group_id', v_grp.id);
elsif v_t in ('coins', 'gems') then
v_amt := case when jsonb_typeof(v_g->'amount') = 'number' then _redeem_int(v_g->>'amount') end;
if v_amt is null or v_amt < 1 or v_amt > 1000000 then
return jsonb_build_object('ok', false, 'error', 'invalid_amount');
end if;
if v_platform then
v_src := coalesce(v_g->>'source', case when v_scope = 'org' then 'treasury' else 'platform' end);
else
-- Owner decision: org owners fund grants from the org treasury, never platform coins.
if v_role is distinct from 'owner' then
return jsonb_build_object('ok', false, 'error', 'forbidden', 'detail', v_t);
end if;
v_src := 'treasury';
end if;
if v_src not in ('treasury', 'platform') then
return jsonb_build_object('ok', false, 'error', 'invalid_grant', 'detail', 'source');
end if;
if v_src = 'treasury' and v_org_id is null then
return jsonb_build_object('ok', false, 'error', 'org_required', 'detail', 'treasury');
end if;
v_ng := jsonb_build_object('type', v_t, 'amount', v_amt, 'source', v_src);
elsif v_t = 'frame' then
select * into v_frame from profile_frames where id = _redeem_uuid(v_g->>'frame_id');
if not found then
return jsonb_build_object('ok', false, 'error', 'invalid_grant', 'detail', 'frame_id');
end if;
if not v_platform and (v_frame.org_id is distinct from v_org_id or v_role not in ('owner', 'admin')) then
return jsonb_build_object('ok', false, 'error', 'forbidden', 'detail', 'frame');
end if;
v_ng := jsonb_build_object('type', v_t, 'frame_id', v_frame.id);
elsif v_t = 'cosmetic' then
if not v_platform then
return jsonb_build_object('ok', false, 'error', 'forbidden', 'detail', 'cosmetic');
end if;
if not exists (select 1 from cosmetics where id = v_g->>'cosmetic_id') then
return jsonb_build_object('ok', false, 'error', 'invalid_grant', 'detail', 'cosmetic_id');
end if;
v_ng := jsonb_build_object('type', v_t, 'cosmetic_id', v_g->>'cosmetic_id');
elsif v_t = 'badge' then
if not v_platform then
return jsonb_build_object('ok', false, 'error', 'forbidden', 'detail', 'badge');
end if;
if not exists (select 1 from achievements where id = v_g->>'achievement_id') then
return jsonb_build_object('ok', false, 'error', 'invalid_grant', 'detail', 'achievement_id');
end if;
v_ng := jsonb_build_object('type', v_t, 'achievement_id', v_g->>'achievement_id');
else
return jsonb_build_object('ok', false, 'error', 'invalid_grant', 'detail', coalesce(v_t, 'type'));
end if;
v_grants := v_grants || jsonb_build_array(v_ng);
end loop;
v_err := _redeem_check_limits(v_kind, v_grants, p_max_uses, p_expires_at, coalesce(p_requires_approval, false));
if v_err is not null then
return jsonb_build_object('ok', false, 'error', v_err);
end if;
-- The code itself.
if v_kind = 'vanity' then
v_code := redeem_canonical(p_vanity);
if v_code is null then
return jsonb_build_object('ok', false, 'error', 'invalid_format');
end if;
if _redeem_blocked(v_code, true) then
return jsonb_build_object('ok', false, 'error', 'code_blocked');
end if;
if exists (select 1 from redeem_codes where code = v_code)
or exists (select 1 from groups g where redeem_canonical(g.invite_code) = v_code) then
return jsonb_build_object('ok', false, 'error', 'code_taken', 'canonical', v_code);
end if;
v_display := btrim(regexp_replace(p_vanity, '[​-‏‪-‮⁦-⁩؜]', '', 'g'));
else
loop
v_try := v_try + 1;
v_code := _redeem_random_code(v_kind);
exit when not _redeem_blocked(v_code, false)
and not exists (select 1 from redeem_codes where code = v_code);
if v_try >= 25 then
return jsonb_build_object('ok', false, 'error', 'code_space_exhausted');
end if;
end loop;
v_display := case when v_kind = 'numeric'
then substr(v_code, 1, 3) || '-' || substr(v_code, 4, 3) || '-' || substr(v_code, 7, 4)
else substr(v_code, 1, 4) || '-' || substr(v_code, 5, 4) end;
end if;
begin
insert into redeem_codes (code, code_display, kind, scope, org_id, grants, label, campaign, max_uses,
starts_at, expires_at, requires_approval, eligibility, owner_player_id,
created_by_player, created_by_admin, welcome_message, welcome_message_ar)
values (v_code, v_display, v_kind, v_scope, v_org_id, v_grants, nullif(btrim(p_label), ''),
nullif(btrim(p_campaign), ''), p_max_uses, p_starts_at, p_expires_at,
coalesce(p_requires_approval, false), v_elig, p_owner_player,
case when p_created_by_admin is null then p_created_by_player end, p_created_by_admin,
nullif(btrim(p_welcome_message), ''), nullif(btrim(p_welcome_message_ar), ''))
returning * into v_row;
exception when unique_violation then
return jsonb_build_object('ok', false, 'error', 'code_taken', 'canonical', v_code);
end;
if p_log and v_org_id is not null then
insert into org_activity_log (org_id, actor_id, action, resource_type, resource_id, details)
values (v_org_id, coalesce(p_created_by_admin, p_created_by_player), 'invite.create', 'redeem_code', v_row.id::text,
jsonb_build_object('code', v_row.code_display, 'kind', v_row.kind, 'grants', v_row.grants,
'max_uses', v_row.max_uses, 'expires_at', v_row.expires_at,
'by_admin', p_created_by_admin is not null));
end if;
return jsonb_build_object('ok', true, 'code', to_jsonb(v_row));
end $$;
create or replace function public.redeem_create(
p_grants jsonb,
p_scope text default 'org',
p_org_id uuid default null,
p_kind text default 'generated',
p_vanity text default null,
p_label text default null,
p_campaign text default null,
p_max_uses int default null,
p_starts_at timestamptz default null,
p_expires_at timestamptz default null,
p_requires_approval boolean default false,
p_eligibility jsonb default '{}',
p_owner_player uuid default null,
p_welcome_message text default null,
p_welcome_message_ar text default null,
p_created_by_admin uuid default null,
p_created_by_player uuid default null)
returns jsonb
language sql
security definer
set search_path = public, extensions, pg_temp
as $$
select _redeem_create(p_grants, p_scope, p_org_id, p_kind, p_vanity, p_label, p_campaign, p_max_uses,
p_starts_at, p_expires_at, p_requires_approval, p_eligibility, p_owner_player,
p_welcome_message, p_welcome_message_ar, p_created_by_admin, p_created_by_player, true)
$$;
-- N codes with the same grants (printed cards, SMS batches). All or nothing.
create or replace function public.redeem_create_batch(
p_count int,
p_grants jsonb,
p_scope text default 'org',
p_org_id uuid default null,
p_kind text default 'generated',
p_label text default null,
p_campaign text default null,
p_max_uses int default 1,
p_starts_at timestamptz default null,
p_expires_at timestamptz default null,
p_requires_approval boolean default false,
p_eligibility jsonb default '{}',
p_welcome_message text default null,
p_welcome_message_ar text default null,
p_created_by_admin uuid default null,
p_created_by_player uuid default null)
returns jsonb
language plpgsql
security definer
set search_path = public, extensions, pg_temp
as $$
declare
v_r jsonb;
v_codes jsonb := '[]';
v_org uuid;
begin
if p_count is null or p_count < 1 or p_count > 1000 then
return jsonb_build_object('ok', false, 'error', 'invalid_count');
end if;
if coalesce(p_kind, 'generated') not in ('generated', 'numeric') then
return jsonb_build_object('ok', false, 'error', 'invalid_kind');
end if;
for i in 1..p_count loop
v_r := _redeem_create(p_grants, p_scope, p_org_id, p_kind, null, p_label, p_campaign, p_max_uses,
p_starts_at, p_expires_at, p_requires_approval, p_eligibility, null,
p_welcome_message, p_welcome_message_ar, p_created_by_admin, p_created_by_player, false);
if not coalesce((v_r->>'ok')::boolean, false) then
if i = 1 then
return v_r;
end if;
raise exception 'redeem_create_batch failed at code %: %', i, v_r->>'error';
end if;
v_codes := v_codes || jsonb_build_array(jsonb_build_object(
'id', v_r->'code'->'id', 'code', v_r->'code'->'code', 'display', v_r->'code'->'code_display'));
v_org := _redeem_uuid(v_r->'code'->>'org_id');
end loop;
if v_org is not null then
insert into org_activity_log (org_id, actor_id, action, resource_type, resource_id, details)
values (v_org, coalesce(p_created_by_admin, p_created_by_player), 'invite.bulk_create', 'redeem_code', null,
jsonb_build_object('count', p_count, 'campaign', p_campaign, 'grants', p_grants,
'by_admin', p_created_by_admin is not null));
end if;
return jsonb_build_object('ok', true, 'count', p_count, 'codes', v_codes);
end $$;
-- 9. Group codes in the same namespace --------------------------------------------
create index if not exists groups_invite_code_canon_idx
on public.groups (public.redeem_canonical(invite_code));
-- 10. Privileges -----------------------------------------------------------------
do $$
declare
f regprocedure;
begin
for f in
select p.oid::regprocedure from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and (p.proname like '\_redeem\_%' or p.proname in
('redeem_code', 'decide_application', 'redeem_reverse', 'redeem_create',
'redeem_create_batch', 'redeem_blocklist'))
loop
execute format('revoke all on function %s from public, anon, authenticated', f);
end loop;
end $$;
grant execute on function public.redeem_code(uuid, text, text, text, text, boolean) to service_role;
grant execute on function public.decide_application(uuid, text, uuid, uuid, text) to service_role;
grant execute on function public.redeem_reverse(uuid, uuid, text) to service_role;
grant execute on function public.redeem_create(jsonb, text, uuid, text, text, text, text, int, timestamptz,
timestamptz, boolean, jsonb, uuid, text, text, uuid, uuid) to service_role;
grant execute on function public.redeem_create_batch(int, jsonb, text, uuid, text, text, text, int, timestamptz,
timestamptz, boolean, jsonb, text, text, uuid, uuid) to service_role;
grant execute on function public.redeem_blocklist() to service_role;
-- redeem_fold / redeem_canonical stay executable by everyone: pure functions,
-- and the groups index evaluates redeem_canonical for whoever inserts a group.
commit;
notify pgrst, 'reload schema';
-- Security lockdown, part 1 of 2 (part 2: 2026-10-26-profiles-guard.sql).
--
-- The player app never talks to PostgREST with a player's token for writes it
-- cares about: every state change goes through PHP with the service key, and
-- the browser only listens on Realtime. Yet several policies let anyone holding
-- the public anon key (it ships in realtime.js) write or delete rows, and the
-- money functions were executable by anon. This removes those rights and keeps
-- every read (and so every Realtime feed) exactly as it was.
--
-- Backup of the schema, policies and grants before this ran:
-- /root/backups/2026-10-26-lockdown/public-schema-before.sql
BEGIN;
SET LOCAL lock_timeout = '5s';
-- 1. Money and match-state functions: service role only. Every caller is PHP
-- with the service key (supabaseRpc, $sdb->rpc).
DO $$
DECLARE f regprocedure;
BEGIN
FOR f IN
SELECT p.oid::regprocedure FROM pg_proc p
WHERE p.pronamespace = 'public'::regnamespace
AND p.proname IN ('complete_match', 'award_coins', 'enforce_turn_timeout', 'match_heartbeat',
'domino_table_join', 'domino_table_leave', 'domino_merge_state',
'cleanup_stale_matches', 'can_player_queue')
LOOP
EXECUTE format('REVOKE EXECUTE ON FUNCTION %s FROM PUBLIC, anon, authenticated', f);
EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', f);
END LOOP;
END $$;
-- 2. Groups: were readable and writable by anyone. The service role manages
-- them; a player may read only their own memberships, which is what the
-- chat_messages read policy checks for group channels.
DROP POLICY IF EXISTS service_full_access_groups ON public.groups;
DROP POLICY IF EXISTS "Service manages groups" ON public.groups;
CREATE POLICY "Service manages groups" ON public.groups FOR ALL TO service_role USING (true) WITH CHECK (true);
DROP POLICY IF EXISTS "Members read their groups" ON public.groups;
CREATE POLICY "Members read their groups" ON public.groups FOR SELECT TO authenticated
USING (EXISTS (SELECT 1 FROM public.group_members gm WHERE gm.group_id = groups.id AND gm.user_id = auth.uid()));
DROP POLICY IF EXISTS service_full_access_group_members ON public.group_members;
DROP POLICY IF EXISTS "Service manages group members" ON public.group_members;
CREATE POLICY "Service manages group members" ON public.group_members FOR ALL TO service_role USING (true) WITH CHECK (true);
DROP POLICY IF EXISTS "Members read own memberships" ON public.group_members;
CREATE POLICY "Members read own memberships" ON public.group_members FOR SELECT TO authenticated USING (user_id = auth.uid());
-- 3. Match rows: the "write" policies were FOR ALL with USING (true), so anyone
-- could delete any ludo, domino or backgammon match. Reads stay public via
-- the separate *_read policies (Realtime depends on them).
DROP POLICY IF EXISTS backgammon_write ON public.backgammon_matches;
DROP POLICY IF EXISTS domino_matches_write ON public.domino_matches;
DROP POLICY IF EXISTS ludo_write ON public.ludo_matches;
DROP POLICY IF EXISTS "Players can create matches" ON public.matches;
DROP POLICY IF EXISTS "Players in match can update" ON public.matches;
-- 4. Queues: open to everyone for every command. Service role only.
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['arena_seekers', 'backgammon_queue', 'domino_queue', 'ludo_queue'] LOOP
EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_all', t);
EXECUTE format('DROP POLICY IF EXISTS "Service manages %s" ON public.%I', t, t);
EXECUTE format('CREATE POLICY "Service manages %s" ON public.%I FOR ALL TO service_role USING (true) WITH CHECK (true)', t, t);
END LOOP;
END $$;
DROP POLICY IF EXISTS "Players can join queue" ON public.matchmaking_queue;
DROP POLICY IF EXISTS "Players can leave queue" ON public.matchmaking_queue;
DROP POLICY IF EXISTS matchmaking_update ON public.matchmaking_queue;
-- 5. Social writes go through friends.php, chat.php and groups.php. Directly,
-- a player could insert an already "accepted" friendship with anyone, or
-- post into any conversation or group channel they are not part of.
DROP POLICY IF EXISTS "Users can send friend requests" ON public.friendships;
DROP POLICY IF EXISTS users_send_friend_messages ON public.friend_messages;
DROP POLICY IF EXISTS "Authenticated users can send messages" ON public.chat_messages;
-- 6. Theme: anyone could rewrite it. Reads stay public (theme_read_all).
DROP POLICY IF EXISTS theme_write_service ON public.theme_settings;
DROP POLICY IF EXISTS "Service manages theme" ON public.theme_settings;
CREATE POLICY "Service manages theme" ON public.theme_settings FOR ALL TO service_role USING (true) WITH CHECK (true);
-- 7. The one table with RLS off.
ALTER TABLE public.mp_log ENABLE ROW LEVEL SECURITY;
COMMIT;
-- 2026-10-26 el3ab_tournament_rounds: signed-in players can no longer write rounds.
--
-- authenticated_write_tournament_rounds (FOR ALL TO authenticated USING true
-- WITH CHECK true) let any player rewrite pairings or results through the REST
-- API. Nothing legitimate needs it: public/js never writes this table (it only
-- subscribes to it over realtime), and every PHP writer (tournament-engine.php
-- via cron.php, tournament-match.php, tournament-admin.php, swiss.php,
-- game.php) and the manager use the service key.
--
-- Read access stays: tournament_rounds_viewable_by_everyone (SELECT, true),
-- which realtime needs. 2026-10-26-manager-columns.sql drops the same policy;
-- both files are safe to run in either order.
--
-- Idempotent.
begin;
drop policy if exists authenticated_write_tournament_rounds on public.el3ab_tournament_rounds;
do $$
begin
if not exists (select 1 from pg_policy
where polrelid = 'public.el3ab_tournament_rounds'::regclass
and polname = 'tournament_rounds_viewable_by_everyone') then
create policy tournament_rounds_viewable_by_everyone on public.el3ab_tournament_rounds
for select using (true);
end if;
end $$;
-- Belt and braces: the API roles keep SELECT only. TRUNCATE is not covered by RLS.
revoke insert, update, delete, truncate on public.el3ab_tournament_rounds from anon, authenticated;
commit;
notify pgrst, 'reload schema';
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