package postgres import ( "context" "fmt" "sort" "strconv" "telesrv/internal/domain" ) func (s *ChannelStore) GetChannelStats(ctx context.Context, req domain.ChannelStatsRequest) (domain.ChannelStats, error) { if req.ViewerUserID == 0 || req.ChannelID == 0 || !req.Period.Valid() { return domain.ChannelStats{}, domain.ErrChannelInvalid } channel, member, err := s.getChannelForMember(ctx, s.db, req.ViewerUserID, req.ChannelID) if err != nil { return domain.ChannelStats{}, err } if member.Role != domain.ChannelRoleCreator && member.Role != domain.ChannelRoleAdmin { return domain.ChannelStats{}, domain.ErrChannelAdminRequired } stats := domain.ChannelStats{Channel: channel, Period: req.Period} days, dayIndex := newPGStatsDays(req.Period) previousMin := req.Period.PreviousMinDate() memberRows, err := s.db.Query(ctx, ` SELECT joined_at, left_at FROM channel_members WHERE channel_id = $1 AND joined_at > 0 AND joined_at < $2 AND (left_at = 0 OR left_at >= $3)`, req.ChannelID, req.Period.MaxDate, previousMin) if err != nil { return domain.ChannelStats{}, fmt.Errorf("query channel stats members: %w", err) } for memberRows.Next() { var joinedAt, leftAt int if err := memberRows.Scan(&joinedAt, &leftAt); err != nil { memberRows.Close() return domain.ChannelStats{}, err } if pgStatsMemberActiveAt(joinedAt, leftAt, req.Period.MaxDate-1) { stats.Members.Current++ } if pgStatsMemberActiveAt(joinedAt, leftAt, req.Period.MinDate-1) { stats.Members.Previous++ } if i, ok := dayIndex[pgStatsDay(joinedAt)]; ok && joinedAt >= req.Period.MinDate && joinedAt < req.Period.MaxDate { days[i].NewMembers++ } for i := range days { at := days[i].Date + 86400 - 1 if at >= req.Period.MaxDate { at = req.Period.MaxDate - 1 } if pgStatsMemberActiveAt(joinedAt, leftAt, at) { days[i].Members++ } } } if err := memberRows.Err(); err != nil { memberRows.Close() return domain.ChannelStats{}, err } memberRows.Close() var currentMessages, previousMessages int var currentViews, previousViews int64 var currentPosters, previousPosters int if err := s.db.QueryRow(ctx, ` SELECT count(*) FILTER (WHERE message_date >= $2)::int, count(*) FILTER (WHERE message_date < $2)::int, COALESCE(sum(views_count) FILTER (WHERE message_date >= $2), 0)::bigint, COALESCE(sum(views_count) FILTER (WHERE message_date < $2), 0)::bigint, count(DISTINCT sender_user_id) FILTER (WHERE message_date >= $2 AND sender_user_id <> 0)::int, count(DISTINCT sender_user_id) FILTER (WHERE message_date < $2 AND sender_user_id <> 0)::int FROM channel_messages WHERE channel_id = $1 AND NOT deleted AND action = '{}'::jsonb AND message_date >= $3 AND message_date < $4`, req.ChannelID, req.Period.MinDate, previousMin, req.Period.MaxDate).Scan( ¤tMessages, &previousMessages, ¤tViews, &previousViews, ¤tPosters, &previousPosters, ); err != nil { return domain.ChannelStats{}, fmt.Errorf("aggregate channel stats messages: %w", err) } var currentViewers, previousViewers int if err := s.db.QueryRow(ctx, ` SELECT count(DISTINCT viewer_user_id) FILTER (WHERE viewed_at >= $2)::int, count(DISTINCT viewer_user_id) FILTER (WHERE viewed_at < $2)::int FROM channel_message_viewers WHERE channel_id = $1 AND viewed_at >= $3 AND viewed_at < $4`, req.ChannelID, req.Period.MinDate, previousMin, req.Period.MaxDate).Scan(¤tViewers, &previousViewers); err != nil { return domain.ChannelStats{}, fmt.Errorf("aggregate channel stats viewers: %w", err) } var currentReactions, previousReactions int if err := s.db.QueryRow(ctx, ` SELECT count(*) FILTER (WHERE m.message_date >= $2)::int, count(*) FILTER (WHERE m.message_date < $2)::int FROM channel_message_reactions r JOIN channel_messages m ON m.channel_id = r.channel_id AND m.id = r.message_id WHERE m.channel_id = $1 AND NOT m.deleted AND m.action = '{}'::jsonb AND m.message_date >= $3 AND m.message_date < $4`, req.ChannelID, req.Period.MinDate, previousMin, req.Period.MaxDate).Scan(¤tReactions, &previousReactions); err != nil { return domain.ChannelStats{}, fmt.Errorf("aggregate channel stats reactions: %w", err) } var currentShares, previousShares int if err := s.db.QueryRow(ctx, ` SELECT count(*) FILTER (WHERE src.message_date >= $2)::int, count(*) FILTER (WHERE src.message_date < $2)::int FROM channel_messages f JOIN channels destination ON destination.id = f.channel_id JOIN channel_messages src ON src.channel_id = $1 AND src.id::text = f.fwd_from #>> '{ChannelPost}' AND NOT src.deleted AND src.action = '{}'::jsonb WHERE NOT f.deleted AND NOT destination.deleted AND (destination.broadcast OR destination.megagroup) AND btrim(COALESCE(destination.username, '')) <> '' AND f.fwd_from #>> '{From,Type}' = $5 AND f.fwd_from #>> '{From,ID}' = $6 AND src.message_date >= $3 AND src.message_date < $4`, req.ChannelID, req.Period.MinDate, previousMin, req.Period.MaxDate, string(domain.PeerTypeChannel), strconv.FormatInt(req.ChannelID, 10)).Scan(¤tShares, &previousShares); err != nil { return domain.ChannelStats{}, fmt.Errorf("aggregate channel stats shares: %w", err) } stats.Messages = domain.StatsValueAndPrev{Current: float64(currentMessages), Previous: float64(previousMessages)} stats.Viewers = domain.StatsValueAndPrev{Current: float64(currentViewers), Previous: float64(previousViewers)} stats.Posters = domain.StatsValueAndPrev{Current: float64(currentPosters), Previous: float64(previousPosters)} stats.ViewsPerPost = pgStatsAverage(currentViews, currentMessages, previousViews, previousMessages) stats.SharesPerPost = pgStatsAverage(int64(currentShares), currentMessages, int64(previousShares), previousMessages) stats.ReactionsPerPost = pgStatsAverage(int64(currentReactions), currentMessages, int64(previousReactions), previousMessages) messageRows, err := s.db.Query(ctx, ` SELECT (message_date / 86400) * 86400 AS day, count(*)::int, COALESCE(sum(views_count), 0)::int, count(DISTINCT sender_user_id) FILTER (WHERE sender_user_id <> 0)::int FROM channel_messages WHERE channel_id = $1 AND NOT deleted AND action = '{}'::jsonb AND message_date >= $2 AND message_date < $3 GROUP BY day ORDER BY day`, req.ChannelID, req.Period.MinDate, req.Period.MaxDate) if err != nil { return domain.ChannelStats{}, fmt.Errorf("query channel stats days: %w", err) } for messageRows.Next() { var date, messages, views, posters int if err := messageRows.Scan(&date, &messages, &views, &posters); err != nil { messageRows.Close() return domain.ChannelStats{}, err } if i, ok := dayIndex[date]; ok { days[i].Messages, days[i].Views, days[i].Posters = messages, views, posters } } if err := messageRows.Err(); err != nil { messageRows.Close() return domain.ChannelStats{}, err } messageRows.Close() viewerRows, err := s.db.Query(ctx, ` SELECT (viewed_at / 86400) * 86400 AS day, count(DISTINCT viewer_user_id)::int FROM channel_message_viewers WHERE channel_id = $1 AND viewed_at >= $2 AND viewed_at < $3 GROUP BY day`, req.ChannelID, req.Period.MinDate, req.Period.MaxDate) if err != nil { return domain.ChannelStats{}, fmt.Errorf("query channel stats viewer days: %w", err) } for viewerRows.Next() { var date, viewers int if err := viewerRows.Scan(&date, &viewers); err != nil { viewerRows.Close() return domain.ChannelStats{}, err } if i, ok := dayIndex[date]; ok { days[i].Viewers = viewers } } if err := viewerRows.Err(); err != nil { viewerRows.Close() return domain.ChannelStats{}, err } viewerRows.Close() reactionRows, err := s.db.Query(ctx, ` SELECT (m.message_date / 86400) * 86400 AS day, r.reaction_type, r.reaction_value, count(*)::int FROM channel_message_reactions r JOIN channel_messages m ON m.channel_id = r.channel_id AND m.id = r.message_id WHERE m.channel_id = $1 AND NOT m.deleted AND m.action = '{}'::jsonb AND m.message_date >= $2 AND m.message_date < $3 GROUP BY day, r.reaction_type, r.reaction_value ORDER BY day, r.reaction_type, r.reaction_value`, req.ChannelID, req.Period.MinDate, req.Period.MaxDate) if err != nil { return domain.ChannelStats{}, fmt.Errorf("query channel stats reaction days: %w", err) } for reactionRows.Next() { var date, count int var reactionType, reactionValue string if err := reactionRows.Scan(&date, &reactionType, &reactionValue, &count); err != nil { reactionRows.Close() return domain.ChannelStats{}, err } reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), reactionValue) if !ok { reactionRows.Close() return domain.ChannelStats{}, fmt.Errorf("invalid persisted channel stats reaction %q/%q", reactionType, reactionValue) } if i, ok := dayIndex[date]; ok { days[i].Reactions += count days[i].ByReaction = append(days[i].ByReaction, domain.StatsReactionCount{Reaction: reaction, Count: count}) } } if err := reactionRows.Err(); err != nil { reactionRows.Close() return domain.ChannelStats{}, err } reactionRows.Close() shareRows, err := s.db.Query(ctx, ` SELECT (src.message_date / 86400) * 86400 AS day, count(*)::int FROM channel_messages f JOIN channels destination ON destination.id = f.channel_id JOIN channel_messages src ON src.channel_id = $1 AND src.id::text = f.fwd_from #>> '{ChannelPost}' AND NOT src.deleted AND src.action = '{}'::jsonb WHERE NOT f.deleted AND NOT destination.deleted AND (destination.broadcast OR destination.megagroup) AND btrim(COALESCE(destination.username, '')) <> '' AND f.fwd_from #>> '{From,Type}' = $4 AND f.fwd_from #>> '{From,ID}' = $5 AND src.message_date >= $2 AND src.message_date < $3 GROUP BY day`, req.ChannelID, req.Period.MinDate, req.Period.MaxDate, string(domain.PeerTypeChannel), strconv.FormatInt(req.ChannelID, 10)) if err != nil { return domain.ChannelStats{}, fmt.Errorf("query channel stats share days: %w", err) } for shareRows.Next() { var date, shares int if err := shareRows.Scan(&date, &shares); err != nil { shareRows.Close() return domain.ChannelStats{}, err } if i, ok := dayIndex[date]; ok { days[i].Shares = shares } } if err := shareRows.Err(); err != nil { shareRows.Close() return domain.ChannelStats{}, err } shareRows.Close() sortPGStatsReactions(days) stats.Days = days topRows, err := s.db.Query(ctx, ` SELECT sender_user_id, count(*)::int, CASE WHEN count(*) = 0 THEN 0 ELSE (sum(char_length(body)) / count(*))::int END FROM channel_messages WHERE channel_id = $1 AND NOT deleted AND action = '{}'::jsonb AND sender_user_id <> 0 AND message_date >= $2 AND message_date < $3 GROUP BY sender_user_id ORDER BY count(*) DESC, sender_user_id LIMIT $4`, req.ChannelID, req.Period.MinDate, req.Period.MaxDate, domain.MaxChannelStatsTopPosters) if err != nil { return domain.ChannelStats{}, fmt.Errorf("query channel stats top posters: %w", err) } for topRows.Next() { var item domain.ChannelStatsTopPoster if err := topRows.Scan(&item.UserID, &item.Messages, &item.AvgChars); err != nil { topRows.Close() return domain.ChannelStats{}, err } stats.TopPosters = append(stats.TopPosters, item) } if err := topRows.Err(); err != nil { topRows.Close() return domain.ChannelStats{}, err } topRows.Close() recentRows, err := s.db.Query(ctx, ` SELECT src.id, src.views_count, (SELECT count(*)::int FROM channel_messages f JOIN channels destination ON destination.id = f.channel_id WHERE NOT f.deleted AND NOT destination.deleted AND (destination.broadcast OR destination.megagroup) AND btrim(COALESCE(destination.username, '')) <> '' AND f.fwd_from #>> '{From,Type}' = $3 AND f.fwd_from #>> '{From,ID}' = $4 AND f.fwd_from #>> '{ChannelPost}' = src.id::text), (SELECT count(*)::int FROM channel_message_reactions r WHERE r.channel_id = src.channel_id AND r.message_id = src.id) FROM channel_messages src WHERE src.channel_id = $1 AND NOT src.deleted AND src.action = '{}'::jsonb ORDER BY src.message_date DESC, src.id DESC LIMIT $2`, req.ChannelID, domain.MaxChannelStatsRecentPosts, string(domain.PeerTypeChannel), strconv.FormatInt(req.ChannelID, 10)) if err != nil { return domain.ChannelStats{}, fmt.Errorf("query channel stats recent posts: %w", err) } for recentRows.Next() { var item domain.ChannelStatsRecentPost if err := recentRows.Scan(&item.MessageID, &item.Views, &item.Forwards, &item.Reactions); err != nil { recentRows.Close() return domain.ChannelStats{}, err } stats.RecentPosts = append(stats.RecentPosts, item) } if err := recentRows.Err(); err != nil { recentRows.Close() return domain.ChannelStats{}, err } recentRows.Close() return stats, nil } func (s *ChannelStore) GetChannelMessageStats(ctx context.Context, req domain.ChannelMessageStatsRequest) (domain.ChannelMessageStats, error) { if req.ViewerUserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID || !req.Period.Valid() { return domain.ChannelMessageStats{}, domain.ErrMessageIDInvalid } channel, member, err := s.getChannelForMember(ctx, s.db, req.ViewerUserID, req.ChannelID) if err != nil { return domain.ChannelMessageStats{}, err } if member.Role != domain.ChannelRoleCreator && member.Role != domain.ChannelRoleAdmin { return domain.ChannelMessageStats{}, domain.ErrChannelAdminRequired } message, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID) if err != nil || message.Deleted { return domain.ChannelMessageStats{}, domain.ErrMessageIDInvalid } days, dayIndex := newPGStatsDays(req.Period) viewRows, err := s.db.Query(ctx, ` SELECT (viewed_at / 86400) * 86400 AS day, count(*)::int FROM channel_message_viewers WHERE channel_id = $1 AND message_id = $2 AND viewed_at >= $3 AND viewed_at < $4 GROUP BY day`, req.ChannelID, req.MessageID, req.Period.MinDate, req.Period.MaxDate) if err != nil { return domain.ChannelMessageStats{}, fmt.Errorf("query channel message stats views: %w", err) } for viewRows.Next() { var date, count int if err := viewRows.Scan(&date, &count); err != nil { viewRows.Close() return domain.ChannelMessageStats{}, err } if i, ok := dayIndex[date]; ok { days[i].Views = count } } if err := viewRows.Err(); err != nil { viewRows.Close() return domain.ChannelMessageStats{}, err } viewRows.Close() reactionRows, err := s.db.Query(ctx, ` SELECT (reaction_date / 86400) * 86400 AS day, reaction_type, reaction_value, count(*)::int FROM channel_message_reactions WHERE channel_id = $1 AND message_id = $2 AND reaction_date >= $3 AND reaction_date < $4 GROUP BY day, reaction_type, reaction_value ORDER BY day, reaction_type, reaction_value`, req.ChannelID, req.MessageID, req.Period.MinDate, req.Period.MaxDate) if err != nil { return domain.ChannelMessageStats{}, fmt.Errorf("query channel message stats reactions: %w", err) } for reactionRows.Next() { var date, count int var reactionType, reactionValue string if err := reactionRows.Scan(&date, &reactionType, &reactionValue, &count); err != nil { reactionRows.Close() return domain.ChannelMessageStats{}, err } reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), reactionValue) if !ok { reactionRows.Close() return domain.ChannelMessageStats{}, fmt.Errorf("invalid persisted message stats reaction %q/%q", reactionType, reactionValue) } if i, ok := dayIndex[date]; ok { days[i].Reactions += count days[i].ByReaction = append(days[i].ByReaction, domain.StatsReactionCount{Reaction: reaction, Count: count}) } } if err := reactionRows.Err(); err != nil { reactionRows.Close() return domain.ChannelMessageStats{}, err } reactionRows.Close() sortPGStatsReactions(days) return domain.ChannelMessageStats{Channel: channel, Message: message, Period: req.Period, Days: days}, nil } func (s *ChannelStore) ListChannelMessagePublicForwards(ctx context.Context, req domain.ChannelMessagePublicForwardListRequest) (domain.ChannelMessagePublicForwardList, error) { if req.ViewerUserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID || req.Limit <= 0 || req.Limit > domain.MaxChannelMessagePublicForwards { return domain.ChannelMessagePublicForwardList{}, domain.ErrChannelInvalid } cursor, err := domain.ParseChannelMessagePublicForwardCursor(req.Offset) if err != nil { return domain.ChannelMessagePublicForwardList{}, err } _, member, err := s.getChannelForMember(ctx, s.db, req.ViewerUserID, req.ChannelID) if err != nil { return domain.ChannelMessagePublicForwardList{}, err } if member.Role != domain.ChannelRoleCreator && member.Role != domain.ChannelRoleAdmin { return domain.ChannelMessagePublicForwardList{}, domain.ErrChannelAdminRequired } source, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID) if err != nil || source.Deleted { return domain.ChannelMessagePublicForwardList{}, domain.ErrMessageIDInvalid } where := ` NOT deleted AND fwd_from #>> '{From,Type}' = $1 AND fwd_from #>> '{From,ID}' = $2 AND fwd_from #>> '{ChannelPost}' = $3 AND EXISTS ( SELECT 1 FROM channels c WHERE c.id = channel_messages.channel_id AND NOT c.deleted AND (c.broadcast OR c.megagroup) AND btrim(COALESCE(c.username, '')) <> '' )` args := []any{string(domain.PeerTypeChannel), strconv.FormatInt(req.ChannelID, 10), strconv.Itoa(req.MessageID)} var count int if err := s.db.QueryRow(ctx, `SELECT count(*)::int FROM channel_messages WHERE `+where, args...).Scan(&count); err != nil { return domain.ChannelMessagePublicForwardList{}, fmt.Errorf("count channel message public forwards: %w", err) } cursorClause := "" if cursor.Date != 0 { args = append(args, cursor.Date, cursor.ChannelID, cursor.MessageID) cursorClause = fmt.Sprintf(` AND ( message_date < $%d OR (message_date = $%d AND ( channel_id > $%d OR (channel_id = $%d AND id < $%d) )) )`, len(args)-2, len(args)-2, len(args)-1, len(args)-1, len(args)) } args = append(args, req.Limit+1) rows, err := s.db.Query(ctx, ` SELECT `+channelMessageColumns+` FROM channel_messages WHERE `+where+cursorClause+` ORDER BY message_date DESC, channel_id ASC, id DESC LIMIT $`+strconv.Itoa(len(args)), args...) if err != nil { return domain.ChannelMessagePublicForwardList{}, fmt.Errorf("list channel message public forwards: %w", err) } defer rows.Close() messages := make([]domain.ChannelMessage, 0, req.Limit+1) for rows.Next() { message, err := scanChannelMessage(rows) if err != nil { return domain.ChannelMessagePublicForwardList{}, err } messages = append(messages, message) } if err := rows.Err(); err != nil { return domain.ChannelMessagePublicForwardList{}, err } next := "" if len(messages) > req.Limit { messages = messages[:req.Limit] next = domain.FormatChannelMessagePublicForwardCursor(messages[len(messages)-1]) } return domain.ChannelMessagePublicForwardList{Count: count, Messages: messages, NextOffset: next}, nil } func newPGStatsDays(period domain.StatsPeriod) ([]domain.ChannelStatsDay, map[int]int) { start, end := pgStatsDay(period.MinDate), pgStatsDay(period.MaxDate-1) days := make([]domain.ChannelStatsDay, 0, (end-start)/86400+1) index := make(map[int]int) for date := start; date <= end; date += 86400 { index[date] = len(days) days = append(days, domain.ChannelStatsDay{Date: date}) } return days, index } func pgStatsDay(date int) int { if date <= 0 { return 0 } return date - date%86400 } func pgStatsMemberActiveAt(joinedAt, leftAt, at int) bool { return joinedAt > 0 && joinedAt <= at && (leftAt == 0 || leftAt > at) } func pgStatsAverage(current int64, currentCount int, previous int64, previousCount int) domain.StatsValueAndPrev { var out domain.StatsValueAndPrev if currentCount > 0 { out.Current = float64(current) / float64(currentCount) } if previousCount > 0 { out.Previous = float64(previous) / float64(previousCount) } return out } func sortPGStatsReactions(days []domain.ChannelStatsDay) { for i := range days { sort.Slice(days[i].ByReaction, func(a, b int) bool { return days[i].ByReaction[a].Reaction.Key() < days[i].ByReaction[b].Reaction.Key() }) } }