package postgres import ( "context" "fmt" "telesrv/internal/domain" ) type channelMessageViewSummary struct { ID int Views int Post bool Discussion *domain.ChannelDiscussionRef Sender domain.Peer } type channelMessageViewReplyRef struct { messageID int replies *domain.ChannelMessageReplies } func (s *ChannelStore) GetChannelMessageViews(ctx context.Context, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid } channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.ChannelMessageViewsResult{}, err } if len(req.IDs) == 0 { return domain.ChannelMessageViewsResult{Channel: channel, Views: map[int]int{}, Replies: map[int]*domain.ChannelMessageReplies{}}, nil } if len(req.IDs) > domain.MaxGetMessageIDs { return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid } id32, _, err := validUniqueChannelMessageIDs(req.IDs) if err != nil { return domain.ChannelMessageViewsResult{}, err } if req.Increment { date := req.Date if date <= 0 { date = nowUnix() } args := []any{req.ChannelID, id32, req.UserID, date, member.AvailableMinID} visibility := "" if channel.Monoforum && !member.CanManageDirectMessages() { args = append(args, string(domain.PeerTypeUser), req.UserID) visibility = fmt.Sprintf( " AND m.saved_peer_type = $%d AND m.saved_peer_id = $%d", len(args)-1, len(args), ) } rows, err := s.db.Query(ctx, ` WITH inserted AS ( INSERT INTO channel_message_viewers (channel_id, message_id, viewer_user_id, viewed_at) SELECT m.channel_id, m.id, $3, $4 FROM channel_messages m WHERE m.channel_id = $1 AND m.id = ANY($2::int[]) AND NOT m.deleted AND m.id > $5 `+visibility+` ON CONFLICT DO NOTHING RETURNING message_id ), updated AS ( UPDATE channel_messages m SET views_count = views_count + 1, updated_at = now() FROM inserted i WHERE m.channel_id = $1 AND m.id = i.message_id RETURNING m.id ) SELECT i.message_id FROM inserted i LEFT JOIN updated u ON u.id = i.message_id`, args...) if err != nil { return domain.ChannelMessageViewsResult{}, fmt.Errorf("increment channel message views: %w", err) } for rows.Next() { var ignored int if err := rows.Scan(&ignored); err != nil { rows.Close() return domain.ChannelMessageViewsResult{}, err } } if err := rows.Err(); err != nil { rows.Close() return domain.ChannelMessageViewsResult{}, err } rows.Close() } summaries, err := s.listChannelMessageViewSummaries(ctx, req.UserID, channel, member, id32) if err != nil { return domain.ChannelMessageViewsResult{}, err } views := make(map[int]int, len(summaries)) peers := make([]domain.Peer, 0, len(summaries)) peerSeen := make(map[domain.Peer]struct{}, len(summaries)) for _, summary := range summaries { views[summary.ID] = summary.Views if summary.Sender.ID != 0 { if _, ok := peerSeen[summary.Sender]; !ok { peerSeen[summary.Sender] = struct{}{} peers = append(peers, summary.Sender) } } } replies, err := s.channelMessageViewReplies(ctx, req.UserID, channel, summaries) if err != nil { return domain.ChannelMessageViewsResult{}, err } return domain.ChannelMessageViewsResult{ Channel: channel, Views: views, Replies: replies, Peers: peers, }, nil } func (s *ChannelStore) listChannelMessageViewSummaries(ctx context.Context, viewerUserID int64, channel domain.Channel, member domain.ChannelMember, ids []int32) ([]channelMessageViewSummary, error) { args := []any{channel.ID, ids} where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted" if member.AvailableMinID > 0 { args = append(args, member.AvailableMinID) where += fmt.Sprintf(" AND id > $%d", len(args)) } if channel.Monoforum && !member.CanManageDirectMessages() { args = append(args, string(domain.PeerTypeUser), viewerUserID) where += fmt.Sprintf(" AND saved_peer_type = $%d AND saved_peer_id = $%d", len(args)-1, len(args)) } rows, err := s.db.Query(ctx, ` SELECT id, views_count, post, discussion_channel_id, discussion_message_id, sender_user_id, from_peer_type, from_peer_id FROM channel_messages WHERE `+where, args...) if err != nil { return nil, fmt.Errorf("get channel message view summaries: %w", err) } defer rows.Close() out := make([]channelMessageViewSummary, 0, len(ids)) for rows.Next() { var summary channelMessageViewSummary var discussionChannelID int64 var discussionMessageID int var senderUserID int64 var fromPeerType string var fromPeerID int64 if err := rows.Scan(&summary.ID, &summary.Views, &summary.Post, &discussionChannelID, &discussionMessageID, &senderUserID, &fromPeerType, &fromPeerID); err != nil { return nil, err } if discussionChannelID != 0 && discussionMessageID != 0 { summary.Discussion = &domain.ChannelDiscussionRef{ChannelID: discussionChannelID, MessageID: discussionMessageID} } summary.Sender = domain.Peer{Type: domain.PeerType(fromPeerType), ID: fromPeerID} if summary.Sender.ID == 0 && senderUserID != 0 { summary.Sender = domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID} } out = append(out, summary) } if err := rows.Err(); err != nil { return nil, err } return out, nil } func (s *ChannelStore) channelMessageViewReplies(ctx context.Context, viewerUserID int64, channel domain.Channel, summaries []channelMessageViewSummary) (map[int]*domain.ChannelMessageReplies, error) { if len(summaries) == 0 || channel.ID == 0 { return map[int]*domain.ChannelMessageReplies{}, nil } indexes := make(map[channelReplyStatKey][]channelMessageViewReplyRef) channelIDs := make([]int64, 0, 2) rootIDs := make([]int32, 0, len(summaries)) addRoot := func(messageID int, targetChannelID int64, rootID int, replies *domain.ChannelMessageReplies) { if targetChannelID == 0 || rootID <= 0 { return } key := channelReplyStatKey{channelID: targetChannelID, rootID: rootID} if _, ok := indexes[key]; !ok { channelIDs = append(channelIDs, targetChannelID) rootIDs = append(rootIDs, int32(rootID)) } indexes[key] = append(indexes[key], channelMessageViewReplyRef{messageID: messageID, replies: replies}) } out := make(map[int]*domain.ChannelMessageReplies) for _, summary := range summaries { targetChannelID := channel.ID rootID := summary.ID replies := &domain.ChannelMessageReplies{} if summary.Discussion != nil && summary.Discussion.ChannelID != 0 && summary.Discussion.MessageID != 0 { targetChannelID = summary.Discussion.ChannelID rootID = summary.Discussion.MessageID replies.Comments = true replies.ChannelID = summary.Discussion.ChannelID } else if channel.Broadcast && channel.LinkedChatID != 0 && summary.Post { targetChannelID = channel.LinkedChatID replies.Comments = true replies.ChannelID = channel.LinkedChatID } if replies.Comments { out[summary.ID] = replies } addRoot(summary.ID, targetChannelID, rootID, replies) } if len(channelIDs) == 0 { return out, nil } rows, err := s.db.Query(ctx, ` WITH roots AS ( SELECT * FROM unnest($1::bigint[], $2::int[]) AS r(channel_id, root_id) ) SELECT r.channel_id, r.root_id, COUNT(m.id)::int, COALESCE(MAX(m.id), 0)::int, COALESCE((array_agg(m.pts ORDER BY m.id DESC) FILTER (WHERE m.id IS NOT NULL))[1], 0)::int, COALESCE(cm.read_inbox_max_id, 0)::int FROM roots r LEFT JOIN channel_members cm ON cm.channel_id = r.channel_id AND cm.user_id = $3 LEFT JOIN channel_messages m ON m.channel_id = r.channel_id AND m.reply_to_top_id = r.root_id AND NOT m.deleted GROUP BY r.channel_id, r.root_id, cm.read_inbox_max_id`, channelIDs, rootIDs, viewerUserID) if err != nil { return nil, fmt.Errorf("load channel message view reply stats: %w", err) } defer rows.Close() for rows.Next() { var channelID int64 var rootID, count, maxID, repliesPts, readMaxID int if err := rows.Scan(&channelID, &rootID, &count, &maxID, &repliesPts, &readMaxID); err != nil { return nil, err } for _, ref := range indexes[channelReplyStatKey{channelID: channelID, rootID: rootID}] { replies := ref.replies replies.ReadMaxID = readMaxID if count > 0 { replies.Replies = count replies.MaxID = maxID replies.RepliesPts = repliesPts out[ref.messageID] = replies } else if replies.Comments { out[ref.messageID] = replies } } } if err := rows.Err(); err != nil { return nil, err } return out, nil }