Files
goyco/internal/handlers/post_handler.go

465 lines
13 KiB
Go

package handlers
import (
"context"
"errors"
"net/http"
"strings"
"time"
"goyco/internal/database"
"goyco/internal/dto"
"goyco/internal/repositories"
"goyco/internal/security"
"goyco/internal/services"
"goyco/internal/validation"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgconn"
)
type PostHandler struct {
postRepo repositories.PostRepository
titleFetcher services.TitleFetcher
voteService *services.VoteService
postQueries *services.PostQueries
}
func NewPostHandler(postRepo repositories.PostRepository, titleFetcher services.TitleFetcher, voteService *services.VoteService) *PostHandler {
return &PostHandler{
postRepo: postRepo,
titleFetcher: titleFetcher,
voteService: voteService,
postQueries: services.NewPostQueries(postRepo, voteService),
}
}
type PostResponse = CommonResponse
type UpdatePostRequest struct {
Title string `json:"title"`
Content string `json:"content"`
}
// @Summary Get posts
// @Description Get a list of posts with pagination. Posts include vote statistics (up_votes, down_votes, score) and current user's vote status.
// @Tags posts
// @Accept json
// @Produce json
// @Param limit query int false "Number of posts to return" default(20)
// @Param offset query int false "Number of posts to skip" default(0)
// @Success 200 {object} PostResponse "Posts retrieved successfully with vote statistics"
// @Failure 400 {object} PostResponse "Invalid pagination parameters"
// @Failure 500 {object} PostResponse "Internal server error"
// @Router /api/posts [get]
func (h *PostHandler) GetPosts(w http.ResponseWriter, r *http.Request) {
limit, offset := parsePagination(r)
opts := services.QueryOptions{
Limit: limit,
Offset: offset,
}
ctx := NewVoteContext(r)
posts, err := h.postQueries.GetAll(opts, ctx)
if err != nil {
SendErrorResponse(w, "Failed to fetch posts", http.StatusInternalServerError)
return
}
postDTOs := dto.ToPostDTOs(posts)
SendSuccessResponse(w, "Posts retrieved successfully", map[string]any{
"posts": postDTOs,
"count": len(postDTOs),
"limit": limit,
"offset": offset,
})
}
// @Summary Get a single post
// @Description Get a post by ID with vote statistics and current user's vote status
// @Tags posts
// @Accept json
// @Produce json
// @Param id path int true "Post ID"
// @Success 200 {object} PostResponse "Post retrieved successfully with vote statistics"
// @Failure 400 {object} PostResponse "Invalid post ID"
// @Failure 404 {object} PostResponse "Post not found"
// @Failure 500 {object} PostResponse "Internal server error"
// @Router /api/posts/{id} [get]
func (h *PostHandler) GetPost(w http.ResponseWriter, r *http.Request) {
postID, ok := ParseUintParam(w, r, "id", "Post")
if !ok {
return
}
ctx := NewVoteContext(r)
post, err := h.postQueries.GetByID(postID, ctx)
if !HandleRepoError(w, err, "Post") {
return
}
postDTO := dto.ToPostDTO(post)
SendSuccessResponse(w, "Post retrieved successfully", postDTO)
}
// @Summary Create a new post
// @Description Create a new post with URL and optional title
// @Tags posts
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body CreatePostRequest true "Post data"
// @Success 201 {object} PostResponse
// @Failure 400 {object} PostResponse "Invalid request data or validation failed"
// @Failure 401 {object} PostResponse "Authentication required"
// @Failure 409 {object} PostResponse "URL already submitted"
// @Failure 502 {object} PostResponse "Failed to fetch title from URL"
// @Failure 500 {object} PostResponse "Internal server error"
// @Router /api/posts [post]
func (h *PostHandler) CreatePost(w http.ResponseWriter, r *http.Request) {
var req struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
}
if !DecodeJSONRequest(w, r, &req) {
return
}
req.Title = security.SanitizeInput(req.Title)
req.URL = security.SanitizeURL(req.URL)
req.Content = security.SanitizePostContent(req.Content)
if req.URL == "" {
SendErrorResponse(w, "URL is required", http.StatusBadRequest)
return
}
if len(req.Title) > 200 {
SendErrorResponse(w, "Title must be no more than 200 characters", http.StatusBadRequest)
return
}
if len(req.Content) > 10000 {
SendErrorResponse(w, "Content must be no more than 10,000 characters", http.StatusBadRequest)
return
}
userID, ok := RequireAuth(w, r)
if !ok {
return
}
title := req.Title
if title == "" && h.titleFetcher != nil {
titleCtx, cancel := context.WithTimeout(r.Context(), 7*time.Second)
defer cancel()
fetchedTitle, err := h.titleFetcher.FetchTitle(titleCtx, req.URL)
if err != nil {
switch {
case errors.Is(err, services.ErrUnsupportedScheme):
SendErrorResponse(w, "Only HTTP and HTTPS URLs are supported", http.StatusBadRequest)
case errors.Is(err, services.ErrTitleNotFound):
SendErrorResponse(w, "Title could not be extracted from the provided URL", http.StatusBadRequest)
default:
SendErrorResponse(w, "Failed to fetch title from URL", http.StatusBadGateway)
}
return
}
title = fetchedTitle
}
if title == "" {
SendErrorResponse(w, "Title is required", http.StatusBadRequest)
return
}
if len(title) < 3 {
SendErrorResponse(w, "Title must be at least 3 characters", http.StatusBadRequest)
return
}
post := &database.Post{
Title: title,
URL: req.URL,
Content: req.Content,
AuthorID: &userID,
}
if err := h.postRepo.Create(post); err != nil {
if errMsg, status := translatePostCreateError(err); status != 0 {
SendErrorResponse(w, errMsg, status)
return
}
SendErrorResponse(w, "Failed to create post", http.StatusInternalServerError)
return
}
postDTO := dto.ToPostDTO(post)
SendCreatedResponse(w, "Post created successfully", postDTO)
}
// @Summary Search posts
// @Description Search posts by title or content keywords. Results include vote statistics and current user's vote status.
// @Tags posts
// @Accept json
// @Produce json
// @Param q query string false "Search term"
// @Param limit query int false "Number of posts to return" default(20)
// @Param offset query int false "Number of posts to skip" default(0)
// @Success 200 {object} PostResponse "Search results with vote statistics"
// @Failure 400 {object} PostResponse "Invalid search parameters"
// @Failure 500 {object} PostResponse "Internal server error"
// @Router /api/posts/search [get]
func (h *PostHandler) SearchPosts(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get("q"))
limit, offset := parsePagination(r)
opts := services.QueryOptions{
Limit: limit,
Offset: offset,
}
ctx := NewVoteContext(r)
posts, err := h.postQueries.GetSearch(query, opts, ctx)
if err != nil {
if searchErr, ok := err.(*repositories.SearchError); ok {
SendErrorResponse(w, "Invalid search query: "+searchErr.Message, http.StatusBadRequest)
return
}
SendErrorResponse(w, "Failed to search posts", http.StatusInternalServerError)
return
}
postDTOs := dto.ToPostDTOs(posts)
SendSuccessResponse(w, "Search results retrieved successfully", map[string]any{
"posts": postDTOs,
"count": len(postDTOs),
"query": query,
"limit": limit,
"offset": offset,
})
}
// @Summary Update a post
// @Description Update the title and content of a post owned by the authenticated user
// @Tags posts
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Post ID"
// @Param request body UpdatePostRequest true "Post update data"
// @Success 200 {object} PostResponse "Post updated successfully"
// @Failure 400 {object} PostResponse "Invalid request data or validation failed"
// @Failure 401 {object} PostResponse "Authentication required"
// @Failure 403 {object} PostResponse "Not authorized to update this post"
// @Failure 404 {object} PostResponse "Post not found"
// @Failure 500 {object} PostResponse "Internal server error"
// @Router /api/posts/{id} [put]
func (h *PostHandler) UpdatePost(w http.ResponseWriter, r *http.Request) {
userID, ok := RequireAuth(w, r)
if !ok {
return
}
postID, ok := ParseUintParam(w, r, "id", "Post")
if !ok {
return
}
post, err := h.postRepo.GetByID(postID)
if !HandleRepoError(w, err, "Post") {
return
}
if post.AuthorID == nil || *post.AuthorID != userID {
SendErrorResponse(w, "You can only edit your own posts", http.StatusForbidden)
return
}
var req struct {
Title string `json:"title"`
Content string `json:"content"`
}
if !DecodeJSONRequest(w, r, &req) {
return
}
req.Title = security.SanitizeInput(req.Title)
req.Content = security.SanitizePostContent(req.Content)
if len(req.Title) > 200 {
SendErrorResponse(w, "Title must be no more than 200 characters", http.StatusBadRequest)
return
}
if len(req.Content) > 10000 {
SendErrorResponse(w, "Content must be no more than 10,000 characters", http.StatusBadRequest)
return
}
if err := validation.ValidateTitle(req.Title); err != nil {
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
return
}
if err := validation.ValidateContent(req.Content); err != nil {
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
return
}
post.Title = req.Title
post.Content = req.Content
if err := h.postRepo.Update(post); err != nil {
SendErrorResponse(w, "Failed to update post", http.StatusInternalServerError)
return
}
postDTO := dto.ToPostDTO(post)
SendSuccessResponse(w, "Post updated successfully", postDTO)
}
// @Summary Delete a post
// @Description Delete a post owned by the authenticated user
// @Tags posts
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Post ID"
// @Success 200 {object} PostResponse "Post deleted successfully"
// @Failure 400 {object} PostResponse "Invalid post ID"
// @Failure 401 {object} PostResponse "Authentication required"
// @Failure 403 {object} PostResponse "Not authorized to delete this post"
// @Failure 404 {object} PostResponse "Post not found"
// @Failure 500 {object} PostResponse "Internal server error"
// @Router /api/posts/{id} [delete]
func (h *PostHandler) DeletePost(w http.ResponseWriter, r *http.Request) {
userID, ok := RequireAuth(w, r)
if !ok {
return
}
postID, ok := ParseUintParam(w, r, "id", "Post")
if !ok {
return
}
post, err := h.postRepo.GetByID(postID)
if !HandleRepoError(w, err, "Post") {
return
}
if post.AuthorID == nil || *post.AuthorID != userID {
SendErrorResponse(w, "You can only delete your own posts", http.StatusForbidden)
return
}
if err := h.voteService.DeleteVotesByPostID(postID); err != nil {
SendErrorResponse(w, "Failed to delete post votes", http.StatusInternalServerError)
return
}
if err := h.postRepo.Delete(postID); err != nil {
SendErrorResponse(w, "Failed to delete post", http.StatusInternalServerError)
return
}
SendSuccessResponse(w, "Post deleted successfully", nil)
}
// @Summary Fetch title from URL
// @Description Fetch the HTML title for the provided URL
// @Tags posts
// @Accept json
// @Produce json
// @Param url query string true "URL to inspect"
// @Success 200 {object} PostResponse "Title fetched successfully"
// @Failure 400 {object} PostResponse "Invalid URL or URL parameter missing"
// @Failure 501 {object} PostResponse "Title fetching is not available"
// @Failure 502 {object} PostResponse "Failed to fetch title from URL"
// @Router /api/posts/title [get]
func (h *PostHandler) FetchTitleFromURL(w http.ResponseWriter, r *http.Request) {
if h.titleFetcher == nil {
SendErrorResponse(w, "Title fetching is not available", http.StatusNotImplemented)
return
}
requestedURL := strings.TrimSpace(r.URL.Query().Get("url"))
if requestedURL == "" {
SendErrorResponse(w, "URL query parameter is required", http.StatusBadRequest)
return
}
titleCtx, cancel := context.WithTimeout(r.Context(), 7*time.Second)
defer cancel()
title, err := h.titleFetcher.FetchTitle(titleCtx, requestedURL)
if err != nil {
switch {
case errors.Is(err, services.ErrUnsupportedScheme):
SendErrorResponse(w, "Only HTTP and HTTPS URLs are supported", http.StatusBadRequest)
case errors.Is(err, services.ErrTitleNotFound):
SendErrorResponse(w, "Title could not be extracted from the provided URL", http.StatusBadRequest)
default:
SendErrorResponse(w, "Failed to fetch title from URL", http.StatusBadGateway)
}
return
}
SendSuccessResponse(w, "Title fetched successfully", map[string]string{
"title": title,
})
}
func translatePostCreateError(err error) (string, int) {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "23505":
return "This URL has already been submitted.", http.StatusConflict
case "23503":
return "Author account not found. Please sign in again.", http.StatusUnauthorized
}
}
errStr := err.Error()
if strings.Contains(errStr, "UNIQUE constraint") || strings.Contains(errStr, "duplicate") {
return "This URL has already been submitted.", http.StatusConflict
}
return "", 0
}
func (h *PostHandler) MountRoutes(r chi.Router, config RouteModuleConfig) {
public := r
if config.GeneralRateLimit != nil {
public = config.GeneralRateLimit(r)
}
public.Get("/posts", h.GetPosts)
public.Get("/posts/search", h.SearchPosts)
public.Get("/posts/title", h.FetchTitleFromURL)
public.Get("/posts/{id}", h.GetPost)
protected := r
if config.AuthMiddleware != nil {
protected = r.With(config.AuthMiddleware)
}
if config.GeneralRateLimit != nil {
protected = config.GeneralRateLimit(protected)
}
protected.Post("/posts", h.CreatePost)
protected.Put("/posts/{id}", h.UpdatePost)
protected.Delete("/posts/{id}", h.DeletePost)
}