851 lines
32 KiB
Go
851 lines
32 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"telesrv/internal/domain"
|
|
)
|
|
|
|
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
|
|
}
|