Ctrl K
ComunidadGuías…

internal/store/postgres/channel_member_helpers.go

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

Archivo original ↗Preguntar a KiBot
package postgres

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"github.com/jackc/pgx/v5"
	"telesrv/internal/domain"
	"telesrv/internal/store/postgres/sqlcgen"
)

func (s *ChannelStore) getChannelForMember(ctx context.Context, db sqlcgen.DBTX, viewerUserID, channelID int64) (domain.Channel, domain.ChannelMember, error) {
	member, err := s.getChannelMember(ctx, db, channelID, viewerUserID)
	if err != nil {
		return domain.Channel{}, domain.ChannelMember{}, err
	}
	if err := validateChannelMemberVisible(member); err != nil {
		return domain.Channel{}, domain.ChannelMember{}, err
	}
	ch, err := s.channelByID(ctx, db, channelID)
	if err != nil {
		return domain.Channel{}, domain.ChannelMember{}, err
	}
	return ch, member, nil
}

func (s *ChannelStore) getChannelForMemberOrLinkedGuest(ctx context.Context, db sqlcgen.DBTX, viewerUserID, channelID int64) (domain.Channel, domain.ChannelMember, error) {
	channel, member, err := s.getChannelForMember(ctx, db, viewerUserID, channelID)
	if !errors.Is(err, domain.ErrChannelPrivate) {
		return channel, member, err
	}
	target, targetErr := s.channelByID(ctx, db, channelID)
	if targetErr != nil {
		return domain.Channel{}, domain.ChannelMember{}, targetErr
	}
	guest, allowed, guestErr := s.getLinkedDiscussionGuest(ctx, db, viewerUserID, target)
	if guestErr != nil {
		return domain.Channel{}, domain.ChannelMember{}, guestErr
	}
	if !allowed {
		return domain.Channel{}, domain.ChannelMember{}, err
	}
	return target, guest, nil
}

// getLinkedDiscussionGuest authorizes a private discussion group through an
// active membership in its bidirectionally linked broadcast. The returned
// member is computed only and must never be persisted. An explicit target ban
// or kick takes precedence over the source-channel membership.
func (s *ChannelStore) getLinkedDiscussionGuest(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, target domain.Channel) (domain.ChannelMember, bool, error) {
	if target.Broadcast || !target.Megagroup || target.LinkedChatID == 0 {
		return domain.ChannelMember{}, false, nil
	}
	existing, err := s.getChannelMember(ctx, db, target.ID, viewerUserID)
	if err == nil {
		if existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages {
			return domain.ChannelMember{}, false, domain.ErrChannelUserBanned
		}
		if existing.Status == domain.ChannelMemberActive {
			return existing, false, nil
		}
	} else if !errors.Is(err, domain.ErrChannelPrivate) {
		return domain.ChannelMember{}, false, err
	}
	source, err := s.channelByID(ctx, db, target.LinkedChatID)
	if err != nil {
		if errors.Is(err, domain.ErrChannelInvalid) {
			return domain.ChannelMember{}, false, nil
		}
		return domain.ChannelMember{}, false, err
	}
	if !source.Broadcast || source.LinkedChatID != target.ID {
		return domain.ChannelMember{}, false, nil
	}
	sourceMember, err := s.getChannelMember(ctx, db, source.ID, viewerUserID)
	if err != nil {
		if errors.Is(err, domain.ErrChannelPrivate) {
			return domain.ChannelMember{}, false, nil
		}
		return domain.ChannelMember{}, false, err
	}
	if err := validateChannelMemberVisible(sourceMember); err != nil {
		return domain.ChannelMember{}, false, err
	}
	guest := domain.ChannelMember{
		ChannelID: target.ID,
		UserID:    viewerUserID,
		Status:    domain.ChannelMemberLeft,
		Role:      domain.ChannelRoleMember,
		Guest:     true,
	}
	if s.memberCacheActive(db) {
		s.memberCache.put(guest)
	}
	return guest, true, nil
}

// listLinkedDiscussionGuests is the bounded batch equivalent of
// getLinkedDiscussionGuest. GetChannels uses it after loading the requested
// channel rows so messages.getPeerDialogs can materialize linked discussion
// histories without an N+1 query per requested peer.
//
// The target membership predicate deliberately excludes active, kicked,
// banned and view-messages-banned rows. Active members were projected by the
// primary GetChannels query; explicit target denial must always win over the
// source broadcast membership. Returned members are transient and are never
// persisted to channel_members/channel_dialogs.
func (s *ChannelStore) listLinkedDiscussionGuests(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, targetIDs []int64) (map[int64]domain.ChannelMember, error) {
	out := make(map[int64]domain.ChannelMember)
	if viewerUserID == 0 || len(targetIDs) == 0 {
		return out, nil
	}
	rows, err := db.Query(ctx, `
SELECT target.id
FROM channels target
JOIN channels source
  ON source.id = target.linked_chat_id
 AND NOT source.deleted
 AND source.broadcast
 AND source.linked_chat_id = target.id
JOIN channel_members source_member
  ON source_member.channel_id = source.id
 AND source_member.user_id = $1
 AND source_member.status = 'active'
 AND NOT COALESCE((source_member.banned_rights->>'ViewMessages')::boolean, false)
LEFT JOIN channel_members target_member
  ON target_member.channel_id = target.id
 AND target_member.user_id = $1
WHERE target.id = ANY($2::bigint[])
  AND NOT target.deleted
  AND target.megagroup
  AND NOT target.broadcast
  AND (
    target_member.user_id IS NULL
    OR (
      target_member.status NOT IN ('active', 'banned', 'kicked')
      AND NOT COALESCE((target_member.banned_rights->>'ViewMessages')::boolean, false)
    )
  )
ORDER BY target.id`, viewerUserID, targetIDs)
	if err != nil {
		return nil, fmt.Errorf("list linked discussion guests: %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		var channelID int64
		if err := rows.Scan(&channelID); err != nil {
			return nil, fmt.Errorf("scan linked discussion guest: %w", err)
		}
		out[channelID] = domain.ChannelMember{
			ChannelID: channelID,
			UserID:    viewerUserID,
			Status:    domain.ChannelMemberLeft,
			Role:      domain.ChannelRoleMember,
			Guest:     true,
		}
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate linked discussion guests: %w", err)
	}
	return out, nil
}

func (s *ChannelStore) getPublicPreviewMember(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, ch domain.Channel) (domain.ChannelMember, error) {
	member, err := s.getChannelMember(ctx, db, ch.ID, viewerUserID)
	if err != nil {
		if errors.Is(err, domain.ErrChannelPrivate) {
			return publicPreviewMember(ch, viewerUserID, domain.ChannelMember{}, false), nil
		}
		return domain.ChannelMember{}, err
	}
	if member.Status == domain.ChannelMemberBanned || member.Status == domain.ChannelMemberKicked || member.BannedRights.ViewMessages {
		return domain.ChannelMember{}, domain.ErrChannelUserBanned
	}
	return publicPreviewMember(ch, viewerUserID, member, true), nil
}

func (s *ChannelStore) getChannelMember(ctx context.Context, db sqlcgen.DBTX, channelID, userID int64) (domain.ChannelMember, error) {
	if s.memberCacheActive(db) {
		return s.memberCache.getOrLoad(ctx, channelID, userID, func() (domain.ChannelMember, error) {
			return getChannelMemberByID(ctx, db, channelID, userID)
		})
	}
	return getChannelMemberByID(ctx, db, channelID, userID)
}

func getChannelMemberByID(ctx context.Context, db sqlcgen.DBTX, channelID, userID int64) (domain.ChannelMember, error) {
	row := db.QueryRow(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at,
       admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts,
       history_clear_anchor_id, history_clear_anchor_date,
       read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
FROM channel_members
WHERE channel_id = $1 AND user_id = $2`, channelID, userID)
	member, err := scanChannelMember(row)
	if errors.Is(err, pgx.ErrNoRows) {
		return domain.ChannelMember{}, domain.ErrChannelPrivate
	}
	if err != nil {
		return domain.ChannelMember{}, err
	}
	return member, nil
}

func upsertChannelMemberTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, member domain.ChannelMember) error {
	adminRights, err := marshalJSON(member.AdminRights, "{}")
	if err != nil {
		return err
	}
	bannedRights, err := marshalJSON(member.BannedRights, "{}")
	if err != nil {
		return err
	}
	if _, err := tx.Exec(ctx, `
INSERT INTO channel_members (
    channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights, banned_rights,
    rank, available_min_id, available_min_pts, history_clear_anchor_id, history_clear_anchor_date,
    read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
ON CONFLICT (channel_id, user_id) DO UPDATE SET
    inviter_user_id = EXCLUDED.inviter_user_id,
    role = EXCLUDED.role,
    status = EXCLUDED.status,
    joined_at = EXCLUDED.joined_at,
    left_at = EXCLUDED.left_at,
    admin_rights = EXCLUDED.admin_rights,
    banned_rights = EXCLUDED.banned_rights,
    rank = EXCLUDED.rank,
    available_min_id = GREATEST(channel_members.available_min_id, EXCLUDED.available_min_id),
    available_min_pts = GREATEST(channel_members.available_min_pts, EXCLUDED.available_min_pts),
    read_inbox_max_id = GREATEST(channel_members.read_inbox_max_id, EXCLUDED.read_inbox_max_id),
    updated_at = now()`,
		member.ChannelID, member.UserID, member.InviterUserID, string(member.Role), string(member.Status),
		member.JoinedAt, member.LeftAt, adminRights, bannedRights, member.Rank, member.AvailableMinID,
		member.AvailableMinPts, member.HistoryClearAnchorID, member.HistoryClearAnchorDate,
		member.ReadInboxMaxID, member.ReadOutboxMaxID, member.UnreadMark, member.SlowmodeLastSendDate); err != nil {
		return fmt.Errorf("upsert channel member: %w", err)
	}
	return upsertUserChannelMemberIndexTx(ctx, tx, channel, member)
}

// resetPrehistoryBoundaryTx zeroes a purely prehistory-sourced read boundary when a user "becomes a member again".
// available_min_id only increases (upsert/batch/clear history are all GREATEST), so when re-joining or
// being re-added by an admin, if the channel has hidden history disabled it must be explicitly lowered, otherwise the member permanently sees an empty channel.
// A boundary carrying history_clear_anchor comes from the member's own "clear history" and has owner-local semantics; it is not reset.
func resetPrehistoryBoundaryTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, userID int64) error {
	if channel.PreHistoryHidden || userID == 0 {
		return nil
	}
	if _, err := tx.Exec(ctx, `
UPDATE channel_members
SET available_min_id = 0,
    updated_at = now()
WHERE channel_id = $1 AND user_id = $2
  AND available_min_id > 0
  AND COALESCE(history_clear_anchor_id, 0) = 0`, channel.ID, userID); err != nil {
		return fmt.Errorf("reset prehistory member boundary: %w", err)
	}
	return nil
}

func upsertUserChannelMemberIndexTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, member domain.ChannelMember) error {
	if channel.ID == 0 || member.UserID == 0 {
		return nil
	}
	if _, err := tx.Exec(ctx, `
INSERT INTO user_channel_member_index (
    user_id, channel_id, status, megagroup, broadcast, deleted,
    role, left_at, forum, public_username, can_pin_messages
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (user_id, channel_id) DO UPDATE SET
    status = EXCLUDED.status,
    megagroup = EXCLUDED.megagroup,
    broadcast = EXCLUDED.broadcast,
    deleted = EXCLUDED.deleted,
    role = EXCLUDED.role,
    left_at = EXCLUDED.left_at,
    forum = EXCLUDED.forum,
    public_username = EXCLUDED.public_username,
    can_pin_messages = EXCLUDED.can_pin_messages,
    updated_at = now()`,
		member.UserID, channel.ID, string(member.Status), channel.Megagroup, channel.Broadcast, channel.Deleted,
		string(member.Role), member.LeftAt, channel.Forum, channel.Username != "", channelMemberCanPinMessages(member)); err != nil {
		return fmt.Errorf("upsert user channel member index: %w", err)
	}
	return nil
}

func channelMemberCanPinMessages(member domain.ChannelMember) bool {
	return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages)
}

func markUserChannelMemberIndexDeletedTx(ctx context.Context, tx pgx.Tx, channelID int64, deleted bool) error {
	if channelID == 0 {
		return nil
	}
	if _, err := tx.Exec(ctx, `
UPDATE user_channel_member_index
SET deleted = $2, updated_at = now()
WHERE channel_id = $1`, channelID, deleted); err != nil {
		return fmt.Errorf("mark user channel member index deleted: %w", err)
	}
	return nil
}

func markUserChannelMemberIndexPublicTx(ctx context.Context, tx pgx.Tx, channelID int64, public bool) error {
	if channelID == 0 {
		return nil
	}
	if _, err := tx.Exec(ctx, `
UPDATE user_channel_member_index
SET public_username = $2, updated_at = now()
WHERE channel_id = $1`, channelID, public); err != nil {
		return fmt.Errorf("mark user channel member index public: %w", err)
	}
	return nil
}

func markUserChannelMemberIndexForumTx(ctx context.Context, tx pgx.Tx, channelID int64, forum bool) error {
	if channelID == 0 {
		return nil
	}
	if _, err := tx.Exec(ctx, `
UPDATE user_channel_member_index
SET forum = $2, updated_at = now()
WHERE channel_id = $1`, channelID, forum); err != nil {
		return fmt.Errorf("mark user channel member index forum: %w", err)
	}
	return nil
}

func scanChannelWithMember(row rowScanner) (domain.Channel, domain.ChannelMember, error) {
	var ch domain.Channel
	var member domain.ChannelMember
	var defaultRights, reactionPolicy, adminRights, bannedRights string
	var wallpaper *string
	var role, status string
	dest := append(channelScanDest(&ch, &defaultRights, &reactionPolicy, &wallpaper),
		&member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status,
		&member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank,
		&member.AvailableMinID, &member.AvailableMinPts, &member.HistoryClearAnchorID, &member.HistoryClearAnchorDate,
		&member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
	)
	if err := row.Scan(dest...); err != nil {
		return domain.Channel{}, domain.ChannelMember{}, err
	}
	member.Role = domain.ChannelMemberRole(role)
	member.Status = domain.ChannelMemberStatus(status)
	finishChannelScan(&ch, defaultRights, reactionPolicy, wallpaper)
	_ = json.Unmarshal([]byte(adminRights), &member.AdminRights)
	_ = json.Unmarshal([]byte(bannedRights), &member.BannedRights)
	return ch, member, nil
}

func scanChannelWithViewerMember(row rowScanner) (domain.Channel, bool, error) {
	var ch domain.Channel
	var viewerMember bool
	var rights, reactionPolicy string
	var wallpaper *string
	dest := append(channelScanDest(&ch, &rights, &reactionPolicy, &wallpaper),
		&viewerMember,
	)
	if err := row.Scan(dest...); err != nil {
		return domain.Channel{}, false, err
	}
	finishChannelScan(&ch, rights, reactionPolicy, wallpaper)
	return ch, viewerMember, nil
}

func scanChannelMember(row rowScanner) (domain.ChannelMember, error) {
	var member domain.ChannelMember
	var adminRights, bannedRights string
	var role, status string
	if err := row.Scan(
		&member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status,
		&member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank,
		&member.AvailableMinID, &member.AvailableMinPts, &member.HistoryClearAnchorID, &member.HistoryClearAnchorDate,
		&member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
	); err != nil {
		return domain.ChannelMember{}, err
	}
	member.Role = domain.ChannelMemberRole(role)
	member.Status = domain.ChannelMemberStatus(status)
	_ = json.Unmarshal([]byte(adminRights), &member.AdminRights)
	_ = json.Unmarshal([]byte(bannedRights), &member.BannedRights)
	return member, nil
}

func scanChannelMemberWithCount(row rowScanner) (domain.ChannelMember, int, error) {
	var member domain.ChannelMember
	var adminRights, bannedRights string
	var role, status string
	var count int
	if err := row.Scan(
		&member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status,
		&member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank,
		&member.AvailableMinID, &member.AvailableMinPts, &member.HistoryClearAnchorID, &member.HistoryClearAnchorDate,
		&member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
		&count,
	); err != nil {
		return domain.ChannelMember{}, 0, err
	}
	member.Role = domain.ChannelMemberRole(role)
	member.Status = domain.ChannelMemberStatus(status)
	_ = json.Unmarshal([]byte(adminRights), &member.AdminRights)
	_ = json.Unmarshal([]byte(bannedRights), &member.BannedRights)
	return member, count, nil
}

func validateChannelMemberVisible(member domain.ChannelMember) error {
	switch member.Status {
	case domain.ChannelMemberActive:
		if member.BannedRights.ViewMessages {
			return domain.ErrChannelUserBanned
		}
		return nil
	case domain.ChannelMemberBanned, domain.ChannelMemberKicked:
		return domain.ErrChannelUserBanned
	default:
		return domain.ErrChannelPrivate
	}
}

func canPostChannel(member domain.ChannelMember) bool {
	return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PostMessages)
}

func isChannelAdmin(member domain.ChannelMember) bool {
	return member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin
}

func canManageDiscussionBroadcast(member domain.ChannelMember) bool {
	return canChangeChannelInfo(member)
}

func canManageDiscussionGroup(member domain.ChannelMember) bool {
	return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages)
}

func canAddChannelAdmins(member domain.ChannelMember) bool {
	return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.AddAdmins)
}

func canBanChannelUsers(member domain.ChannelMember) bool {
	return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.BanUsers)
}

func publicPreviewMember(channel domain.Channel, userID int64, existing domain.ChannelMember, found bool) domain.ChannelMember {
	member := domain.ChannelMember{
		ChannelID:       channel.ID,
		UserID:          userID,
		Role:            domain.ChannelRoleMember,
		Status:          domain.ChannelMemberLeft,
		AvailableMinID:  channelInitialAvailableMinID(channel),
		AvailableMinPts: channelInitialAvailableMinPts(channel),
		ReadInboxMaxID:  channel.TopMessageID,
		ReadOutboxMaxID: channel.TopMessageID,
	}
	if found {
		member.InviterUserID = existing.InviterUserID
		member.JoinedAt = existing.JoinedAt
		member.LeftAt = existing.LeftAt
		member.AvailableMinID = maxInt(member.AvailableMinID, existing.AvailableMinID)
		member.AvailableMinPts = maxInt(member.AvailableMinPts, existing.AvailableMinPts)
		member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, existing.ReadInboxMaxID)
		member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, existing.ReadOutboxMaxID)
	}
	return member
}

func (s *ChannelStore) monoforumAdminPreview(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, mono domain.Channel) (domain.ChannelMember, domain.Channel, bool, error) {
	if viewerUserID == 0 || !mono.Monoforum || mono.LinkedMonoforumID == 0 {
		return domain.ChannelMember{}, domain.Channel{}, false, nil
	}
	parent, parentMember, err := s.getChannelForMember(ctx, db, viewerUserID, mono.LinkedMonoforumID)
	if err != nil {
		if errors.Is(err, domain.ErrChannelPrivate) {
			return domain.ChannelMember{}, domain.Channel{}, false, nil
		}
		return domain.ChannelMember{}, domain.Channel{}, false, err
	}
	if !parentMember.CanManageDirectMessages() {
		return domain.ChannelMember{}, domain.Channel{}, false, nil
	}
	return syntheticMonoforumAdminMember(mono, parentMember), parent, true, nil
}

func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.ChannelMember) domain.ChannelMember {
	member := parentMember
	member.ChannelID = mono.ID
	member.Status = domain.ChannelMemberActive
	if mono.CreatorUserID == parentMember.UserID {
		member.Role = domain.ChannelRoleCreator
	} else {
		member.Role = domain.ChannelRoleAdmin
	}
	member.AvailableMinID = 0
	member.AvailableMinPts = 0
	member.HistoryClearAnchorID = 0
	member.HistoryClearAnchorDate = 0
	member.ReadInboxMaxID = mono.TopMessageID
	member.ReadOutboxMaxID = mono.TopMessageID
	member.UnreadMark = false
	member.SlowmodeLastSendDate = 0
	return member
}

func syntheticMonoforumUserMember(mono domain.Channel, userID int64) domain.ChannelMember {
	return domain.ChannelMember{
		ChannelID: mono.ID,
		UserID:    userID,
		Role:      domain.ChannelRoleMember,
		Status:    domain.ChannelMemberActive,
	}
}

func zeroChannelAdminRights(rights domain.ChannelAdminRights) bool {
	return rights == domain.ChannelAdminRights{}
}

func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool {
	return rights == domain.ChannelBannedRights{}
}

func creatorChannelMember(channelID, userID int64, date int) domain.ChannelMember {
	return domain.ChannelMember{
		ChannelID:   channelID,
		UserID:      userID,
		Role:        domain.ChannelRoleCreator,
		Status:      domain.ChannelMemberActive,
		JoinedAt:    date,
		AdminRights: domain.CreatorChannelAdminRights(),
	}
}

func channelMemberIDs(members []domain.ChannelMember) []int64 {
	out := make([]int64, 0, len(members))
	for _, member := range members {
		if member.UserID != 0 {
			out = append(out, member.UserID)
		}
	}
	return out
}