Ctrl K
ComunidadGuías…

internal/store/postgres/business.go

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

Archivo original ↗Preguntar a KiBot
package postgres

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

	"github.com/jackc/pgx/v5"

	"telesrv/internal/domain"
)

func (s *PasswordStore) HasBusinessAutomation(ctx context.Context, userID int64) (bool, error) {
	var exists bool
	err := s.db.QueryRow(ctx, `
SELECT EXISTS (
  SELECT 1
  FROM user_business_profiles
  WHERE user_id = $1
    AND (greeting_message <> '{}'::jsonb OR away_message <> '{}'::jsonb)
  UNION ALL
  SELECT 1
  FROM business_connected_bots
  WHERE owner_user_id = $1
)`, userID).Scan(&exists)
	if err != nil {
		return false, fmt.Errorf("check business automation: %w", err)
	}
	return exists, nil
}

func (s *PasswordStore) GetBusinessProfile(ctx context.Context, userID int64) (domain.BusinessProfile, bool, error) {
	row := s.db.QueryRow(ctx, `
SELECT
  COALESCE(work_hours::text, '{}')::text,
  COALESCE(location::text, '{}')::text,
  COALESCE(intro::text, '{}')::text,
  COALESCE(greeting_message::text, '{}')::text,
  COALESCE(away_message::text, '{}')::text,
  COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint
FROM user_business_profiles
WHERE user_id = $1`, userID)
	var workHoursJSON, locationJSON, introJSON, greetingJSON, awayJSON string
	profile := domain.BusinessProfile{UserID: userID}
	if err := row.Scan(&workHoursJSON, &locationJSON, &introJSON, &greetingJSON, &awayJSON, &profile.UpdatedAtUnix); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.BusinessProfile{}, false, nil
		}
		return domain.BusinessProfile{}, false, fmt.Errorf("get business profile: %w", err)
	}
	if err := decodeNullableJSON(workHoursJSON, &profile.WorkHours); err != nil {
		return domain.BusinessProfile{}, false, fmt.Errorf("decode work hours: %w", err)
	}
	if err := decodeNullableJSON(locationJSON, &profile.Location); err != nil {
		return domain.BusinessProfile{}, false, fmt.Errorf("decode location: %w", err)
	}
	if err := decodeNullableJSON(introJSON, &profile.Intro); err != nil {
		return domain.BusinessProfile{}, false, fmt.Errorf("decode intro: %w", err)
	}
	if err := decodeNullableJSON(greetingJSON, &profile.Greeting); err != nil {
		return domain.BusinessProfile{}, false, fmt.Errorf("decode greeting: %w", err)
	}
	if err := decodeNullableJSON(awayJSON, &profile.Away); err != nil {
		return domain.BusinessProfile{}, false, fmt.Errorf("decode away: %w", err)
	}
	return profile, true, nil
}

func (s *PasswordStore) SaveBusinessProfile(ctx context.Context, profile domain.BusinessProfile) error {
	workHours, err := encodeNullableJSON(profile.WorkHours)
	if err != nil {
		return err
	}
	location, err := encodeNullableJSON(profile.Location)
	if err != nil {
		return err
	}
	intro, err := encodeNullableJSON(profile.Intro)
	if err != nil {
		return err
	}
	greeting, err := encodeNullableJSON(profile.Greeting)
	if err != nil {
		return err
	}
	away, err := encodeNullableJSON(profile.Away)
	if err != nil {
		return err
	}
	if _, err := s.db.Exec(ctx, `
INSERT INTO user_business_profiles (
  user_id, work_hours, location, intro, greeting_message, away_message, updated_at
) VALUES ($1,$2::jsonb,$3::jsonb,$4::jsonb,$5::jsonb,$6::jsonb,now())
ON CONFLICT (user_id) DO UPDATE SET
  work_hours = EXCLUDED.work_hours,
  location = EXCLUDED.location,
  intro = EXCLUDED.intro,
  greeting_message = EXCLUDED.greeting_message,
  away_message = EXCLUDED.away_message,
  updated_at = now()`, profile.UserID, string(workHours), string(location), string(intro), string(greeting), string(away)); err != nil {
		return fmt.Errorf("save business profile: %w", err)
	}
	return nil
}

func (s *PasswordStore) ListBusinessChatLinks(ctx context.Context, ownerUserID int64) ([]domain.BusinessChatLink, error) {
	rows, err := s.db.Query(ctx, `
SELECT slug, owner_user_id, link, message, COALESCE(entities::text, '[]')::text, title, views,
       COALESCE(EXTRACT(EPOCH FROM created_at), 0)::bigint,
       COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint
FROM business_chat_links
WHERE owner_user_id = $1
ORDER BY created_at ASC, slug ASC
LIMIT $2`, ownerUserID, domain.MaxBusinessChatLinks)
	if err != nil {
		return nil, fmt.Errorf("list business chat links: %w", err)
	}
	defer rows.Close()
	out := make([]domain.BusinessChatLink, 0)
	for rows.Next() {
		link, err := scanBusinessChatLink(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, link)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("scan business chat links: %w", err)
	}
	return out, nil
}

func (s *PasswordStore) CreateBusinessChatLink(ctx context.Context, link domain.BusinessChatLink) (domain.BusinessChatLink, error) {
	return link, withTx(ctx, s.db, "create business chat link", func(tx pgx.Tx) error {
		var count int
		if err := tx.QueryRow(ctx, `SELECT COUNT(*)::int FROM business_chat_links WHERE owner_user_id = $1`, link.OwnerUserID).Scan(&count); err != nil {
			return fmt.Errorf("count business chat links: %w", err)
		}
		if count >= domain.MaxBusinessChatLinks {
			return domain.ErrBusinessChatLinksTooMuch
		}
		entities, err := encodeMessageEntities(link.Entities)
		if err != nil {
			return err
		}
		row := tx.QueryRow(ctx, `
INSERT INTO business_chat_links (slug, owner_user_id, link, message, entities, title, views, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5::jsonb,$6,$7,now(),now())
RETURNING slug, owner_user_id, link, message, COALESCE(entities::text, '[]')::text, title, views,
          COALESCE(EXTRACT(EPOCH FROM created_at), 0)::bigint,
          COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint`,
			link.Slug, link.OwnerUserID, link.Link, link.Message, string(entities), link.Title, link.Views)
		saved, err := scanBusinessChatLink(row)
		if err != nil {
			return fmt.Errorf("insert business chat link: %w", err)
		}
		link = saved
		return nil
	})
}

func (s *PasswordStore) UpdateBusinessChatLink(ctx context.Context, ownerUserID int64, slug string, input domain.BusinessChatLinkInput) (domain.BusinessChatLink, error) {
	entities, err := encodeMessageEntities(input.Entities)
	if err != nil {
		return domain.BusinessChatLink{}, err
	}
	row := s.db.QueryRow(ctx, `
UPDATE business_chat_links
SET message = $3,
    entities = $4::jsonb,
    title = $5,
    updated_at = now()
WHERE owner_user_id = $1
  AND slug = $2
RETURNING slug, owner_user_id, link, message, COALESCE(entities::text, '[]')::text, title, views,
          COALESCE(EXTRACT(EPOCH FROM created_at), 0)::bigint,
          COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint`, ownerUserID, slug, input.Message, string(entities), input.Title)
	link, err := scanBusinessChatLink(row)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.BusinessChatLink{}, domain.ErrBusinessChatLinkNotFound
		}
		return domain.BusinessChatLink{}, fmt.Errorf("update business chat link: %w", err)
	}
	return link, nil
}

func (s *PasswordStore) DeleteBusinessChatLink(ctx context.Context, ownerUserID int64, slug string) (bool, error) {
	tag, err := s.db.Exec(ctx, `DELETE FROM business_chat_links WHERE owner_user_id = $1 AND slug = $2`, ownerUserID, slug)
	if err != nil {
		return false, fmt.Errorf("delete business chat link: %w", err)
	}
	return tag.RowsAffected() > 0, nil
}

func (s *PasswordStore) ResolveBusinessChatLink(ctx context.Context, slug string, bumpViews bool) (domain.BusinessChatLink, bool, error) {
	sqlText := `
SELECT slug, owner_user_id, link, message, COALESCE(entities::text, '[]')::text, title, views,
       COALESCE(EXTRACT(EPOCH FROM created_at), 0)::bigint,
       COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint
FROM business_chat_links
WHERE slug = $1`
	if bumpViews {
		sqlText = `
UPDATE business_chat_links
SET views = views + 1,
    updated_at = now()
WHERE slug = $1
RETURNING slug, owner_user_id, link, message, COALESCE(entities::text, '[]')::text, title, views,
          COALESCE(EXTRACT(EPOCH FROM created_at), 0)::bigint,
          COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint`
	}
	link, err := scanBusinessChatLink(s.db.QueryRow(ctx, sqlText, slug))
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.BusinessChatLink{}, false, nil
		}
		return domain.BusinessChatLink{}, false, fmt.Errorf("resolve business chat link: %w", err)
	}
	return link, true, nil
}

func (s *PasswordStore) ListQuickReplies(ctx context.Context, ownerUserID int64, includeTopMessages bool) (domain.QuickReplyList, error) {
	replies, err := s.listQuickReplies(ctx, ownerUserID)
	if err != nil {
		return domain.QuickReplyList{}, err
	}
	messages := make([]domain.QuickReplyMessage, 0, len(replies))
	if includeTopMessages {
		for _, reply := range replies {
			if reply.TopMessage == 0 {
				continue
			}
			msgs, err := s.GetQuickReplyMessages(ctx, ownerUserID, reply.ID, []int{reply.TopMessage})
			if err != nil {
				return domain.QuickReplyList{}, err
			}
			messages = append(messages, msgs.Messages...)
		}
	}
	return domain.QuickReplyList{
		OwnerUserID:  ownerUserID,
		QuickReplies: replies,
		Messages:     messages,
		Hash:         postgresQuickReplyListHash(replies),
	}, nil
}

func (s *PasswordStore) CheckQuickReplyShortcut(ctx context.Context, ownerUserID int64, shortcut string) (bool, error) {
	shortcut, err := domain.NormalizeQuickReplyShortcut(shortcut)
	if err != nil {
		return false, err
	}
	var id int
	err = s.db.QueryRow(ctx, `SELECT shortcut_id FROM quick_replies WHERE owner_user_id = $1 AND lower(shortcut) = lower($2)`, ownerUserID, shortcut).Scan(&id)
	if errors.Is(err, pgx.ErrNoRows) {
		return true, nil
	}
	if err != nil {
		return false, fmt.Errorf("check quick reply shortcut: %w", err)
	}
	return false, nil
}

func (s *PasswordStore) SaveQuickReplyText(ctx context.Context, ownerUserID int64, shortcut string, msg domain.QuickReplyMessage) (domain.QuickReplyMutation, error) {
	shortcut, err := domain.NormalizeQuickReplyShortcut(shortcut)
	if err != nil {
		return domain.QuickReplyMutation{}, err
	}
	var mutation domain.QuickReplyMutation
	err = withTx(ctx, s.db, "save quick reply text", func(tx pgx.Tx) error {
		shortcutID, created, err := ensureQuickReplyShortcut(ctx, tx, ownerUserID, shortcut)
		if err != nil {
			return err
		}
		var count int
		if err := tx.QueryRow(ctx, `SELECT COUNT(*)::int FROM quick_reply_messages WHERE owner_user_id = $1 AND shortcut_id = $2`, ownerUserID, shortcutID).Scan(&count); err != nil {
			return fmt.Errorf("count quick reply messages: %w", err)
		}
		if count >= domain.MaxQuickReplyMessages {
			return domain.ErrShortcutInvalid
		}
		var messageID int
		if err := tx.QueryRow(ctx, `SELECT COALESCE(MAX(message_id), 0)::int + 1 FROM quick_reply_messages WHERE owner_user_id = $1`, ownerUserID).Scan(&messageID); err != nil {
			return fmt.Errorf("allocate quick reply message id: %w", err)
		}
		entities, err := encodeMessageEntities(msg.Entities)
		if err != nil {
			return err
		}
		if _, err := tx.Exec(ctx, `
INSERT INTO quick_reply_messages (owner_user_id, shortcut_id, message_id, random_id, message_date, body, entities, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,now(),now())`,
			ownerUserID, shortcutID, messageID, msg.RandomID, msg.Date, msg.Message, string(entities)); err != nil {
			return fmt.Errorf("insert quick reply message: %w", err)
		}
		replies, err := listQuickRepliesTx(ctx, tx, ownerUserID)
		if err != nil {
			return err
		}
		message := domain.QuickReplyMessage{
			OwnerUserID: ownerUserID,
			ShortcutID:  shortcutID,
			ID:          messageID,
			RandomID:    msg.RandomID,
			Date:        msg.Date,
			Message:     msg.Message,
			Entities:    append([]domain.MessageEntity(nil), msg.Entities...),
		}
		reply := quickReplyByID(replies, shortcutID)
		kind := domain.QuickReplyMutationMessage
		if created {
			kind = domain.QuickReplyMutationNew
		}
		mutation = domain.QuickReplyMutation{
			Kind:       kind,
			List:       domain.QuickReplyList{OwnerUserID: ownerUserID, QuickReplies: replies, Messages: []domain.QuickReplyMessage{message}, Hash: postgresQuickReplyListHash(replies)},
			QuickReply: reply,
			ShortcutID: shortcutID,
			Message:    message,
		}
		return nil
	})
	if err != nil {
		return domain.QuickReplyMutation{}, err
	}
	return mutation, nil
}

func (s *PasswordStore) GetQuickReplyMessages(ctx context.Context, ownerUserID int64, shortcutID int, ids []int) (domain.QuickReplyMessages, error) {
	if err := s.ensureQuickReplyExists(ctx, ownerUserID, shortcutID); err != nil {
		return domain.QuickReplyMessages{}, err
	}
	args := []any{ownerUserID, shortcutID}
	filter := ""
	if len(ids) > 0 {
		ids32 := make([]int32, len(ids))
		for i, id := range ids {
			ids32[i] = int32(id)
		}
		filter = " AND message_id = ANY($3::int[])"
		args = append(args, ids32)
	}
	rows, err := s.db.Query(ctx, `
SELECT message_id, random_id, message_date, body, COALESCE(entities::text, '[]')::text
FROM quick_reply_messages
WHERE owner_user_id = $1
  AND shortcut_id = $2`+filter+`
ORDER BY message_id ASC`, args...)
	if err != nil {
		return domain.QuickReplyMessages{}, fmt.Errorf("list quick reply messages: %w", err)
	}
	defer rows.Close()
	out := domain.QuickReplyMessages{OwnerUserID: ownerUserID, ShortcutID: shortcutID}
	for rows.Next() {
		msg, err := scanQuickReplyMessage(ownerUserID, shortcutID, rows)
		if err != nil {
			return domain.QuickReplyMessages{}, err
		}
		out.Messages = append(out.Messages, msg)
	}
	if err := rows.Err(); err != nil {
		return domain.QuickReplyMessages{}, fmt.Errorf("scan quick reply messages: %w", err)
	}
	var total int
	if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM quick_reply_messages WHERE owner_user_id = $1 AND shortcut_id = $2`, ownerUserID, shortcutID).Scan(&total); err != nil {
		return domain.QuickReplyMessages{}, fmt.Errorf("count quick reply messages: %w", err)
	}
	if len(ids) > 0 && len(out.Messages) != len(ids) {
		return domain.QuickReplyMessages{}, domain.ErrShortcutInvalid
	}
	out.Count = total
	out.Hash = postgresQuickReplyMessagesHash(out.Messages)
	return out, nil
}

func (s *PasswordStore) RenameQuickReplyShortcut(ctx context.Context, ownerUserID int64, shortcutID int, shortcut string) (domain.QuickReplyMutation, error) {
	shortcut, err := domain.NormalizeQuickReplyShortcut(shortcut)
	if err != nil {
		return domain.QuickReplyMutation{}, err
	}
	tag, err := s.db.Exec(ctx, `
UPDATE quick_replies
SET shortcut = $3,
    updated_at = now()
WHERE owner_user_id = $1
  AND shortcut_id = $2`, ownerUserID, shortcutID, shortcut)
	if err != nil {
		return domain.QuickReplyMutation{}, quickReplyPGErr(err)
	}
	if tag.RowsAffected() == 0 {
		return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
	}
	list, err := s.ListQuickReplies(ctx, ownerUserID, true)
	if err != nil {
		return domain.QuickReplyMutation{}, err
	}
	return domain.QuickReplyMutation{Kind: domain.QuickReplyMutationList, List: list}, nil
}

func (s *PasswordStore) ReorderQuickReplies(ctx context.Context, ownerUserID int64, order []int) (domain.QuickReplyMutation, error) {
	err := withTx(ctx, s.db, "reorder quick replies", func(tx pgx.Tx) error {
		replies, err := listQuickRepliesTx(ctx, tx, ownerUserID)
		if err != nil {
			return err
		}
		if len(order) != len(replies) {
			return domain.ErrShortcutInvalid
		}
		seen := make(map[int]struct{}, len(order))
		byID := make(map[int]struct{}, len(replies))
		for _, reply := range replies {
			byID[reply.ID] = struct{}{}
		}
		for i, id := range order {
			if _, ok := byID[id]; !ok {
				return domain.ErrShortcutInvalid
			}
			if _, ok := seen[id]; ok {
				return domain.ErrShortcutInvalid
			}
			seen[id] = struct{}{}
			if _, err := tx.Exec(ctx, `UPDATE quick_replies SET sort_order = $3, updated_at = now() WHERE owner_user_id = $1 AND shortcut_id = $2`, ownerUserID, id, i+1); err != nil {
				return fmt.Errorf("update quick reply order: %w", err)
			}
		}
		return nil
	})
	if err != nil {
		return domain.QuickReplyMutation{}, err
	}
	list, err := s.ListQuickReplies(ctx, ownerUserID, true)
	if err != nil {
		return domain.QuickReplyMutation{}, err
	}
	return domain.QuickReplyMutation{Kind: domain.QuickReplyMutationList, List: list}, nil
}

func (s *PasswordStore) DeleteQuickReplyShortcut(ctx context.Context, ownerUserID int64, shortcutID int) (domain.QuickReplyMutation, error) {
	tag, err := s.db.Exec(ctx, `DELETE FROM quick_replies WHERE owner_user_id = $1 AND shortcut_id = $2`, ownerUserID, shortcutID)
	if err != nil {
		return domain.QuickReplyMutation{}, fmt.Errorf("delete quick reply shortcut: %w", err)
	}
	if tag.RowsAffected() == 0 {
		return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
	}
	_ = s.normalizeQuickReplyOrder(ctx, ownerUserID)
	list, err := s.ListQuickReplies(ctx, ownerUserID, true)
	if err != nil {
		return domain.QuickReplyMutation{}, err
	}
	return domain.QuickReplyMutation{Kind: domain.QuickReplyMutationDelete, List: list, ShortcutID: shortcutID}, nil
}

func (s *PasswordStore) DeleteQuickReplyMessages(ctx context.Context, ownerUserID int64, shortcutID int, ids []int) (domain.QuickReplyMutation, error) {
	if len(ids) == 0 {
		return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
	}
	if err := s.ensureQuickReplyExists(ctx, ownerUserID, shortcutID); err != nil {
		return domain.QuickReplyMutation{}, err
	}
	ids32 := make([]int32, len(ids))
	for i, id := range ids {
		ids32[i] = int32(id)
	}
	tag, err := s.db.Exec(ctx, `DELETE FROM quick_reply_messages WHERE owner_user_id = $1 AND shortcut_id = $2 AND message_id = ANY($3::int[])`, ownerUserID, shortcutID, ids32)
	if err != nil {
		return domain.QuickReplyMutation{}, fmt.Errorf("delete quick reply messages: %w", err)
	}
	if int(tag.RowsAffected()) != len(ids) {
		return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
	}
	list, err := s.ListQuickReplies(ctx, ownerUserID, true)
	if err != nil {
		return domain.QuickReplyMutation{}, err
	}
	return domain.QuickReplyMutation{Kind: domain.QuickReplyMutationIDs, List: list, ShortcutID: shortcutID, MessageIDs: append([]int(nil), ids...)}, nil
}

func (s *PasswordStore) ReserveBusinessAutomationDelivery(ctx context.Context, delivery domain.BusinessAutomationDelivery) (bool, error) {
	if delivery.OwnerUserID == 0 || delivery.PeerUserID == 0 || delivery.Kind == "" || delivery.TriggerMessageID == 0 {
		return false, domain.ErrBusinessProfileInvalid
	}
	tag, err := s.db.Exec(ctx, `
INSERT INTO business_automation_deliveries (
  owner_user_id, peer_user_id, kind, trigger_message_id, shortcut_id, sent_at, created_at
) VALUES ($1,$2,$3,$4,$5,$6,now())
ON CONFLICT (owner_user_id, peer_user_id, kind, trigger_message_id) DO NOTHING`,
		delivery.OwnerUserID, delivery.PeerUserID, string(delivery.Kind), delivery.TriggerMessageID, delivery.ShortcutID, delivery.SentAt)
	if err != nil {
		return false, fmt.Errorf("reserve business automation delivery: %w", err)
	}
	return tag.RowsAffected() > 0, nil
}

func (s *PasswordStore) LastBusinessAutomationDelivery(ctx context.Context, ownerUserID, peerUserID int64, kind domain.BusinessAutomationKind) (domain.BusinessAutomationDelivery, bool, error) {
	row := s.db.QueryRow(ctx, `
SELECT owner_user_id, peer_user_id, kind, trigger_message_id, shortcut_id, sent_at
FROM business_automation_deliveries
WHERE owner_user_id = $1
  AND peer_user_id = $2
  AND kind = $3
ORDER BY sent_at DESC, trigger_message_id DESC
LIMIT 1`, ownerUserID, peerUserID, string(kind))
	var out domain.BusinessAutomationDelivery
	var kindText string
	if err := row.Scan(&out.OwnerUserID, &out.PeerUserID, &kindText, &out.TriggerMessageID, &out.ShortcutID, &out.SentAt); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.BusinessAutomationDelivery{}, false, nil
		}
		return domain.BusinessAutomationDelivery{}, false, fmt.Errorf("last business automation delivery: %w", err)
	}
	out.Kind = domain.BusinessAutomationKind(kindText)
	return out, true, nil
}

func (s *PasswordStore) GetConnectedBusinessBot(ctx context.Context, ownerUserID int64) (domain.ConnectedBusinessBot, bool, error) {
	row := s.db.QueryRow(ctx, `
SELECT owner_user_id, bot_user_id, COALESCE(recipients::text, '{}')::text, COALESCE(rights::text, '{}')::text,
       COALESCE(EXTRACT(EPOCH FROM created_at), 0)::bigint,
       COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint
FROM business_connected_bots
WHERE owner_user_id = $1`, ownerUserID)
	bot, err := scanConnectedBusinessBot(row)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.ConnectedBusinessBot{}, false, nil
		}
		return domain.ConnectedBusinessBot{}, false, fmt.Errorf("get connected business bot: %w", err)
	}
	return bot, true, nil
}

func (s *PasswordStore) SaveConnectedBusinessBot(ctx context.Context, bot domain.ConnectedBusinessBot) (domain.ConnectedBusinessBot, error) {
	recipients, err := json.Marshal(bot.Recipients)
	if err != nil {
		return domain.ConnectedBusinessBot{}, fmt.Errorf("encode connected business bot recipients: %w", err)
	}
	rights, err := json.Marshal(bot.Rights)
	if err != nil {
		return domain.ConnectedBusinessBot{}, fmt.Errorf("encode connected business bot rights: %w", err)
	}
	row := s.db.QueryRow(ctx, `
INSERT INTO business_connected_bots (owner_user_id, bot_user_id, recipients, rights, created_at, updated_at)
VALUES ($1,$2,$3::jsonb,$4::jsonb,now(),now())
ON CONFLICT (owner_user_id) DO UPDATE SET
  bot_user_id = EXCLUDED.bot_user_id,
  recipients = EXCLUDED.recipients,
  rights = EXCLUDED.rights,
  updated_at = now()
RETURNING owner_user_id, bot_user_id, COALESCE(recipients::text, '{}')::text, COALESCE(rights::text, '{}')::text,
          COALESCE(EXTRACT(EPOCH FROM created_at), 0)::bigint,
          COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint`, bot.OwnerUserID, bot.BotUserID, string(recipients), string(rights))
	saved, err := scanConnectedBusinessBot(row)
	if err != nil {
		return domain.ConnectedBusinessBot{}, fmt.Errorf("save connected business bot: %w", err)
	}
	return saved, nil
}

func (s *PasswordStore) DeleteConnectedBusinessBot(ctx context.Context, ownerUserID, botUserID int64) (bool, error) {
	deleted := false
	err := withTx(ctx, s.db, "delete connected business bot", func(tx pgx.Tx) error {
		tag, err := tx.Exec(ctx, `DELETE FROM business_connected_bots WHERE owner_user_id = $1 AND bot_user_id = $2`, ownerUserID, botUserID)
		if err != nil {
			return fmt.Errorf("delete connected business bot: %w", err)
		}
		deleted = tag.RowsAffected() > 0
		if !deleted {
			return nil
		}
		if _, err := tx.Exec(ctx, `DELETE FROM business_connected_bot_peer_states WHERE owner_user_id = $1`, ownerUserID); err != nil {
			return fmt.Errorf("delete connected business bot peer states: %w", err)
		}
		return nil
	})
	if err != nil {
		return false, err
	}
	return deleted, nil
}

func (s *PasswordStore) SetConnectedBusinessBotPaused(ctx context.Context, ownerUserID, peerUserID int64, paused bool) (domain.ConnectedBusinessBotPeerState, error) {
	row := s.db.QueryRow(ctx, `
INSERT INTO business_connected_bot_peer_states (owner_user_id, peer_user_id, paused, disabled, created_at, updated_at)
VALUES ($1,$2,$3,false,now(),now())
ON CONFLICT (owner_user_id, peer_user_id) DO UPDATE SET
  paused = EXCLUDED.paused,
  updated_at = now()
RETURNING owner_user_id, peer_user_id, paused, disabled,
          COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint`, ownerUserID, peerUserID, paused)
	state, err := scanConnectedBusinessBotPeerState(row)
	if err != nil {
		return domain.ConnectedBusinessBotPeerState{}, fmt.Errorf("set connected business bot paused: %w", err)
	}
	return state, nil
}

func (s *PasswordStore) DisableConnectedBusinessBotForPeer(ctx context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, error) {
	row := s.db.QueryRow(ctx, `
INSERT INTO business_connected_bot_peer_states (owner_user_id, peer_user_id, paused, disabled, created_at, updated_at)
VALUES ($1,$2,false,true,now(),now())
ON CONFLICT (owner_user_id, peer_user_id) DO UPDATE SET
  paused = false,
  disabled = true,
  updated_at = now()
RETURNING owner_user_id, peer_user_id, paused, disabled,
          COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint`, ownerUserID, peerUserID)
	state, err := scanConnectedBusinessBotPeerState(row)
	if err != nil {
		return domain.ConnectedBusinessBotPeerState{}, fmt.Errorf("disable connected business bot for peer: %w", err)
	}
	return state, nil
}

func (s *PasswordStore) GetConnectedBusinessBotPeerState(ctx context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, bool, error) {
	row := s.db.QueryRow(ctx, `
SELECT owner_user_id, peer_user_id, paused, disabled,
       COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint
FROM business_connected_bot_peer_states
WHERE owner_user_id = $1
  AND peer_user_id = $2`, ownerUserID, peerUserID)
	state, err := scanConnectedBusinessBotPeerState(row)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.ConnectedBusinessBotPeerState{}, false, nil
		}
		return domain.ConnectedBusinessBotPeerState{}, false, fmt.Errorf("get connected business bot peer state: %w", err)
	}
	return state, true, nil
}

func (s *PasswordStore) listQuickReplies(ctx context.Context, ownerUserID int64) ([]domain.QuickReply, error) {
	return listQuickRepliesDB(ctx, s.db, ownerUserID)
}

func listQuickRepliesTx(ctx context.Context, tx pgx.Tx, ownerUserID int64) ([]domain.QuickReply, error) {
	return listQuickRepliesDB(ctx, tx, ownerUserID)
}

func listQuickRepliesDB(ctx context.Context, db interface {
	Query(context.Context, string, ...interface{}) (pgx.Rows, error)
}, ownerUserID int64) ([]domain.QuickReply, error) {
	rows, err := db.Query(ctx, `
SELECT qr.shortcut_id, qr.shortcut, qr.sort_order,
       COALESCE(MAX(qm.message_id), 0)::int AS top_message,
       COUNT(qm.message_id)::int AS message_count,
       COALESCE(EXTRACT(EPOCH FROM qr.created_at), 0)::bigint,
       COALESCE(EXTRACT(EPOCH FROM qr.updated_at), 0)::bigint
FROM quick_replies qr
LEFT JOIN quick_reply_messages qm
  ON qm.owner_user_id = qr.owner_user_id
 AND qm.shortcut_id = qr.shortcut_id
WHERE qr.owner_user_id = $1
GROUP BY qr.shortcut_id, qr.shortcut, qr.sort_order, qr.created_at, qr.updated_at
ORDER BY qr.sort_order ASC, qr.shortcut_id ASC
LIMIT $2`, ownerUserID, domain.MaxQuickReplies)
	if err != nil {
		return nil, fmt.Errorf("list quick replies: %w", err)
	}
	defer rows.Close()
	out := make([]domain.QuickReply, 0)
	for rows.Next() {
		var item domain.QuickReply
		item.OwnerUserID = ownerUserID
		if err := rows.Scan(&item.ID, &item.Shortcut, &item.SortOrder, &item.TopMessage, &item.Count, &item.CreatedAt, &item.UpdatedAt); err != nil {
			return nil, fmt.Errorf("scan quick reply: %w", err)
		}
		out = append(out, item)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("scan quick replies: %w", err)
	}
	return out, nil
}

func ensureQuickReplyShortcut(ctx context.Context, tx pgx.Tx, ownerUserID int64, shortcut string) (int, bool, error) {
	var id int
	err := tx.QueryRow(ctx, `SELECT shortcut_id FROM quick_replies WHERE owner_user_id = $1 AND lower(shortcut) = lower($2)`, ownerUserID, shortcut).Scan(&id)
	if err == nil {
		return id, false, nil
	}
	if !errors.Is(err, pgx.ErrNoRows) {
		return 0, false, fmt.Errorf("get quick reply shortcut: %w", err)
	}
	var count int
	if err := tx.QueryRow(ctx, `SELECT COUNT(*)::int FROM quick_replies WHERE owner_user_id = $1`, ownerUserID).Scan(&count); err != nil {
		return 0, false, fmt.Errorf("count quick replies: %w", err)
	}
	if count >= domain.MaxQuickReplies {
		return 0, false, domain.ErrQuickRepliesTooMuch
	}
	if err := tx.QueryRow(ctx, `SELECT COALESCE(MAX(shortcut_id), 0)::int + 1 FROM quick_replies WHERE owner_user_id = $1`, ownerUserID).Scan(&id); err != nil {
		return 0, false, fmt.Errorf("allocate quick reply shortcut id: %w", err)
	}
	if _, err := tx.Exec(ctx, `
INSERT INTO quick_replies (owner_user_id, shortcut_id, shortcut, sort_order, created_at, updated_at)
VALUES ($1,$2,$3,$4,now(),now())`, ownerUserID, id, shortcut, count+1); err != nil {
		return 0, false, quickReplyPGErr(err)
	}
	return id, true, nil
}

func (s *PasswordStore) ensureQuickReplyExists(ctx context.Context, ownerUserID int64, shortcutID int) error {
	var id int
	err := s.db.QueryRow(ctx, `SELECT shortcut_id FROM quick_replies WHERE owner_user_id = $1 AND shortcut_id = $2`, ownerUserID, shortcutID).Scan(&id)
	if errors.Is(err, pgx.ErrNoRows) {
		return domain.ErrShortcutInvalid
	}
	if err != nil {
		return fmt.Errorf("get quick reply shortcut: %w", err)
	}
	return nil
}

func (s *PasswordStore) normalizeQuickReplyOrder(ctx context.Context, ownerUserID int64) error {
	return withTx(ctx, s.db, "normalize quick reply order", func(tx pgx.Tx) error {
		replies, err := listQuickRepliesTx(ctx, tx, ownerUserID)
		if err != nil {
			return err
		}
		for i, reply := range replies {
			if _, err := tx.Exec(ctx, `UPDATE quick_replies SET sort_order = $3 WHERE owner_user_id = $1 AND shortcut_id = $2`, ownerUserID, reply.ID, i+1); err != nil {
				return fmt.Errorf("normalize quick reply order: %w", err)
			}
		}
		return nil
	})
}

func scanBusinessChatLink(row interface {
	Scan(dest ...any) error
}) (domain.BusinessChatLink, error) {
	var link domain.BusinessChatLink
	var entitiesJSON string
	if err := row.Scan(&link.Slug, &link.OwnerUserID, &link.Link, &link.Message, &entitiesJSON, &link.Title, &link.Views, &link.CreatedAt, &link.UpdatedAt); err != nil {
		return domain.BusinessChatLink{}, err
	}
	entities, err := decodeMessageEntities(entitiesJSON)
	if err != nil {
		return domain.BusinessChatLink{}, fmt.Errorf("decode business chat link entities: %w", err)
	}
	link.Entities = entities
	return link, nil
}

func scanQuickReplyMessage(ownerUserID int64, shortcutID int, row interface {
	Scan(dest ...any) error
}) (domain.QuickReplyMessage, error) {
	var msg domain.QuickReplyMessage
	var entitiesJSON string
	msg.OwnerUserID = ownerUserID
	msg.ShortcutID = shortcutID
	if err := row.Scan(&msg.ID, &msg.RandomID, &msg.Date, &msg.Message, &entitiesJSON); err != nil {
		return domain.QuickReplyMessage{}, fmt.Errorf("scan quick reply message: %w", err)
	}
	entities, err := decodeMessageEntities(entitiesJSON)
	if err != nil {
		return domain.QuickReplyMessage{}, fmt.Errorf("decode quick reply entities: %w", err)
	}
	msg.Entities = entities
	return msg, nil
}

func scanConnectedBusinessBot(row interface {
	Scan(dest ...any) error
}) (domain.ConnectedBusinessBot, error) {
	var bot domain.ConnectedBusinessBot
	var recipientsJSON, rightsJSON string
	if err := row.Scan(&bot.OwnerUserID, &bot.BotUserID, &recipientsJSON, &rightsJSON, &bot.CreatedAtUnix, &bot.UpdatedAtUnix); err != nil {
		return domain.ConnectedBusinessBot{}, err
	}
	if recipientsJSON != "" && recipientsJSON != "{}" {
		if err := json.Unmarshal([]byte(recipientsJSON), &bot.Recipients); err != nil {
			return domain.ConnectedBusinessBot{}, fmt.Errorf("decode connected business bot recipients: %w", err)
		}
	}
	if rightsJSON != "" && rightsJSON != "{}" {
		if err := json.Unmarshal([]byte(rightsJSON), &bot.Rights); err != nil {
			return domain.ConnectedBusinessBot{}, fmt.Errorf("decode connected business bot rights: %w", err)
		}
	}
	return bot, nil
}

func scanConnectedBusinessBotPeerState(row interface {
	Scan(dest ...any) error
}) (domain.ConnectedBusinessBotPeerState, error) {
	var state domain.ConnectedBusinessBotPeerState
	if err := row.Scan(&state.OwnerUserID, &state.PeerUserID, &state.Paused, &state.Disabled, &state.UpdatedAtUnix); err != nil {
		return domain.ConnectedBusinessBotPeerState{}, err
	}
	return state, nil
}

func encodeNullableJSON[T any](value *T) ([]byte, error) {
	if value == nil {
		return []byte("{}"), nil
	}
	raw, err := json.Marshal(value)
	if err != nil {
		return nil, fmt.Errorf("encode business json: %w", err)
	}
	return raw, nil
}

func decodeNullableJSON[T any](raw string, out **T) error {
	if raw == "" || raw == "{}" {
		*out = nil
		return nil
	}
	var value T
	if err := json.Unmarshal([]byte(raw), &value); err != nil {
		return err
	}
	*out = &value
	return nil
}

func quickReplyByID(replies []domain.QuickReply, id int) domain.QuickReply {
	for _, reply := range replies {
		if reply.ID == id {
			return reply
		}
	}
	return domain.QuickReply{}
}

func postgresQuickReplyListHash(items []domain.QuickReply) int64 {
	h := fnv.New64a()
	for _, item := range items {
		postgresWriteHashInt(h, item.ID)
		postgresWriteHashString(h, item.Shortcut)
		postgresWriteHashInt(h, item.TopMessage)
		postgresWriteHashInt(h, item.Count)
	}
	return int64(h.Sum64() & 0x7fffffffffffffff)
}

func postgresQuickReplyMessagesHash(items []domain.QuickReplyMessage) int64 {
	h := fnv.New64a()
	for _, item := range items {
		postgresWriteHashInt(h, item.ID)
		postgresWriteHashString(h, item.Message)
		postgresWriteHashInt(h, item.Date)
	}
	return int64(h.Sum64() & 0x7fffffffffffffff)
}

func postgresWriteHashInt(h interface{ Write([]byte) (int, error) }, v int) {
	_, _ = h.Write([]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)})
}

func postgresWriteHashString(h interface{ Write([]byte) (int, error) }, v string) {
	_, _ = h.Write([]byte(v))
	_, _ = h.Write([]byte{0})
}

func quickReplyPGErr(err error) error {
	if err == nil {
		return nil
	}
	if strings.Contains(err.Error(), "quick_replies_owner_shortcut_lower_idx") {
		return domain.ErrShortcutOccupied
	}
	return err
}