package bot import ( "database/sql" "encoding/json" "errors" "net/http" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "github.com/go-chi/chi/v5" ) // Handler handles bot CRUD and server-assignment routes. type Handler struct { db *sql.DB } // NewHandler creates a new bot Handler. func NewHandler(db *sql.DB) *Handler { return &Handler{db: db} } // RegisterRoutes registers authenticated bot routes under the given router. func (h *Handler) RegisterRoutes(r chi.Router) { r.Post("/", h.Create) r.Get("/", h.List) r.Get("/{botID}", h.Get) r.Patch("/{botID}", h.Update) r.Delete("/{botID}", h.Delete) r.Post("/{botID}/servers", h.AddToServer) r.Delete("/{botID}/servers/{serverID}", h.RemoveFromServer) r.Post("/{botID}/regenerate-token", h.RegenerateToken) } // ---- response / request types ---- type botResponse struct { ID string `json:"id"` Name string `json:"name"` Avatar *string `json:"avatar"` Description string `json:"description"` OwnerID string `json:"owner_id"` CreatedAt string `json:"created_at"` } // botWithToken is returned only on create / regenerate-token. type botWithToken struct { botResponse Token string `json:"token"` } type createBotRequest struct { Name string `json:"name"` Description string `json:"description"` } type updateBotRequest struct { Name *string `json:"name"` Description *string `json:"description"` Avatar *string `json:"avatar"` } type addToServerRequest struct { ServerID string `json:"server_id"` } // ---- helpers ---- func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func writeErr(w http.ResponseWriter, status int, msg string) { http.Error(w, `{"error":"`+msg+`"}`, status) } // ---- handlers ---- // @Summary Create a bot // @Description Create a new bot // @Tags bots // @Accept json // @Produce json // @Security SessionAuth // @Param body body createBotRequest true "Bot data" // @Success 201 {object} botWithToken // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Router /bots [post] func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { writeErr(w, http.StatusUnauthorized, "unauthorized") return } var req createBotRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid request") return } if req.Name == "" { writeErr(w, http.StatusBadRequest, "name is required") return } token := GenerateToken() tokenHash := HashToken(token) var bot botWithToken var avatar sql.NullString var createdAt sql.NullString err := h.db.QueryRowContext(r.Context(), ` INSERT INTO bots (name, description, owner_id, token) VALUES ($1, $2, $3, $4) RETURNING id, name, avatar, description, owner_id, created_at::text `, req.Name, req.Description, userID, tokenHash).Scan( &bot.ID, &bot.Name, &avatar, &bot.Description, &bot.OwnerID, &createdAt, ) if err != nil { writeErr(w, http.StatusInternalServerError, "failed to create bot") return } if avatar.Valid { bot.Avatar = &avatar.String } bot.CreatedAt = createdAt.String bot.Token = token writeJSON(w, http.StatusCreated, bot) } // @Summary List bots // @Description List all bots owned by the current user // @Tags bots // @Produce json // @Security SessionAuth // @Success 200 {array} botResponse // @Failure 401 {object} map[string]string // @Router /bots [get] func (h *Handler) List(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { writeErr(w, http.StatusUnauthorized, "unauthorized") return } rows, err := h.db.QueryContext(r.Context(), ` SELECT id, name, avatar, description, owner_id, created_at::text FROM bots WHERE owner_id = $1 ORDER BY created_at DESC `, userID) if err != nil { writeErr(w, http.StatusInternalServerError, "server error") return } defer rows.Close() bots := make([]botResponse, 0) for rows.Next() { var b botResponse var avatar sql.NullString var createdAt sql.NullString if err := rows.Scan(&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt); err != nil { writeErr(w, http.StatusInternalServerError, "server error") return } if avatar.Valid { b.Avatar = &avatar.String } b.CreatedAt = createdAt.String bots = append(bots, b) } if err := rows.Err(); err != nil { writeErr(w, http.StatusInternalServerError, "server error") return } writeJSON(w, http.StatusOK, bots) } // @Summary Get a bot // @Description Get a bot by ID (owner only) // @Tags bots // @Produce json // @Security SessionAuth // @Param botID path string true "Bot ID" // @Success 200 {object} botResponse // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /bots/{botID} [get] func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { writeErr(w, http.StatusUnauthorized, "unauthorized") return } botID := chi.URLParam(r, "botID") var b botResponse var avatar sql.NullString var createdAt sql.NullString err := h.db.QueryRowContext(r.Context(), ` SELECT id, name, avatar, description, owner_id, created_at::text FROM bots WHERE id = $1 `, botID).Scan(&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { writeErr(w, http.StatusNotFound, "bot not found") } else { writeErr(w, http.StatusInternalServerError, "server error") } return } if b.OwnerID != userID { writeErr(w, http.StatusForbidden, "forbidden") return } if avatar.Valid { b.Avatar = &avatar.String } b.CreatedAt = createdAt.String writeJSON(w, http.StatusOK, b) } // @Summary Update a bot // @Description Update a bot's name, description, or avatar (owner only) // @Tags bots // @Accept json // @Produce json // @Security SessionAuth // @Param botID path string true "Bot ID" // @Param body body updateBotRequest true "Bot update data" // @Success 200 {object} botResponse // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /bots/{botID} [patch] func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { writeErr(w, http.StatusUnauthorized, "unauthorized") return } botID := chi.URLParam(r, "botID") // Verify ownership var ownerID string err := h.db.QueryRowContext(r.Context(), ` SELECT owner_id FROM bots WHERE id = $1 `, botID).Scan(&ownerID) if err != nil { if errors.Is(err, sql.ErrNoRows) { writeErr(w, http.StatusNotFound, "bot not found") } else { writeErr(w, http.StatusInternalServerError, "server error") } return } if ownerID != userID { writeErr(w, http.StatusForbidden, "forbidden") return } var req updateBotRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid request") return } if req.Name == nil && req.Description == nil && req.Avatar == nil { writeErr(w, http.StatusBadRequest, "nothing to update") return } var b botResponse var avatar sql.NullString var createdAt sql.NullString err = h.db.QueryRowContext(r.Context(), ` UPDATE bots SET name = COALESCE($1, name), description = COALESCE($2, description), avatar = COALESCE($3, avatar) WHERE id = $4 RETURNING id, name, avatar, description, owner_id, created_at::text `, req.Name, req.Description, req.Avatar, botID).Scan( &b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt, ) if err != nil { writeErr(w, http.StatusInternalServerError, "failed to update bot") return } if avatar.Valid { b.Avatar = &avatar.String } b.CreatedAt = createdAt.String writeJSON(w, http.StatusOK, b) } // @Summary Delete a bot // @Description Delete a bot (owner only) // @Tags bots // @Security SessionAuth // @Param botID path string true "Bot ID" // @Success 204 // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /bots/{botID} [delete] func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { writeErr(w, http.StatusUnauthorized, "unauthorized") return } botID := chi.URLParam(r, "botID") var ownerID string err := h.db.QueryRowContext(r.Context(), ` SELECT owner_id FROM bots WHERE id = $1 `, botID).Scan(&ownerID) if err != nil { if errors.Is(err, sql.ErrNoRows) { writeErr(w, http.StatusNotFound, "bot not found") } else { writeErr(w, http.StatusInternalServerError, "server error") } return } if ownerID != userID { writeErr(w, http.StatusForbidden, "forbidden") return } _, err = h.db.ExecContext(r.Context(), `DELETE FROM bots WHERE id = $1`, botID) if err != nil { writeErr(w, http.StatusInternalServerError, "failed to delete bot") return } w.WriteHeader(http.StatusNoContent) } // @Summary Add bot to server // @Description Add a bot to a server // @Tags bots // @Accept json // @Produce json // @Security SessionAuth // @Param botID path string true "Bot ID" // @Param body body addToServerRequest true "Server data" // @Success 201 {object} map[string]string // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /bots/{botID}/servers [post] func (h *Handler) AddToServer(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { writeErr(w, http.StatusUnauthorized, "unauthorized") return } botID := chi.URLParam(r, "botID") // Verify bot ownership var ownerID string err := h.db.QueryRowContext(r.Context(), ` SELECT owner_id FROM bots WHERE id = $1 `, botID).Scan(&ownerID) if err != nil { if errors.Is(err, sql.ErrNoRows) { writeErr(w, http.StatusNotFound, "bot not found") } else { writeErr(w, http.StatusInternalServerError, "server error") } return } if ownerID != userID { writeErr(w, http.StatusForbidden, "forbidden") return } var req addToServerRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid request") return } if req.ServerID == "" { writeErr(w, http.StatusBadRequest, "server_id is required") return } // Verify user is a member of the target server var exists bool err = h.db.QueryRowContext(r.Context(), ` SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2) `, userID, req.ServerID).Scan(&exists) if err != nil { writeErr(w, http.StatusInternalServerError, "server error") return } if !exists { writeErr(w, http.StatusForbidden, "not a member of this server") return } _, err = h.db.ExecContext(r.Context(), ` INSERT INTO bot_servers (bot_id, server_id, added_by) VALUES ($1, $2, $3) ON CONFLICT (bot_id, server_id) DO NOTHING `, botID, req.ServerID, userID) if err != nil { writeErr(w, http.StatusInternalServerError, "failed to add bot to server") return } writeJSON(w, http.StatusCreated, map[string]string{ "bot_id": botID, "server_id": req.ServerID, }) } // @Summary Remove bot from server // @Description Remove a bot from a server // @Tags bots // @Security SessionAuth // @Param botID path string true "Bot ID" // @Param serverID path string true "Server ID" // @Success 204 // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /bots/{botID}/servers/{serverID} [delete] func (h *Handler) RemoveFromServer(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { writeErr(w, http.StatusUnauthorized, "unauthorized") return } botID := chi.URLParam(r, "botID") serverID := chi.URLParam(r, "serverID") // Verify bot ownership var ownerID string err := h.db.QueryRowContext(r.Context(), ` SELECT owner_id FROM bots WHERE id = $1 `, botID).Scan(&ownerID) if err != nil { if errors.Is(err, sql.ErrNoRows) { writeErr(w, http.StatusNotFound, "bot not found") } else { writeErr(w, http.StatusInternalServerError, "server error") } return } if ownerID != userID { writeErr(w, http.StatusForbidden, "forbidden") return } result, err := h.db.ExecContext(r.Context(), ` DELETE FROM bot_servers WHERE bot_id = $1 AND server_id = $2 `, botID, serverID) if err != nil { writeErr(w, http.StatusInternalServerError, "failed to remove bot from server") return } rows, _ := result.RowsAffected() if rows == 0 { writeErr(w, http.StatusNotFound, "bot not in this server") return } w.WriteHeader(http.StatusNoContent) } // @Summary Regenerate bot token // @Description Regenerate a bot's API token (owner only) // @Tags bots // @Produce json // @Security SessionAuth // @Param botID path string true "Bot ID" // @Success 200 {object} botWithToken // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /bots/{botID}/regenerate-token [post] func (h *Handler) RegenerateToken(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { writeErr(w, http.StatusUnauthorized, "unauthorized") return } botID := chi.URLParam(r, "botID") // Verify ownership var ownerID string err := h.db.QueryRowContext(r.Context(), ` SELECT owner_id FROM bots WHERE id = $1 `, botID).Scan(&ownerID) if err != nil { if errors.Is(err, sql.ErrNoRows) { writeErr(w, http.StatusNotFound, "bot not found") } else { writeErr(w, http.StatusInternalServerError, "server error") } return } if ownerID != userID { writeErr(w, http.StatusForbidden, "forbidden") return } token := GenerateToken() tokenHash := HashToken(token) var b botWithToken var avatar sql.NullString var createdAt sql.NullString err = h.db.QueryRowContext(r.Context(), ` UPDATE bots SET token = $1 WHERE id = $2 RETURNING id, name, avatar, description, owner_id, created_at::text `, tokenHash, botID).Scan( &b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt, ) if err != nil { writeErr(w, http.StatusInternalServerError, "failed to regenerate token") return } if avatar.Valid { b.Avatar = &avatar.String } b.CreatedAt = createdAt.String b.Token = token writeJSON(w, http.StatusOK, b) }