package repository import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" ) // TelegramRepo — привязка Telegram, токены собственных ботов и выборка // заявок-кандидатов на уведомление (миграции 0011–0012). Держим отдельно // от профилей и заявок: тема одна, а SQL здесь — про «куда слать», // «чем слать» и «сколько уже слали». type TelegramRepo struct { db Querier } // NewTelegramRepo создаёт репозиторий поверх Querier (пул или транзакция). func NewTelegramRepo(db Querier) *TelegramRepo { return &TelegramRepo{db: db} } // Target — куда слать уведомления ("" — не настроено; NULL в базе // нормализуется в пустую строку). ErrNotFound — профиля нет. func (r *TelegramRepo) Target(ctx context.Context, userID string) (string, error) { var target *string err := r.db.QueryRow(ctx, `SELECT telegram_target FROM profiles WHERE id = $1`, userID).Scan(&target) if errors.Is(err, pgx.ErrNoRows) { return "", ErrNotFound } if err != nil { return "", fmt.Errorf("telegram target: %w", err) } if target == nil { return "", nil } return *target, nil } // SetTarget записывает цель уведомлений; пустая строка очищает // (NULLIF кладёт NULL — «отключено» физически, а не пустым текстом). // ErrNotFound — профиля нет (сессия могла пережить удаление). func (r *TelegramRepo) SetTarget(ctx context.Context, userID, target string) error { tag, err := r.db.Exec(ctx, ` UPDATE profiles SET telegram_target = NULLIF($2, '') WHERE id = $1`, userID, target) if err != nil { return fmt.Errorf("set telegram target: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // CreateBindCode выпускает одноразовый код привязки: прежние // неиспользованные коды пользователя гаснут — действует ровно один // свежий, как и токены сброса пароля (CreateResetToken). // Возвращает срок жизни (time.Now()+ttl). func (r *TelegramRepo) CreateBindCode(ctx context.Context, userID, codeHash string, ttl time.Duration) (time.Time, error) { if _, err := r.db.Exec(ctx, ` DELETE FROM telegram_bind_codes WHERE user_id = $1 AND used_at IS NULL`, userID); err != nil { return time.Time{}, fmt.Errorf("supersede bind codes: %w", err) } expires := time.Now().Add(ttl) if _, err := r.db.Exec(ctx, ` INSERT INTO telegram_bind_codes (user_id, code_hash, expires_at) VALUES ($1, $2, $3)`, userID, codeHash, expires); err != nil { return time.Time{}, fmt.Errorf("create bind code: %w", err) } return expires, nil } // ConsumeBindCode гасит код одним UPDATE — одноразовость и срок живут // в самом WHERE, поэтому две параллельные привязки невозможны. // Возвращает владельца кода; ErrNotFound — неизвестен, уже использован // или истёк (причина наружу не раскрывается). func (r *TelegramRepo) ConsumeBindCode(ctx context.Context, codeHash string) (string, error) { var userID string err := r.db.QueryRow(ctx, ` UPDATE telegram_bind_codes SET used_at = now() WHERE code_hash = $1 AND used_at IS NULL AND expires_at > now() RETURNING user_id`, codeHash).Scan(&userID) if errors.Is(err, pgx.ErrNoRows) { return "", ErrNotFound } if err != nil { return "", fmt.Errorf("consume bind code: %w", err) } return userID, nil } // CleanupBindCodes удаляет протухшие строки (использованные тоже: // история кодов никому не нужна, таблица должна оставаться маленькой). func (r *TelegramRepo) CleanupBindCodes(ctx context.Context, before time.Time) (int64, error) { tag, err := r.db.Exec(ctx, `DELETE FROM telegram_bind_codes WHERE expires_at < $1`, before) if err != nil { return 0, fmt.Errorf("cleanup bind codes: %w", err) } return tag.RowsAffected(), nil } // BotTokenInfo — есть ли у пользователя свой токен бота и как его бот // называется (имя из getMe при сохранении). Токен сюда не входит: он // наружу не отдаётся никому, включая владельца. // ErrNotFound — профиля нет (сессия могла пережить удаление). func (r *TelegramRepo) BotTokenInfo(ctx context.Context, userID string) (has bool, username string, err error) { var enc []byte var name *string err = r.db.QueryRow(ctx, ` SELECT telegram_bot_token, telegram_bot_username FROM profiles WHERE id = $1`, userID).Scan(&enc, &name) if errors.Is(err, pgx.ErrNoRows) { return false, "", ErrNotFound } if err != nil { return false, "", fmt.Errorf("telegram bot token info: %w", err) } if name != nil { username = *name } return enc != nil, username, nil } // SetBotToken сохраняет зашифрованный токен и имя бота (оба поля одной // операцией — чтобы статус не показывал имя бота без токена и наоборот). // ErrNotFound — профиля нет. func (r *TelegramRepo) SetBotToken(ctx context.Context, userID string, encToken, username string) error { tag, err := r.db.Exec(ctx, ` UPDATE profiles SET telegram_bot_token = $2, telegram_bot_username = NULLIF($3, '') WHERE id = $1`, userID, []byte(encToken), username) if err != nil { return fmt.Errorf("set telegram bot token: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // ClearBotToken убирает свой токен: уведомления перестают уходить через // этого бота, его поллер останавливается (см. notify.Registry). // ErrNotFound — профиля нет. func (r *TelegramRepo) ClearBotToken(ctx context.Context, userID string) error { tag, err := r.db.Exec(ctx, ` UPDATE profiles SET telegram_bot_token = NULL, telegram_bot_username = NULL WHERE id = $1`, userID) if err != nil { return fmt.Errorf("clear telegram bot token: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // UserBot — пользователь со своим ботом: токен приходит зашифрованным, // расшифровывает вызывающий (репозиторий не знает про ключ). type UserBot struct { UserID string // TokenEnc — зашифрованный токен (AES-GCM от crypt.Cipher). TokenEnc string // Username — имя бота из getMe, чтобы показывать его до первого // сетевого вызова. Username string } // BotTokens — все сохранённые токены: читается один раз при старте, // чтобы поднять клиенты ботов и их поллеры. func (r *TelegramRepo) BotTokens(ctx context.Context) ([]UserBot, error) { rows, err := r.db.Query(ctx, ` SELECT id, telegram_bot_token, telegram_bot_username FROM profiles WHERE telegram_bot_token IS NOT NULL ORDER BY created_at`) if err != nil { return nil, fmt.Errorf("list telegram bot tokens: %w", err) } defer rows.Close() out := make([]UserBot, 0, 8) for rows.Next() { var u UserBot var enc []byte var name *string if err := rows.Scan(&u.UserID, &enc, &name); err != nil { return nil, fmt.Errorf("list telegram bot tokens scan: %w", err) } u.TokenEnc = string(enc) if name != nil { u.Username = *name } out = append(out, u) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list telegram bot tokens: %w", err) } return out, nil } // StaleTicket — заявка, которую пора напомнить, и куда напоминание слать. type StaleTicket struct { ID int64 Address string Category string Priority string ClientName string // ClientID — владелец заявки: по нему выбирается бот, через который // уйдёт сообщение (свой токен пользователя или глобальный). ClientID string // Target — profiles.telegram_target: числовой chat_id или @группа. Target string } // StaleTickets — кандидаты на уведомление: всё ещё 'new', созданы раньше // cutoff, и последнее уведомление либо не уходило, либо тоже старше cutoff // (повтор — раз в сутки). Без telegram_target и с выключенным аккаунтом — // не попадают: слать некуда. Каким ботом слать — выбирается по ClientID // на стороне нотификатора (личный токен пользователя или глобальный). func (r *TelegramRepo) StaleTickets(ctx context.Context, cutoff time.Time, limit int) ([]StaleTicket, error) { rows, err := r.db.Query(ctx, ` SELECT t.id, t.address, t.category, t.priority, p.name, t.client_id, p.telegram_target FROM tickets t JOIN profiles p ON p.id = t.client_id WHERE t.status = 'new' AND t.created_at <= $1 AND (t.tg_notified_at IS NULL OR t.tg_notified_at <= $1) AND p.is_active AND p.telegram_target IS NOT NULL ORDER BY t.created_at LIMIT $2`, cutoff, limit) if err != nil { return nil, fmt.Errorf("stale tickets: %w", err) } defer rows.Close() out := make([]StaleTicket, 0, 16) for rows.Next() { var s StaleTicket if err := rows.Scan(&s.ID, &s.Address, &s.Category, &s.Priority, &s.ClientName, &s.ClientID, &s.Target); err != nil { return nil, fmt.Errorf("stale tickets scan: %w", err) } out = append(out, s) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("stale tickets: %w", err) } return out, nil } // MarkNotified фиксирует отправку: со следующего тика заявка молчит // сутки. Условие status='new' — гонка со взятием заявки в работу: // если её успели назначить, напоминание уже неактуально. func (r *TelegramRepo) MarkNotified(ctx context.Context, ticketID int64) error { tag, err := r.db.Exec(ctx, ` UPDATE tickets SET tg_notified_at = now() WHERE id = $1 AND status = 'new'`, ticketID) if err != nil { return fmt.Errorf("mark notified: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil }