feat(admin): deleted-account dossier + operator delete-user action

The user-detail console gains a Deletion & retention panel: last login (time + IP),
the tombstone (deleted-at + retained real name), and the retention journal (the legal
dossier of detached credentials). A Delete-user action runs the same deletion
orchestration as the in-app flow (mirrors the email-erase pattern). Store readers
RetainedIdentities + DeletionInfo back the view; integration test covers them.
This commit is contained in:
Ilia Denisov
2026-07-03 13:22:33 +02:00
parent cabcd94d92
commit 251c7af3f6
5 changed files with 181 additions and 0 deletions
+68
View File
@@ -3,13 +3,16 @@ package account
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
@@ -110,6 +113,71 @@ func (s *Store) ReapExpiredRetention(ctx context.Context, cutoff time.Time) (ide
return identities, feedback, nil
}
// RetainedIdentity is one row of the retention journal, for the admin dossier.
type RetainedIdentity struct {
Kind string
ExternalID string
Reason string
Confirmed bool
LinkedAt time.Time
DetachedAt time.Time
}
// RetainedIdentities returns the account's retention-journal rows (the legal dossier of
// detached credentials), newest detach first, for the admin console.
func (s *Store) RetainedIdentities(ctx context.Context, accountID uuid.UUID) ([]RetainedIdentity, error) {
var rows []model.RetainedIdentities
err := postgres.SELECT(table.RetainedIdentities.AllColumns).
FROM(table.RetainedIdentities).
WHERE(table.RetainedIdentities.AccountID.EQ(postgres.UUID(accountID))).
ORDER_BY(table.RetainedIdentities.DetachedAt.DESC()).
QueryContext(ctx, s.db, &rows)
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
return nil, fmt.Errorf("account: retained identities %s: %w", accountID, err)
}
out := make([]RetainedIdentity, 0, len(rows))
for _, r := range rows {
out = append(out, RetainedIdentity{
Kind: r.Kind, ExternalID: r.ExternalID, Reason: r.Reason,
Confirmed: r.Confirmed, LinkedAt: r.LinkedAt, DetachedAt: r.DetachedAt,
})
}
return out, nil
}
// DeletionInfo is a tombstoned account's dossier header, for the admin console.
type DeletionInfo struct {
DeletedAt *time.Time
DeletedDisplayName string
LastLoginAt *time.Time
LastLoginIP string
}
// DeletionInfo reads the account's deletion tombstone + last-login dossier fields.
func (s *Store) DeletionInfo(ctx context.Context, accountID uuid.UUID) (DeletionInfo, error) {
var row model.Accounts
err := postgres.SELECT(
table.Accounts.DeletedAt, table.Accounts.DeletedDisplayName,
table.Accounts.LastLoginAt, table.Accounts.LastLoginIP,
).FROM(table.Accounts).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(accountID))).
QueryContext(ctx, s.db, &row)
if err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return DeletionInfo{}, ErrNotFound
}
return DeletionInfo{}, fmt.Errorf("account: deletion info %s: %w", accountID, err)
}
info := DeletionInfo{DeletedAt: row.DeletedAt, LastLoginAt: row.LastLoginAt}
if row.DeletedDisplayName != nil {
info.DeletedDisplayName = *row.DeletedDisplayName
}
if row.LastLoginIP != nil {
info.LastLoginIP = *row.LastLoginIP
}
return info, nil
}
// RetentionReaper periodically purges expired account-deletion retention data via
// Store.ReapExpiredRetention, mirroring GuestReaper: one background goroutine started once
// from main.
@@ -107,6 +107,24 @@
</form>
{{end}}
</section>
<section class="panel"><h2>Deletion &amp; retention</h2>
{{if .LastLoginAt}}<p class="note">Last login: {{.LastLoginAt}}{{if .LastLoginIP}} — <code>{{.LastLoginIP}}</code>{{end}}</p>{{end}}
{{if .Deleted}}<p><span class="warn">Deleted</span> at {{.DeletedAt}}{{if .DeletedName}} — was <code>{{.DeletedName}}</code>{{end}}</p>{{end}}
{{if .Retained}}
<h3>Retention journal (legal dossier of detached credentials)</h3>
<table class="list">
<thead><tr><th>Kind</th><th>Credential</th><th>Reason</th><th>Detached</th></tr></thead>
<tbody>
{{range .Retained}}<tr><td>{{.Kind}}</td><td><code>{{.ExternalID}}</code></td><td>{{.Reason}}</td><td>{{.DetachedAt}}</td></tr>{{end}}
</tbody>
</table>
{{end}}
{{if not .Deleted}}{{if not .Guest}}
<form class="form" method="post" action="/_gm/users/{{.ID}}/delete" onsubmit="return confirm('Delete this account? Its credentials are journalled and freed, its data anonymised, and its sessions revoked. This cannot be undone.')">
<button type="submit">Delete user</button>
</form>
{{end}}{{end}}
</section>
<section class="panel"><h2>Friends</h2>
<table class="list">
<thead><tr><th>Account</th><th>Friends since</th></tr></thead>
+20
View File
@@ -151,6 +151,15 @@ type UserDetailView struct {
// MergedInto is the primary account id when this account has been retired by a
// merge, or empty for a live account.
MergedInto string
// The account-deletion dossier. Deleted marks a tombstoned account; DeletedAt and
// DeletedName are its deletion time and retained real name; LastLoginAt/IP are the
// last cold-load stamp (shown for any account); Retained is the credential journal.
Deleted bool
DeletedAt string
DeletedName string
LastLoginAt string
LastLoginIP string
Retained []RetainedRow
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
// empty for an unflagged account; the card shows it with the Clear action.
FlaggedHighRateAt string
@@ -237,6 +246,17 @@ type IdentityRow struct {
CreatedAt string
}
// RetainedRow is one credential in the account-deletion retention journal (the legal
// dossier of detached credentials): what was detached, when, and why.
type RetainedRow struct {
Kind string
ExternalID string
Reason string
Confirmed bool
LinkedAt string
DetachedAt string
}
// GameRow is one game row in a list.
type GameRow struct {
ID string
+39
View File
@@ -95,6 +95,45 @@ func TestAnonymizeAndTombstone(t *testing.T) {
}
}
// TestDeletionDossierReaders: after deletion the admin readers expose the credential
// journal and the tombstone dossier.
func TestDeletionDossierReaders(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
deleter := accountdelete.NewDeleter(testDB)
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "handle", "Иван", "+03:00")
if err != nil {
t.Fatalf("provision: %v", err)
}
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "dos-"+uuid.NewString()+"@example.com", true); err != nil {
t.Fatalf("attach email: %v", err)
}
if err := deleter.AnonymizeAndTombstone(ctx, acc.ID); err != nil {
t.Fatalf("delete: %v", err)
}
rets, err := store.RetainedIdentities(ctx, acc.ID)
if err != nil || len(rets) != 2 {
t.Fatalf("RetainedIdentities = (%+v, %v), want 2 rows", rets, err)
}
for _, r := range rets {
if r.Reason != "delete" {
t.Errorf("retained reason = %q, want delete", r.Reason)
}
}
info, err := store.DeletionInfo(ctx, acc.ID)
if err != nil {
t.Fatalf("DeletionInfo: %v", err)
}
if info.DeletedAt == nil {
t.Error("DeletionInfo.DeletedAt should be set")
}
if info.DeletedDisplayName != "Иван" {
t.Errorf("DeletionInfo.DeletedDisplayName = %q, want Иван", info.DeletedDisplayName)
}
}
// TestDropAllRobotGames drops the deletee's solo vs-AI game but keeps a game with a human
// opponent.
func TestDropAllRobotGames(t *testing.T) {
@@ -62,6 +62,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
gm.POST("/users/:id/remove-email", s.consoleRemoveEmail)
gm.POST("/users/:id/delete", s.consoleDeleteUser)
gm.GET("/reasons", s.consoleReasons)
gm.POST("/reasons", s.consoleCreateReason)
gm.POST("/reasons/:id/update", s.consoleUpdateReason)
@@ -368,6 +369,25 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
view.Identities = append(view.Identities, adminconsole.IdentityRow{Kind: idn.Kind, ExternalID: idn.ExternalID, Confirmed: idn.Confirmed, CreatedAt: fmtTime(idn.CreatedAt)})
}
}
if info, err := s.accounts.DeletionInfo(ctx, id); err == nil {
if info.LastLoginAt != nil {
view.LastLoginAt = fmtTime(*info.LastLoginAt)
}
view.LastLoginIP = info.LastLoginIP
if info.DeletedAt != nil {
view.Deleted = true
view.DeletedAt = fmtTime(*info.DeletedAt)
view.DeletedName = info.DeletedDisplayName
}
}
if rets, err := s.accounts.RetainedIdentities(ctx, id); err == nil {
for _, r := range rets {
view.Retained = append(view.Retained, adminconsole.RetainedRow{
Kind: r.Kind, ExternalID: r.ExternalID, Reason: r.Reason,
Confirmed: r.Confirmed, LinkedAt: fmtTime(r.LinkedAt), DetachedAt: fmtTime(r.DetachedAt),
})
}
}
if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
view.TelegramID = tg
}
@@ -979,6 +999,22 @@ func (s *Server) consoleRemoveEmail(c *gin.Context) {
}
}
// consoleDeleteUser deletes an account from the console — the operator-initiated equivalent
// of the in-app deletion (legal retention, not erasure): the account is tombstoned, its
// credentials journalled + freed, its live surfaces anonymised, and its sessions revoked.
func (s *Server) consoleDeleteUser(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
if err := s.deleteAccount(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Deleted", "the account was deleted: its credentials were journalled and freed, its data anonymised, and its sessions revoked", back)
}
// consoleBlockUser manually blocks an account: it records the suspension (permanent or until a
// parsed deadline, snapshotting the chosen reason's en/ru text) and forfeits the player's active
// games, removing them from matchmaking. The block takes effect on the player's next request.