Ctrl K
ComunidadGuías…

internal/store/postgres/community.go

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

Archivo original ↗Preguntar a KiBot
package postgres

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"hash/fnv"
	"sort"
	"strings"

	"github.com/jackc/pgx/v5"

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

type CommunityStore struct {
	db           sqlcgen.DBTX
	ids          store.ChannelIDAllocator
	msgIDs       store.ChannelMessageIDAllocator
	catalogCache *CommunityCatalogCache
}

type CommunityStoreOption func(*CommunityStore)

func WithCommunityCatalogCache(cache *CommunityCatalogCache) CommunityStoreOption {
	return func(s *CommunityStore) { s.catalogCache = cache }
}

func NewCommunityStore(db sqlcgen.DBTX, ids store.ChannelIDAllocator, msgIDs store.ChannelMessageIDAllocator, opts ...CommunityStoreOption) *CommunityStore {
	if ids == nil {
		ids = pgChannelIDAllocator{db: db}
	}
	if msgIDs == nil {
		msgIDs = pgChannelMessageIDAllocator{db: db}
	}
	s := &CommunityStore{db: db, ids: ids, msgIDs: msgIDs}
	for _, opt := range opts {
		opt(s)
	}
	return s
}

func (s *CommunityStore) appendCommunityServiceMessageTx(ctx context.Context, tx pgx.Tx, peer domain.Peer, actorUserID int64, date int, communityID int64) (*domain.SendChannelMessageResult, error) {
	if peer.Type != domain.PeerTypeChannel {
		return nil, nil
	}
	channel, err := getChannelByID(ctx, tx, peer.ID)
	if err != nil {
		return nil, err
	}
	channelStore := NewChannelStore(tx, WithChannelAllocators(s.ids, s.msgIDs))
	message, event, err := channelStore.insertServiceMessage(ctx, tx, channel, actorUserID, date, domain.ChannelMessageAction{
		Type:        domain.ChannelActionChangeCommunity,
		CommunityID: communityID,
	})
	if err != nil {
		return nil, err
	}
	channel.TopMessageID = message.ID
	channel.Pts = event.Pts
	// The Community transaction has already validated the actor and holds the
	// linked channel row. Use the internal membership scan here: the public
	// method treats viewerUserID=0 as an unauthenticated private-channel read.
	recipients, err := channelStore.listActiveChannelMemberIDs(ctx, tx, channel.ID, 0)
	if err != nil {
		return nil, err
	}
	return &domain.SendChannelMessageResult{Channel: channel, Message: message, Event: event, Recipients: recipients}, nil
}

const communityColumns = `id, access_hash, creator_user_id, title, about,
default_banned_rights::text, photo_id, photo_dc_id, photo_stripped, date, deleted`

func scanCommunity(row rowScanner) (domain.Community, error) {
	var c domain.Community
	var rights string
	if err := row.Scan(&c.ID, &c.AccessHash, &c.CreatorUserID, &c.Title, &c.About,
		&rights, &c.PhotoID, &c.PhotoDCID, &c.PhotoStripped, &c.Date, &c.Deleted); err != nil {
		return domain.Community{}, err
	}
	if err := json.Unmarshal([]byte(rights), &c.DefaultBannedRights); err != nil {
		return domain.Community{}, fmt.Errorf("decode community banned rights: %w", err)
	}
	c.PhotoStripped = append([]byte(nil), c.PhotoStripped...)
	return c, nil
}

func scanCommunityMember(row rowScanner) (domain.CommunityMember, error) {
	var m domain.CommunityMember
	var role, status, rights string
	if err := row.Scan(&m.CommunityID, &m.UserID, &role, &status, &rights, &m.Rank, &m.Date); err != nil {
		return domain.CommunityMember{}, err
	}
	m.Role = domain.CommunityMemberRole(role)
	m.Status = domain.CommunityMemberStatus(status)
	if err := json.Unmarshal([]byte(rights), &m.AdminRights); err != nil {
		return domain.CommunityMember{}, fmt.Errorf("decode community admin rights: %w", err)
	}
	return m, nil
}

func (s *CommunityStore) begin(ctx context.Context) (pgx.Tx, error) {
	b, ok := s.db.(txBeginner)
	if !ok {
		return nil, errors.New("community store requires transaction-capable db")
	}
	return b.Begin(ctx)
}

func withCommunityTx[T any](ctx context.Context, s *CommunityStore, fn func(pgx.Tx) (T, error)) (T, error) {
	var zero T
	tx, err := s.begin(ctx)
	if err != nil {
		return zero, err
	}
	committed := false
	defer func() {
		if !committed {
			_ = tx.Rollback(ctx)
		}
	}()
	out, err := fn(tx)
	if err != nil {
		return zero, err
	}
	if err := tx.Commit(ctx); err != nil {
		return zero, fmt.Errorf("commit community transaction: %w", err)
	}
	committed = true
	return out, nil
}

func communityByID(ctx context.Context, db sqlcgen.DBTX, id int64, forUpdate bool) (domain.Community, error) {
	lock := ""
	if forUpdate {
		lock = " FOR UPDATE"
	}
	c, err := scanCommunity(db.QueryRow(ctx, `SELECT `+communityColumns+` FROM communities WHERE id=$1 AND NOT deleted`+lock, id))
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.Community{}, domain.ErrCommunityInvalid
		}
		return domain.Community{}, fmt.Errorf("get community: %w", err)
	}
	return c, nil
}

func explicitCommunityMember(ctx context.Context, db sqlcgen.DBTX, communityID, userID int64) (domain.CommunityMember, bool, error) {
	m, err := scanCommunityMember(db.QueryRow(ctx, `
SELECT community_id, user_id, role, status, admin_rights::text, rank, date
FROM community_members WHERE community_id=$1 AND user_id=$2`, communityID, userID))
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.CommunityMember{}, false, nil
		}
		return domain.CommunityMember{}, false, fmt.Errorf("get community member: %w", err)
	}
	return m, true, nil
}

func derivedCommunityMember(ctx context.Context, db sqlcgen.DBTX, c domain.Community, userID int64) (domain.CommunityMember, bool, error) {
	if userID == 0 {
		return domain.CommunityMember{}, false, nil
	}
	if m, ok, err := explicitCommunityMember(ctx, db, c.ID, userID); err != nil || ok {
		return m, ok, err
	}
	var joined bool
	err := db.QueryRow(ctx, `
SELECT EXISTS (
  SELECT 1
  FROM community_peer_links l
  JOIN channel_members cm ON l.peer_type='channel' AND cm.channel_id=l.peer_id
  WHERE l.community_id=$1 AND cm.user_id=$2 AND cm.status='active'
  UNION ALL
  SELECT 1
  FROM community_peer_links l
  JOIN dialogs d ON l.peer_type='user' AND d.peer_id=l.peer_id AND d.peer_type='user'
  WHERE l.community_id=$1 AND d.user_id=$2 AND d.top_message_id > 0
  LIMIT 1
)`, c.ID, userID).Scan(&joined)
	if err != nil {
		return domain.CommunityMember{}, false, fmt.Errorf("derive community member: %w", err)
	}
	if !joined {
		return domain.CommunityMember{}, false, nil
	}
	return domain.CommunityMember{
		CommunityID: c.ID, UserID: userID, Role: domain.CommunityRoleMember,
		Status: domain.CommunityMemberActive, Date: c.Date,
	}, true, nil
}

func communityState(ctx context.Context, db sqlcgen.DBTX, communityID, userID int64) (domain.CommunityUserState, error) {
	state := domain.CommunityUserState{CommunityID: communityID, UserID: userID}
	err := db.QueryRow(ctx, `
SELECT collapsed, pinned, pinned_order FROM community_user_states
WHERE community_id=$1 AND user_id=$2`, communityID, userID).
		Scan(&state.Collapsed, &state.Pinned, &state.PinnedOrder)
	if err != nil && !errors.Is(err, pgx.ErrNoRows) {
		return domain.CommunityUserState{}, fmt.Errorf("get community state: %w", err)
	}
	return state, nil
}

func communityLinkRows(ctx context.Context, db sqlcgen.DBTX, c domain.Community, viewer domain.CommunityMember) ([]domain.CommunityPeerLink, error) {
	rows, err := db.Query(ctx, `
SELECT l.peer_type, l.peer_id, l.visibility, l.created_by, l.date,
       CASE
         WHEN l.peer_type='channel' THEN EXISTS (
           SELECT 1 FROM channel_members cm
           WHERE cm.channel_id=l.peer_id AND cm.user_id=$2 AND cm.status='active')
         ELSE EXISTS (
           SELECT 1 FROM dialogs d
           WHERE d.user_id=$2 AND d.peer_type='user' AND d.peer_id=l.peer_id AND d.top_message_id > 0)
       END AS joined,
       CASE
         WHEN l.peer_type='user' THEN true
         ELSE EXISTS (
           SELECT 1 FROM channels c
           WHERE c.id=l.peer_id AND NOT c.deleted AND COALESCE(c.username,'') <> '')
       END AS inherently_viewable
FROM community_peer_links l
WHERE l.community_id=$1
ORDER BY l.date, l.peer_type, l.peer_id`, c.ID, viewer.UserID)
	if err != nil {
		return nil, fmt.Errorf("list community links: %w", err)
	}
	defer rows.Close()
	canManage := viewer.CanManageLinkedPeers()
	out := make([]domain.CommunityPeerLink, 0)
	for rows.Next() {
		var typ, visibility string
		var id, createdBy int64
		var date int
		var joined, inherentlyViewable bool
		if err := rows.Scan(&typ, &id, &visibility, &createdBy, &date, &joined, &inherentlyViewable); err != nil {
			return nil, fmt.Errorf("scan community link: %w", err)
		}
		if visibility == string(domain.CommunityPeerHidden) && !joined && !canManage {
			continue
		}
		out = append(out, domain.CommunityPeerLink{
			CommunityID: c.ID, Peer: domain.Peer{Type: domain.PeerType(typ), ID: id},
			Visibility: domain.CommunityPeerVisibility(visibility), CanViewHistory: joined || inherentlyViewable,
			CreatedBy: createdBy, Date: date,
		})
	}
	return out, rows.Err()
}

func communityView(ctx context.Context, db sqlcgen.DBTX, viewerUserID, communityID int64) (domain.CommunityView, error) {
	c, err := communityByID(ctx, db, communityID, false)
	if err != nil {
		return domain.CommunityView{}, err
	}
	self, joined, err := derivedCommunityMember(ctx, db, c, viewerUserID)
	if err != nil {
		return domain.CommunityView{}, err
	}
	if !joined || !self.Active() {
		return domain.CommunityView{Community: c, Self: self, Forbidden: true}, domain.ErrCommunityPrivate
	}
	state, err := communityState(ctx, db, c.ID, viewerUserID)
	if err != nil {
		return domain.CommunityView{}, err
	}
	links, err := communityLinkRows(ctx, db, c, self)
	if err != nil {
		return domain.CommunityView{}, err
	}
	view := domain.CommunityView{Community: c, Self: self, State: state, Links: links}
	channelIDs, userIDs := make([]int64, 0, len(links)), make([]int64, 0, len(links))
	for _, link := range links {
		if link.Peer.Type == domain.PeerTypeChannel {
			channelIDs = append(channelIDs, link.Peer.ID)
		} else {
			userIDs = append(userIDs, link.Peer.ID)
		}
	}
	view.Channels, err = listChannelsByIDs(ctx, db, channelIDs)
	if err != nil {
		return domain.CommunityView{}, err
	}
	view.Users, err = listUsersByIDs(ctx, db, userIDs)
	if err != nil {
		return domain.CommunityView{}, err
	}
	err = db.QueryRow(ctx, `
SELECT
  COUNT(*) FILTER (WHERE status='active' AND role IN ('creator','admin'))::int,
  COUNT(*) FILTER (WHERE status='kicked')::int,
  (SELECT COUNT(*)::int FROM community_peer_link_requests WHERE community_id=$1)
FROM community_members WHERE community_id=$1`, c.ID).
		Scan(&view.AdminsCount, &view.KickedCount, &view.PendingRequests)
	if err != nil {
		return domain.CommunityView{}, fmt.Errorf("get community counts: %w", err)
	}
	return view, nil
}

func (s *CommunityStore) GetCommunity(ctx context.Context, viewerUserID, communityID int64) (domain.CommunityView, error) {
	return communityView(ctx, s.db, viewerUserID, communityID)
}

func (s *CommunityStore) GetCommunities(ctx context.Context, viewerUserID int64, ids []int64) ([]domain.CommunityView, error) {
	seen := make(map[int64]struct{}, len(ids))
	out := make([]domain.CommunityView, 0, len(ids))
	for _, id := range ids {
		if id == 0 {
			continue
		}
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		view, err := communityView(ctx, s.db, viewerUserID, id)
		if errors.Is(err, domain.ErrCommunityInvalid) || errors.Is(err, domain.ErrCommunityPrivate) {
			continue
		}
		if err != nil {
			return nil, err
		}
		out = append(out, view)
	}
	return out, nil
}

func (s *CommunityStore) ListJoinedCommunities(ctx context.Context, viewerUserID int64) ([]domain.CommunityView, error) {
	if viewerUserID == 0 {
		return nil, nil
	}
	if s.catalogCache != nil {
		active, err := s.catalogCache.hasActive(ctx, s.db)
		if err != nil {
			return nil, fmt.Errorf("check community catalog: %w", err)
		}
		if !active {
			return nil, nil
		}
	}
	rows, err := s.db.Query(ctx, `
SELECT DISTINCT c.id
FROM communities c
LEFT JOIN community_members explicit ON explicit.community_id=c.id AND explicit.user_id=$1
WHERE NOT c.deleted AND (
  (explicit.status='active')
  OR (explicit.user_id IS NULL AND EXISTS (
    SELECT 1 FROM community_peer_links l
    JOIN channel_members cm ON l.peer_type='channel' AND cm.channel_id=l.peer_id
    WHERE l.community_id=c.id AND cm.user_id=$1 AND cm.status='active'))
  OR (explicit.user_id IS NULL AND EXISTS (
    SELECT 1 FROM community_peer_links l
    JOIN dialogs d ON l.peer_type='user' AND d.peer_id=l.peer_id AND d.peer_type='user'
    WHERE l.community_id=c.id AND d.user_id=$1 AND d.top_message_id > 0))
)
ORDER BY c.id`, viewerUserID)
	if err != nil {
		return nil, fmt.Errorf("list joined communities: %w", err)
	}
	defer rows.Close()
	ids := make([]int64, 0)
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			return nil, err
		}
		ids = append(ids, id)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return s.GetCommunities(ctx, viewerUserID, ids)
}

func validateCommunityPeerForLink(ctx context.Context, db sqlcgen.DBTX, actorUserID int64, peer domain.Peer, lock bool) error {
	lockSQL := ""
	if lock {
		lockSQL = " FOR UPDATE"
	}
	switch peer.Type {
	case domain.PeerTypeChannel:
		var linked int64
		var deleted, monoforum bool
		if err := db.QueryRow(ctx, `SELECT linked_community_id, deleted, monoforum FROM channels WHERE id=$1`+lockSQL, peer.ID).
			Scan(&linked, &deleted, &monoforum); err != nil {
			return domain.ErrCommunityPeerInvalid
		}
		if deleted || monoforum {
			return domain.ErrCommunityPeerInvalid
		}
		if linked != 0 {
			return domain.ErrCommunityPeerLinked
		}
		var allowed bool
		if err := db.QueryRow(ctx, `SELECT EXISTS (
SELECT 1 FROM channel_members WHERE channel_id=$1 AND user_id=$2 AND status='active' AND role IN ('creator','admin'))`, peer.ID, actorUserID).Scan(&allowed); err != nil || !allowed {
			return domain.ErrCommunityAdminRequired
		}
	case domain.PeerTypeUser:
		var bot, deleted bool
		var linked int64
		if err := db.QueryRow(ctx, `SELECT is_bot, deleted_at IS NOT NULL, linked_community_id FROM users WHERE id=$1`+lockSQL, peer.ID).
			Scan(&bot, &deleted, &linked); err != nil || !bot || deleted {
			return domain.ErrCommunityPeerInvalid
		}
		if linked != 0 {
			return domain.ErrCommunityPeerLinked
		}
		var owned bool
		if err := db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM bots WHERE bot_user_id=$1 AND owner_user_id=$2)`, peer.ID, actorUserID).Scan(&owned); err != nil || !owned {
			return domain.ErrCommunityAdminRequired
		}
	default:
		return domain.ErrCommunityPeerInvalid
	}
	return nil
}

func setPeerLinkedCommunity(ctx context.Context, db sqlcgen.DBTX, peer domain.Peer, communityID int64) error {
	var tag string
	switch peer.Type {
	case domain.PeerTypeChannel:
		tag = "UPDATE channels SET linked_community_id=$2, updated_at=now() WHERE id=$1"
	case domain.PeerTypeUser:
		tag = "UPDATE users SET linked_community_id=$2, updated_at=now() WHERE id=$1"
	default:
		return domain.ErrCommunityPeerInvalid
	}
	cmd, err := db.Exec(ctx, tag, peer.ID, communityID)
	if err != nil {
		return fmt.Errorf("set linked community: %w", err)
	}
	if cmd.RowsAffected() != 1 {
		return domain.ErrCommunityPeerInvalid
	}
	return nil
}

func insertCommunityLink(ctx context.Context, db sqlcgen.DBTX, communityID, actorUserID int64, peer domain.Peer, visibility domain.CommunityPeerVisibility, date int) (domain.CommunityPeerLink, error) {
	if err := validateCommunityPeerForLink(ctx, db, actorUserID, peer, true); err != nil {
		return domain.CommunityPeerLink{}, err
	}
	var peers, bots int
	if err := db.QueryRow(ctx, `SELECT
COUNT(*) FILTER (WHERE peer_type='channel')::int,
COUNT(*) FILTER (WHERE peer_type='user')::int
FROM community_peer_links WHERE community_id=$1`, communityID).Scan(&peers, &bots); err != nil {
		return domain.CommunityPeerLink{}, err
	}
	if (peer.Type == domain.PeerTypeChannel && peers >= domain.MaxCommunityPeers) ||
		(peer.Type == domain.PeerTypeUser && bots >= domain.MaxCommunityBotPeers) {
		return domain.CommunityPeerLink{}, domain.ErrCommunityPeersTooMuch
	}
	if _, err := db.Exec(ctx, `
INSERT INTO community_peer_links(community_id,peer_type,peer_id,visibility,created_by,date)
VALUES($1,$2,$3,$4,$5,$6)`, communityID, string(peer.Type), peer.ID, string(visibility), actorUserID, date); err != nil {
		if isUniqueViolation(err) {
			return domain.CommunityPeerLink{}, domain.ErrCommunityPeerLinked
		}
		return domain.CommunityPeerLink{}, fmt.Errorf("insert community link: %w", err)
	}
	if err := setPeerLinkedCommunity(ctx, db, peer, communityID); err != nil {
		return domain.CommunityPeerLink{}, err
	}
	return domain.CommunityPeerLink{CommunityID: communityID, Peer: peer, Visibility: visibility, CanViewHistory: true, CreatedBy: actorUserID, Date: date}, nil
}

func (s *CommunityStore) CreateCommunity(ctx context.Context, req domain.CreateCommunityRequest) (domain.CommunityView, error) {
	return withCommunityTx(ctx, s, func(tx pgx.Tx) (domain.CommunityView, error) {
		if err := validateCommunityPeerForLink(ctx, tx, req.CreatorUserID, req.InitialPeer, true); err != nil {
			return domain.CommunityView{}, err
		}
		id, err := s.ids.NextChannelID(ctx)
		if err != nil {
			return domain.CommunityView{}, fmt.Errorf("allocate community id: %w", err)
		}
		hash, err := randomChannelAccessHash()
		if err != nil {
			return domain.CommunityView{}, fmt.Errorf("community access hash: %w", err)
		}
		c := domain.Community{ID: id, AccessHash: hash, CreatorUserID: req.CreatorUserID, Title: req.Title, About: req.About, Date: req.Date}
		rights, _ := json.Marshal(c.DefaultBannedRights)
		if _, err := tx.Exec(ctx, `
INSERT INTO communities(id,access_hash,creator_user_id,title,about,default_banned_rights,date)
VALUES($1,$2,$3,$4,$5,$6,$7)`, c.ID, c.AccessHash, c.CreatorUserID, c.Title, c.About, rights, c.Date); err != nil {
			return domain.CommunityView{}, fmt.Errorf("insert community: %w", err)
		}
		adminRights, _ := json.Marshal(domain.CreatorChannelAdminRights())
		if _, err := tx.Exec(ctx, `
INSERT INTO community_members(community_id,user_id,role,status,admin_rights,date)
VALUES($1,$2,'creator','active',$3,$4)`, c.ID, c.CreatorUserID, adminRights, c.Date); err != nil {
			return domain.CommunityView{}, fmt.Errorf("insert community creator: %w", err)
		}
		link, err := insertCommunityLink(ctx, tx, c.ID, c.CreatorUserID, req.InitialPeer, req.Visibility, req.Date)
		if err != nil {
			return domain.CommunityView{}, err
		}
		serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, req.InitialPeer, req.CreatorUserID, req.Date, c.ID)
		if err != nil {
			return domain.CommunityView{}, err
		}
		self := domain.CommunityMember{CommunityID: c.ID, UserID: c.CreatorUserID, Role: domain.CommunityRoleCreator, Status: domain.CommunityMemberActive, AdminRights: domain.CreatorChannelAdminRights(), Date: c.Date}
		view := domain.CommunityView{Community: c, Self: self, Links: []domain.CommunityPeerLink{link}, AdminsCount: 1}
		if serviceMessage != nil {
			view.ServiceMessages = append(view.ServiceMessages, *serviceMessage)
		}
		return view, nil
	})
}

func lockCommunityActor(ctx context.Context, tx pgx.Tx, actorUserID, communityID int64) (domain.Community, domain.CommunityMember, error) {
	c, err := communityByID(ctx, tx, communityID, true)
	if err != nil {
		return domain.Community{}, domain.CommunityMember{}, err
	}
	m, ok, err := derivedCommunityMember(ctx, tx, c, actorUserID)
	if err != nil {
		return domain.Community{}, domain.CommunityMember{}, err
	}
	if !ok || !m.Active() {
		return domain.Community{}, domain.CommunityMember{}, domain.ErrCommunityPrivate
	}
	return c, m, nil
}

func (s *CommunityStore) ToggleCommunityPeerLink(ctx context.Context, req domain.CommunityTogglePeerLinkRequest) (domain.CommunityTogglePeerLinkResult, error) {
	return withCommunityTx(ctx, s, func(tx pgx.Tx) (domain.CommunityTogglePeerLinkResult, error) {
		c, actor, err := lockCommunityActor(ctx, tx, req.ActorUserID, req.CommunityID)
		if err != nil {
			return domain.CommunityTogglePeerLinkResult{}, err
		}
		if req.Deleted {
			if !actor.CanManageLinkedPeers() {
				return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityAdminRequired
			}
			cmd, err := tx.Exec(ctx, `DELETE FROM community_peer_links WHERE community_id=$1 AND peer_type=$2 AND peer_id=$3`, c.ID, string(req.Peer.Type), req.Peer.ID)
			if err != nil {
				return domain.CommunityTogglePeerLinkResult{}, err
			}
			if cmd.RowsAffected() == 0 {
				return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityPeerInvalid
			}
			if err := setPeerLinkedCommunity(ctx, tx, req.Peer, 0); err != nil {
				return domain.CommunityTogglePeerLinkResult{}, err
			}
			serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, req.Peer, req.ActorUserID, req.Date, 0)
			if err != nil {
				return domain.CommunityTogglePeerLinkResult{}, err
			}
			return domain.CommunityTogglePeerLinkResult{Community: c, Peer: req.Peer, ServiceMessage: serviceMessage, Removed: true}, nil
		}
		if actor.CanManageLinkedPeers() {
			link, err := insertCommunityLink(ctx, tx, c.ID, req.ActorUserID, req.Peer, req.Visibility, req.Date)
			if err != nil {
				return domain.CommunityTogglePeerLinkResult{}, err
			}
			serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, req.Peer, req.ActorUserID, req.Date, c.ID)
			if err != nil {
				return domain.CommunityTogglePeerLinkResult{}, err
			}
			return domain.CommunityTogglePeerLinkResult{Community: c, Peer: req.Peer, Link: &link, ServiceMessage: serviceMessage}, nil
		}
		if c.DefaultBannedRights.ManageLinkedPeers {
			return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityAdminRequired
		}
		if err := validateCommunityPeerForLink(ctx, tx, req.ActorUserID, req.Peer, true); err != nil {
			return domain.CommunityTogglePeerLinkResult{}, err
		}
		if _, err := tx.Exec(ctx, `
INSERT INTO community_peer_link_requests(community_id,peer_type,peer_id,requested_by,visibility,date)
VALUES($1,$2,$3,$4,$5,$6)
ON CONFLICT(community_id,peer_type,peer_id) DO UPDATE SET requested_by=EXCLUDED.requested_by, visibility=EXCLUDED.visibility, date=EXCLUDED.date, created_at=now()`,
			c.ID, string(req.Peer.Type), req.Peer.ID, req.ActorUserID, string(req.Visibility), req.Date); err != nil {
			return domain.CommunityTogglePeerLinkResult{}, fmt.Errorf("save community link request: %w", err)
		}
		return domain.CommunityTogglePeerLinkResult{Community: c, Peer: req.Peer, RequestCreated: true}, nil
	})
}

func (s *CommunityStore) SetCommunityCollapsed(ctx context.Context, userID, communityID int64, collapsed bool) (domain.CommunityView, bool, error) {
	changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) {
		if _, _, err := lockCommunityActor(ctx, tx, userID, communityID); err != nil {
			return false, err
		}
		var old bool
		err := tx.QueryRow(ctx, `SELECT collapsed FROM community_user_states WHERE community_id=$1 AND user_id=$2 FOR UPDATE`, communityID, userID).Scan(&old)
		if err != nil && !errors.Is(err, pgx.ErrNoRows) {
			return false, err
		}
		if err == nil && old == collapsed {
			return false, nil
		}
		_, err = tx.Exec(ctx, `
INSERT INTO community_user_states(community_id,user_id,collapsed,pinned,pinned_order)
VALUES($1,$2,$3,false,0)
ON CONFLICT(community_id,user_id) DO UPDATE SET collapsed=EXCLUDED.collapsed,
  pinned=CASE WHEN EXCLUDED.collapsed THEN community_user_states.pinned ELSE false END,
  pinned_order=CASE WHEN EXCLUDED.collapsed THEN community_user_states.pinned_order ELSE 0 END,
  updated_at=now()`, communityID, userID, collapsed)
		return true, err
	})
	if err != nil {
		return domain.CommunityView{}, false, err
	}
	view, err := s.GetCommunity(ctx, userID, communityID)
	return view, changed, err
}

type communityRequestCursor struct {
	Date int    `json:"d"`
	Type string `json:"t"`
	ID   int64  `json:"i"`
}

func decodeCommunityRequestCursor(raw string) (communityRequestCursor, error) {
	if strings.TrimSpace(raw) == "" {
		return communityRequestCursor{}, nil
	}
	b, err := base64.RawURLEncoding.DecodeString(raw)
	if err != nil {
		return communityRequestCursor{}, domain.ErrCommunityInvalid
	}
	var c communityRequestCursor
	if json.Unmarshal(b, &c) != nil || c.Date <= 0 || c.ID <= 0 {
		return communityRequestCursor{}, domain.ErrCommunityInvalid
	}
	return c, nil
}

func encodeCommunityRequestCursor(c communityRequestCursor) string {
	b, _ := json.Marshal(c)
	return base64.RawURLEncoding.EncodeToString(b)
}

func (s *CommunityStore) ListCommunityPeerLinkRequests(ctx context.Context, viewerUserID, communityID int64, offset string, limit int) (domain.CommunityPeerLinkRequestPage, error) {
	view, err := s.GetCommunity(ctx, viewerUserID, communityID)
	if err != nil {
		return domain.CommunityPeerLinkRequestPage{}, err
	}
	if !view.Self.CanManageLinkedPeers() {
		return domain.CommunityPeerLinkRequestPage{}, domain.ErrCommunityAdminRequired
	}
	cursor, err := decodeCommunityRequestCursor(offset)
	if err != nil {
		return domain.CommunityPeerLinkRequestPage{}, err
	}
	if limit <= 0 || limit > domain.MaxCommunityLinkRequests {
		limit = domain.MaxCommunityLinkRequests
	}
	var total int
	if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM community_peer_link_requests WHERE community_id=$1`, communityID).Scan(&total); err != nil {
		return domain.CommunityPeerLinkRequestPage{}, err
	}
	rows, err := s.db.Query(ctx, `
SELECT peer_type,peer_id,requested_by,visibility,date
FROM community_peer_link_requests
WHERE community_id=$1 AND ($2::int=0 OR (date,peer_type,peer_id) < ($2,$3,$4))
ORDER BY date DESC,peer_type DESC,peer_id DESC LIMIT $5`, communityID, cursor.Date, cursor.Type, cursor.ID, limit+1)
	if err != nil {
		return domain.CommunityPeerLinkRequestPage{}, err
	}
	defer rows.Close()
	page := domain.CommunityPeerLinkRequestPage{TotalCount: total}
	for rows.Next() {
		var typ, visibility string
		var peerID, requestedBy int64
		var date int
		if err := rows.Scan(&typ, &peerID, &requestedBy, &visibility, &date); err != nil {
			return domain.CommunityPeerLinkRequestPage{}, err
		}
		page.Requests = append(page.Requests, domain.CommunityPeerLinkRequest{CommunityID: communityID, Peer: domain.Peer{Type: domain.PeerType(typ), ID: peerID}, RequestedBy: requestedBy, Visibility: domain.CommunityPeerVisibility(visibility), Date: date})
	}
	if len(page.Requests) > limit {
		last := page.Requests[limit-1]
		page.NextOffset = encodeCommunityRequestCursor(communityRequestCursor{Date: last.Date, Type: string(last.Peer.Type), ID: last.Peer.ID})
		page.Requests = page.Requests[:limit]
	}
	channelIDs, userIDs := make([]int64, 0), make([]int64, 0)
	for _, req := range page.Requests {
		userIDs = append(userIDs, req.RequestedBy)
		if req.Peer.Type == domain.PeerTypeChannel {
			channelIDs = append(channelIDs, req.Peer.ID)
		} else {
			userIDs = append(userIDs, req.Peer.ID)
		}
	}
	page.Channels, err = listChannelsByIDs(ctx, s.db, channelIDs)
	if err != nil {
		return domain.CommunityPeerLinkRequestPage{}, err
	}
	page.Users, err = listUsersByIDs(ctx, s.db, uniqueInt64s(userIDs))
	return page, err
}

func requestForUpdate(ctx context.Context, tx pgx.Tx, communityID int64, peer domain.Peer) (domain.CommunityPeerLinkRequest, error) {
	var typ, visibility string
	var out domain.CommunityPeerLinkRequest
	err := tx.QueryRow(ctx, `
SELECT peer_type,peer_id,requested_by,visibility,date FROM community_peer_link_requests
WHERE community_id=$1 AND peer_type=$2 AND peer_id=$3 FOR UPDATE`, communityID, string(peer.Type), peer.ID).
		Scan(&typ, &out.Peer.ID, &out.RequestedBy, &visibility, &out.Date)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.CommunityPeerLinkRequest{}, domain.ErrCommunityRequestMissing
		}
		return domain.CommunityPeerLinkRequest{}, err
	}
	out.CommunityID, out.Peer.Type, out.Visibility = communityID, domain.PeerType(typ), domain.CommunityPeerVisibility(visibility)
	return out, nil
}

func (s *CommunityStore) DecideCommunityPeerLinkRequest(ctx context.Context, actorUserID, communityID int64, peer domain.Peer, reject bool, date int) (domain.CommunityTogglePeerLinkResult, error) {
	return withCommunityTx(ctx, s, func(tx pgx.Tx) (domain.CommunityTogglePeerLinkResult, error) {
		c, actor, err := lockCommunityActor(ctx, tx, actorUserID, communityID)
		if err != nil {
			return domain.CommunityTogglePeerLinkResult{}, err
		}
		if !actor.CanManageLinkedPeers() {
			return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityAdminRequired
		}
		req, err := requestForUpdate(ctx, tx, communityID, peer)
		if err != nil {
			return domain.CommunityTogglePeerLinkResult{}, err
		}
		if _, err := tx.Exec(ctx, `DELETE FROM community_peer_link_requests WHERE community_id=$1 AND peer_type=$2 AND peer_id=$3`, communityID, string(peer.Type), peer.ID); err != nil {
			return domain.CommunityTogglePeerLinkResult{}, err
		}
		if reject {
			return domain.CommunityTogglePeerLinkResult{Community: c, Peer: peer, RequestedBy: req.RequestedBy}, nil
		}
		link, err := insertCommunityLink(ctx, tx, communityID, req.RequestedBy, peer, req.Visibility, date)
		if err != nil {
			return domain.CommunityTogglePeerLinkResult{}, err
		}
		serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, peer, actorUserID, date, c.ID)
		if err != nil {
			return domain.CommunityTogglePeerLinkResult{}, err
		}
		return domain.CommunityTogglePeerLinkResult{Community: c, Peer: peer, RequestedBy: req.RequestedBy, Link: &link, ServiceMessage: serviceMessage}, nil
	})
}

func (s *CommunityStore) DecideAllCommunityPeerLinkRequests(ctx context.Context, actorUserID, communityID int64, reject bool, date int) ([]domain.CommunityTogglePeerLinkResult, error) {
	return withCommunityTx(ctx, s, func(tx pgx.Tx) ([]domain.CommunityTogglePeerLinkResult, error) {
		c, actor, err := lockCommunityActor(ctx, tx, actorUserID, communityID)
		if err != nil {
			return nil, err
		}
		if !actor.CanManageLinkedPeers() {
			return nil, domain.ErrCommunityAdminRequired
		}
		rows, err := tx.Query(ctx, `SELECT peer_type,peer_id,requested_by,visibility,date FROM community_peer_link_requests WHERE community_id=$1 ORDER BY date,peer_type,peer_id FOR UPDATE`, communityID)
		if err != nil {
			return nil, err
		}
		requests := make([]domain.CommunityPeerLinkRequest, 0)
		for rows.Next() {
			var typ, visibility string
			var r domain.CommunityPeerLinkRequest
			if err := rows.Scan(&typ, &r.Peer.ID, &r.RequestedBy, &visibility, &r.Date); err != nil {
				rows.Close()
				return nil, err
			}
			r.CommunityID, r.Peer.Type, r.Visibility = communityID, domain.PeerType(typ), domain.CommunityPeerVisibility(visibility)
			requests = append(requests, r)
		}
		rows.Close()
		if reject {
			_, err := tx.Exec(ctx, `DELETE FROM community_peer_link_requests WHERE community_id=$1`, communityID)
			return make([]domain.CommunityTogglePeerLinkResult, len(requests)), err
		}
		var currentChannels, currentBots int
		if err := tx.QueryRow(ctx, `SELECT COUNT(*) FILTER(WHERE peer_type='channel')::int, COUNT(*) FILTER(WHERE peer_type='user')::int FROM community_peer_links WHERE community_id=$1`, communityID).Scan(&currentChannels, &currentBots); err != nil {
			return nil, err
		}
		for _, r := range requests {
			if r.Peer.Type == domain.PeerTypeChannel {
				currentChannels++
			} else {
				currentBots++
			}
			if currentChannels > domain.MaxCommunityPeers || currentBots > domain.MaxCommunityBotPeers {
				return nil, domain.ErrCommunityPeersTooMuch
			}
			if err := validateCommunityPeerForLink(ctx, tx, r.RequestedBy, r.Peer, true); err != nil {
				return nil, err
			}
		}
		out := make([]domain.CommunityTogglePeerLinkResult, 0, len(requests))
		for _, r := range requests {
			link, err := insertCommunityLink(ctx, tx, communityID, r.RequestedBy, r.Peer, r.Visibility, date)
			if err != nil {
				return nil, err
			}
			serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, r.Peer, actorUserID, date, c.ID)
			if err != nil {
				return nil, err
			}
			out = append(out, domain.CommunityTogglePeerLinkResult{Community: c, Peer: r.Peer, RequestedBy: r.RequestedBy, Link: &link, ServiceMessage: serviceMessage})
		}
		_, err = tx.Exec(ctx, `DELETE FROM community_peer_link_requests WHERE community_id=$1`, communityID)
		return out, err
	})
}

func (s *CommunityStore) GetCommunityParticipantJoinedChats(ctx context.Context, viewerUserID, communityID, participantUserID int64) (domain.CommunityParticipantJoinedChats, error) {
	view, err := s.GetCommunity(ctx, viewerUserID, communityID)
	if err != nil {
		return domain.CommunityParticipantJoinedChats{}, err
	}
	if !view.Self.CanBanUsers() && viewerUserID != participantUserID {
		return domain.CommunityParticipantJoinedChats{}, domain.ErrCommunityAdminRequired
	}
	rows, err := s.db.Query(ctx, `
SELECT l.peer_id, cm.role
FROM community_peer_links l
JOIN channel_members cm ON cm.channel_id=l.peer_id AND cm.user_id=$2 AND cm.status='active'
WHERE l.community_id=$1 AND l.peer_type='channel'
ORDER BY l.peer_id`, communityID, participantUserID)
	if err != nil {
		return domain.CommunityParticipantJoinedChats{}, err
	}
	defer rows.Close()
	out := domain.CommunityParticipantJoinedChats{}
	ids := make([]int64, 0)
	for rows.Next() {
		var id int64
		var role string
		if err := rows.Scan(&id, &role); err != nil {
			return out, err
		}
		ids = append(ids, id)
		out.JoinedChatIDs = append(out.JoinedChatIDs, id)
		if role == string(domain.ChannelRoleCreator) {
			out.CreatorChatIDs = append(out.CreatorChatIDs, id)
		}
	}
	if err := rows.Err(); err != nil {
		return out, err
	}
	out.Channels, err = listChannelsByIDs(ctx, s.db, ids)
	if err != nil {
		return out, err
	}
	out.Users, err = listUsersByIDs(ctx, s.db, []int64{participantUserID})
	return out, err
}

func (s *CommunityStore) banCommunityParticipantFromChannelTx(ctx context.Context, tx pgx.Tx, actorUserID, channelID, participantUserID int64, date int) (domain.EditChannelBannedResult, bool, error) {
	channelStore := NewChannelStore(tx, WithChannelAllocators(s.ids, s.msgIDs))
	channel, err := getChannelByID(ctx, tx, channelID)
	if err != nil {
		return domain.EditChannelBannedResult{}, false, err
	}
	previous, err := channelStore.getChannelMember(ctx, tx, channelID, participantUserID)
	if errors.Is(err, domain.ErrChannelPrivate) {
		return domain.EditChannelBannedResult{}, false, nil
	}
	if err != nil {
		return domain.EditChannelBannedResult{}, false, err
	}
	if previous.Status != domain.ChannelMemberActive {
		return domain.EditChannelBannedResult{}, false, nil
	}
	member := previous
	member.InviterUserID = actorUserID
	member.Role = domain.ChannelRoleMember
	member.Status = domain.ChannelMemberKicked
	member.LeftAt = date
	member.BannedRights = domain.ChannelBannedRights{ViewMessages: true, UntilDate: 0}
	if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil {
		return domain.EditChannelBannedResult{}, false, err
	}
	if err := channelStore.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
		ChannelID: channelID, UserID: actorUserID, Date: date, Type: domain.ChannelAdminLogParticipantKick,
		PrevParticipant: &previous, NewParticipant: &member,
	}); err != nil {
		return domain.EditChannelBannedResult{}, false, err
	}
	channel, err = refreshChannelCountsTx(ctx, tx, channel)
	if err != nil {
		return domain.EditChannelBannedResult{}, false, err
	}
	event := transientChannelParticipantEvent(channel.ID, actorUserID, previous, member, date)
	if err := clearChannelMentionsForUserTx(ctx, tx, channelID, participantUserID); err != nil {
		return domain.EditChannelBannedResult{}, false, err
	}
	if err := deleteWelcomeMessageDeliveriesTx(ctx, tx, channelID, []int64{participantUserID}); err != nil {
		return domain.EditChannelBannedResult{}, false, err
	}
	var serviceMessage domain.ChannelMessage
	var serviceEvent domain.ChannelUpdateEvent
	if channel.Megagroup {
		serviceMessage, serviceEvent, err = channelStore.insertServiceMessage(ctx, tx, channel, actorUserID, date, domain.ChannelMessageAction{
			Type: domain.ChannelActionChatDelete, UserIDs: []int64{participantUserID},
		})
		if err != nil {
			return domain.EditChannelBannedResult{}, false, err
		}
		channel.TopMessageID, channel.Pts = serviceMessage.ID, serviceEvent.Pts
	}
	recipients, err := channelStore.listActiveChannelMemberIDs(ctx, tx, channelID, 0)
	if err != nil {
		return domain.EditChannelBannedResult{}, false, err
	}
	recipients = append(recipients, participantUserID)
	return domain.EditChannelBannedResult{
		Channel: channel, Previous: previous, Participant: member, Event: event,
		Recipients: recipients, Date: date, Message: serviceMessage, ServiceEvent: serviceEvent,
	}, true, nil
}

func (s *CommunityStore) ToggleCommunityParticipantBanned(ctx context.Context, actorUserID, communityID, participantUserID int64, unban bool, date int) (domain.CommunityParticipantBanResult, error) {
	return withCommunityTx(ctx, s, func(tx pgx.Tx) (domain.CommunityParticipantBanResult, error) {
		c, actor, err := lockCommunityActor(ctx, tx, actorUserID, communityID)
		if err != nil {
			return domain.CommunityParticipantBanResult{}, err
		}
		if !actor.CanBanUsers() || participantUserID == c.CreatorUserID {
			return domain.CommunityParticipantBanResult{}, domain.ErrCommunityAdminRequired
		}
		if unban {
			cmd, err := tx.Exec(ctx, `DELETE FROM community_members WHERE community_id=$1 AND user_id=$2 AND role='member' AND status='kicked'`, communityID, participantUserID)
			return domain.CommunityParticipantBanResult{Changed: cmd.RowsAffected() > 0}, err
		}
		participant, found, err := derivedCommunityMember(ctx, tx, c, participantUserID)
		if err != nil {
			return domain.CommunityParticipantBanResult{}, err
		}
		if !found || (participant.Status != domain.CommunityMemberActive && participant.Status != domain.CommunityMemberKicked) {
			return domain.CommunityParticipantBanResult{}, domain.ErrCommunityParticipantInvalid
		}
		var alreadyKicked bool
		if err := tx.QueryRow(ctx, `SELECT EXISTS(
SELECT 1 FROM community_members WHERE community_id=$1 AND user_id=$2 AND role='member' AND status='kicked')`, communityID, participantUserID).Scan(&alreadyKicked); err != nil {
			return domain.CommunityParticipantBanResult{}, err
		}
		// Chats owned by the banned participant (and their bots) leave the
		// Community; the participant is kicked from every remaining linked chat.
		rows, err := tx.Query(ctx, `
SELECT l.peer_type,l.peer_id
FROM community_peer_links l
LEFT JOIN channels c ON l.peer_type='channel' AND c.id=l.peer_id
LEFT JOIN bots b ON l.peer_type='user' AND b.bot_user_id=l.peer_id
WHERE l.community_id=$1 AND (c.creator_user_id=$2 OR b.owner_user_id=$2)
FOR UPDATE OF l`, communityID, participantUserID)
		if err != nil {
			return domain.CommunityParticipantBanResult{}, err
		}
		owned := make([]domain.Peer, 0)
		for rows.Next() {
			var typ string
			var id int64
			if err := rows.Scan(&typ, &id); err != nil {
				rows.Close()
				return domain.CommunityParticipantBanResult{}, err
			}
			owned = append(owned, domain.Peer{Type: domain.PeerType(typ), ID: id})
		}
		rows.Close()
		result := domain.CommunityParticipantBanResult{}
		for _, p := range owned {
			if _, err := tx.Exec(ctx, `DELETE FROM community_peer_links WHERE community_id=$1 AND peer_type=$2 AND peer_id=$3`, communityID, string(p.Type), p.ID); err != nil {
				return domain.CommunityParticipantBanResult{}, err
			}
			if err := setPeerLinkedCommunity(ctx, tx, p, 0); err != nil {
				return domain.CommunityParticipantBanResult{}, err
			}
			serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, p, actorUserID, date, 0)
			if err != nil {
				return domain.CommunityParticipantBanResult{}, err
			}
			result.RemovedLinks = append(result.RemovedLinks, domain.CommunityTogglePeerLinkResult{Community: c, Peer: p, Removed: true, ServiceMessage: serviceMessage})
		}
		channelRows, err := tx.Query(ctx, `SELECT peer_id FROM community_peer_links WHERE community_id=$1 AND peer_type='channel' ORDER BY peer_id FOR UPDATE`, communityID)
		if err != nil {
			return domain.CommunityParticipantBanResult{}, err
		}
		channelIDs := make([]int64, 0)
		for channelRows.Next() {
			var channelID int64
			if err := channelRows.Scan(&channelID); err != nil {
				channelRows.Close()
				return domain.CommunityParticipantBanResult{}, err
			}
			channelIDs = append(channelIDs, channelID)
		}
		channelRows.Close()
		for _, channelID := range channelIDs {
			ban, changed, err := s.banCommunityParticipantFromChannelTx(ctx, tx, actorUserID, channelID, participantUserID, date)
			if err != nil {
				return domain.CommunityParticipantBanResult{}, err
			}
			if changed {
				result.ChannelBans = append(result.ChannelBans, ban)
			}
		}
		if !alreadyKicked {
			if _, err := tx.Exec(ctx, `
INSERT INTO community_members(community_id,user_id,role,status,admin_rights,date)
VALUES($1,$2,'member','kicked','{}',$3)
ON CONFLICT(community_id,user_id) DO UPDATE SET role='member',status='kicked',admin_rights='{}',rank='',date=EXCLUDED.date,updated_at=now()`, communityID, participantUserID, date); err != nil {
				return domain.CommunityParticipantBanResult{}, err
			}
		}
		result.Changed = !alreadyKicked || len(result.ChannelBans) > 0 || len(result.RemovedLinks) > 0
		return result, nil
	})
}

func communityParticipantHash(items []domain.CommunityMember) int64 {
	h := fnv.New64a()
	for _, m := range items {
		fmt.Fprintf(h, "%d:%s:%s;", m.UserID, m.Role, m.Status)
	}
	return int64(h.Sum64() & 0x7fffffffffffffff)
}

func (s *CommunityStore) ListCommunityParticipants(ctx context.Context, viewerUserID, communityID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.CommunityParticipantList, error) {
	view, err := s.GetCommunity(ctx, viewerUserID, communityID)
	if err != nil {
		return domain.CommunityParticipantList{}, err
	}
	restricted := filter.Kind == domain.ChannelParticipantsKicked || filter.Kind == domain.ChannelParticipantsBanned
	if restricted && !view.Self.CanManageLinkedPeers() {
		return domain.CommunityParticipantList{}, domain.ErrCommunityAdminRequired
	}
	where := "am.status='active'"
	switch filter.Kind {
	case domain.ChannelParticipantsAdmins:
		where = "am.status='active' AND am.role IN ('creator','admin')"
	case domain.ChannelParticipantsKicked, domain.ChannelParticipantsBanned:
		where = "am.status='kicked'"
	}
	query := `
WITH derived AS (
 SELECT cm.user_id FROM community_peer_links l JOIN channel_members cm ON l.peer_type='channel' AND cm.channel_id=l.peer_id
 WHERE l.community_id=$1 AND cm.status='active'
 UNION
 SELECT d.user_id FROM community_peer_links l JOIN dialogs d ON l.peer_type='user' AND d.peer_id=l.peer_id AND d.peer_type='user'
 WHERE l.community_id=$1 AND d.top_message_id>0
), all_members AS (
 SELECT community_id,user_id,role,status,admin_rights,rank,date FROM community_members WHERE community_id=$1
 UNION ALL
 SELECT $1,d.user_id,'member','active','{}'::jsonb,'',0 FROM derived d
 WHERE NOT EXISTS(SELECT 1 FROM community_members m WHERE m.community_id=$1 AND m.user_id=d.user_id)
)
SELECT am.community_id,am.user_id,am.role,am.status,am.admin_rights::text,am.rank,am.date,COUNT(*) OVER()::int
FROM all_members am
JOIN users u ON u.id=am.user_id
WHERE ` + where + `
  AND ($4='' OR strpos(lower(concat_ws(' ',am.user_id::text,u.first_name,u.last_name,u.username,u.phone)),lower($4))>0)
ORDER BY CASE am.role WHEN 'creator' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,am.user_id OFFSET $2 LIMIT $3`
	rows, err := s.db.Query(ctx, query, communityID, offset, limit, strings.TrimSpace(filter.Query))
	if err != nil {
		return domain.CommunityParticipantList{}, err
	}
	defer rows.Close()
	out := domain.CommunityParticipantList{Community: view.Community}
	ids := make([]int64, 0)
	for rows.Next() {
		var m domain.CommunityMember
		var role, status, rights string
		var count int
		if err := rows.Scan(&m.CommunityID, &m.UserID, &role, &status, &rights, &m.Rank, &m.Date, &count); err != nil {
			return out, err
		}
		m.Role, m.Status = domain.CommunityMemberRole(role), domain.CommunityMemberStatus(status)
		_ = json.Unmarshal([]byte(rights), &m.AdminRights)
		out.Count = count
		out.Participants = append(out.Participants, m)
		ids = append(ids, m.UserID)
	}
	out.Users, err = listUsersByIDs(ctx, s.db, ids)
	out.Hash = communityParticipantHash(out.Participants)
	return out, err
}

func (s *CommunityStore) EditCommunityTitle(ctx context.Context, actorUserID, communityID int64, title string) (domain.CommunityView, bool, error) {
	changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) {
		c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID)
		if e != nil {
			return false, e
		}
		if !m.CanChangeInfo() {
			return false, domain.ErrCommunityAdminRequired
		}
		if c.Title == title {
			return false, nil
		}
		_, e = tx.Exec(ctx, `UPDATE communities SET title=$2,updated_at=now() WHERE id=$1`, communityID, title)
		return true, e
	})
	if err != nil {
		return domain.CommunityView{}, false, err
	}
	v, err := s.GetCommunity(ctx, actorUserID, communityID)
	return v, changed, err
}

func (s *CommunityStore) EditCommunityAbout(ctx context.Context, actorUserID, communityID int64, about string) (domain.CommunityView, bool, error) {
	changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) {
		c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID)
		if e != nil {
			return false, e
		}
		if !m.CanChangeInfo() {
			return false, domain.ErrCommunityAdminRequired
		}
		if c.About == about {
			return false, nil
		}
		_, e = tx.Exec(ctx, `UPDATE communities SET about=$2,updated_at=now() WHERE id=$1`, communityID, about)
		return true, e
	})
	if err != nil {
		return domain.CommunityView{}, false, err
	}
	v, err := s.GetCommunity(ctx, actorUserID, communityID)
	return v, changed, err
}

func zeroCommunityAdminRights(r domain.ChannelAdminRights) bool {
	return r == (domain.ChannelAdminRights{})
}

func (s *CommunityStore) EditCommunityAdmin(ctx context.Context, req domain.CommunityEditAdminRequest) (domain.CommunityView, bool, error) {
	changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) {
		c, actor, e := lockCommunityActor(ctx, tx, req.ActorUserID, req.CommunityID)
		if e != nil {
			return false, e
		}
		if req.UserID == req.ActorUserID && actor.Role == domain.CommunityRoleAdmin && zeroCommunityAdminRights(req.Rights) {
			cmd, e := tx.Exec(ctx, `DELETE FROM community_members WHERE community_id=$1 AND user_id=$2 AND role='admin'`, req.CommunityID, req.UserID)
			return cmd.RowsAffected() > 0, e
		}
		if !actor.CanAddAdmins() {
			return false, domain.ErrCommunityAdminRequired
		}
		if req.UserID == c.CreatorUserID {
			return false, domain.ErrCommunityCreatorRequired
		}
		if zeroCommunityAdminRights(req.Rights) {
			cmd, e := tx.Exec(ctx, `DELETE FROM community_members WHERE community_id=$1 AND user_id=$2 AND role='admin'`, req.CommunityID, req.UserID)
			return cmd.RowsAffected() > 0, e
		}
		var exists bool
		if e := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE id=$1 AND deleted_at IS NULL)`, req.UserID).Scan(&exists); e != nil || !exists {
			return false, domain.ErrCommunityParticipantInvalid
		}
		rights, _ := json.Marshal(req.Rights)
		_, e = tx.Exec(ctx, `
INSERT INTO community_members(community_id,user_id,role,status,admin_rights,rank,date)
VALUES($1,$2,'admin','active',$3,$4,$5)
ON CONFLICT(community_id,user_id) DO UPDATE SET role='admin',status='active',admin_rights=EXCLUDED.admin_rights,rank=EXCLUDED.rank,date=EXCLUDED.date,updated_at=now()`, req.CommunityID, req.UserID, rights, req.Rank, req.Date)
		return true, e
	})
	if err != nil {
		return domain.CommunityView{}, false, err
	}
	v, err := s.GetCommunity(ctx, req.ActorUserID, req.CommunityID)
	if changed && req.UserID == req.ActorUserID && errors.Is(err, domain.ErrCommunityPrivate) {
		c, loadErr := communityByID(ctx, s.db, req.CommunityID, false)
		if loadErr != nil {
			return domain.CommunityView{}, false, loadErr
		}
		return domain.CommunityView{Community: c, Forbidden: true}, true, nil
	}
	return v, changed, err
}

func (s *CommunityStore) EditCommunityDefaultBannedRights(ctx context.Context, actorUserID, communityID int64, rights domain.ChannelBannedRights) (domain.CommunityView, bool, error) {
	changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) {
		c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID)
		if e != nil {
			return false, e
		}
		if !m.CanChangeInfo() {
			return false, domain.ErrCommunityAdminRequired
		}
		if c.DefaultBannedRights == rights {
			return false, nil
		}
		raw, _ := json.Marshal(rights)
		_, e = tx.Exec(ctx, `UPDATE communities SET default_banned_rights=$2,updated_at=now() WHERE id=$1`, communityID, raw)
		return true, e
	})
	if err != nil {
		return domain.CommunityView{}, false, err
	}
	v, err := s.GetCommunity(ctx, actorUserID, communityID)
	return v, changed, err
}

func (s *CommunityStore) SetCommunityPhoto(ctx context.Context, actorUserID, communityID int64, photo *domain.Photo, date int) (domain.CommunityView, bool, error) {
	if photo != nil && (photo.ID == 0 || photo.DCID <= 0) {
		return domain.CommunityView{}, false, domain.ErrPhotoInvalid
	}
	changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) {
		c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID)
		if e != nil {
			return false, e
		}
		if !m.CanChangeInfo() {
			return false, domain.ErrCommunityAdminRequired
		}
		id, dc := int64(0), 0
		var stripped []byte
		if photo != nil {
			id, dc = photo.ID, photo.DCID
			stripped = domain.StrippedFromSizes(photo.Sizes)
		}
		if stripped == nil {
			stripped = []byte{}
		}
		if c.PhotoID == id && c.PhotoDCID == dc && string(c.PhotoStripped) == string(stripped) {
			return false, nil
		}
		_, e = tx.Exec(ctx, `UPDATE communities SET photo_id=$2,photo_dc_id=$3,photo_stripped=$4,updated_at=now() WHERE id=$1`, communityID, id, dc, stripped)
		return true, e
	})
	if err != nil {
		return domain.CommunityView{}, false, err
	}
	v, err := s.GetCommunity(ctx, actorUserID, communityID)
	return v, changed, err
}

func (s *CommunityStore) DeleteCommunity(ctx context.Context, actorUserID, communityID int64, date int) (domain.CommunityView, []domain.Peer, error) {
	type result struct {
		view  domain.CommunityView
		peers []domain.Peer
	}
	r, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (result, error) {
		c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID)
		if e != nil {
			return result{}, e
		}
		if m.Role != domain.CommunityRoleCreator {
			return result{}, domain.ErrCommunityCreatorRequired
		}
		links, e := communityLinkRows(ctx, tx, c, m)
		if e != nil {
			return result{}, e
		}
		peers := make([]domain.Peer, 0, len(links))
		serviceMessages := make([]domain.SendChannelMessageResult, 0, len(links))
		for _, l := range links {
			peers = append(peers, l.Peer)
			if e := setPeerLinkedCommunity(ctx, tx, l.Peer, 0); e != nil {
				return result{}, e
			}
			serviceMessage, e := s.appendCommunityServiceMessageTx(ctx, tx, l.Peer, actorUserID, date, 0)
			if e != nil {
				return result{}, e
			}
			if serviceMessage != nil {
				serviceMessages = append(serviceMessages, *serviceMessage)
			}
		}
		if _, e = tx.Exec(ctx, `DELETE FROM community_peer_links WHERE community_id=$1`, communityID); e != nil {
			return result{}, e
		}
		if _, e = tx.Exec(ctx, `DELETE FROM community_peer_link_requests WHERE community_id=$1`, communityID); e != nil {
			return result{}, e
		}
		if _, e = tx.Exec(ctx, `DELETE FROM community_user_states WHERE community_id=$1`, communityID); e != nil {
			return result{}, e
		}
		if _, e = tx.Exec(ctx, `UPDATE communities SET deleted=true,title='',about='',updated_at=now() WHERE id=$1`, communityID); e != nil {
			return result{}, e
		}
		c.Deleted = true
		c.Title = ""
		c.About = ""
		return result{view: domain.CommunityView{Community: c, Self: m, Forbidden: true, ServiceMessages: serviceMessages}, peers: peers}, nil
	})
	return r.view, r.peers, err
}

func (s *CommunityStore) SetCommunityPinned(ctx context.Context, userID, communityID int64, pinned bool) (bool, error) {
	return withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) {
		if _, _, e := lockCommunityActor(ctx, tx, userID, communityID); e != nil {
			return false, e
		}
		var collapsed, old bool
		var order int
		e := tx.QueryRow(ctx, `SELECT collapsed,pinned,pinned_order FROM community_user_states WHERE community_id=$1 AND user_id=$2 FOR UPDATE`, communityID, userID).Scan(&collapsed, &old, &order)
		if e != nil {
			return false, domain.ErrCommunityInvalid
		}
		if !collapsed {
			return false, domain.ErrCommunityInvalid
		}
		if old == pinned {
			return false, nil
		}
		if pinned {
			if e := tx.QueryRow(ctx, `SELECT GREATEST(1000000000,COALESCE(MAX(pinned_order),0))+1 FROM community_user_states WHERE user_id=$1 AND pinned`, userID).Scan(&order); e != nil {
				return false, e
			}
		} else {
			order = 0
		}
		_, e = tx.Exec(ctx, `UPDATE community_user_states SET pinned=$3,pinned_order=$4,updated_at=now() WHERE community_id=$1 AND user_id=$2`, communityID, userID, pinned, order)
		return true, e
	})
}

func (s *CommunityStore) ReorderCommunityPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) (bool, error) {
	return withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) {
		seen := map[int64]struct{}{}
		for _, peer := range order {
			if peer.Type != domain.PeerTypeCommunity {
				continue
			}
			if peer.ID == 0 {
				return false, domain.ErrCommunityInvalid
			}
			if _, ok := seen[peer.ID]; ok {
				return false, domain.ErrCommunityInvalid
			}
			seen[peer.ID] = struct{}{}
		}
		rows, e := tx.Query(ctx, `SELECT community_id,pinned_order FROM community_user_states WHERE user_id=$1 AND pinned FOR UPDATE`, userID)
		if e != nil {
			return false, e
		}
		old := map[int64]int{}
		for rows.Next() {
			var id int64
			var pinnedOrder int
			if e := rows.Scan(&id, &pinnedOrder); e != nil {
				rows.Close()
				return false, e
			}
			old[id] = pinnedOrder
		}
		rows.Close()
		changed := false
		if force {
			if _, e = tx.Exec(ctx, `UPDATE community_user_states SET pinned=false,pinned_order=0,updated_at=now() WHERE user_id=$1 AND pinned`, userID); e != nil {
				return false, e
			}
			changed = len(old) > 0
		}
		for i, peer := range order {
			if peer.Type != domain.PeerTypeCommunity {
				continue
			}
			pinnedOrder := len(order) - i
			if old[peer.ID] == pinnedOrder && !force {
				continue
			}
			cmd, e := tx.Exec(ctx, `UPDATE community_user_states SET pinned=true,pinned_order=$3,updated_at=now() WHERE user_id=$1 AND community_id=$2 AND collapsed`, userID, peer.ID, pinnedOrder)
			if e != nil {
				return false, e
			}
			if cmd.RowsAffected() != 1 {
				return false, domain.ErrCommunityInvalid
			}
			changed = true
		}
		return changed, nil
	})
}

func (s *CommunityStore) CommunitySearchScope(ctx context.Context, viewerUserID, communityID int64) (domain.CommunitySearchScope, error) {
	v, err := s.GetCommunity(ctx, viewerUserID, communityID)
	if err != nil {
		return domain.CommunitySearchScope{}, err
	}
	out := domain.CommunitySearchScope{CommunityID: communityID}
	for _, l := range v.Links {
		if l.Peer.Type == domain.PeerTypeChannel {
			if l.CanViewHistory {
				out.ChannelIDs = append(out.ChannelIDs, l.Peer.ID)
			}
		} else {
			out.BotUserIDs = append(out.BotUserIDs, l.Peer.ID)
		}
	}
	return out, nil
}

func uniqueInt64s(ids []int64) []int64 {
	seen := map[int64]struct{}{}
	out := make([]int64, 0, len(ids))
	for _, id := range ids {
		if id == 0 {
			continue
		}
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		out = append(out, id)
	}
	sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
	return out
}