owpengram-server/internal/store/postgres/star_gift_collectibles.go
A 0c99ae0a9d feat: sync monoforum and collectible emoji status
Sync telesrv bd15657 (feat(account): implement collectible emoji status).

Skipped telesrv docs changes per public sync rules.
2026-07-19 20:37:10 +08:00

898 lines
36 KiB
Go

package postgres
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
func nullablePermille(attribute domain.StarGiftCollectibleAttribute) any {
if attribute.RarityKind != domain.StarGiftRarityPermille {
return nil
}
return attribute.RarityPermille
}
func nullableSHA256(value []byte) any {
if len(value) == 0 {
return nil
}
return value
}
func nullablePositiveInt64(value int64) any {
if value <= 0 {
return nil
}
return value
}
func nullableOfficialGiftJSON(value []byte) any {
if len(value) == 0 {
return nil
}
return string(value)
}
func (s *StarGiftStore) PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix))
write.Actor = strings.TrimSpace(write.Actor)
write.CommandID = strings.TrimSpace(write.CommandID)
if err := domain.ValidateStarGiftCollectibleWrite(write); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
var result domain.StarGiftCollectibleRevision
err := withTx(ctx, s.db, "publish collectible star gift revision", func(tx pgx.Tx) error {
var ignored int64
if err := tx.QueryRow(ctx, `SELECT gift_id FROM star_gift_catalog WHERE gift_id=$1 FOR UPDATE`, write.GiftID).Scan(&ignored); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrStarGiftNotFound
}
return fmt.Errorf("lock collectible catalog gift: %w", err)
}
var revision int
if err := tx.QueryRow(ctx, `
SELECT COALESCE(MAX(revision), 0) + 1 FROM star_gift_collectible_revisions WHERE gift_id=$1`, write.GiftID).Scan(&revision); err != nil {
return fmt.Errorf("allocate collectible revision: %w", err)
}
var revisionID int64
if err := tx.QueryRow(ctx, `
INSERT INTO star_gift_collectible_revisions
(gift_id, revision, upgrade_stars, supply_total, slug_prefix, status, created_by, command_id,
official_gift_id, source_manifest_sha256)
VALUES ($1,$2,$3,$4,$5,'draft',$6,$7,NULLIF($8::bigint,0),$9)
RETURNING id`, write.GiftID, revision, write.UpgradeStars, write.SupplyTotal, write.SlugPrefix, write.Actor, write.CommandID,
write.OfficialGiftID, nullableSHA256(write.SourceManifestSHA256)).Scan(&revisionID); err != nil {
return fmt.Errorf("insert collectible revision: %w", err)
}
media := NewMediaStore(tx)
insertAnimated := func(table string, attributes []domain.StarGiftCollectibleAttribute, models bool) error {
for _, attribute := range attributes {
if err := media.PutDocument(ctx, *attribute.Document); err != nil {
return fmt.Errorf("put collectible %s document: %w", attribute.Kind, err)
}
if err := media.PutFileBlob(ctx, *attribute.Blob); err != nil {
return fmt.Errorf("put collectible %s blob: %w", attribute.Kind, err)
}
animation := attribute.Animation
var query string
if models {
query = fmt.Sprintf(`
INSERT INTO %s
(collectible_revision_id, name, document_id, animation_json, animation_sha256,
source_name, source_format, width, height, frame_rate, in_point, out_point,
rarity_kind, rarity_permille, crafted, official_document_id, sort_order)
VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`, table)
if _, err := tx.Exec(ctx, query, revisionID, strings.TrimSpace(attribute.Name), attribute.Document.ID,
string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat),
animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint,
string(attribute.RarityKind), nullablePermille(attribute), attribute.Crafted,
nullablePositiveInt64(attribute.OfficialDocumentID), attribute.SortOrder); err != nil {
return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err)
}
} else {
query = fmt.Sprintf(`
INSERT INTO %s
(collectible_revision_id, name, document_id, animation_json, animation_sha256,
source_name, source_format, width, height, frame_rate, in_point, out_point,
rarity_kind, rarity_permille, official_document_id, sort_order)
VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, table)
if _, err := tx.Exec(ctx, query, revisionID, strings.TrimSpace(attribute.Name), attribute.Document.ID,
string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat),
animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint,
string(attribute.RarityKind), nullablePermille(attribute), nullablePositiveInt64(attribute.OfficialDocumentID),
attribute.SortOrder); err != nil {
return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err)
}
}
}
return nil
}
if err := insertAnimated("star_gift_collectible_models", write.Models, true); err != nil {
return err
}
if err := insertAnimated("star_gift_collectible_patterns", write.Patterns, false); err != nil {
return err
}
for _, attribute := range write.Backdrops {
if _, err := tx.Exec(ctx, `
INSERT INTO star_gift_collectible_backdrops
(collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color,
text_color, rarity_kind, rarity_permille, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, revisionID, strings.TrimSpace(attribute.Name), attribute.BackdropID,
attribute.CenterColor, attribute.EdgeColor, attribute.PatternColor, attribute.TextColor,
string(attribute.RarityKind), nullablePermille(attribute), attribute.SortOrder); err != nil {
return fmt.Errorf("insert collectible backdrop: %w", err)
}
}
if _, err := tx.Exec(ctx, `
UPDATE star_gift_collectible_revisions SET status='published', published_at=now() WHERE id=$1`, revisionID); err != nil {
return fmt.Errorf("publish collectible revision: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE star_gift_catalog SET collectible_revision_id=$2, updated_at=now() WHERE gift_id=$1`, write.GiftID, revisionID); err != nil {
return fmt.Errorf("activate collectible revision: %w", err)
}
var err error
result, err = collectibleRevisionByID(ctx, tx, revisionID)
return err
})
return result, err
}
func (s *StarGiftStore) ActiveCollectibleRevision(ctx context.Context, giftID int64) (domain.StarGiftCollectibleRevision, bool, error) {
var revisionID int64
err := s.db.QueryRow(ctx, `
SELECT collectible_revision_id FROM star_gift_catalog
WHERE gift_id=$1 AND collectible_revision_id IS NOT NULL`, giftID).Scan(&revisionID)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGiftCollectibleRevision{}, false, nil
}
if err != nil {
return domain.StarGiftCollectibleRevision{}, false, fmt.Errorf("get active collectible revision: %w", err)
}
revision, err := collectibleRevisionByID(ctx, s.db, revisionID)
if err != nil {
return domain.StarGiftCollectibleRevision{}, false, err
}
return revision, true, nil
}
func (s *StarGiftStore) CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
out := make(map[int64]domain.StarGiftCollectibleAvailability, len(giftIDs))
if len(giftIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
SELECT c.gift_id, r.upgrade_stars, r.supply_total, r.issued
FROM star_gift_catalog c
JOIN star_gift_collectible_revisions r ON r.id=c.collectible_revision_id
WHERE c.gift_id=ANY($1) AND r.status='published'`, giftIDs)
if err != nil {
return nil, fmt.Errorf("list collectible availability: %w", err)
}
defer rows.Close()
for rows.Next() {
var giftID int64
var availability domain.StarGiftCollectibleAvailability
if err := rows.Scan(&giftID, &availability.UpgradeStars, &availability.SupplyTotal, &availability.Issued); err != nil {
return nil, fmt.Errorf("scan collectible availability: %w", err)
}
out[giftID] = availability
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list collectible availability rows: %w", err)
}
return out, nil
}
func collectibleRevisionByID(ctx context.Context, db sqlcgen.DBTX, revisionID int64) (domain.StarGiftCollectibleRevision, error) {
var revision domain.StarGiftCollectibleRevision
var status string
var publishedAt pgtype.Timestamptz
if err := db.QueryRow(ctx, `
SELECT id, gift_id, revision, upgrade_stars, supply_total, issued, slug_prefix, status,
created_by, created_at, published_at, COALESCE(official_gift_id,0), source_manifest_sha256
FROM star_gift_collectible_revisions WHERE id=$1`, revisionID).Scan(
&revision.ID, &revision.GiftID, &revision.Revision, &revision.UpgradeStars, &revision.SupplyTotal,
&revision.Issued, &revision.SlugPrefix, &status, &revision.CreatedBy, &revision.CreatedAt, &publishedAt,
&revision.OfficialGiftID, &revision.SourceManifestSHA256,
); err != nil {
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("get collectible revision: %w", err)
}
revision.Published = status == "published"
if publishedAt.Valid {
revision.PublishedAt = publishedAt.Time
}
var err error
if revision.Models, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectibleModel); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
if revision.Patterns, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectiblePattern); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
if revision.Backdrops, err = listCollectibleBackdrops(ctx, db, revisionID); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
return revision, nil
}
func listAnimatedCollectibleAttributes(ctx context.Context, db sqlcgen.DBTX, revisionID int64, kind domain.StarGiftCollectibleAttributeKind) ([]domain.StarGiftCollectibleAttribute, error) {
table := "star_gift_collectible_models"
if kind == domain.StarGiftCollectiblePattern {
table = "star_gift_collectible_patterns"
} else if kind != domain.StarGiftCollectibleModel {
return nil, domain.ErrStarGiftCollectibleInvalid
}
rows, err := db.Query(ctx, fmt.Sprintf(`
SELECT a.id, a.collectible_revision_id, a.name, a.rarity_kind, COALESCE(a.rarity_permille,0),
%s, COALESCE(a.official_document_id,0), a.sort_order,
a.animation_json::text, a.animation_sha256, a.source_name, a.source_format,
a.width, a.height, a.frame_rate, a.in_point, a.out_point,
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text
FROM %s a JOIN documents d ON d.id=a.document_id
WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`,
func() string {
if kind == domain.StarGiftCollectibleModel {
return "a.crafted"
}
return "false"
}(), table), revisionID)
if err != nil {
return nil, fmt.Errorf("list collectible %s attributes: %w", kind, err)
}
defer rows.Close()
out := make([]domain.StarGiftCollectibleAttribute, 0)
for rows.Next() {
attribute := domain.StarGiftCollectibleAttribute{Kind: kind, Document: &domain.Document{}, Animation: &domain.StarGiftAnimation{}}
var attrsJSON, thumbsJSON, sourceFormat string
if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.RarityKind,
&attribute.RarityPermille, &attribute.Crafted, &attribute.OfficialDocumentID, &attribute.SortOrder,
&attribute.Animation.JSON, &attribute.Animation.SHA256, &attribute.Animation.SourceName, &sourceFormat,
&attribute.Animation.Width, &attribute.Animation.Height, &attribute.Animation.FrameRate, &attribute.Animation.InPoint, &attribute.Animation.OutPoint,
&attribute.Document.ID, &attribute.Document.AccessHash, &attribute.Document.FileReference, &attribute.Document.Date,
&attribute.Document.MimeType, &attribute.Document.Size, &attribute.Document.DCID, &attrsJSON, &thumbsJSON); err != nil {
return nil, err
}
attribute.Animation.SourceFormat = domain.StarGiftAnimationFormat(sourceFormat)
if attribute.Document.Attributes, err = decodeDocumentAttributes(attrsJSON); err != nil {
return nil, err
}
if attribute.Document.Thumbs, err = decodePhotoSizes(thumbsJSON); err != nil {
return nil, err
}
out = append(out, attribute)
}
return out, rows.Err()
}
func listCollectibleBackdrops(ctx context.Context, db sqlcgen.DBTX, revisionID int64) ([]domain.StarGiftCollectibleAttribute, error) {
rows, err := db.Query(ctx, `
SELECT id, collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color,
text_color, rarity_kind, COALESCE(rarity_permille,0), sort_order
FROM star_gift_collectible_backdrops WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, revisionID)
if err != nil {
return nil, fmt.Errorf("list collectible backdrops: %w", err)
}
defer rows.Close()
out := make([]domain.StarGiftCollectibleAttribute, 0)
for rows.Next() {
attribute := domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectibleBackdrop}
if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.BackdropID,
&attribute.CenterColor, &attribute.EdgeColor, &attribute.PatternColor, &attribute.TextColor,
&attribute.RarityKind, &attribute.RarityPermille, &attribute.SortOrder); err != nil {
return nil, err
}
out = append(out, attribute)
}
return out, rows.Err()
}
func (s *StarGiftStore) CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error) {
table := "star_gift_collectible_models"
if kind == domain.StarGiftCollectiblePattern {
table = "star_gift_collectible_patterns"
} else if kind != domain.StarGiftCollectibleModel {
return nil, false, nil
}
var raw []byte
err := s.db.QueryRow(ctx, fmt.Sprintf(`
SELECT a.animation_json::text FROM %s a
JOIN star_gift_catalog c ON c.collectible_revision_id=a.collectible_revision_id
WHERE c.gift_id=$1 AND a.id=$2`, table), giftID, attributeID).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("get collectible animation: %w", err)
}
return raw, true, nil
}
func (s *StarGiftStore) UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error) {
return s.uniqueByPredicate(ctx, "u.slug=$1", strings.ToLower(strings.TrimSpace(slug)))
}
func (s *StarGiftStore) UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) {
return s.uniqueByPredicate(ctx, "u.id=$1", uniqueGiftID)
}
func (s *StarGiftStore) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) {
out := make(map[int64]domain.UniqueStarGift, len(uniqueGiftIDs))
if len(uniqueGiftIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, uniqueStarGiftQuery("u.id=ANY($1::bigint[])"), uniqueGiftIDs)
if err != nil {
return nil, fmt.Errorf("list unique star gifts: %w", err)
}
defer rows.Close()
for rows.Next() {
unique, err := scanUniqueStarGift(rows)
if err != nil {
return nil, err
}
out[unique.ID] = unique
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate unique star gifts: %w", err)
}
return out, nil
}
func (s *StarGiftStore) ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) {
if owner.ID <= 0 || limit <= 0 {
return []domain.UniqueStarGift{}, nil
}
if limit > domain.MaxSavedStarGiftsLimit {
limit = domain.MaxSavedStarGiftsLimit
}
rows, err := s.db.Query(ctx, uniqueStarGiftQuery(`
u.owner_peer_type=$1 AND u.owner_peer_id=$2
AND NOT u.burned AND u.owner_address=''
AND sg.lifecycle_status='active'`)+`
ORDER BY u.id DESC
LIMIT $3`, string(owner.Type), owner.ID, limit)
if err != nil {
return nil, fmt.Errorf("list unique star gifts by owner: %w", err)
}
defer rows.Close()
out := make([]domain.UniqueStarGift, 0, limit)
for rows.Next() {
gift, err := scanUniqueStarGift(rows)
if err != nil {
return nil, err
}
out = append(out, gift)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate unique star gifts by owner: %w", err)
}
return out, nil
}
func (s *StarGiftStore) uniqueByPredicate(ctx context.Context, predicate string, value any) (domain.UniqueStarGift, bool, error) {
row := s.db.QueryRow(ctx, uniqueStarGiftQuery(predicate), value)
unique, err := scanUniqueStarGift(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.UniqueStarGift{}, false, nil
}
return domain.UniqueStarGift{}, false, err
}
return unique, true, nil
}
func uniqueStarGiftQuery(predicate string) string {
return fmt.Sprintf(`
SELECT u.id, u.gift_id, u.collectible_revision_id, u.source_saved_gift_id, u.title, u.slug, u.num,
COALESCE(u.owner_peer_type,''), COALESCE(u.owner_peer_id,0), u.keep_original_details, u.created_at,
u.require_premium, u.resale_ton_only, u.theme_available, u.burned, u.crafted,
u.owner_name, u.owner_address, u.gift_address,
COALESCE(l.currency,''), COALESCE(l.amount,0), COALESCE(l.version,0),
COALESCE(u.released_by_peer_type,''), COALESCE(u.released_by_peer_id,0),
u.value_amount, u.value_currency, u.value_usd_amount,
COALESCE(u.theme_peer_type,''), COALESCE(u.theme_peer_id,0),
COALESCE(u.host_peer_type,''), COALESCE(u.host_peer_id,0),
u.offer_min_stars, u.craft_chance_permille, u.last_sale_date,
u.last_sale_currency, u.last_sale_amount,
r.issued, r.supply_total, sg.from_user_id, u.original_owner_peer_type, u.original_owner_peer_id,
sg.gift_date, sg.message, sg.name_hidden,
m.id, m.name, m.rarity_kind, COALESCE(m.rarity_permille,0), m.crafted, md.id, md.access_hash, md.file_reference, md.date,
md.mime_type, md.size, md.dc_id, md.attributes::text, md.thumbs::text,
p.id, p.name, p.rarity_kind, COALESCE(p.rarity_permille,0), pd.id, pd.access_hash, pd.file_reference, pd.date,
pd.mime_type, pd.size, pd.dc_id, pd.attributes::text, pd.thumbs::text,
b.id, b.name, b.backdrop_id, b.center_color, b.edge_color, b.pattern_color, b.text_color,
b.rarity_kind, COALESCE(b.rarity_permille,0)
FROM unique_star_gifts u
JOIN star_gift_collectible_revisions r ON r.id=u.collectible_revision_id
JOIN star_gift_collectible_models m ON m.id=u.model_attribute_id
JOIN documents md ON md.id=m.document_id
JOIN star_gift_collectible_patterns p ON p.id=u.pattern_attribute_id
JOIN documents pd ON pd.id=p.document_id
JOIN star_gift_collectible_backdrops b ON b.id=u.backdrop_attribute_id
JOIN peer_star_gifts sg ON sg.id=u.source_saved_gift_id
LEFT JOIN star_gift_listings l ON l.unique_gift_id=u.id
WHERE %s`, predicate)
}
func scanUniqueStarGift(row rowScanner) (domain.UniqueStarGift, error) {
var unique domain.UniqueStarGift
var ownerType, originalOwnerType, listingCurrency, releasedByType, themePeerType, hostPeerType, lastSaleCurrency string
var listingAmount, lastSaleAmount int64
unique.Model.Kind = domain.StarGiftCollectibleModel
unique.Pattern.Kind = domain.StarGiftCollectiblePattern
unique.Backdrop.Kind = domain.StarGiftCollectibleBackdrop
unique.Model.Document = &domain.Document{}
unique.Pattern.Document = &domain.Document{}
var modelAttrs, modelThumbs, patternAttrs, patternThumbs string
if err := row.Scan(&unique.ID, &unique.GiftID, &unique.CollectibleRevisionID, &unique.SourceSavedGiftID,
&unique.Title, &unique.Slug, &unique.Num, &ownerType, &unique.Owner.ID, &unique.KeepOriginalDetails,
&unique.CreatedAt, &unique.RequirePremium, &unique.ResaleTonOnly, &unique.ThemeAvailable,
&unique.Burned, &unique.Crafted, &unique.OwnerName, &unique.OwnerAddress, &unique.GiftAddress,
&listingCurrency, &listingAmount, &unique.ResellVersion, &releasedByType, &unique.ReleasedBy.ID,
&unique.ValueAmount, &unique.ValueCurrency, &unique.ValueUSD,
&themePeerType, &unique.ThemePeer.ID, &hostPeerType, &unique.Host.ID,
&unique.OfferMinStars, &unique.CraftChancePermille, &unique.LastSaleDate,
&lastSaleCurrency, &lastSaleAmount,
&unique.AvailabilityIssued, &unique.AvailabilityTotal,
&unique.OriginalFromUserID, &originalOwnerType, &unique.OriginalOwner.ID, &unique.OriginalDate,
&unique.OriginalMessage, &unique.OriginalNameHidden,
&unique.Model.ID, &unique.Model.Name, &unique.Model.RarityKind, &unique.Model.RarityPermille, &unique.Model.Crafted,
&unique.Model.Document.ID, &unique.Model.Document.AccessHash, &unique.Model.Document.FileReference,
&unique.Model.Document.Date, &unique.Model.Document.MimeType, &unique.Model.Document.Size,
&unique.Model.Document.DCID, &modelAttrs, &modelThumbs,
&unique.Pattern.ID, &unique.Pattern.Name, &unique.Pattern.RarityKind, &unique.Pattern.RarityPermille,
&unique.Pattern.Document.ID, &unique.Pattern.Document.AccessHash, &unique.Pattern.Document.FileReference,
&unique.Pattern.Document.Date, &unique.Pattern.Document.MimeType, &unique.Pattern.Document.Size,
&unique.Pattern.Document.DCID, &patternAttrs, &patternThumbs,
&unique.Backdrop.ID, &unique.Backdrop.Name, &unique.Backdrop.BackdropID, &unique.Backdrop.CenterColor,
&unique.Backdrop.EdgeColor, &unique.Backdrop.PatternColor, &unique.Backdrop.TextColor,
&unique.Backdrop.RarityKind, &unique.Backdrop.RarityPermille); err != nil {
return domain.UniqueStarGift{}, fmt.Errorf("get unique star gift: %w", err)
}
unique.Owner.Type = domain.PeerType(ownerType)
unique.OriginalOwner.Type = domain.PeerType(originalOwnerType)
unique.ReleasedBy.Type = domain.PeerType(releasedByType)
unique.ThemePeer.Type = domain.PeerType(themePeerType)
unique.Host.Type = domain.PeerType(hostPeerType)
if listingCurrency != "" && listingAmount > 0 {
unique.ResellAmount = &domain.StarGiftAmount{Currency: domain.StarGiftCurrency(listingCurrency), Amount: listingAmount}
}
if lastSaleCurrency != "" && unique.LastSaleDate > 0 {
unique.LastSaleAmount = &domain.StarGiftAmount{Currency: domain.StarGiftCurrency(lastSaleCurrency), Amount: lastSaleAmount}
}
unique.Model.CollectibleRevisionID = unique.CollectibleRevisionID
unique.Pattern.CollectibleRevisionID = unique.CollectibleRevisionID
unique.Backdrop.CollectibleRevisionID = unique.CollectibleRevisionID
var err error
if unique.Model.Document.Attributes, err = decodeDocumentAttributes(modelAttrs); err != nil {
return domain.UniqueStarGift{}, err
}
if unique.Model.Document.Thumbs, err = decodePhotoSizes(modelThumbs); err != nil {
return domain.UniqueStarGift{}, err
}
if unique.Pattern.Document.Attributes, err = decodeDocumentAttributes(patternAttrs); err != nil {
return domain.UniqueStarGift{}, err
}
if unique.Pattern.Document.Thumbs, err = decodePhotoSizes(patternThumbs); err != nil {
return domain.UniqueStarGift{}, err
}
return unique, nil
}
func (s *StarGiftStore) ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) {
rows, err := s.db.Query(ctx, `
SELECT c.collection_id, c.title, c.hash, c.sort_order, c.created_at, c.updated_at, i.saved_gift_id
FROM star_gift_collections c
LEFT JOIN star_gift_collection_items i ON i.collection_id=c.collection_id
WHERE c.owner_peer_type=$1 AND c.owner_peer_id=$2
ORDER BY c.sort_order, c.collection_id, i.sort_order, i.saved_gift_id`, string(owner.Type), owner.ID)
if err != nil {
return nil, fmt.Errorf("list star gift collections: %w", err)
}
defer rows.Close()
out := make([]domain.StarGiftCollection, 0)
index := make(map[int]int)
for rows.Next() {
var collection domain.StarGiftCollection
var giftID pgtype.Int8
if err := rows.Scan(&collection.CollectionID, &collection.Title, &collection.Hash, &collection.SortOrder,
&collection.CreatedAt, &collection.UpdatedAt, &giftID); err != nil {
return nil, err
}
position, ok := index[collection.CollectionID]
if !ok {
collection.Owner = owner
position = len(out)
index[collection.CollectionID] = position
out = append(out, collection)
}
if giftID.Valid {
out[position].GiftIDs = append(out[position].GiftIDs, giftID.Int64)
}
}
return out, rows.Err()
}
func (s *StarGiftStore) CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error) {
title = strings.TrimSpace(title)
if !validPostgresStarGiftOwner(owner) || title == "" || len([]rune(title)) > domain.MaxStarGiftCollectionTitleRunes {
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectibleInvalid
}
var result domain.StarGiftCollection
err := withTx(ctx, s.db, "create star gift collection", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
return err
}
var count int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_collections WHERE owner_peer_type=$1 AND owner_peer_id=$2`, string(owner.Type), owner.ID).Scan(&count); err != nil {
return err
}
if count >= domain.MaxStarGiftCollectionsPerPeer {
return domain.ErrStarGiftCollectionsFull
}
ids, err := validatePostgresCollectionGiftIDs(ctx, tx, owner, savedGiftIDs)
if err != nil {
return err
}
result = domain.StarGiftCollection{Owner: owner, Title: title, GiftIDs: ids, SortOrder: count}
result.Hash = domain.StarGiftCollectionHash(title, ids)
if err := tx.QueryRow(ctx, `
INSERT INTO star_gift_collections(owner_peer_type, owner_peer_id, title, sort_order, hash)
VALUES ($1,$2,$3,$4,$5) RETURNING collection_id, created_at, updated_at`, string(owner.Type), owner.ID,
title, count, result.Hash).Scan(&result.CollectionID, &result.CreatedAt, &result.UpdatedAt); err != nil {
return err
}
return replaceCollectionItems(ctx, tx, result.CollectionID, ids)
})
return result, err
}
func (s *StarGiftStore) UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error) {
var result domain.StarGiftCollection
err := withTx(ctx, s.db, "update star gift collection", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
return err
}
if err := tx.QueryRow(ctx, `
SELECT title, hash, sort_order, created_at, updated_at FROM star_gift_collections
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND collection_id=$3 FOR UPDATE`, string(owner.Type), owner.ID, collectionID).Scan(
&result.Title, &result.Hash, &result.SortOrder, &result.CreatedAt, &result.UpdatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrStarGiftCollectionNotFound
}
return err
}
result.Owner = owner
result.CollectionID = collectionID
rows, err := tx.Query(ctx, `SELECT saved_gift_id FROM star_gift_collection_items WHERE collection_id=$1 ORDER BY sort_order, saved_gift_id`, collectionID)
if err != nil {
return err
}
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
result.GiftIDs = append(result.GiftIDs, id)
}
rows.Close()
if patch.Title != nil {
title := strings.TrimSpace(*patch.Title)
if title == "" || len([]rune(title)) > domain.MaxStarGiftCollectionTitleRunes {
return domain.ErrStarGiftCollectibleInvalid
}
result.Title = title
}
deleted := make(map[int64]struct{}, len(patch.DeleteIDs))
for _, id := range patch.DeleteIDs {
deleted[id] = struct{}{}
}
next := make([]int64, 0, len(result.GiftIDs)+len(patch.AddIDs))
for _, id := range result.GiftIDs {
if _, ok := deleted[id]; !ok {
next = append(next, id)
}
}
add, err := validatePostgresCollectionGiftIDs(ctx, tx, owner, patch.AddIDs)
if err != nil {
return err
}
next = appendUniquePostgresIDs(next, add...)
if patch.Order != nil {
order, err := validatePostgresCollectionGiftIDs(ctx, tx, owner, patch.Order)
if err != nil || !samePostgresIDSet(order, next) {
return domain.ErrStarGiftCollectibleInvalid
}
next = order
}
if len(next) > domain.MaxStarGiftCollectionItems {
return domain.ErrStarGiftCollectibleInvalid
}
result.GiftIDs = next
result.Hash = domain.StarGiftCollectionHash(result.Title, result.GiftIDs)
if err := tx.QueryRow(ctx, `
UPDATE star_gift_collections SET title=$4, hash=$5, updated_at=now()
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND collection_id=$3 RETURNING updated_at`,
string(owner.Type), owner.ID, collectionID, result.Title, result.Hash).Scan(&result.UpdatedAt); err != nil {
return err
}
return replaceCollectionItems(ctx, tx, collectionID, result.GiftIDs)
})
return result, err
}
func (s *StarGiftStore) DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error) {
var changed bool
err := withTx(ctx, s.db, "delete star gift collection", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
return err
}
tag, err := tx.Exec(ctx, `DELETE FROM star_gift_collections WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND collection_id=$3`, string(owner.Type), owner.ID, collectionID)
if err != nil {
return err
}
changed = tag.RowsAffected() > 0
if changed {
_, err = tx.Exec(ctx, `
WITH ordered AS (
SELECT collection_id, row_number() OVER (ORDER BY sort_order, collection_id) - 1 AS next_order
FROM star_gift_collections WHERE owner_peer_type=$1 AND owner_peer_id=$2
)
UPDATE star_gift_collections c SET sort_order=o.next_order, updated_at=now()
FROM ordered o WHERE c.collection_id=o.collection_id`, string(owner.Type), owner.ID)
}
return err
})
return changed, err
}
func (s *StarGiftStore) ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error {
return withTx(ctx, s.db, "reorder star gift collections", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
return err
}
rows, err := tx.Query(ctx, `SELECT collection_id FROM star_gift_collections WHERE owner_peer_type=$1 AND owner_peer_id=$2 FOR UPDATE`, string(owner.Type), owner.ID)
if err != nil {
return err
}
existing := make([]int, 0)
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
existing = append(existing, id)
}
rows.Close()
if !samePostgresIntSet(existing, collectionIDs) {
return domain.ErrStarGiftCollectibleInvalid
}
for order, id := range collectionIDs {
if _, err := tx.Exec(ctx, `UPDATE star_gift_collections SET sort_order=$4, updated_at=now() WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND collection_id=$3`, string(owner.Type), owner.ID, id, order); err != nil {
return err
}
}
return nil
})
}
func (s *StarGiftStore) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error {
return withTx(ctx, s.db, "set pinned star gifts", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
return err
}
ids, err := validatePostgresCollectionGiftIDs(ctx, tx, owner, savedGiftIDs)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=0 WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND pinned_order<>0`, string(owner.Type), owner.ID); err != nil {
return err
}
for order, id := range ids {
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$2 WHERE id=$1`, id, order+1); err != nil {
return err
}
}
return nil
})
}
func validatePostgresCollectionGiftIDs(ctx context.Context, db sqlcgen.DBTX, owner domain.Peer, ids []int64) ([]int64, error) {
ids = dedupePostgresIDs(ids)
if len(ids) > domain.MaxStarGiftCollectionItems {
return nil, domain.ErrStarGiftCollectibleInvalid
}
if len(ids) == 0 {
return []int64{}, nil
}
rows, err := db.Query(ctx, `
SELECT id FROM peer_star_gifts
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND lifecycle_status='active' AND id=ANY($3::bigint[])
FOR UPDATE`, string(owner.Type), owner.ID, ids)
if err != nil {
return nil, err
}
defer rows.Close()
found := make(map[int64]struct{}, len(ids))
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
found[id] = struct{}{}
}
if len(found) != len(ids) {
return nil, domain.ErrStarGiftNotFound
}
return ids, rows.Err()
}
// removeSavedGiftFromCollections runs under the owner advisory lock. It removes
// terminal gifts and updates every affected collection hash in bounded batches,
// so getStarGiftCollections cannot return NotModified for changed membership.
func removeSavedGiftFromCollections(ctx context.Context, tx pgx.Tx, owner domain.Peer, savedGiftID int64) error {
rows, err := tx.Query(ctx, `
SELECT c.collection_id, c.title
FROM star_gift_collections c
JOIN star_gift_collection_items i ON i.collection_id=c.collection_id
WHERE c.owner_peer_type=$1 AND c.owner_peer_id=$2 AND i.saved_gift_id=$3
ORDER BY c.collection_id
FOR UPDATE OF c`, string(owner.Type), owner.ID, savedGiftID)
if err != nil {
return fmt.Errorf("lock converted gift collections: %w", err)
}
titles := make(map[int]string)
ids := make([]int, 0)
for rows.Next() {
var id int
var title string
if err := rows.Scan(&id, &title); err != nil {
rows.Close()
return err
}
ids = append(ids, id)
titles[id] = title
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
if len(ids) == 0 {
return nil
}
if _, err := tx.Exec(ctx, `DELETE FROM star_gift_collection_items WHERE saved_gift_id=$1`, savedGiftID); err != nil {
return fmt.Errorf("remove converted gift collection memberships: %w", err)
}
memberships := make(map[int][]int64, len(ids))
itemRows, err := tx.Query(ctx, `
SELECT collection_id, saved_gift_id
FROM star_gift_collection_items
WHERE collection_id=ANY($1::integer[])
ORDER BY collection_id, sort_order, saved_gift_id`, ids)
if err != nil {
return fmt.Errorf("list remaining collection memberships: %w", err)
}
for itemRows.Next() {
var collectionID int
var giftID int64
if err := itemRows.Scan(&collectionID, &giftID); err != nil {
itemRows.Close()
return err
}
memberships[collectionID] = append(memberships[collectionID], giftID)
}
if err := itemRows.Err(); err != nil {
itemRows.Close()
return err
}
itemRows.Close()
hashes := make([]int64, len(ids))
for i, collectionID := range ids {
hashes[i] = domain.StarGiftCollectionHash(titles[collectionID], memberships[collectionID])
}
if _, err := tx.Exec(ctx, `
UPDATE star_gift_collections c SET hash=x.hash, updated_at=now()
FROM unnest($1::integer[], $2::bigint[]) AS x(collection_id, hash)
WHERE c.collection_id=x.collection_id`, ids, hashes); err != nil {
return fmt.Errorf("refresh converted gift collection hashes: %w", err)
}
return nil
}
func replaceCollectionItems(ctx context.Context, tx pgx.Tx, collectionID int, ids []int64) error {
if _, err := tx.Exec(ctx, `DELETE FROM star_gift_collection_items WHERE collection_id=$1`, collectionID); err != nil {
return err
}
for order, id := range ids {
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_collection_items(collection_id, saved_gift_id, sort_order) VALUES ($1,$2,$3)`, collectionID, id, order); err != nil {
return err
}
}
return nil
}
func validPostgresStarGiftOwner(owner domain.Peer) bool {
return owner.ID > 0 && (owner.Type == domain.PeerTypeUser || owner.Type == domain.PeerTypeChannel)
}
func starGiftCollectionLockKey(owner domain.Peer) string {
return fmt.Sprintf("star_gift_collection:%s:%d", owner.Type, owner.ID)
}
func dedupePostgresIDs(ids []int64) []int64 {
out := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, ok := seen[id]; !ok {
seen[id] = struct{}{}
out = append(out, id)
}
}
return out
}
func appendUniquePostgresIDs(dst []int64, values ...int64) []int64 {
seen := make(map[int64]struct{}, len(dst)+len(values))
for _, id := range dst {
seen[id] = struct{}{}
}
for _, id := range values {
if _, ok := seen[id]; !ok {
seen[id] = struct{}{}
dst = append(dst, id)
}
}
return dst
}
func samePostgresIDSet(a, b []int64) bool {
if len(a) != len(b) {
return false
}
a = append([]int64(nil), a...)
b = append([]int64(nil), b...)
sort.Slice(a, func(i, j int) bool { return a[i] < a[j] })
sort.Slice(b, func(i, j int) bool { return b[i] < b[j] })
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func samePostgresIntSet(a, b []int) bool {
if len(a) != len(b) {
return false
}
seen := make(map[int]struct{}, len(a))
for _, id := range a {
seen[id] = struct{}{}
}
for _, id := range b {
if _, ok := seen[id]; !ok {
return false
}
delete(seen, id)
}
return len(seen) == 0
}