package account import ( "context" "fmt" "github.com/go-jet/jet/v2/postgres" "github.com/google/uuid" "scrabble/backend/internal/postgres/jet/backend/table" ) // Per-account roles. A role is a named capability or restriction attached to an // account, the reusable replacement for per-feature boolean flags. The set is // expected to grow; roles are validated against KnownRoles in Go so adding one // needs no migration. Granted/revoked from the admin console (/users and the // feedback section). const ( // RoleFeedbackBanned forbids the account from submitting feedback (only that; // it is not a full account suspension). See internal/feedback. RoleFeedbackBanned = "feedback_banned" // RoleNoBanner suppresses the in-app advertising banner for the account // unconditionally, overriding the usual eligibility (a free account with an // empty hint wallet otherwise sees it). See internal/ads. RoleNoBanner = "no_banner" // RoleChatMuted forbids the account from writing in the moderated Telegram // discussion chat, without otherwise restricting the game (the chat-only // counterpart to a full account suspension). It is one input to the chat-access // gate; an active admin suspension mutes the player regardless, so this role only // matters for an account that is not suspended. Granting or revoking it re-pushes // the chat-gate command for a member currently in the chat. RoleChatMuted = "chat_muted" ) // KnownRoles is the set of roles the console may grant or revoke; an operator // cannot assign an unrecognised role. var KnownRoles = []string{RoleFeedbackBanned, RoleNoBanner, RoleChatMuted} // IsKnownRole reports whether role is a recognised account role. func IsKnownRole(role string) bool { for _, r := range KnownRoles { if r == role { return true } } return false } // GrantRole gives the account the role, idempotently (a repeat grant is a no-op). func (s *Store) GrantRole(ctx context.Context, accountID uuid.UUID, role string) error { stmt := table.AccountRoles. INSERT(table.AccountRoles.AccountID, table.AccountRoles.Role). VALUES(accountID, role). ON_CONFLICT(table.AccountRoles.AccountID, table.AccountRoles.Role).DO_NOTHING() if _, err := stmt.ExecContext(ctx, s.db); err != nil { return fmt.Errorf("account: grant role %q to %s: %w", role, accountID, err) } return nil } // RevokeRole removes the role from the account, idempotently. func (s *Store) RevokeRole(ctx context.Context, accountID uuid.UUID, role string) error { stmt := table.AccountRoles. DELETE(). WHERE(table.AccountRoles.AccountID.EQ(postgres.UUID(accountID)). AND(table.AccountRoles.Role.EQ(postgres.String(role)))) if _, err := stmt.ExecContext(ctx, s.db); err != nil { return fmt.Errorf("account: revoke role %q from %s: %w", role, accountID, err) } return nil } // HasRole reports whether the account holds the role. func (s *Store) HasRole(ctx context.Context, accountID uuid.UUID, role string) (bool, error) { var ok bool err := s.db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM backend.account_roles WHERE account_id = $1 AND role = $2)`, accountID, role).Scan(&ok) if err != nil { return false, fmt.Errorf("account: has-role %q %s: %w", role, accountID, err) } return ok, nil } // ListRoles returns the account's roles, oldest grant first. func (s *Store) ListRoles(ctx context.Context, accountID uuid.UUID) ([]string, error) { rows, err := s.db.QueryContext(ctx, `SELECT role FROM backend.account_roles WHERE account_id = $1 ORDER BY granted_at ASC`, accountID) if err != nil { return nil, fmt.Errorf("account: list roles %s: %w", accountID, err) } defer rows.Close() var out []string for rows.Next() { var role string if err := rows.Scan(&role); err != nil { return nil, fmt.Errorf("account: scan role: %w", err) } out = append(out, role) } return out, rows.Err() }