diff --git a/cmd/server/main.go b/cmd/server/main.go index 9027c18..29b2c6a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -26,6 +26,7 @@ import ( "github.com/buhapp/backend/internal/redis" "github.com/buhapp/backend/internal/reviews" "github.com/buhapp/backend/internal/storage" + "github.com/buhapp/backend/internal/telegram" "github.com/buhapp/backend/internal/users" "github.com/buhapp/backend/internal/ws" ) @@ -78,10 +79,22 @@ func main() { authSvc := auth.NewService(cfg.JWTSecret, cfg.JwTAccessTTL(), cfg.JwtRefreshTTL()) hub := ws.NewHub() + // Telegram bot (опционально) + var bot *telegram.Bot + var tgH *handlers.TGHandler + if cfg.TelegramBotToken != "" { + bot = telegram.NewBot(cfg.TelegramBotToken) + tgH = handlers.NewTGHandler(bot, usersRepo, authSvc, consentRepo, auditRepo, []byte(cfg.JWTSecret)) + log.Println("telegram bot: configured") + } else { + log.Println("telegram bot: NOT configured (set TELEGRAM_BOT_TOKEN)") + } + authH := handlers.NewAuthHandler(cfg, usersRepo, consentRepo, auditRepo, authSvc) h := handlers.New(cfg, usersRepo, prefsRepo, locsRepo, consentRepo, auditRepo, authSvc) chatH := handlers.NewChatHandlers(chatRepo, auditRepo, hub) reviewH := handlers.NewReviewHandlers(reviewRepo, chatRepo, auditRepo) + consentH := handlers.NewConsentHandlers(consentRepo, auditRepo) _ = rdb _ = st @@ -120,6 +133,11 @@ func main() { return c.JSON(fiber.Map{"version": cfg.DisclaimerVersion, "url": "/legal/DISCLAIMER.md"}) }) + // Telegram WebApp auth (публичный) + if tgH != nil { + api.Post("/auth/telegram", tgH.TGAuth) + } + api := app.Group("/api/v1") api.Post("/auth/register", authH.Register) api.Post("/auth/login", authH.Login) @@ -154,6 +172,9 @@ func main() { protected.Get("/users/:id/reviews", reviewH.ListForUser) protected.Get("/users/:id/stats", reviewH.Stats) + // Consents (повторное принятие) + protected.Post("/auth/consents", consentH.Accept) + // WebSocket — авторизация по ?token= app.Get("/ws", ws.AuthMiddleware(authSvc), ws.Upgrade, ws.Handler(hub)) @@ -165,6 +186,16 @@ func main() { _ = app.ShutdownWithTimeout(10 * time.Second) }() + // Запуск Telegram-бота (long polling) — если настроен + if bot != nil && tgH != nil { + go func() { + log.Println("telegram bot: starting long-poll loop") + bot.RunLoop(func(u telegram.Update) { + tgH.ProcessUpdate(u) + }) + }() + } + addr := fmt.Sprintf(":%d", cfg.AppPort) log.Printf("listening on %s", addr) if err := app.Listen(addr); err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index b557602..48d41f0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,6 +39,9 @@ type Config struct { TermsVersion string PrivacyVersion string DisclaimerVersion string + + // Telegram + TelegramBotToken string } func Load() (*Config, error) { @@ -74,6 +77,8 @@ func Load() (*Config, error) { TermsVersion: getEnv("TERMS_VERSION", "1.0"), PrivacyVersion: getEnv("PRIVACY_VERSION", "1.0"), DisclaimerVersion: getEnv("DISCLAIMER_VERSION", "1.0"), + + TelegramBotToken: getEnv("TELEGRAM_BOT_TOKEN", ""), } if cfg.JWTSecret == "change-me" { diff --git a/internal/handlers/consent.go b/internal/handlers/consent.go new file mode 100644 index 0000000..1eb83fb --- /dev/null +++ b/internal/handlers/consent.go @@ -0,0 +1,66 @@ +package handlers + +import ( + "time" + + "github.com/gofiber/fiber/v2" + + "github.com/buhapp/backend/internal/audit" + "github.com/buhapp/backend/internal/consent" +) + +type ConsentHandlers struct { + Consent *consent.Repo + Audit *audit.Repo +} + +func NewConsentHandlers(c *consent.Repo, a *audit.Repo) *ConsentHandlers { + return &ConsentHandlers{Consent: c, Audit: a} +} + +type consentRequest struct { + Adult bool `json:"adult"` + Terms bool `json:"terms"` + Privacy bool `json:"privacy"` + Disclaimer bool `json:"disclaimer"` +} + +func (h *ConsentHandlers) Accept(c *fiber.Ctx) error { + me, err := userID(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"}) + } + var req consentRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"}) + } + if !req.Adult || !req.Terms || !req.Privacy || !req.Disclaimer { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "all consents required"}) + } + docs := []struct { + Type consent.DocType + }{ + {consent.DocTerms}, + {consent.DocPrivacy}, + {consent.DocDisclaimer}, + {consent.DocAdult}, + } + for _, d := range docs { + err := h.Consent.Record(c.UserContext(), &consent.Consent{ + UserID: me, + DocType: d.Type, + DocVersion: "1.0", + IP: c.IP(), + UserAgent: c.Get("User-Agent"), + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"}) + } + } + + _ = h.Audit.Log(c.UserContext(), &audit.Event{ + UserID: &me, Action: "consents.accept", + IP: c.IP(), UserAgent: c.Get("User-Agent"), + }) + return c.JSON(fiber.Map{"ok": true, "accepted_at": time.Now()}) +} \ No newline at end of file diff --git a/internal/handlers/telegram.go b/internal/handlers/telegram.go new file mode 100644 index 0000000..d57cac6 --- /dev/null +++ b/internal/handlers/telegram.go @@ -0,0 +1,170 @@ +package handlers + +import ( + "net/http" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + + "github.com/buhapp/backend/internal/audit" + "github.com/buhapp/backend/internal/auth" + "github.com/buhapp/backend/internal/consent" + "github.com/buhapp/backend/internal/telegram" + "github.com/buhapp/backend/internal/users" +) + +// TGHandler — обработчики для Telegram WebApp +type TGHandler struct { + Bot *telegram.Bot + Users *users.Repo + Auth *auth.Service + Consent *consent.Repo + Audit *audit.Repo + JWTSecret []byte +} + +func NewTGHandler(bot *telegram.Bot, u *users.Repo, a *auth.Service, c *consent.Repo, au *audit.Repo, secret []byte) *TGHandler { + return &TGHandler{Bot: bot, Users: u, Auth: a, Consent: c, Audit: au, JWTSecret: secret} +} + +type tgAuthRequest struct { + InitData string `json:"init_data"` +} + +// TGAuth — логин/регистрация через Telegram WebApp initData +func (h *TGHandler) TGAuth(c *fiber.Ctx) error { + var req tgAuthRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"}) + } + if h.Bot == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "telegram not configured"}) + } + + // Проверяем подпись + tgData, err := telegram.ValidateInitData(h.Bot.Token, req.InitData, 5*time.Minute) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid init data: " + err.Error()}) + } + if tgData.User == nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no user in init data"}) + } + + tgID := tgData.User.ID + uid := telegram.UserIDFromTelegram(tgID) + + // Получаем или создаём пользователя + u, err := h.Users.GetByTelegramID(c.UserContext(), tgID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"}) + } + if u == nil { + // Регистрируем нового + name := strings.TrimSpace(tgData.User.FirstName) + if tgData.User.LastName != "" { + name += " " + tgData.User.LastName + } + if name == "" { + name = "User" + } + u = &users.User{ + ID: uid, + Name: name, + IsVerified: false, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := h.Users.CreateWithTelegramID(c.UserContext(), u, tgID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "create user failed: " + err.Error()}) + } + // аудит + _ = h.Audit.Log(c.UserContext(), &audit.Event{ + UserID: &uid, Action: "user.register_telegram", + IP: c.IP(), UserAgent: c.Get("User-Agent"), + }) + } else { + // обновить last_seen_at + _ = h.Users.TouchLastSeen(c.UserContext(), u.ID) + } + + // Выпускаем JWT + tokens, err := h.Auth.Generate(u.ID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "token generation failed"}) + } + + return c.JSON(fiber.Map{ + "user": u, + "tokens": tokens, + "tg": fiber.Map{ + "id": tgData.User.ID, + "username": tgData.User.Username, + "first_name": tgData.User.FirstName, + "photo_url": tgData.User.PhotoURL, + }, + }) +} + +// Webhook для бота — если будешь использовать webhook +func (h *TGHandler) BotWebhook(c *fiber.Ctx) error { + var u telegram.Update + if err := c.BodyParser(&u); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid"}) + } + h.handleUpdate(c.UserContext(), u) + return c.JSON(fiber.Map{"ok": true}) +} + +// ProcessUpdate — обработчик апдейтов (вызывается из bot loop) +func (h *TGHandler) ProcessUpdate(u telegram.Update) { + h.handleUpdate(nil, u) +} + +func (h *TGHandler) handleUpdate(ctx interface{}, u telegram.Update) { + if u.Message != nil { + h.handleMessage(u.Message) + } + if u.Callback != nil { + h.handleCallback(u.Callback) + } +} + +func (h *TGHandler) handleMessage(m *telegram.Message) { + if m.Text == "" || m.Chat == nil { + return + } + switch m.Text { + case "/start": + name := "" + if m.From != nil { + name = m.From.FirstName + } + h.Bot.SendMessage(m.Chat.ID, + "Привет"+ifNonEmpty(name, ", "+name)+"!\n\nBuhApp — приложение для поиска компании.\n\nНажми кнопку ниже, чтобы открыть:", + func(opts *telegram.MessageOptions) { + opts.ParseMode = "HTML" + opts.ReplyMarkup = telegram.InlineKeyboard([][]telegram.InlineButton{{ + {Text: "🚀 Открыть BuhApp", WebApp: &telegram.WebAppInfo{URL: "https://app.buhapp.mygoodservice.ru"}}, + }}) + }, + ) + } +} + +func (h *TGHandler) handleCallback(cb *telegram.Callback) { + _ = h.Bot.AnswerCallback(cb.ID, "Открывай Mini App!") +} + +func ifNonEmpty(s, prefix string) string { + if s == "" { + return "" + } + return prefix +} + +// bcrypt не используется здесь, оставлю для импорта +var _ = bcrypt.MinCost +var _ = uuid.Nil \ No newline at end of file diff --git a/internal/telegram/auth.go b/internal/telegram/auth.go new file mode 100644 index 0000000..97dc68d --- /dev/null +++ b/internal/telegram/auth.go @@ -0,0 +1,155 @@ +package telegram + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "sort" + "strconv" + "strings" + "time" + + "github.com/google/uuid" +) + +// WebAppUser — пользователь из Telegram WebApp +type WebAppUser struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name,omitempty"` + Username string `json:"username,omitempty"` + LanguageCode string `json:"language_code,omitempty"` + IsBot bool `json:"is_bot,omitempty"` + IsPremium bool `json:"is_premium,omitempty"` + PhotoURL string `json:"photo_url,omitempty"` +} + +// InitData — сырой initData от Telegram WebApp +type InitData struct { + QueryID string `json:"query_id,omitempty"` + User *WebAppUser `json:"user,omitempty"` + Receiver *WebAppUser `json:"receiver,omitempty"` + Chat *Chat `json:"chat,omitempty"` + ChatType string `json:"chat_type,omitempty"` + ChatInst string `json:"chat_instance,omitempty"` + StartParam string `json:"start_param,omitempty"` + CanSendAfter int `json:"can_send_after,omitempty"` + AuthDate int64 `json:"auth_date"` + Hash string `json:"hash"` + Signature string `json:"signature,omitempty"` +} + +type Chat struct { + ID int64 `json:"id"` + Type string `json:"type"` + Title string `json:"title,omitempty"` + Username string `json:"username,omitempty"` +} + +// ValidateInitData — проверка подписи Telegram +func ValidateInitData(botToken string, initData string, maxAge time.Duration) (*InitData, error) { + parts := strings.Split(initData, "&") + if len(parts) == 0 { + return nil, errors.New("empty initData") + } + m := make(map[string]string) + for _, p := range parts { + kv := strings.SplitN(p, "=", 2) + if len(kv) != 2 { + continue + } + m[kv[0]] = kv[1] + } + + hash, ok := m["hash"] + if !ok { + return nil, errors.New("no hash") + } + delete(m, "hash") + + // сортируем ключи + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + // собираем data-check-string + var b strings.Builder + for i, k := range keys { + if i > 0 { + b.WriteByte('\n') + } + b.WriteString(k) + b.WriteByte('=') + b.WriteString(m[k]) + } + checkString := b.String() + + // secret = HMAC-SHA256(bot_token, "WebAppData") + secret := hmac.New(sha256.New, []byte("WebAppData")) + secret.Write([]byte(botToken)) + secretBytes := secret.Sum(nil) + + // hash = HMAC-SHA256(check_string, secret) + h := hmac.New(sha256.New, secretBytes) + h.Write([]byte(checkString)) + computed := hex.EncodeToString(h.Sum(nil)) + + if !hmac.Equal([]byte(computed), []byte(hash)) { + return nil, errors.New("invalid signature") + } + + // парсим initData + var data InitData + data.Hash = hash + if v, ok := m["auth_date"]; ok { + ts, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return nil, errors.New("bad auth_date") + } + data.AuthDate = ts + // проверка свежести + if maxAge > 0 { + if time.Now().Unix()-ts > int64(maxAge.Seconds()) { + return nil, errors.New("initData expired") + } + } + } + if v, ok := m["query_id"]; ok { data.QueryID = v } + if v, ok := m["chat_type"]; ok { data.ChatType = v } + if v, ok := m["chat_instance"]; ok { data.ChatInst = v } + if v, ok := m["start_param"]; ok { data.StartParam = v } + if v, ok := m["can_send_after"]; ok { + n, _ := strconv.Atoi(v) + data.CanSendAfter = n + } + if v, ok := m["user"]; ok { + var u WebAppUser + if err := json.Unmarshal([]byte(v), &u); err == nil { + data.User = &u + } + } + if v, ok := m["receiver"]; ok { + var u WebAppUser + if err := json.Unmarshal([]byte(v), &u); err == nil { + data.Receiver = &u + } + } + if v, ok := m["chat"]; ok { + var c Chat + if err := json.Unmarshal([]byte(v), &c); err == nil { + data.Chat = &c + } + } + + return &data, nil +} + +// UserIDFromTelegram — стабильный UUID из tg_id +func UserIDFromTelegram(tgID int64) uuid.UUID { + ns := uuid.MustParse("1d4a3f50-2a8a-4f55-b1a3-b7e0c1f4e6a0") + return uuid.NewSHA1(ns, []byte(strconv.FormatInt(tgID, 10))) +} \ No newline at end of file diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go new file mode 100644 index 0000000..b57aebd --- /dev/null +++ b/internal/telegram/bot.go @@ -0,0 +1,180 @@ +package telegram + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" +) + +const apiBase = "https://api.telegram.org/bot" + +type Bot struct { + Token string + BaseURL string + hc *http.Client +} + +func NewBot(token string) *Bot { + return &Bot{ + Token: token, + BaseURL: apiBase + token, + hc: &http.Client{Timeout: 15 * time.Second}, + } +} + +type Update struct { + UpdateID int64 `json:"update_id"` + Message *Message `json:"message,omitempty"` + Callback *Callback `json:"callback_query,omitempty"` +} + +type Message struct { + MessageID int64 `json:"message_id"` + From *User `json:"from,omitempty"` + Chat *Chat `json:"chat"` + Text string `json:"text,omitempty"` + Date int64 `json:"date"` +} + +type Callback struct { + ID string `json:"id"` + From *User `json:"from,omitempty"` + Message *Message `json:"message,omitempty"` + Data string `json:"data,omitempty"` +} + +type User struct { + ID int64 `json:"id"` + IsBot bool `json:"is_bot"` + FirstName string `json:"first_name"` + LastName string `json:"last_name,omitempty"` + Username string `json:"username,omitempty"` +} + +// call — обёртка для вызова Telegram Bot API +func (b *Bot) call(method string, params any, result any) error { + body, err := json.Marshal(params) + if err != nil { + return err + } + r, err := b.hc.Post(b.BaseURL+"/"+method, "application/json", bytes.NewReader(body)) + if err != nil { + return err + } + defer r.Body.Close() + if r.StatusCode != 200 { + buf, _ := io.ReadAll(r.Body) + return fmt.Errorf("telegram %s: HTTP %d: %s", method, r.StatusCode, string(buf)) + } + var resp struct { + OK bool `json:"ok"` + Result json.RawMessage `json:"result"` + Error string `json:"description,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&resp); err != nil { + return err + } + if !resp.OK { + return fmt.Errorf("telegram error: %s", resp.Error) + } + if result != nil && len(resp.Result) > 0 { + return json.Unmarshal(resp.Result, result) + } + return nil +} + +// GetUpdates — long polling (для простого бота без webhook) +func (b *Bot) GetUpdates(offset int64, timeoutSec int) ([]Update, error) { + var updates []Update + err := b.call("getUpdates", map[string]any{ + "offset": offset, + "timeout": timeoutSec, + "allowed_updates": []string{"message", "callback_query"}, + }, &updates) + return updates, err +} + +// SendMessage — отправить текстовое сообщение +func (b *Bot) SendMessage(chatID int64, text string, opts ...func(m *MessageOptions)) error { + m := &MessageOptions{} + for _, o := range opts { + o(m) + } + params := map[string]any{ + "chat_id": chatID, + "text": text, + } + if m.ParseMode != "" { + params["parse_mode"] = m.ParseMode + } + if m.ReplyMarkup != nil { + params["reply_markup"] = m.ReplyMarkup + } + return b.call("sendMessage", params, nil) +} + +// AnswerCallback — ответ на callback_query +func (b *Bot) AnswerCallback(callbackID, text string) error { + return b.call("answerCallbackQuery", map[string]any{ + "callback_query_id": callbackID, + "text": text, + }, nil) +} + +type MessageOptions struct { + ParseMode string + ReplyMarkup any +} + +// ReplyMarkupInline — кнопка-ссылка на WebApp +type ReplyMarkupInline struct { + InlineKeyboard [][]InlineButton `json:"inline_keyboard"` +} + +type InlineButton struct { + Text string `json:"text"` + URL string `json:"url,omitempty"` + // WebApp URL — открывает Mini App + WebApp *WebAppInfo `json:"web_app,omitempty"` +} + +type WebAppInfo struct { + URL string `json:"url"` +} + +func InlineKeyboard(rows ...[]InlineButton) *ReplyMarkupInline { + return &ReplyMarkupInline{InlineKeyboard: rows} +} + +// RunLoop — простой long-poll loop с обработчиком update +func (b *Bot) RunLoop(handler func(u Update)) { + var offset int64 + for { + updates, err := b.GetUpdates(offset, 30) + if err != nil { + log.Printf("telegram getUpdates: %v", err) + time.Sleep(3 * time.Second) + continue + } + for _, u := range updates { + offset = u.UpdateID + 1 + handler(u) + } + } +} + +// RunOnce — однократная обработка (для теста) +func (b *Bot) RunOnce(handler func(u Update)) error { + updates, err := b.GetUpdates(0, 0) + if err != nil { + return err + } + for _, u := range updates { + handler(u) + } + return nil +} \ No newline at end of file diff --git a/internal/users/repo.go b/internal/users/repo.go index 172c0db..8fb2dc8 100644 --- a/internal/users/repo.go +++ b/internal/users/repo.go @@ -14,6 +14,7 @@ type User struct { ID uuid.UUID `json:"id"` Email string `json:"email,omitempty"` Phone string `json:"phone,omitempty"` + TelegramID *int64 `json:"telegram_id,omitempty"` Name string `json:"name"` Birthdate *time.Time `json:"birthdate,omitempty"` Gender string `json:"gender"` @@ -52,6 +53,51 @@ func (r *Repo) UpdateRaw(ctx context.Context, sql string, args ...interface{}) ( return tag.RowsAffected(), nil } +// CreateWithTelegramID — создать пользователя с привязкой к Telegram +func (r *Repo) CreateWithTelegramID(ctx context.Context, u *User, tgID int64) error { + return r.pool.QueryRow(ctx, ` + INSERT INTO users (id, telegram_id, name, password_hash) + VALUES ($1, $2, $3, '') + ON CONFLICT (telegram_id) DO UPDATE + SET name = EXCLUDED.name, updated_at = NOW() + RETURNING id, created_at, updated_at`, + u.ID, tgID, u.Name, + ).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt) +} + +func (r *Repo) GetByTelegramID(ctx context.Context, tgID int64) (*User, error) { + u := &User{} + var email, phone, gender, city, bio, photo *string + var birth *time.Time + var telegramID *int64 + err := r.pool.QueryRow(ctx, ` + SELECT id, email, phone, telegram_id, name, birthdate, gender, city, bio, photo_url, + is_verified, is_blocked, created_at, updated_at, last_seen_at + FROM users WHERE telegram_id=$1`, tgID, + ).Scan(&u.ID, &email, &phone, &telegramID, &u.Name, &birth, &gender, &city, &bio, &photo, + &u.IsVerified, &u.IsBlocked, &u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + if email != nil { u.Email = *email } + if phone != nil { u.Phone = *phone } + if telegramID != nil { u.TelegramID = telegramID } + if gender != nil { u.Gender = *gender } + if city != nil { u.City = *city } + if bio != nil { u.Bio = *bio } + if photo != nil { u.PhotoURL = *photo } + if birth != nil { u.Birthdate = birth } + return u, nil +} + +func (r *Repo) TouchLastSeen(ctx context.Context, id uuid.UUID) error { + _, err := r.pool.Exec(ctx, `UPDATE users SET last_seen_at=NOW() WHERE id=$1`, id) + return err +} + func (r *Repo) GetByID(ctx context.Context, id uuid.UUID) (*User, error) { u := &User{} var email, phone, gender, city, bio, photo *string diff --git a/migrations/0005_telegram.down.sql b/migrations/0005_telegram.down.sql new file mode 100644 index 0000000..8073ab0 --- /dev/null +++ b/migrations/0005_telegram.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users DROP COLUMN IF EXISTS tg_username; +ALTER TABLE users DROP COLUMN IF EXISTS telegram_id; \ No newline at end of file diff --git a/migrations/0005_telegram.up.sql b/migrations/0005_telegram.up.sql new file mode 100644 index 0000000..3c5bc9c --- /dev/null +++ b/migrations/0005_telegram.up.sql @@ -0,0 +1,6 @@ +-- Telegram ID +ALTER TABLE users ADD COLUMN IF NOT EXISTS telegram_id BIGINT UNIQUE; +CREATE INDEX IF NOT EXISTS idx_users_telegram ON users(telegram_id) WHERE telegram_id IS NOT NULL; + +-- username в Telegram (опционально) +ALTER TABLE users ADD COLUMN IF NOT EXISTS tg_username TEXT; \ No newline at end of file