| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381 |
- // telegrambots.go — HTTP handlers for the /v1/tenants/{id}/telegram/bots/*
- // routes (M13b W3).
- //
- // Routes (all require super_admin role per the existing
- // canManageTelegram scope):
- //
- // GET /v1/tenants/{id}/telegram/bots — list
- // POST /v1/tenants/{id}/telegram/bots — create
- // GET /v1/tenants/{id}/telegram/bots/{bid} — detail
- // PATCH /v1/tenants/{id}/telegram/bots/{bid} — update
- // POST /v1/tenants/{id}/telegram/bots/{bid}/status — set status
- // POST /v1/tenants/{id}/telegram/bots/{bid}/rotate-token — rotate token
- //
- // Errors:
- // 400 — bad input (validation, JSON parse, bad UUID, bad bot id)
- // 401 — handled by RequireAuth middleware (no body rewrite here)
- // 403 — caller is not super_admin (RequireRole gate)
- // 404 — tenant or bot id not found
- // 409 — duplicate bot id on create
- // 500 — unexpected DB error
- //
- // The plaintext bot_token is NEVER returned in any response.
- // The wire shape is the authd.TelegramBot struct, which exposes
- // `bot_token_set: bool` instead.
- package main
- import (
- "encoding/json"
- "errors"
- "log/slog"
- "net/http"
- "strconv"
- "strings"
- "git3.techno-world.net/lrosales/broad-announce/internal/authd"
- )
- // telegramBotsListResponse is the wire shape for GET
- // /v1/tenants/{id}/telegram/bots.
- type telegramBotsListResponse struct {
- Items []authd.TelegramBot `json:"items"`
- Total int `json:"total"`
- Limit int `json:"limit"`
- Offset int `json:"offset"`
- }
- // listTelegramBotsHandler wires GET /v1/tenants/{id}/telegram/bots.
- func listTelegramBotsHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- claims := authd.ClaimsFromContext(r.Context())
- if claims == nil {
- writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
- return
- }
- tenantID := r.PathValue("id")
- if !isUUID(tenantID) {
- writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
- return
- }
- q := strings.TrimSpace(r.URL.Query().Get("q"))
- statusFilter := strings.TrimSpace(r.URL.Query().Get("status"))
- limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
- offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
- filter := authd.TelegramBotFilter{
- CompanyID: tenantID,
- Q: q,
- Status: statusFilter,
- Limit: limit,
- Offset: offset,
- }
- items, total, err := ad.Store().ListTelegramBots(r.Context(), filter)
- if err != nil {
- logger.Error("list telegram_bots", "err", err, "actor", claims.UserID, "tenant_id", tenantID)
- writeErr(w, http.StatusInternalServerError, "internal", "list failed")
- return
- }
- if filter.Limit <= 0 {
- filter.Limit = 100
- }
- if filter.Limit > 500 {
- filter.Limit = 500
- }
- writeJSON(w, http.StatusOK, telegramBotsListResponse{
- Items: items, Total: total, Limit: filter.Limit, Offset: filter.Offset,
- })
- }
- }
- // createTelegramBotRequest is the POST body. bot_token is
- // REQUIRED on create (the operator got it from @BotFather).
- // All other fields optional.
- type createTelegramBotRequest struct {
- ID string `json:"id"`
- Name string `json:"name"`
- BotToken string `json:"bot_token"`
- WelcomeMessage string `json:"welcome_message"`
- DefaultSourceID string `json:"default_source_id"`
- Description string `json:"description"`
- }
- // createTelegramBotHandler wires POST /v1/tenants/{id}/telegram/bots.
- func createTelegramBotHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- claims := authd.ClaimsFromContext(r.Context())
- if claims == nil {
- writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
- return
- }
- tenantID := r.PathValue("id")
- if !isUUID(tenantID) {
- writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
- return
- }
- // W3 gates telegram to super_admin only (see canManageTelegram
- // in web/src/lib/scope.ts). RequireRole is wired in main.go;
- // this is belt-and-suspenders.
- if claims.Role != "super_admin" {
- writeErr(w, http.StatusForbidden, "forbidden", "super_admin only")
- return
- }
- var req createTelegramBotRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
- return
- }
- in := authd.CreateTelegramBotInput{
- ID: strings.TrimSpace(req.ID),
- Name: strings.TrimSpace(req.Name),
- BotToken: strings.TrimSpace(req.BotToken),
- WelcomeMessage: req.WelcomeMessage,
- DefaultSourceID: strings.TrimSpace(req.DefaultSourceID),
- Description: req.Description,
- }
- // Look up the auth tenant's display name so the
- // bridge INSERT into public.companies (needed because
- // telegram_bots.company_id FKs into public.companies,
- // not auth.tenants) has a sensible name value.
- tenant, err := ad.Store().GetTenant(r.Context(), tenantID)
- if err != nil {
- if errors.Is(err, authd.ErrTenantNotFound) {
- writeErr(w, http.StatusNotFound, "not_found", "tenant not found")
- return
- }
- logger.Error("create telegram_bot: lookup tenant", "err", err, "tenant_id", tenantID)
- writeErr(w, http.StatusInternalServerError, "internal", "lookup failed")
- return
- }
- ip := clientIP(r)
- ua := r.UserAgent()
- bot, err := ad.Store().CreateTelegramBot(r.Context(), tenantID, tenant.DisplayName, in, claims.UserID, ip, ua)
- if err != nil {
- switch {
- case errors.Is(err, authd.ErrTelegramBotIDTaken):
- writeErr(w, http.StatusConflict, "id_taken", "telegram bot id already in use")
- case errors.Is(err, authd.ErrTelegramBotInvalid):
- writeErr(w, http.StatusBadRequest, "invalid", err.Error())
- default:
- logger.Error("create telegram_bot", "err", err, "tenant_id", tenantID, "actor", claims.UserID)
- writeErr(w, http.StatusInternalServerError, "internal", "create failed")
- }
- return
- }
- logger.Info("telegram_bot created",
- "tenant_id", tenantID, "bot_id", bot.ID, "actor", claims.UserID)
- writeJSON(w, http.StatusCreated, bot)
- }
- }
- // getTelegramBotHandler wires GET /v1/tenants/{id}/telegram/bots/{bid}.
- func getTelegramBotHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- claims := authd.ClaimsFromContext(r.Context())
- if claims == nil {
- writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
- return
- }
- tenantID := r.PathValue("id")
- if !isUUID(tenantID) {
- writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
- return
- }
- if claims.Role != "super_admin" {
- writeErr(w, http.StatusForbidden, "forbidden", "super_admin only")
- return
- }
- botID := r.PathValue("bid")
- if botID == "" {
- writeErr(w, http.StatusBadRequest, "bad_request", "bot id is required")
- return
- }
- bot, err := ad.Store().GetTelegramBot(r.Context(), tenantID, botID)
- if err != nil {
- if errors.Is(err, authd.ErrTelegramBotNotFound) {
- writeErr(w, http.StatusNotFound, "not_found", "telegram bot not found")
- return
- }
- logger.Error("get telegram_bot", "err", err, "tenant_id", tenantID, "bot_id", botID)
- writeErr(w, http.StatusInternalServerError, "internal", "lookup failed")
- return
- }
- writeJSON(w, http.StatusOK, bot)
- }
- }
- // updateTelegramBotRequest is the PATCH body. bot_token is
- // NOT updatable here — use POST .../rotate-token.
- type updateTelegramBotRequest struct {
- Name *string `json:"name"`
- WelcomeMessage *string `json:"welcome_message"`
- DefaultSourceID *string `json:"default_source_id"`
- Description *string `json:"description"`
- }
- // updateTelegramBotHandler wires PATCH /v1/tenants/{id}/telegram/bots/{bid}.
- func updateTelegramBotHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- claims := authd.ClaimsFromContext(r.Context())
- if claims == nil {
- writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
- return
- }
- tenantID := r.PathValue("id")
- if !isUUID(tenantID) {
- writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
- return
- }
- if claims.Role != "super_admin" {
- writeErr(w, http.StatusForbidden, "forbidden", "super_admin only")
- return
- }
- botID := r.PathValue("bid")
- if botID == "" {
- writeErr(w, http.StatusBadRequest, "bad_request", "bot id is required")
- return
- }
- var req updateTelegramBotRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
- return
- }
- in := authd.UpdateTelegramBotInput{
- Name: req.Name,
- WelcomeMessage: req.WelcomeMessage,
- DefaultSourceID: req.DefaultSourceID,
- Description: req.Description,
- }
- ip := clientIP(r)
- ua := r.UserAgent()
- bot, err := ad.Store().UpdateTelegramBot(r.Context(), tenantID, botID, in, claims.UserID, ip, ua)
- if err != nil {
- switch {
- case errors.Is(err, authd.ErrTelegramBotNotFound):
- writeErr(w, http.StatusNotFound, "not_found", "telegram bot not found")
- case errors.Is(err, authd.ErrTelegramBotInvalid):
- writeErr(w, http.StatusBadRequest, "invalid", err.Error())
- default:
- logger.Error("update telegram_bot", "err", err, "tenant_id", tenantID, "bot_id", botID, "actor", claims.UserID)
- writeErr(w, http.StatusInternalServerError, "internal", "update failed")
- }
- return
- }
- logger.Info("telegram_bot updated",
- "tenant_id", tenantID, "bot_id", botID, "actor", claims.UserID)
- writeJSON(w, http.StatusOK, bot)
- }
- }
- // setTelegramBotStatusRequest is the POST /status body.
- type setTelegramBotStatusRequest struct {
- Status string `json:"status"`
- }
- // setTelegramBotStatusHandler wires POST /v1/tenants/{id}/telegram/bots/{bid}/status.
- func setTelegramBotStatusHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- claims := authd.ClaimsFromContext(r.Context())
- if claims == nil {
- writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
- return
- }
- tenantID := r.PathValue("id")
- if !isUUID(tenantID) {
- writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
- return
- }
- if claims.Role != "super_admin" {
- writeErr(w, http.StatusForbidden, "forbidden", "super_admin only")
- return
- }
- botID := r.PathValue("bid")
- if botID == "" {
- writeErr(w, http.StatusBadRequest, "bad_request", "bot id is required")
- return
- }
- var req setTelegramBotStatusRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
- return
- }
- ip := clientIP(r)
- ua := r.UserAgent()
- bot, err := ad.Store().SetTelegramBotStatus(r.Context(), tenantID, botID, strings.TrimSpace(req.Status), claims.UserID, ip, ua)
- if err != nil {
- switch {
- case errors.Is(err, authd.ErrTelegramBotNotFound):
- writeErr(w, http.StatusNotFound, "not_found", "telegram bot not found")
- case errors.Is(err, authd.ErrTelegramBotInvalid):
- writeErr(w, http.StatusBadRequest, "invalid", err.Error())
- default:
- logger.Error("set telegram_bot status", "err", err, "tenant_id", tenantID, "bot_id", botID, "actor", claims.UserID)
- writeErr(w, http.StatusInternalServerError, "internal", "update failed")
- }
- return
- }
- logger.Info("telegram_bot status changed",
- "tenant_id", tenantID, "bot_id", botID, "to", bot.Status, "actor", claims.UserID)
- writeJSON(w, http.StatusOK, bot)
- }
- }
- // rotateTelegramBotTokenRequest is the POST /rotate-token body.
- // bot_token is REQUIRED (the operator got a new one from
- // @BotFather and is pasting it in).
- type rotateTelegramBotTokenRequest struct {
- BotToken string `json:"bot_token"`
- }
- // rotateTelegramBotTokenHandler wires POST
- // /v1/tenants/{id}/telegram/bots/{bid}/rotate-token.
- //
- // The new bot_token replaces the existing one in the DB and is
- // bcrypt-hashed for the bot_token_hash column. The response
- // does NOT include the plaintext (the operator already has it;
- // the server doesn't echo it back).
- func rotateTelegramBotTokenHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- claims := authd.ClaimsFromContext(r.Context())
- if claims == nil {
- writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
- return
- }
- tenantID := r.PathValue("id")
- if !isUUID(tenantID) {
- writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
- return
- }
- if claims.Role != "super_admin" {
- writeErr(w, http.StatusForbidden, "forbidden", "super_admin only")
- return
- }
- botID := r.PathValue("bid")
- if botID == "" {
- writeErr(w, http.StatusBadRequest, "bad_request", "bot id is required")
- return
- }
- var req rotateTelegramBotTokenRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
- return
- }
- ip := clientIP(r)
- ua := r.UserAgent()
- bot, err := ad.Store().RotateTelegramBotToken(r.Context(), tenantID, botID, strings.TrimSpace(req.BotToken), claims.UserID, ip, ua)
- if err != nil {
- switch {
- case errors.Is(err, authd.ErrTelegramBotNotFound):
- writeErr(w, http.StatusNotFound, "not_found", "telegram bot not found")
- case errors.Is(err, authd.ErrTelegramBotInvalid):
- writeErr(w, http.StatusBadRequest, "invalid", err.Error())
- default:
- logger.Error("rotate telegram_bot token", "err", err, "tenant_id", tenantID, "bot_id", botID, "actor", claims.UserID)
- writeErr(w, http.StatusInternalServerError, "internal", "rotate failed")
- }
- return
- }
- logger.Info("telegram_bot token rotated",
- "tenant_id", tenantID, "bot_id", botID, "actor", claims.UserID)
- writeJSON(w, http.StatusOK, bot)
- }
- }
|