Soft-delete users instead of hard-deleting, add last login tracking

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.
This commit is contained in:
Frank
2026-07-11 23:09:52 +02:00
parent 3a7bc3ed49
commit 3c58e153dc
11 changed files with 188 additions and 8 deletions
+14 -2
View File
@@ -18,6 +18,8 @@
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Roles</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Verified</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Marketing</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Last Login</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Status</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Actions</th>
</tr>
</thead>
@@ -57,6 +59,16 @@
<span style="color: #dc2626;">✗ No</span>
{% endif %}
</td>
<td style="padding: 0.75rem 1rem; color: #475569;">
{{ user.lastLoginAt ? user.lastLoginAt|date('Y-m-d H:i') : 'Never' }}
</td>
<td style="padding: 0.75rem 1rem;">
{% if user.deleted %}
<span style="color: #dc2626; font-weight: 500;">Deleted</span>
{% else %}
<span style="color: #16a34a; font-weight: 500;">Active</span>
{% endif %}
</td>
<td style="padding: 0.75rem 1rem;">
<a href="{{ path('game_admin_user_edit', {id: user.id}) }}" style="
color: #3b82f6;
@@ -65,7 +77,7 @@
margin-right: 0.75rem;
">Edit</a>
{% if user != app.user %}
{% if user != app.user and not user.deleted %}
<form method="post" action="{{ path('game_admin_user_delete', {id: user.id}) }}" style="display: inline;" onsubmit="return confirm('Delete user {{ user.username }}?')">
<input type="hidden" name="_token" value="{{ csrf_token('delete_user_' ~ user.id) }}">
<button type="submit" style="
@@ -82,7 +94,7 @@
</tr>
{% else %}
<tr>
<td colspan="7" style="padding: 2rem; text-align: center; color: #94a3b8;">No users found.</td>
<td colspan="9" style="padding: 2rem; text-align: center; color: #94a3b8;">No users found.</td>
</tr>
{% endfor %}
</tbody>