Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review. The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation. Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9 Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
750 lines
28 KiB
Go
750 lines
28 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store"
|
|
"telesrv/internal/store/postgres/sqlcgen"
|
|
)
|
|
|
|
// CollectibleUsernameStore is the PostgreSQL implementation of the collectible
|
|
// username registry and the asset lifecycle behind it.
|
|
//
|
|
// The asset (collectible_usernames) and its registry projection (peer_usernames)
|
|
// are always written in one transaction: an asset can never be owned without
|
|
// being resolvable, and a resolvable collectible row always has a live owner.
|
|
// The asset row is locked with SELECT ... FOR UPDATE before any mutation, and the
|
|
// name itself is locked through the registry row, so two concurrent commands on
|
|
// the same name or the same asset serialise instead of interleaving.
|
|
type CollectibleUsernameStore struct {
|
|
db sqlcgen.DBTX
|
|
}
|
|
|
|
// NewCollectibleUsernameStore builds the store on a pgx pool or transaction.
|
|
func NewCollectibleUsernameStore(db sqlcgen.DBTX) *CollectibleUsernameStore {
|
|
return &CollectibleUsernameStore{db: db}
|
|
}
|
|
|
|
var (
|
|
_ store.UsernameRegistryStore = (*CollectibleUsernameStore)(nil)
|
|
_ store.CollectibleUsernameStore = (*CollectibleUsernameStore)(nil)
|
|
)
|
|
|
|
const (
|
|
defaultCollectibleUsernameListLimit = 50
|
|
maxCollectibleUsernameListLimit = 200
|
|
)
|
|
|
|
// collectibleUsernameColumns is the asset projection shared by every reader.
|
|
const collectibleUsernameColumns = `id, username, status, owner_peer_type, owner_peer_id,
|
|
purchase_date, currency, amount, crypto_currency, crypto_amount, url,
|
|
original_owner_peer_type, original_owner_peer_id, transfer_count, version,
|
|
created_at, updated_at`
|
|
|
|
// PeerUsernames returns the peer's registry rows in projection order.
|
|
func (s *CollectibleUsernameStore) PeerUsernames(ctx context.Context, peer domain.Peer) ([]domain.Username, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, fmt.Errorf("collectible username store is not configured")
|
|
}
|
|
return listPeerUsernames(ctx, s.db, peer)
|
|
}
|
|
|
|
// PeerUsernamesBatch resolves several peers in one round trip.
|
|
func (s *CollectibleUsernameStore) PeerUsernamesBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, fmt.Errorf("collectible username store is not configured")
|
|
}
|
|
return listPeerUsernamesBatch(ctx, s.db, peers)
|
|
}
|
|
|
|
// SetUsernameActive toggles one collectible row. The editable slot is rejected:
|
|
// the client owns it through account.updateUsername, not through this path.
|
|
func (s *CollectibleUsernameStore) SetUsernameActive(ctx context.Context, peer domain.Peer, username string, active bool) (bool, error) {
|
|
if s == nil || s.db == nil {
|
|
return false, fmt.Errorf("collectible username store is not configured")
|
|
}
|
|
username = domain.NormalizeUsername(username)
|
|
if peer.Type == "" || peer.ID <= 0 || username == "" {
|
|
return false, domain.ErrUsernameInvalid
|
|
}
|
|
usernameLower := strings.ToLower(username)
|
|
changed := false
|
|
err := withTx(ctx, s.db, "set collectible username active", func(tx pgx.Tx) error {
|
|
current, err := lockPeerUsernamesTx(ctx, tx, peer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := domain.ValidateUsernameToggle(current, username, active); err != nil {
|
|
return err
|
|
}
|
|
tag, err := tx.Exec(ctx, `
|
|
UPDATE peer_usernames SET active = $4, updated_at = now()
|
|
WHERE username_lower = $1 AND peer_type = $2 AND peer_id = $3
|
|
AND collectible_id IS NOT NULL AND active <> $4`,
|
|
usernameLower, string(peer.Type), peer.ID, active)
|
|
if err != nil {
|
|
return fmt.Errorf("update collectible username active: %w", err)
|
|
}
|
|
changed = tag.RowsAffected() > 0
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return changed, nil
|
|
}
|
|
|
|
// ReorderUsernames rewrites the peer's username sort order. order carries every
|
|
// active username the peer has, the editable slot included -- see
|
|
// domain.ValidateUsernameReorder -- so the editable row is repositioned like any
|
|
// other and a collectible may end up first.
|
|
func (s *CollectibleUsernameStore) ReorderUsernames(ctx context.Context, peer domain.Peer, order []string) (bool, error) {
|
|
if s == nil || s.db == nil {
|
|
return false, fmt.Errorf("collectible username store is not configured")
|
|
}
|
|
if peer.Type == "" || peer.ID <= 0 {
|
|
return false, domain.ErrUsernameInvalid
|
|
}
|
|
changed := false
|
|
err := withTx(ctx, s.db, "reorder collectible usernames", func(tx pgx.Tx) error {
|
|
current, err := lockPeerUsernamesTx(ctx, tx, peer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
next, err := domain.ApplyUsernameReorder(current, order)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
previous := make(map[string]int, len(current))
|
|
for _, item := range current {
|
|
previous[strings.ToLower(item.Username)] = item.SortOrder
|
|
}
|
|
// Renumbering always happens; "changed" is about what a client can see.
|
|
changed = !domain.SameUsernameOrder(current, next)
|
|
for _, item := range next {
|
|
key := strings.ToLower(item.Username)
|
|
if key == "" || previous[key] == item.SortOrder {
|
|
continue
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE peer_usernames SET sort_order = $4, updated_at = now()
|
|
WHERE username_lower = $1 AND peer_type = $2 AND peer_id = $3`,
|
|
key, string(peer.Type), peer.ID, item.SortOrder); err != nil {
|
|
return fmt.Errorf("update username sort order: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return changed, nil
|
|
}
|
|
|
|
// DeactivateAllUsernames clears the active flag on every collectible row, which
|
|
// is what losing a public surface does to the peer's collectible names. The
|
|
// editable slot keeps its own flag.
|
|
func (s *CollectibleUsernameStore) DeactivateAllUsernames(ctx context.Context, peer domain.Peer) (bool, error) {
|
|
if s == nil || s.db == nil {
|
|
return false, fmt.Errorf("collectible username store is not configured")
|
|
}
|
|
if peer.Type == "" || peer.ID <= 0 {
|
|
return false, domain.ErrUsernameInvalid
|
|
}
|
|
tag, err := s.db.Exec(ctx, `
|
|
UPDATE peer_usernames SET active = false, updated_at = now()
|
|
WHERE peer_type = $1 AND peer_id = $2 AND collectible_id IS NOT NULL AND active`,
|
|
string(peer.Type), peer.ID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("deactivate collectible usernames: %w", err)
|
|
}
|
|
return tag.RowsAffected() > 0, nil
|
|
}
|
|
|
|
// MintCollectibleUsername creates the asset, optionally assigning it in the same
|
|
// transaction. A non-empty CommandKey makes the mint replay-safe: the recorded
|
|
// provenance row carries the key, so a retry returns the original asset.
|
|
func (s *CollectibleUsernameStore) MintCollectibleUsername(ctx context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.CollectibleUsername{}, false, fmt.Errorf("collectible username store is not configured")
|
|
}
|
|
req.Username = domain.NormalizeUsername(req.Username)
|
|
req.Actor = strings.TrimSpace(req.Actor)
|
|
req.Reason = strings.TrimSpace(req.Reason)
|
|
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
|
if err := req.Validate(); err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
usernameLower := strings.ToLower(req.Username)
|
|
var asset domain.CollectibleUsername
|
|
created := false
|
|
err := withTx(ctx, s.db, "mint collectible username", func(tx pgx.Tx) error {
|
|
if replayed, found, err := replayCollectibleUsernameCommand(ctx, tx, req.CommandKey); err != nil {
|
|
return err
|
|
} else if found {
|
|
asset = replayed
|
|
return nil
|
|
}
|
|
now := time.Now().UTC()
|
|
purchaseDate := req.PurchaseDate.UTC()
|
|
if req.PurchaseDate.IsZero() {
|
|
purchaseDate = now
|
|
}
|
|
// The registry row locks the name against editable usernames. Occupancy of
|
|
// the asset itself is decided by the live rows only: 0152 narrowed
|
|
// uniqueness to status <> 'burned', so a retired name can be issued again
|
|
// while its burned rows stay as provenance.
|
|
if _, found, err := getPeerUsernameOwner(ctx, tx, usernameLower, true); err != nil {
|
|
return err
|
|
} else if found {
|
|
return domain.ErrUsernameOccupied
|
|
}
|
|
var existing int64
|
|
switch err := tx.QueryRow(ctx, `
|
|
SELECT id FROM collectible_usernames
|
|
WHERE username_lower = $1 AND status <> 'burned' FOR UPDATE`, usernameLower).Scan(&existing); {
|
|
case err == nil:
|
|
return domain.ErrUsernameOccupied
|
|
case errors.Is(err, pgx.ErrNoRows):
|
|
default:
|
|
return fmt.Errorf("lock collectible username: %w", err)
|
|
}
|
|
owner := req.Owner
|
|
status := domain.CollectibleUsernameStatusVault
|
|
if owner.Type != "" {
|
|
status = domain.CollectibleUsernameStatusOwned
|
|
count, err := countPeerCollectibleUsernamesTx(ctx, tx, string(owner.Type), owner.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count >= domain.MaxPeerCollectibleUsernames {
|
|
return domain.ErrCollectibleUsernameLimit
|
|
}
|
|
}
|
|
var collectibleID int64
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO collectible_usernames
|
|
(username, username_lower, status, owner_peer_type, owner_peer_id, purchase_date,
|
|
currency, amount, crypto_currency, crypto_amount, url,
|
|
original_owner_peer_type, original_owner_peer_id, transfer_count, version,
|
|
created_at, updated_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$4,$5,0,1,$12,$12)
|
|
RETURNING id`,
|
|
req.Username, usernameLower, string(status), string(owner.Type), owner.ID, purchaseDate,
|
|
req.Currency, req.Amount, req.CryptoCurrency, req.CryptoAmount, req.URL, now,
|
|
).Scan(&collectibleID); err != nil {
|
|
if isUniqueViolation(err) {
|
|
return domain.ErrUsernameOccupied
|
|
}
|
|
return fmt.Errorf("insert collectible username: %w", err)
|
|
}
|
|
if owner.Type != "" {
|
|
if err := insertCollectiblePeerUsernameTx(ctx, tx, string(owner.Type), owner.ID,
|
|
req.Username, usernameLower, collectibleID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// A mint that assigns an owner records a single 'mint' row carrying the
|
|
// recipient: the command key is unique, so one command owns one row.
|
|
if err := insertCollectibleUsernameTransferTx(ctx, tx, collectibleUsernameTransfer{
|
|
collectibleID: collectibleID,
|
|
kind: domain.CollectibleUsernameKindMint,
|
|
to: owner,
|
|
currency: req.Currency,
|
|
amount: req.Amount,
|
|
actor: req.Actor,
|
|
reason: req.Reason,
|
|
commandKey: req.CommandKey,
|
|
createdAt: now,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
loaded, err := collectibleUsernameByIDTx(ctx, tx, collectibleID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
asset = loaded
|
|
created = true
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
return asset, created, nil
|
|
}
|
|
|
|
// TransferCollectibleUsername moves the asset to req.To, out of the vault or from
|
|
// the current holder. The old registry row is removed and the new one inserted in
|
|
// the same transaction, so the name never resolves to the wrong peer.
|
|
func (s *CollectibleUsernameStore) TransferCollectibleUsername(ctx context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.CollectibleUsername{}, false, fmt.Errorf("collectible username store is not configured")
|
|
}
|
|
req.Username = domain.NormalizeUsername(req.Username)
|
|
req.Actor = strings.TrimSpace(req.Actor)
|
|
req.Reason = strings.TrimSpace(req.Reason)
|
|
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
|
if err := req.Validate(); err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
usernameLower := strings.ToLower(req.Username)
|
|
var asset domain.CollectibleUsername
|
|
changed := false
|
|
err := withTx(ctx, s.db, "transfer collectible username", func(tx pgx.Tx) error {
|
|
if replayed, found, err := replayCollectibleUsernameCommand(ctx, tx, req.CommandKey); err != nil {
|
|
return err
|
|
} else if found {
|
|
asset = replayed
|
|
return nil
|
|
}
|
|
current, err := lockCollectibleUsernameTx(ctx, tx, usernameLower)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current.Status == domain.CollectibleUsernameStatusBurned {
|
|
return domain.ErrCollectibleUsernameBurned
|
|
}
|
|
if current.Owned() && current.Owner == req.To {
|
|
asset = current
|
|
return nil
|
|
}
|
|
now := time.Now().UTC()
|
|
if err := deleteCollectiblePeerUsernameTx(ctx, tx, current.ID); err != nil {
|
|
return err
|
|
}
|
|
count, err := countPeerCollectibleUsernamesTx(ctx, tx, string(req.To.Type), req.To.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count >= domain.MaxPeerCollectibleUsernames {
|
|
return domain.ErrCollectibleUsernameLimit
|
|
}
|
|
if err := insertCollectiblePeerUsernameTx(ctx, tx, string(req.To.Type), req.To.ID,
|
|
current.Username, usernameLower, current.ID); err != nil {
|
|
return err
|
|
}
|
|
// The original owner is the first holder and is recorded once: a name that
|
|
// left the vault keeps its provenance across every later move and the burn.
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE collectible_usernames
|
|
SET status = 'owned',
|
|
owner_peer_type = $2,
|
|
owner_peer_id = $3,
|
|
original_owner_peer_type = CASE WHEN original_owner_peer_type = '' THEN $2 ELSE original_owner_peer_type END,
|
|
original_owner_peer_id = CASE WHEN original_owner_peer_type = '' THEN $3 ELSE original_owner_peer_id END,
|
|
transfer_count = transfer_count + 1,
|
|
version = version + 1,
|
|
updated_at = $4
|
|
WHERE id = $1`, current.ID, string(req.To.Type), req.To.ID, now); err != nil {
|
|
return fmt.Errorf("update transferred collectible username: %w", err)
|
|
}
|
|
if err := insertCollectibleUsernameTransferTx(ctx, tx, collectibleUsernameTransfer{
|
|
collectibleID: current.ID,
|
|
kind: domain.CollectibleUsernameKindTransfer,
|
|
from: current.Owner,
|
|
to: req.To,
|
|
actor: req.Actor,
|
|
reason: req.Reason,
|
|
commandKey: req.CommandKey,
|
|
createdAt: now,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
loaded, err := collectibleUsernameByIDTx(ctx, tx, current.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
asset = loaded
|
|
changed = true
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
return asset, changed, nil
|
|
}
|
|
|
|
// RevokeCollectibleUsername returns the asset to the vault, or burns it when
|
|
// req.Burn is set. Either way the registry row goes away, so the name stops
|
|
// resolving to the former holder; a burn additionally retires the asset.
|
|
func (s *CollectibleUsernameStore) RevokeCollectibleUsername(ctx context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.CollectibleUsername{}, false, fmt.Errorf("collectible username store is not configured")
|
|
}
|
|
req.Username = domain.NormalizeUsername(req.Username)
|
|
req.Actor = strings.TrimSpace(req.Actor)
|
|
req.Reason = strings.TrimSpace(req.Reason)
|
|
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
|
if err := req.Validate(); err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
usernameLower := strings.ToLower(req.Username)
|
|
var asset domain.CollectibleUsername
|
|
changed := false
|
|
err := withTx(ctx, s.db, "revoke collectible username", func(tx pgx.Tx) error {
|
|
if replayed, found, err := replayCollectibleUsernameCommand(ctx, tx, req.CommandKey); err != nil {
|
|
return err
|
|
} else if found {
|
|
asset = replayed
|
|
return nil
|
|
}
|
|
current, err := lockCollectibleUsernameTx(ctx, tx, usernameLower)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current.Status == domain.CollectibleUsernameStatusBurned {
|
|
return domain.ErrCollectibleUsernameBurned
|
|
}
|
|
if !req.Burn && !current.Owned() {
|
|
// Already in the vault: nothing to release, nothing to record.
|
|
asset = current
|
|
return nil
|
|
}
|
|
now := time.Now().UTC()
|
|
if err := deleteCollectiblePeerUsernameTx(ctx, tx, current.ID); err != nil {
|
|
return err
|
|
}
|
|
status := domain.CollectibleUsernameStatusVault
|
|
kind := domain.CollectibleUsernameKindRevoke
|
|
if req.Burn {
|
|
status = domain.CollectibleUsernameStatusBurned
|
|
kind = domain.CollectibleUsernameKindBurn
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE collectible_usernames
|
|
SET status = $2,
|
|
owner_peer_type = '',
|
|
owner_peer_id = 0,
|
|
version = version + 1,
|
|
updated_at = $3
|
|
WHERE id = $1`, current.ID, string(status), now); err != nil {
|
|
return fmt.Errorf("update revoked collectible username: %w", err)
|
|
}
|
|
if err := insertCollectibleUsernameTransferTx(ctx, tx, collectibleUsernameTransfer{
|
|
collectibleID: current.ID,
|
|
kind: kind,
|
|
from: current.Owner,
|
|
actor: req.Actor,
|
|
reason: req.Reason,
|
|
commandKey: req.CommandKey,
|
|
createdAt: now,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
loaded, err := collectibleUsernameByIDTx(ctx, tx, current.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
asset = loaded
|
|
changed = true
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
return asset, changed, nil
|
|
}
|
|
|
|
// DeleteCollectibleUsername removes the live asset for a name outright: the
|
|
// registry row, the asset and its provenance log all go away, and the name
|
|
// becomes free for any use. This is the operator's escape hatch for a mistaken
|
|
// issue, as opposed to Revoke+Burn, which retires an asset but keeps its history.
|
|
//
|
|
// The command key cannot make this idempotent -- a replay has no record left to
|
|
// return -- so a second call simply reports deleted=false once no live asset
|
|
// remains.
|
|
func (s *CollectibleUsernameStore) DeleteCollectibleUsername(ctx context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
|
|
if s == nil || s.db == nil {
|
|
return false, fmt.Errorf("collectible username store is not configured")
|
|
}
|
|
req.Username = domain.NormalizeUsername(req.Username)
|
|
req.Actor = strings.TrimSpace(req.Actor)
|
|
req.Reason = strings.TrimSpace(req.Reason)
|
|
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
|
if err := req.Validate(); err != nil {
|
|
return false, err
|
|
}
|
|
usernameLower := strings.ToLower(req.Username)
|
|
deleted := false
|
|
err := withTx(ctx, s.db, "delete collectible username", func(tx pgx.Tx) error {
|
|
var id int64
|
|
switch err := tx.QueryRow(ctx, `
|
|
SELECT id FROM collectible_usernames
|
|
WHERE username_lower = $1 AND status <> 'burned'
|
|
ORDER BY id DESC
|
|
LIMIT 1
|
|
FOR UPDATE`, usernameLower).Scan(&id); {
|
|
case err == nil:
|
|
case errors.Is(err, pgx.ErrNoRows):
|
|
// Either the name was never issued, or only burned history remains.
|
|
// Both are "nothing live to delete" rather than an error, so a repeated
|
|
// command stays safe.
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("lock collectible username for delete: %w", err)
|
|
}
|
|
if err := deleteCollectiblePeerUsernameTx(ctx, tx, id); err != nil {
|
|
return err
|
|
}
|
|
// collectible_username_transfers references the asset with ON DELETE
|
|
// CASCADE, so the provenance rows go with it.
|
|
if _, err := tx.Exec(ctx, `DELETE FROM collectible_usernames WHERE id = $1`, id); err != nil {
|
|
return fmt.Errorf("delete collectible username: %w", err)
|
|
}
|
|
deleted = true
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return deleted, nil
|
|
}
|
|
|
|
// CollectibleUsername looks the asset up by name. A live asset wins; when the
|
|
// name only has burned rows the newest one is returned, because the provenance of
|
|
// a retired name still has to be inspectable.
|
|
func (s *CollectibleUsernameStore) CollectibleUsername(ctx context.Context, username string) (domain.CollectibleUsername, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.CollectibleUsername{}, fmt.Errorf("collectible username store is not configured")
|
|
}
|
|
usernameLower := strings.ToLower(domain.NormalizeUsername(username))
|
|
if usernameLower == "" {
|
|
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
|
}
|
|
asset, err := scanCollectibleUsername(s.db.QueryRow(ctx, `
|
|
SELECT `+collectibleUsernameColumns+`
|
|
FROM collectible_usernames
|
|
WHERE username_lower = $1
|
|
ORDER BY (status <> 'burned') DESC, id DESC
|
|
LIMIT 1`, usernameLower))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
|
}
|
|
if err != nil {
|
|
return domain.CollectibleUsername{}, fmt.Errorf("get collectible username: %w", err)
|
|
}
|
|
return asset, nil
|
|
}
|
|
|
|
// CollectibleUsernameByID looks the asset up by identity.
|
|
func (s *CollectibleUsernameStore) CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.CollectibleUsername{}, fmt.Errorf("collectible username store is not configured")
|
|
}
|
|
if id <= 0 {
|
|
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
|
}
|
|
asset, err := collectibleUsernameByIDTx(ctx, s.db, id)
|
|
if err != nil {
|
|
return domain.CollectibleUsername{}, err
|
|
}
|
|
return asset, nil
|
|
}
|
|
|
|
// ListCollectibleUsernames is the admin listing query with keyset paging on the
|
|
// asset id, which matches the (status, id DESC) and (owner, id DESC) indexes.
|
|
func (s *CollectibleUsernameStore) ListCollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, fmt.Errorf("collectible username store is not configured")
|
|
}
|
|
if filter.Status != "" && !filter.Status.Valid() {
|
|
return nil, domain.ErrCollectibleUsernameStateInvalid
|
|
}
|
|
if filter.Owner.Type != "" && filter.Owner.ID <= 0 {
|
|
return nil, domain.ErrCollectibleUsernameStateInvalid
|
|
}
|
|
limit := filter.Limit
|
|
if limit <= 0 {
|
|
limit = defaultCollectibleUsernameListLimit
|
|
}
|
|
if limit > maxCollectibleUsernameListLimit {
|
|
limit = maxCollectibleUsernameListLimit
|
|
}
|
|
query := strings.ToLower(domain.NormalizeUsername(filter.Query))
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT `+collectibleUsernameColumns+`
|
|
FROM collectible_usernames
|
|
WHERE ($1 = '' OR status = $1)
|
|
AND ($2 = '' OR (owner_peer_type = $2 AND owner_peer_id = $3))
|
|
AND ($4 = '' OR username_lower LIKE $5 || '%')
|
|
AND ($6 = 0 OR id < $6)
|
|
ORDER BY id DESC
|
|
LIMIT $7`,
|
|
string(filter.Status), string(filter.Owner.Type), filter.Owner.ID,
|
|
query, escapeLike(query), filter.BeforeID, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list collectible usernames: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := make([]domain.CollectibleUsername, 0, limit)
|
|
for rows.Next() {
|
|
asset, err := scanCollectibleUsername(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan collectible username: %w", err)
|
|
}
|
|
out = append(out, asset)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate collectible usernames: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// CollectibleUsernameTransfers returns the provenance log, newest first.
|
|
func (s *CollectibleUsernameStore) CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, fmt.Errorf("collectible username store is not configured")
|
|
}
|
|
if collectibleID <= 0 {
|
|
return nil, domain.ErrCollectibleUsernameNotFound
|
|
}
|
|
if limit <= 0 {
|
|
limit = defaultCollectibleUsernameListLimit
|
|
}
|
|
if limit > maxCollectibleUsernameListLimit {
|
|
limit = maxCollectibleUsernameListLimit
|
|
}
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT id, collectible_id, kind, from_peer_type, from_peer_id, to_peer_type, to_peer_id,
|
|
currency, amount, actor, reason, COALESCE(command_key, ''), created_at
|
|
FROM collectible_username_transfers
|
|
WHERE collectible_id = $1
|
|
ORDER BY id DESC
|
|
LIMIT $2`, collectibleID, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list collectible username transfers: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := make([]domain.CollectibleUsernameTransfer, 0, limit)
|
|
for rows.Next() {
|
|
var item domain.CollectibleUsernameTransfer
|
|
var kind, fromType, toType string
|
|
if err := rows.Scan(&item.ID, &item.CollectibleID, &kind, &fromType, &item.From.ID,
|
|
&toType, &item.To.ID, &item.Currency, &item.Amount, &item.Actor, &item.Reason,
|
|
&item.CommandKey, &item.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("scan collectible username transfer: %w", err)
|
|
}
|
|
item.Kind = domain.CollectibleUsernameTransferKind(kind)
|
|
item.From.Type = domain.PeerType(fromType)
|
|
item.To.Type = domain.PeerType(toType)
|
|
out = append(out, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate collectible username transfers: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type collectibleUsernameTransfer struct {
|
|
collectibleID int64
|
|
kind domain.CollectibleUsernameTransferKind
|
|
from domain.Peer
|
|
to domain.Peer
|
|
currency string
|
|
amount int64
|
|
actor string
|
|
reason string
|
|
commandKey string
|
|
createdAt time.Time
|
|
}
|
|
|
|
func insertCollectibleUsernameTransferTx(ctx context.Context, tx pgx.Tx, entry collectibleUsernameTransfer) error {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO collectible_username_transfers
|
|
(collectible_id, kind, from_peer_type, from_peer_id, to_peer_type, to_peer_id,
|
|
currency, amount, actor, reason, command_key, created_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NULLIF($11,''),$12)`,
|
|
entry.collectibleID, string(entry.kind), string(entry.from.Type), entry.from.ID,
|
|
string(entry.to.Type), entry.to.ID, entry.currency, entry.amount,
|
|
entry.actor, entry.reason, entry.commandKey, entry.createdAt); err != nil {
|
|
return fmt.Errorf("insert collectible username transfer: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// replayCollectibleUsernameCommand resolves an already-recorded command key to
|
|
// the asset it touched, which is what makes mint/transfer/revoke retry-safe.
|
|
//
|
|
// The transaction-scoped advisory lock serialises commands sharing a key, so two
|
|
// concurrent retries cannot both pass the lookup and race on the unique
|
|
// command_key index -- the second one waits and then observes the recorded row.
|
|
func replayCollectibleUsernameCommand(ctx context.Context, tx pgx.Tx, commandKey string) (domain.CollectibleUsername, bool, error) {
|
|
if commandKey == "" {
|
|
return domain.CollectibleUsername{}, false, nil
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
SELECT pg_advisory_xact_lock(hashtextextended('collectible-username:' || $1::text, 0))`, commandKey); err != nil {
|
|
return domain.CollectibleUsername{}, false, fmt.Errorf("lock collectible username command: %w", err)
|
|
}
|
|
var collectibleID int64
|
|
err := tx.QueryRow(ctx, `
|
|
SELECT collectible_id FROM collectible_username_transfers WHERE command_key = $1`, commandKey).Scan(&collectibleID)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.CollectibleUsername{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return domain.CollectibleUsername{}, false, fmt.Errorf("lookup collectible username command: %w", err)
|
|
}
|
|
asset, err := collectibleUsernameByIDTx(ctx, tx, collectibleID)
|
|
if err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
return asset, true, nil
|
|
}
|
|
|
|
// lockCollectibleUsernameTx locks the row a name currently resolves to. After
|
|
// 0151 one name can carry several burned rows plus at most one live row, so the
|
|
// live row wins and the newest burned row is the fallback. That keeps a mutation
|
|
// of a retired name reporting ErrCollectibleUsernameBurned instead of degrading
|
|
// to a not-found.
|
|
func lockCollectibleUsernameTx(ctx context.Context, tx pgx.Tx, usernameLower string) (domain.CollectibleUsername, error) {
|
|
asset, err := scanCollectibleUsername(tx.QueryRow(ctx, `
|
|
SELECT `+collectibleUsernameColumns+`
|
|
FROM collectible_usernames
|
|
WHERE username_lower = $1
|
|
ORDER BY (status <> 'burned') DESC, id DESC
|
|
LIMIT 1
|
|
FOR UPDATE`, usernameLower))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
|
}
|
|
if err != nil {
|
|
return domain.CollectibleUsername{}, fmt.Errorf("lock collectible username: %w", err)
|
|
}
|
|
return asset, nil
|
|
}
|
|
|
|
func collectibleUsernameByIDTx(ctx context.Context, db sqlcgen.DBTX, id int64) (domain.CollectibleUsername, error) {
|
|
asset, err := scanCollectibleUsername(db.QueryRow(ctx, `
|
|
SELECT `+collectibleUsernameColumns+`
|
|
FROM collectible_usernames WHERE id = $1`, id))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
|
}
|
|
if err != nil {
|
|
return domain.CollectibleUsername{}, fmt.Errorf("get collectible username by id: %w", err)
|
|
}
|
|
return asset, nil
|
|
}
|
|
|
|
func scanCollectibleUsername(row pgx.Row) (domain.CollectibleUsername, error) {
|
|
var asset domain.CollectibleUsername
|
|
var status, ownerType, originalOwnerType string
|
|
if err := row.Scan(&asset.ID, &asset.Username, &status, &ownerType, &asset.Owner.ID,
|
|
&asset.PurchaseDate, &asset.Currency, &asset.Amount, &asset.CryptoCurrency,
|
|
&asset.CryptoAmount, &asset.URL, &originalOwnerType, &asset.OriginalOwner.ID,
|
|
&asset.TransferCount, &asset.Version, &asset.CreatedAt, &asset.UpdatedAt); err != nil {
|
|
return domain.CollectibleUsername{}, err
|
|
}
|
|
asset.Status = domain.CollectibleUsernameStatus(status)
|
|
asset.Owner.Type = domain.PeerType(ownerType)
|
|
asset.OriginalOwner.Type = domain.PeerType(originalOwnerType)
|
|
return asset, nil
|
|
}
|