1. 26 Oct, 2026 7 commits
    • Mahmoud Aglan's avatar
      feat(codes): org invites and platform promo codes on redeem_codes · 59a746e4
      Mahmoud Aglan authored
      org-invites used org_invite_links: 8 lowercase hex characters, no link or
      QR, hard deletes, roles the org model does not have, and nothing ever
      redeemed one. It is rebuilt on redeem_codes, and a new global module,
      /redeem-codes, does the same for platform promo codes. Both share
      core/RedeemService.php and modules/org-invites/CodesBase.php:
      
      - per-org (or platform) list with uses/max, reserved, link opens, previews
        and status (active, scheduled, expired, full, revoked), status and
        campaign filters;
      - create through redeem_create with every grant type: org membership with
        a role, tournament entry (fee waiver), group join, coins and gems (from
        the org treasury or, for platform staff, minted), frame, shop item and
        badge; generated, numeric or vanity; window, max uses, approval,
        eligibility and welcome messages. The database's hard limits (staff
        codes, value codes, vanity codes) come back as Arabic messages; an
        org-scoped account creates as its org's owner;
      - bulk: N single-use generated or numeric codes for a campaign through
        redeem_create_batch, and CSV export of a campaign's live codes for
        printed cards;
      - usage page: link, QR, the funnel (redeem_code_funnel), every redemption
        with its source and new-signup flag, and reverse through redeem_reverse;
      - soft revoke with a reason, logged in org_activity_log as invite.revoke;
      - printable A4 poster (org, scan and join, QR at error level Q, the code,
        expiry);
      - /redeem-codes/campaigns: every campaign across orgs and the platform
        with its funnel (redeem_campaign_stats).
      
      Links are https://el3ab-player.caprover.al-arcade.com/r/<code>.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      59a746e4
    • Mahmoud Aglan's avatar
      fix(qr): replace the hand-rolled QR encoder, which no decoder could read · 42caf1c3
      Mahmoud Aglan authored
      QRCodeService wrote the payload bytes straight into the grid with no mode or
      length header, no Reed-Solomon error correction, no format bits and no mask;
      zbar finds no symbol in its output, so every QR the live pages printed was
      dead. It now wraps Kazuhiko Arase's qrcode-generator (MIT), vendored
      unchanged in core/lib/qrcode-arase.php with its licence beside it
      (source and sha256 in the class header). Byte mode at the smallest version
      that fits, L/M/Q/H selectable, dark modules on white with a 4-module quiet
      zone, one crisp-edged SVG path. Verified by decoding with zbar: Latin,
      numeric and percent-encoded Arabic /r/ links at M, Q and H, and the live
      page's 150 px share code.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      42caf1c3
    • Mahmoud Aglan's avatar
      fix(orgs): applications decide through decide_application; org staff stay in their org · fb97035e
      Mahmoud Aglan authored
      org-applications: approve and reject are one call each to
      decide_application() (El3ab docs/redeem-contract.md 7.5), which adds or
      upgrades the membership, runs any grants a code application was holding and
      records the staff reviewer in reviewed_by_admin, all in one transaction. The
      player is notified only after it answers ok, with the org's name from that
      answer in title_ar/body_ar and the reviewer's note or reason in the body.
      The old code wrote missing columns and an admin id into a profiles key, so
      nothing was ever approved, and then told the player they were in. Bulk
      approve and reject go through the same path (up to 50) and say how many
      failed and why. The list and show pages read el3ab_organizations (the org
      column was blank for all 13), with per-status counts and the cancelled
      status. The proof is shown through a ten-minute signed link, from the
      private org-proofs bucket when the file is there (core/ProofDocument.php),
      and a proof still sitting in the old public bucket is flagged. Signed links
      now carry the apikey Kong requires.
      
      Org scoping: core/OrgScope.php, called by the Router before every route,
      keeps an org-scoped staff account (admin_users.org_id) inside its own org:
      the org in organizations/{id}/... and {orgId}, the owner org of any record
      named in the path (application, code, event, channel, frame, transfer, ...),
      and org ids in the query or form are all checked with Auth::canAccessOrg;
      lists are pinned to the account's org, the org directory goes straight to
      it, and modules the role has no permission for at all are refused.
      OrgPermissions uses player_id and status='active', ranks moderator with
      arbiter, and treats an org-scoped staff account as the owner of its own org
      only, as the database functions do.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      fb97035e
    • Mahmoud Aglan's avatar
      fix(tournaments): Swiss-only tools say why they do not apply to game-run tournaments · 9679dd19
      Mahmoud Aglan authored
      Categories, manual pairing, unpairing and TRF/crosstable export belong to
      the external Swiss service; for a tournament the app runs they now point to
      the arbiter tools and the full JSON export instead of 'no Swiss link' or
      'round not found'.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      9679dd19
    • Mahmoud Aglan's avatar
      feat(game-content): manager pages for seasons, shop, achievements, challenges, puzzles · a945b003
      Mahmoud Aglan authored
      The game reads all five tables and the manager had no screen for any of
      them; the only battle-pass season ended on 2026-07-01, so the pass has been
      dead since. One small config-driven CRUD covers them, every field a real
      column, with Arabic labels and a note on what the game actually uses:
      
      - seasons: create the next one (Cairo times), and "activate", which makes
        it the single is_active row the game's battlepass.php reads (an ended
        season cannot be activated, the active one cannot be deleted);
      - shop items (cosmetics) with their enum types and rarities;
      - achievements, with the condition written as {type, count} and the
        condition types the game never counts marked as such;
      - daily challenge templates, marking the types the game does not count;
      - puzzles, validating the FEN and the UCI solution.
      
      Deleting something players own or have earned is refused by the foreign
      key and says so instead of failing silently.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      a945b003
    • Mahmoud Aglan's avatar
      feat(notifications): scheduled campaigns are actually sent · 52d5591d
      Mahmoud Aglan authored
      Campaigns saved as 'scheduled' were never looked at again: nothing in either
      app processed them. CampaignService::processDue() sends every due campaign
      into the game's bell (notifications with title_ar/body_ar, as the game's
      notifications.php reads them) and records recipients, claiming each one with
      a conditional scheduled -> sending update so overlapping runs never send
      twice. It runs from GET/POST /api/cron/notifications (X-Service-Key, or
      X-Cron-Secret / ?secret= when CRON_SECRET is set, the same credentials as
      the game's api/cron.php) for a scheduler, and as a fallback at most once a
      minute after any staff page load.
      
      Scheduling now stores the typed Cairo time as UTC (the raw datetime-local
      value was saved as UTC, 2-3 hours late) and refuses past times; filters
      are stored as jsonb, not a JSON string; cancelling only cancels a campaign
      that has not started; send-now shares the same delivery code.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      52d5591d
    • Mahmoud Aglan's avatar
      feat(branding): edit the colours the game reads; say which images it uses · 9b743e4f
      Mahmoud Aglan authored
      Both apps keep their theme in platform_theme, but the game only applies the
      keys in its theme.js COLOR_KEYS (back_*, card, ink, gold, board_*, ludo_*,
      bg_* ...). The palette this page edited (color_primary, surface_* ...) is
      the manager's own theme, so no change here ever reached a player. A new
      "app colours" tab edits exactly the game's keys, in groups with Arabic
      names, showing each one's default read from the game's tokens.css; reset
      removes the override so the app falls back to its design. The manager
      palette stays, labelled as the manager's.
      
      Every asset slot is marked as read by the game, read only for uploads made
      after 2026-10-19 (logo, tab icons), or ignored (the old emoji slots and
      the game-icon slots the app now draws itself), and new uploads may create
      any slot the game reads (icon_*, chess_piece_*, chess_bot_*,
      emoji_dice_1..6). Uploads go through SupabaseStorage: the hand-written
      request lacked the apikey header and Kong refused every upload.
      
      Flash messages are JSON-encoded into the toast script; a multi-line
      message (such as a storage error) used to break the script.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      9b743e4f
  2. 25 Oct, 2026 9 commits
    • Mahmoud Aglan's avatar
      feat(settings): edit reward_config, the table the game pays from · c0f05e75
      Mahmoud Aglan authored
      The settings page edited system_config, which nothing in the game reads.
      It now edits reward_config: coins and XP per game and place (read by
      complete_match() and domino-match), the seven-day daily reward ladder and
      the chess Elo K-factor steps (El3ab includes/game-config.php). All 47 keys
      have an Arabic label and group and say whether the game reads them today;
      chess draw/loss and backgammon loss rows are marked unpaid because those
      games report only the winner to complete_match(). Values are validated
      whole numbers stored as plain text (complete_match casts value::int); only
      changed rows are written and the change is audited.
      
      system_config is shown read-only for reference, with where each value
      really comes from. Its update no longer json_encode()s the value before
      PostgREST encodes it again (the cause of elo_k_factor_master = "\"20\"",
      repaired in the migration).
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      c0f05e75
    • Mahmoud Aglan's avatar
      fix(live): prize table reads the {place, coins} list the form writes · 10af61a4
      Mahmoud Aglan authored
      Iterating a list of objects as a place => amount map passed an array to
      htmlspecialchars() and broke the public page; places were also marked with
      emoji. Both shapes are read now, places are medal icons.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      10af61a4
    • Mahmoud Aglan's avatar
      fix(tournaments): form hints state what the fields do, not what the game lacks · bd0d91ef
      Mahmoud Aglan authored
      The game team is landing registration-window, rating-limit and prize-payout
      enforcement; hints that said those were not enforced would go stale the
      moment it deploys.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      bd0d91ef
    • Mahmoud Aglan's avatar
      fix(feature-flags): the list shows the real flags and edits land · 7d9a4f22
      Mahmoud Aglan authored
      feature_flags has id, name, description, is_enabled, rollout_percentage and
      conditions. The list ordered by a category column that does not exist, so
      the query failed and the page always said there were no flags (13 exist);
      create and edit wrote label, label_ar, target, category and target_value
      and were all rejected. Flags now get Arabic names and groups for the
      platform's own ids, a partial rollout is rollout_percentage, and the
      maintenance_mode message, English message and end time go in conditions
      ({message_ar, message, until}) as the game's api/config.php reads them.
      Turning maintenance on asks for confirmation; built-in flags cannot be
      deleted, only switched off.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      7d9a4f22
    • Mahmoud Aglan's avatar
      fix: writes that named columns the database does not have · 952e2ce6
      Mahmoud Aglan authored
      Every one of these was rejected by PostgREST and reported as success
      (found by checking each insert/update/select against the live schema):
      
      - workflows: approval_requests has reviewed_by/review_notes, not
        reviewer_id/review_note; reviewers and requesters are staff uuids, so the
        pages now show their names, "my requests" filters by the account id, and
        search no longer runs ilike on a uuid. workflow_rules has event_type, not
        event; created_by is a uuid; conditions/actions go in as jsonb lists.
      - RuleEngine: event_type, and its notify action writes a real notifications
        row (user_id, title_ar/body_ar) only when addressed to a player.
      - moderation: cheat_reports.reviewed_by references profiles, so a staff
        reviewer is stored in the new reviewed_by_admin column.
      - users: admin_users has no created_by.
      - org-transfers, org-frames, org-chat: org_members has player_id (not
        user_id) and no status column. Membership logic is unchanged.
      - org-achievements: an org achievement cannot be stored in
        player_achievements (its achievement_id references the game's
        achievements); granting now says so instead of failing the insert and
        still paying the reward. Progress reads completed/completed_at.
      - org-loyalty, org-partnerships: those tables have no updated_at.
      - live pages: phases are in tournament_phases ordered by phase_number; the
        arena tables never existed.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      952e2ce6
    • Mahmoud Aglan's avatar
      fix(players, economy): grants are atomic and tell the player; create and edit players work · ef9854c7
      Mahmoud Aglan authored
      Grant and revoke (player page and economy module) used to read the balance,
      add in PHP, write it back and insert the ledger row separately, racing any
      game payout in between. They now call admin_adjust_currency() (one statement,
      coins or gems, never below zero, booked as admin_grant / debit; see El3ab
      migrations/2026-10-26-manager-columns.sql) and put a notice in the player's
      bell (title_ar/body_ar, as the game's notifications.php reads them).
      award_coins() was not used: coins only, no floor, and it books staff grants
      as game income.
      
      Edit no longer sends profiles.email (no such column; PGRST204 rejected the
      whole update); the login email is shown read-only from the auth account.
      Create now makes a real login account through GoTrue's admin API, whose
      trigger creates the profile; the old insert had no id and always failed.
      The match history query used a players column matches does not have, and
      the transactions tab read a variable that was never set.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      ef9854c7
    • Mahmoud Aglan's avatar
      fix(tournaments): bot testing can no longer touch a real tournament · 1f8152ac
      Mahmoud Aglan authored
      Populate used to attach a new Swiss-service tournament to whatever it was
      pointed at, which switched a game-run tournament to the other engine, and
      registered bots with no profile that real players were then paired against.
      Population is refused once a real player has registered or the tournament
      has left draft, and simulation while any real player is entered, unless a
      superadmin ticks an explicit test override. Game-run test tournaments are
      simulated natively: start through the game, then results drawn from the
      bots' ratings are recorded through the game's arbiter endpoint and the
      engine pairs the next round itself. bot_metadata is stored as jsonb, not a
      JSON string.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      1f8152ac
    • Mahmoud Aglan's avatar
      fix(tournaments): the manager runs the tournaments the game actually plays · aa025be9
      Mahmoud Aglan authored
      Create and edit write real el3ab_tournaments columns. Edit used to send
      organization_id, rounds_count, entry_fee, prize_pool, start_date and
      end_date (none exist) and complete sent completed_at, so PostgREST rejected
      both and the page said saved. The form now also covers name_ar, min_players,
      min/max rating, is_rated, auto_start, the registration window and a real
      prize distribution ([{place, coins}]), validates them, and reads and writes
      times in Cairo (stored as UTC, DST included). Editing is allowed while
      registration is open; the edit page no longer crashes on phase_config.
      
      No more Swiss linking on create: picking an organisation used to create a
      Swiss-service tournament and hand the event to a second engine.
      
      Tournaments the player app runs (no swiss_api_tournament_id) are shown from
      el3ab_tournament_rounds.pairings through NativeTournamentService: every
      board with names, results, reasons and who set them; standings with the
      engine's own scoring (aborted boards are losses, per-time-control rating)
      and the chosen tiebreak columns; the players tab resolves names from
      profiles (there is no player_name column) including withdrawn players.
      
      Arbiter tools relay to the game's api/tournament-admin.php with the service
      key: set or override a result, forfeit (either side or both), next round,
      close the round now (unfinished boards 0-0), pause/resume, end early with
      final placings, add a player by username (late entry once running),
      withdraw, bulk add by usernames. Start goes through the game so the
      minimum-players check is no longer skipped. Tiebreaks save directly for
      these tournaments and offer only the four keys the engine computes, under
      the names the app shows players.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      aa025be9
    • Mahmoud Aglan's avatar
      fix(core): a write PostgREST rejects can no longer be reported as saved · d5bedc75
      Mahmoud Aglan authored
      Database used to decode the PostgREST error body and hand it back as if it
      were the written row, so writes to missing columns (PGRST204), bad types and
      broken foreign keys came back truthy and the controller flashed success.
      Every failed request is now recorded and logged; insert/update/rpc return
      null on failure; Response::success, redirect-with-success and json refuse to
      report success over a recorded write failure and show the database's reason.
      Best-effort writes (audit trail, page-view counters, last login) go through
      Database::soft() so they cannot fail the action they describe.
      
      AuditLog had never written a row: staff ids broke the actor_id foreign key
      to profiles, names broke the resource_id uuid, and details was double
      encoded. Staff now go in admin_user_id and text ids in resource_ref
      (El3ab migrations/2026-10-26-manager-columns.sql); the audit page shows the
      admin's name and searches the text reference.
      
      Staff type times in Cairo: the app now runs in Africa/Cairo and core/Time
      converts typed times to UTC instants and back, DST included.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      d5bedc75
  3. 01 Sep, 2026 3 commits
    • Mahmoud Aglan's avatar
      docs: playbook for running a Swiss + top-8 knockout championship · aeeb1740
      Mahmoud Aglan authored
      Covers hosting, announcing and monetising the format end to end, verified
      against the live code and database rather than inferred from column names.
      
      The headline finding is that the built-in multi-phase path — the preset
      literally labelled سويسري ← إقصاء — cannot work: startSwissPhase reads a
      swiss_event_id column that does not exist, startPhase merges that error into a
      success envelope so it fails silently, getQualifiedPlayers can only read the
      external Swiss service, and tournamentFinish completes the tournament before
      the advance buttons can be pressed. tournament_phases, tournament_brackets and
      bracket_matches all hold zero rows in production; it has never run.
      
      The document gives the path that does work — the Swiss natively, a manual cut,
      and the knockout as a second tournament whose phase 1 is the bracket, which is
      reachable because getPhasePlayers reads registrations directly for phase 1 —
      and is blunt about entry fees and prizes being displayed but never moved.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      aeeb1740
    • Mahmoud Aglan's avatar
      fix(live): give Swiss tournaments their player names back · bc768f8b
      Mahmoud Aglan authored
      The external Swiss service returns pairings carrying whitePlayerId and
      blackPlayerId and no names at all, so every board on a Swiss tournament
      rendered "لاعب" against "لاعب" and the rounds timeline showed a column of
      question marks. Its standings response does carry names, so the two are joined
      in LiveDataService and both engines' pairings are normalised to one shape
      before any view sees them — which is also where the array-into-string fatals
      on this page came from.
      
      Its stats were hardcoded zeros: a finished five-round, 64-player championship
      reported that no game had ever been played, and the vitals section fell through
      to "nothing has been played yet". The counts are now derived from the rounds.
      
      Tiebreaks arrive as an ordered tiebreakValues list rather than named columns;
      read either, and drop the column entirely when a tournament has none rather
      than printing a column of em-dashes.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      bc768f8b
    • Mahmoud Aglan's avatar
      fix(live): make the manager show the tournaments EL3AB actually runs · 4a2cb04f
      Mahmoud Aglan authored
      The manager could only ever see the external Swiss service. Every tournament
      the player app's own engine ran was invisible here: standings came back empty,
      pairings were read from a column that does not exist (swiss_api_round_id,
      against a swiss_round_id column), and three of the fallback tables the service
      queried have never existed in this schema.
      
      The public live page was also fatal. The Swiss service wraps its rows in
      {"data": [...]}; the standings view iterated the wrapper, so "data" + 1
      crashed PHP 8 and printed a stack trace with server paths to anyone holding the
      link. Two more views did the same thing to the native pairing shape, which puts
      an object where the Swiss shape puts a name.
      
      - NativeTournamentService reads the engine's rounds and computes standings,
        pairings and tiebreaks from them; LiveDataService is native-first throughout.
      - display_errors is off in the image, so a fatal is logged, never rendered.
      - Start no longer flips a tournament to in_progress and leaves it unpaired, and
        Generate Round no longer answers "no link to the Swiss system" for every
        tournament EL3AB runs. Both ask the player app's engine, authenticating with
        the service key both apps already hold.
      
      Two controls on the live page were scenery. The player-compare selects were
      filled from a class the players view no longer emits, and choosing two names ran
      a function whose whole body hid the result; the player card modal needed a
      data-player payload nothing rendered. Both now work off data already on the page.
      
      Visually the page was rebuilt around what a spectator opens it for: the boards
      and the leaderboard first (the share box and compare tool used to be pulled
      above them on a phone by order:-1), a podium, avatars and form dots, board cards
      that show at a glance which games are still running, a readable rounds timeline,
      a knockout bracket with connectors, and a QR that leads the share card instead of
      hiding behind a disclosure. Sections no longer render at opacity 0 when script
      does not run.
      
      tools/verify-cycle.mjs runs the whole cycle across both apps and checks the
      manager reports what the players did; it passes.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      4a2cb04f
  4. 03 Jul, 2026 2 commits
  5. 22 Jun, 2026 1 commit
  6. 21 Jun, 2026 6 commits
  7. 06 Jun, 2026 3 commits
  8. 05 Jun, 2026 1 commit
  9. 31 May, 2026 1 commit
  10. 30 May, 2026 7 commits