Ctrl K
ComunidadGuías…

internal/store/postgres/collectible_phone.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package postgres

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/jackc/pgx/v5"

	"telesrv/internal/domain"
	"telesrv/internal/store"
	"telesrv/internal/store/postgres/sqlcgen"
)

type CollectiblePhoneStore struct{ db sqlcgen.DBTX }

func NewCollectiblePhoneStore(db sqlcgen.DBTX) *CollectiblePhoneStore {
	return &CollectiblePhoneStore{db: db}
}

var _ store.CollectiblePhoneStore = (*CollectiblePhoneStore)(nil)

const collectiblePhoneColumns = `id, phone, tier, status, owner_user_id, purchase_date,
currency, amount, crypto_currency, crypto_amount, url, original_owner_user_id,
transfer_count, version, created_at, updated_at`

type collectiblePhoneScanner interface{ Scan(...any) error }

func scanCollectiblePhone(row collectiblePhoneScanner) (domain.CollectiblePhone, error) {
	var a domain.CollectiblePhone
	err := row.Scan(&a.ID, &a.Phone, &a.Tier, &a.Status, &a.OwnerUserID, &a.PurchaseDate,
		&a.Currency, &a.Amount, &a.CryptoCurrency, &a.CryptoAmount, &a.URL,
		&a.OriginalOwnerUserID, &a.TransferCount, &a.Version, &a.CreatedAt, &a.UpdatedAt)
	return a, err
}

func normalizePhoneCommand(phone, actor, reason, key string) (string, string, string, string) {
	return domain.NormalizeCollectiblePhone(phone), strings.TrimSpace(actor), strings.TrimSpace(reason), strings.TrimSpace(key)
}

func ensureCollectiblePhoneOwner(ctx context.Context, tx pgx.Tx, userID int64) error {
	var bot, deleted bool
	err := tx.QueryRow(ctx, `SELECT is_bot, deleted_at IS NOT NULL FROM users WHERE id=$1`, userID).Scan(&bot, &deleted)
	if errors.Is(err, pgx.ErrNoRows) || bot || deleted {
		return domain.ErrCollectiblePhoneInvalid
	}
	if err != nil {
		return fmt.Errorf("check collectible phone owner: %w", err)
	}
	var occupied bool
	if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM collectible_phones WHERE status='owned' AND owner_user_id=$1)`, userID).Scan(&occupied); err != nil {
		return fmt.Errorf("check collectible phone owner limit: %w", err)
	}
	if occupied {
		return domain.ErrCollectiblePhoneOwnerLimit
	}
	return nil
}

func replayCollectiblePhone(ctx context.Context, tx pgx.Tx, key string) (domain.CollectiblePhone, bool, error) {
	if key == "" {
		return domain.CollectiblePhone{}, false, nil
	}
	row := tx.QueryRow(ctx, `SELECT cp.id, cp.phone, cp.tier, cp.status, cp.owner_user_id, cp.purchase_date,
cp.currency, cp.amount, cp.crypto_currency, cp.crypto_amount, cp.url, cp.original_owner_user_id,
cp.transfer_count, cp.version, cp.created_at, cp.updated_at
FROM collectible_phone_transfers t JOIN collectible_phones cp ON cp.id=t.collectible_id
WHERE t.command_key=$1`, key)
	a, err := scanCollectiblePhone(row)
	if errors.Is(err, pgx.ErrNoRows) {
		return domain.CollectiblePhone{}, false, nil
	}
	if err != nil {
		return domain.CollectiblePhone{}, false, fmt.Errorf("replay collectible phone command: %w", err)
	}
	return a, true, nil
}

func insertCollectiblePhoneTransfer(ctx context.Context, tx pgx.Tx, id int64, kind domain.CollectibleUsernameTransferKind, from, to int64, currency string, amount int64, actor, reason, key string) error {
	var nullableKey any
	if key != "" {
		nullableKey = key
	}
	_, err := tx.Exec(ctx, `INSERT INTO collectible_phone_transfers
(collectible_id,kind,from_user_id,to_user_id,currency,amount,actor,reason,command_key,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,now())`, id, string(kind), from, to, currency, amount, actor, reason, nullableKey)
	if err != nil {
		return fmt.Errorf("insert collectible phone transfer: %w", err)
	}
	return nil
}

func (s *CollectiblePhoneStore) MintCollectiblePhone(ctx context.Context, req domain.MintCollectiblePhoneRequest) (domain.CollectiblePhone, bool, error) {
	if s == nil || s.db == nil {
		return domain.CollectiblePhone{}, false, fmt.Errorf("collectible phone store is not configured")
	}
	req.Phone, req.Actor, req.Reason, req.CommandKey = normalizePhoneCommand(req.Phone, req.Actor, req.Reason, req.CommandKey)
	if req.Tier == "" {
		req.Tier = domain.CollectiblePhoneTierStandard
	}
	if req.Currency == "" {
		req.Currency = domain.CollectibleCurrencyUSD
	}
	if req.URL == "" {
		req.URL = "https://fragment.com/number/" + req.Phone
	}
	if err := req.Validate(); err != nil {
		return domain.CollectiblePhone{}, false, err
	}
	var out domain.CollectiblePhone
	created := false
	err := withTx(ctx, s.db, "mint collectible phone", func(tx pgx.Tx) error {
		if replay, found, err := replayCollectiblePhone(ctx, tx, req.CommandKey); err != nil {
			return err
		} else if found {
			out = replay
			return nil
		}
		var exists bool
		if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM collectible_phones WHERE phone=$1 AND status<>'burned')`, req.Phone).Scan(&exists); err != nil {
			return err
		}
		if exists {
			return domain.ErrCollectiblePhoneInvalid
		}
		if req.OwnerUserID > 0 {
			if err := ensureCollectiblePhoneOwner(ctx, tx, req.OwnerUserID); err != nil {
				return err
			}
		}
		status := domain.CollectibleUsernameStatusVault
		if req.OwnerUserID > 0 {
			status = domain.CollectibleUsernameStatusOwned
		}
		purchaseDate := req.PurchaseDate.UTC()
		if req.PurchaseDate.IsZero() {
			purchaseDate = time.Now().UTC()
		}
		row := tx.QueryRow(ctx, `INSERT INTO collectible_phones
(phone,tier,status,owner_user_id,purchase_date,currency,amount,crypto_currency,crypto_amount,url,
 original_owner_user_id,transfer_count,version,created_at,updated_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$4,0,1,now(),now()) RETURNING `+collectiblePhoneColumns,
			req.Phone, string(req.Tier), string(status), req.OwnerUserID, purchaseDate, req.Currency, req.Amount, req.CryptoCurrency, req.CryptoAmount, req.URL)
		var err error
		out, err = scanCollectiblePhone(row)
		if err != nil {
			if isUniqueViolation(err) {
				return domain.ErrCollectiblePhoneOwnerLimit
			}
			return err
		}
		if err := insertCollectiblePhoneTransfer(ctx, tx, out.ID, domain.CollectibleUsernameKindMint, 0, req.OwnerUserID, req.Currency, req.Amount, req.Actor, req.Reason, req.CommandKey); err != nil {
			return err
		}
		created = true
		return nil
	})
	return out, created, err
}

func (s *CollectiblePhoneStore) UpdateCollectiblePhonePrice(ctx context.Context, req domain.UpdateCollectiblePhonePriceRequest) (domain.CollectiblePhone, bool, error) {
	if s == nil || s.db == nil {
		return domain.CollectiblePhone{}, false, fmt.Errorf("collectible phone store is not configured")
	}
	req.Phone = domain.NormalizeCollectiblePhone(req.Phone)
	req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency))
	req.CryptoCurrency = strings.ToUpper(strings.TrimSpace(req.CryptoCurrency))
	req.Actor = strings.TrimSpace(req.Actor)
	req.Reason = strings.TrimSpace(req.Reason)
	if err := req.Validate(); err != nil {
		return domain.CollectiblePhone{}, false, err
	}
	var out domain.CollectiblePhone
	changed := false
	err := withTx(ctx, s.db, "update collectible phone price", func(tx pgx.Tx) error {
		a, err := scanCollectiblePhone(tx.QueryRow(ctx, `SELECT `+collectiblePhoneColumns+` FROM collectible_phones WHERE phone=$1 AND status<>'burned' FOR UPDATE`, req.Phone))
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.ErrCollectiblePhoneNotFound
		}
		if err != nil {
			return err
		}
		if a.Currency == req.Currency && a.Amount == req.Amount && a.CryptoCurrency == req.CryptoCurrency && a.CryptoAmount == req.CryptoAmount {
			out = a
			return nil
		}
		out, err = scanCollectiblePhone(tx.QueryRow(ctx, `UPDATE collectible_phones SET currency=$2, amount=$3,
crypto_currency=$4, crypto_amount=$5, version=version+1, updated_at=now()
WHERE id=$1 RETURNING `+collectiblePhoneColumns, a.ID, req.Currency, req.Amount, req.CryptoCurrency, req.CryptoAmount))
		if err != nil {
			return err
		}
		changed = true
		return nil
	})
	return out, changed, err
}

func (s *CollectiblePhoneStore) TransferCollectiblePhone(ctx context.Context, req domain.TransferCollectiblePhoneRequest) (domain.CollectiblePhone, bool, error) {
	req.Phone, req.Actor, req.Reason, req.CommandKey = normalizePhoneCommand(req.Phone, req.Actor, req.Reason, req.CommandKey)
	if err := req.Validate(); err != nil {
		return domain.CollectiblePhone{}, false, err
	}
	var out domain.CollectiblePhone
	changed := false
	err := withTx(ctx, s.db, "transfer collectible phone", func(tx pgx.Tx) error {
		if replay, found, err := replayCollectiblePhone(ctx, tx, req.CommandKey); err != nil {
			return err
		} else if found {
			out = replay
			return nil
		}
		a, err := scanCollectiblePhone(tx.QueryRow(ctx, `SELECT `+collectiblePhoneColumns+` FROM collectible_phones WHERE phone=$1 AND status<>'burned' FOR UPDATE`, req.Phone))
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.ErrCollectiblePhoneNotFound
		}
		if err != nil {
			return err
		}
		if a.OwnerUserID == req.ToUserID {
			out = a
			return nil
		}
		if err := ensureCollectiblePhoneOwner(ctx, tx, req.ToUserID); err != nil {
			return err
		}
		original := a.OriginalOwnerUserID
		if original == 0 {
			original = req.ToUserID
		}
		out, err = scanCollectiblePhone(tx.QueryRow(ctx, `UPDATE collectible_phones SET status='owned', owner_user_id=$2,
original_owner_user_id=$3, transfer_count=transfer_count+1, version=version+1, updated_at=now()
WHERE id=$1 RETURNING `+collectiblePhoneColumns, a.ID, req.ToUserID, original))
		if err != nil {
			if isUniqueViolation(err) {
				return domain.ErrCollectiblePhoneOwnerLimit
			}
			return err
		}
		if err := insertCollectiblePhoneTransfer(ctx, tx, a.ID, domain.CollectibleUsernameKindTransfer, a.OwnerUserID, req.ToUserID, "", 0, req.Actor, req.Reason, req.CommandKey); err != nil {
			return err
		}
		changed = true
		return nil
	})
	return out, changed, err
}

func (s *CollectiblePhoneStore) RevokeCollectiblePhone(ctx context.Context, req domain.RevokeCollectiblePhoneRequest) (domain.CollectiblePhone, bool, error) {
	req.Phone, req.Actor, req.Reason, req.CommandKey = normalizePhoneCommand(req.Phone, req.Actor, req.Reason, req.CommandKey)
	if err := req.Validate(); err != nil {
		return domain.CollectiblePhone{}, false, err
	}
	var out domain.CollectiblePhone
	changed := false
	err := withTx(ctx, s.db, "revoke collectible phone", func(tx pgx.Tx) error {
		if replay, found, err := replayCollectiblePhone(ctx, tx, req.CommandKey); err != nil {
			return err
		} else if found {
			out = replay
			return nil
		}
		a, err := scanCollectiblePhone(tx.QueryRow(ctx, `SELECT `+collectiblePhoneColumns+` FROM collectible_phones WHERE phone=$1 AND status<>'burned' FOR UPDATE`, req.Phone))
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.ErrCollectiblePhoneNotFound
		}
		if err != nil {
			return err
		}
		if a.Status == domain.CollectibleUsernameStatusVault && !req.Burn {
			out = a
			return nil
		}
		status := domain.CollectibleUsernameStatusVault
		kind := domain.CollectibleUsernameKindRevoke
		if req.Burn {
			status = domain.CollectibleUsernameStatusBurned
			kind = domain.CollectibleUsernameKindBurn
		}
		out, err = scanCollectiblePhone(tx.QueryRow(ctx, `UPDATE collectible_phones SET status=$2, owner_user_id=0,
version=version+1, updated_at=now() WHERE id=$1 RETURNING `+collectiblePhoneColumns, a.ID, string(status)))
		if err != nil {
			return err
		}
		if err := insertCollectiblePhoneTransfer(ctx, tx, a.ID, kind, a.OwnerUserID, 0, "", 0, req.Actor, req.Reason, req.CommandKey); err != nil {
			return err
		}
		changed = true
		return nil
	})
	return out, changed, err
}

func (s *CollectiblePhoneStore) DeleteCollectiblePhone(ctx context.Context, req domain.DeleteCollectiblePhoneRequest) (bool, error) {
	req.Phone, req.Actor, req.Reason, req.CommandKey = normalizePhoneCommand(req.Phone, req.Actor, req.Reason, req.CommandKey)
	if err := req.Validate(); err != nil {
		return false, err
	}
	tag, err := s.db.Exec(ctx, `DELETE FROM collectible_phones WHERE id=(SELECT id FROM collectible_phones WHERE phone=$1 ORDER BY id DESC LIMIT 1)`, req.Phone)
	if err != nil {
		return false, fmt.Errorf("delete collectible phone: %w", err)
	}
	return tag.RowsAffected() > 0, nil
}

func (s *CollectiblePhoneStore) CollectiblePhone(ctx context.Context, phone string) (domain.CollectiblePhone, error) {
	phone = domain.NormalizeCollectiblePhone(phone)
	if !domain.ValidCollectiblePhone(phone) {
		return domain.CollectiblePhone{}, domain.ErrCollectiblePhoneInvalid
	}
	a, err := scanCollectiblePhone(s.db.QueryRow(ctx, `SELECT `+collectiblePhoneColumns+` FROM collectible_phones WHERE phone=$1 AND status<>'burned' ORDER BY id DESC LIMIT 1`, phone))
	if errors.Is(err, pgx.ErrNoRows) {
		return domain.CollectiblePhone{}, domain.ErrCollectiblePhoneNotFound
	}
	return a, err
}

func (s *CollectiblePhoneStore) CollectiblePhoneByID(ctx context.Context, id int64) (domain.CollectiblePhone, error) {
	a, err := scanCollectiblePhone(s.db.QueryRow(ctx, `SELECT `+collectiblePhoneColumns+` FROM collectible_phones WHERE id=$1`, id))
	if errors.Is(err, pgx.ErrNoRows) {
		return domain.CollectiblePhone{}, domain.ErrCollectiblePhoneNotFound
	}
	return a, err
}

func (s *CollectiblePhoneStore) OwnedCollectiblePhones(ctx context.Context, userIDs []int64) (map[int64]domain.CollectiblePhone, error) {
	out := make(map[int64]domain.CollectiblePhone)
	if len(userIDs) == 0 {
		return out, nil
	}
	rows, err := s.db.Query(ctx, `SELECT `+collectiblePhoneColumns+` FROM collectible_phones WHERE status='owned' AND owner_user_id=ANY($1::bigint[])`, userIDs)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	for rows.Next() {
		a, err := scanCollectiblePhone(rows)
		if err != nil {
			return nil, err
		}
		out[a.OwnerUserID] = a
	}
	return out, rows.Err()
}

func (s *CollectiblePhoneStore) ListCollectiblePhones(ctx context.Context, f domain.CollectiblePhoneFilter) ([]domain.CollectiblePhone, error) {
	if f.Limit <= 0 {
		f.Limit = 50
	}
	if f.Limit > 200 {
		f.Limit = 200
	}
	query := strings.TrimPrefix(domain.NormalizeCollectiblePhone(f.Query), "")
	rows, err := s.db.Query(ctx, `SELECT `+collectiblePhoneColumns+` FROM collectible_phones
WHERE ($1='' OR status=$1) AND ($2='' OR tier=$2) AND ($3::bigint=0 OR owner_user_id=$3)
AND ($4='' OR phone LIKE $4||'%') AND ($5::bigint=0 OR id<$5) ORDER BY id DESC LIMIT $6`,
		string(f.Status), string(f.Tier), f.OwnerUserID, query, f.BeforeID, f.Limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := make([]domain.CollectiblePhone, 0, f.Limit)
	for rows.Next() {
		a, err := scanCollectiblePhone(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, a)
	}
	return out, rows.Err()
}

func (s *CollectiblePhoneStore) CollectiblePhoneTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectiblePhoneTransfer, error) {
	if limit <= 0 {
		limit = 100
	}
	if limit > 500 {
		limit = 500
	}
	rows, err := s.db.Query(ctx, `SELECT id,collectible_id,kind,from_user_id,to_user_id,currency,amount,actor,reason,COALESCE(command_key,''),created_at
FROM collectible_phone_transfers WHERE collectible_id=$1 ORDER BY id DESC LIMIT $2`, collectibleID, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := make([]domain.CollectiblePhoneTransfer, 0)
	for rows.Next() {
		var v domain.CollectiblePhoneTransfer
		if err := rows.Scan(&v.ID, &v.CollectibleID, &v.Kind, &v.FromUserID, &v.ToUserID, &v.Currency, &v.Amount, &v.Actor, &v.Reason, &v.CommandKey, &v.CreatedAt); err != nil {
			return nil, err
		}
		out = append(out, v)
	}
	return out, rows.Err()
}