owpengram-server/internal/store/postgres/star_gift_lifecycle_projection.go

196 lines
6.8 KiB
Go

package postgres
import (
"context"
"errors"
"fmt"
"sort"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// retireUserStarGiftMessagesTx closes every user-scoped unique-gift action
// emitted for the source ownership epoch. Ownership moves and terminal export
// must not leave an older chat card with Craft/transfer/resale capabilities.
// The aggregate mutation and all message edits share one transaction and each
// visible box receives its own durable pts/event/outbox entry.
func (s *StarGiftLifecycleStore) retireUserStarGiftMessagesTx(
ctx context.Context,
tx pgx.Tx,
source domain.SavedStarGift,
current domain.UniqueStarGift,
date int,
) ([]domain.EditedMessageForUser, error) {
if s == nil || s.messages == nil || source.Owner.Type != domain.PeerTypeUser || source.Owner.ID <= 0 ||
source.ID <= 0 || source.UniqueGiftID <= 0 || current.ID != source.UniqueGiftID || date <= 0 {
return nil, domain.ErrStarGiftTransferUnavailable
}
messageIDs := map[int]struct{}{}
if source.MsgID > 0 {
messageIDs[source.MsgID] = struct{}{}
}
if source.UpgradeMsgID > 0 {
messageIDs[source.UpgradeMsgID] = struct{}{}
}
rows, err := tx.Query(ctx, `
SELECT msg_id FROM star_gift_user_message_refs
WHERE owner_user_id=$1 AND saved_gift_id=$2
ORDER BY msg_id`, source.Owner.ID, source.ID)
if err != nil {
return nil, fmt.Errorf("list star gift message projections: %w", err)
}
for rows.Next() {
var msgID int
if err := rows.Scan(&msgID); err != nil {
rows.Close()
return nil, fmt.Errorf("scan star gift message projection: %w", err)
}
if msgID > 0 {
messageIDs[msgID] = struct{}{}
}
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, fmt.Errorf("iterate star gift message projections: %w", err)
}
rows.Close()
ids := make([]int, 0, len(messageIDs))
for msgID := range messageIDs {
ids = append(ids, msgID)
}
sort.Ints(ids)
q := sqlcgen.New(tx)
edits := make([]domain.EditedMessageForUser, 0, len(ids)*2)
seenPrivateMessages := make(map[string]struct{}, len(ids))
for _, msgID := range ids {
var peerType string
var peerID int64
err := tx.QueryRow(ctx, `
SELECT peer_type,peer_id FROM message_boxes
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted
FOR UPDATE`, source.Owner.ID, msgID).Scan(&peerType, &peerID)
if errors.Is(err, pgx.ErrNoRows) {
continue
}
if err != nil {
return nil, fmt.Errorf("lock star gift message projection: %w", err)
}
if peerType != string(domain.PeerTypeUser) || peerID <= 0 {
return nil, fmt.Errorf("star gift message projection %d is not private", msgID)
}
target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{
OwnerUserID: source.Owner.ID, BoxID: int32(msgID), PeerType: peerType, PeerID: peerID,
})
if err != nil {
return nil, fmt.Errorf("load star gift message projection: %w", err)
}
logicalKey := fmt.Sprintf("%d:%d", target.MessageSenderID, target.PrivateMessageID)
if _, duplicate := seenPrivateMessages[logicalKey]; duplicate {
continue
}
seenPrivateMessages[logicalKey] = struct{}{}
boxes, err := q.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
OwnerUserIds: privateMessageOwnerIDs(source.Owner.ID, peerID),
MessageSenderID: target.MessageSenderID, PrivateMessageID: target.PrivateMessageID,
})
if err != nil {
return nil, fmt.Errorf("list visible star gift message projections: %w", err)
}
var privateMediaJSON []byte
matched := false
for _, box := range boxes {
media, err := decodeMessageMedia(box.MediaJson)
if err != nil {
return nil, fmt.Errorf("decode star gift message projection: %w", err)
}
if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique || media.ServiceAction.StarGiftUnique == nil ||
media.ServiceAction.StarGiftUnique.Gift.ID != current.ID {
continue
}
matched = true
action := media.ServiceAction.StarGiftUnique
retiredGift := current
retiredGift.CraftChancePermille = 0
retiredGift.ResellAmount = nil
action.Gift = retiredGift
action.Peer = domain.Peer{}
action.SavedID = 0
action.Saved = false
if validLifecyclePeer(current.Owner) && current.Owner != source.Owner {
action.Transferred = true
}
action.CanExportAt = 0
action.TransferStars = 0
action.ResaleAmount = nil
action.CanTransferAt = 0
action.CanResellAt = 0
action.DropOriginalDetailsStars = 0
action.CanCraftAt = 0
mediaJSON, err := encodeMessageMedia(media)
if err != nil {
return nil, fmt.Errorf("encode retired star gift projection: %w", err)
}
pts, err := s.messages.reservePts(ctx, tx, box.OwnerUserID)
if err != nil {
return nil, fmt.Errorf("allocate retired star gift pts: %w", err)
}
tag, err := tx.Exec(ctx, `
UPDATE message_boxes SET media=$3,pts=$4
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, box.OwnerUserID, box.BoxID, mediaJSON, int32(pts))
if err != nil {
return nil, fmt.Errorf("update retired star gift projection: %w", err)
}
if tag.RowsAffected() != 1 {
return nil, fmt.Errorf("update retired star gift projection lost row")
}
msg, err := messageFromVisibleBoxRow(box)
if err != nil {
return nil, err
}
msg.Media = media
msg.Pts = pts
if err := replaceMessageBoxMediaIndexTx(ctx, tx, msg.OwnerUserID, msg.Peer.ID, msg.ID, msg.Date, msg.Media, msg.Entities); err != nil {
return nil, err
}
event := domain.UpdateEvent{UserID: msg.OwnerUserID, Type: domain.UpdateEventEditMessage,
Pts: pts, PtsCount: 1, Date: date, Message: msg}
if err := appendUserUpdateEvent(ctx, tx, q, msg.OwnerUserID, event); err != nil {
return nil, fmt.Errorf("append retired star gift edit event: %w", err)
}
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
TargetUserID: msg.OwnerUserID, Pts: int32(pts), EventType: string(domain.UpdateEventEditMessage),
ExcludeAuthKeyID: 0, ExcludeSessionID: 0,
}); err != nil {
return nil, fmt.Errorf("enqueue retired star gift edit: %w", err)
}
if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 {
privateMediaJSON, err = encodeSharedPrivateStarGiftMedia(media)
if err != nil {
return nil, err
}
}
edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event})
}
if !matched {
continue
}
if len(privateMediaJSON) == 0 {
return nil, fmt.Errorf("retired star gift projection missing shared media")
}
if _, err := tx.Exec(ctx, `
UPDATE private_messages SET media=$3
WHERE sender_user_id=$1 AND id=$2`, target.MessageSenderID, target.PrivateMessageID, privateMediaJSON); err != nil {
return nil, fmt.Errorf("update retired star gift private media: %w", err)
}
}
return edits, nil
}