Admin can now open a message via a per-row "View" page (which stamps
read_at the first time it's opened), see a sortable Read column on the
list instead of the raw message text, and soft-delete messages via a
Delete button on both the list and detail view. Deleted messages are
excluded from the default list so it doesn't pile up over time.
Adds a public /contact page (name/email/message, protected by the same
reCAPTCHA v3 setup used on registration) that persists submissions to
a new contact_message table, plus a Contact section in the admin panel
to read them. Linked from the site footer.
Hints used to be 11 hardcoded PHP strings/thresholds in
SendMainframeHintsCommand, walked through a fixed if/else chain. They
are now Hint rows (game, condition, thresholdSeconds, message,
sortOrder) manageable at /admin/games/{game}/hints - freely
addable, removable, and reorderable per game, which sets up exactly
what's needed for difficulty-dependent hint timing later (Easy/Medium/
Hard are separate games, so each gets its own hint set).
The condition a hint fires behind (e.g. "hasn't used help yet",
"isn't verified yet") is still a fixed, code-defined check
(HintCondition enum) tied to real game state - a true free-form
condition editor would need a full rules engine, which is out of
scope. What's fully free-form is which hints exist, in what order,
gated behind which of those conditions, with what wording and timing.
SendMainframeHintsCommand now reads hints from the DB: walking them in
sortOrder, it finds the first distinct condition still unresolved for
a player, fires the most-escalated hint sharing that condition whose
threshold has passed, and stops - matching the original "stop at the
first unresolved, dependent step" behavior.
Migration seeds every existing game with the previous hardcoded
hints so behavior doesn't regress on deploy. Two minor fidelity
simplifications from the original hardcoded logic, called out here
since they're easy to miss: the "go to rapports directory" hint no
longer additionally checks whether the player is already in that
folder, and the two chat hints are now independent conditions
(broadcast done / contacted everyone privately) instead of one
combined check - both are edge-case-only behavior changes, not
regressions in the common path.
The chat used to disappear the moment a session left CREATED status.
Sessions now record a finishedAt timestamp when they're won or lost,
and the lobby (with chat) stays reachable via /game/{session} for an
hour afterward instead of immediately redirecting to the win/lose
feedback page. The lobby template shows a distinct "game finished"
header with a link to that feedback page during this window.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Players used to get bounced back to the dashboard when a session
wasn't full yet. Now they land on a lobby page showing who has
joined, and can chat with each other while waiting - messages are
broadcast live over the existing Mercure hub, and the session
auto-starts (and the lobby notifies everyone) once it fills up.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixes the 500 on admin user deletion: deleting a user with an
email_log row (i.e. basically anyone who received any email) hit an
unhandled ForeignKeyConstraintViolationException, since email_log,
reset_password_request and player all have non-nullable FKs to user
with no cascade at the DB level.
Instead of cascading the delete (which would be fine for email_log
and reset_password_request but risky for player - removing a player
row could corrupt other real players' session state), admin delete
now sets a deletedAt timestamp instead of removing the row:
- UserChecker blocks login for deleted users (checkPreAuth).
- EmailLoggerListener rejects the message before send for deleted
recipients (checked at actual send time, not at queue time).
- Added a last_login_at column + a LoginSuccessEvent listener to
populate it, and surfaced both last login and status in the admin
users list.
Added `app:users:purge-deleted`, a command intended to run on a
schedule that permanently removes users who were soft-deleted more
than 3 months ago and never played a game (join to Player via a
NOT EXISTS subquery). For that eventual hard-delete to actually
succeed, email_log and reset_password_request now cascade-delete at
the DB level (migration drops+recreates both FK constraints with ON
DELETE CASCADE) - player intentionally still isn't cascaded, so a
user with game history can never be purged this way even by mistake.
Registration now requires agreeing to the Terms and Conditions and lets
users opt in to hear about future projects. The opt-in is persisted on
the user via a new marketing_opt_in column (migration included).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>