// Package handlers package handlers import ( "encoding/json" "errors" "fmt" "log/slog" "net/http" "strconv" "strings" "github.com/go-chi/chi/v5" "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/middleware" "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models" "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/services" "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/validator" ) type PlaceHandler struct { placeSvc PlaceService authSvc AuthService } func NewPlaceHandler(placeSvc PlaceService, authSvc AuthService) *PlaceHandler { return &PlaceHandler{placeSvc: placeSvc, authSvc: authSvc} } func (h *PlaceHandler) List(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() status := q.Get("status") if status == "" { status = "published" } if status != "published" { role := middleware.GetUserRole(r.Context()) if role == "" { if parts := strings.SplitN(r.Header.Get("Authorization"), " ", 2); len(parts) == 2 && parts[0] == "Bearer" { if claims, err := h.authSvc.ValidateAccessToken(parts[1]); err == nil { role = claims.Role } } } if role != "moderator" && role != "superadmin" { writeError(w, http.StatusForbidden, "access denied", nil) return } } filter := models.PlaceFilter{ Type: q.Get("type"), Status: status, } if tags := q["tags"]; len(tags) > 0 { filter.TagIDs = tags } if features := q["features"]; len(features) > 0 { filter.FeatureIDs = features } if mr := q.Get("min_rating"); mr != "" { if v, err := strconv.ParseFloat(mr, 64); err == nil { filter.MinRating = v } } if pmin := q.Get("price_min"); pmin != "" { if v, err := strconv.Atoi(pmin); err == nil { filter.PriceMin = &v } } if pmax := q.Get("price_max"); pmax != "" { if v, err := strconv.Atoi(pmax); err == nil { filter.PriceMax = &v } } if bounds := q.Get("bounds"); bounds != "" { var b models.Bounds if _, err := fmt.Sscanf(bounds, "%f,%f,%f,%f", &b.SWLat, &b.SWLng, &b.NELat, &b.NELng); err == nil { filter.Bounds = &b } else { slog.Warn("invalid bounds format", "bounds", bounds, "error", err) } } if limit := q.Get("limit"); limit != "" { if v, err := strconv.Atoi(limit); err == nil { filter.Limit_ = v } } filter.Sort = q.Get("sort") if q.Get("user_lat") != "" && q.Get("user_lng") != "" { if lat, err := strconv.ParseFloat(q.Get("user_lat"), 64); err == nil { filter.UserLat = &lat } if lng, err := strconv.ParseFloat(q.Get("user_lng"), 64); err == nil { filter.UserLng = &lng } } // Include tags and features if requested (e.g., ?include=tags,features) if include := q.Get("include"); include != "" { for _, v := range strings.Split(include, ",") { if v == "tags" || v == "features" { filter.IncludeTagsFeatures = true break } } } if cursor := q.Get("cursor"); cursor != "" { id, createdAt, rating, err := services.DecodeCursor(cursor) if err != nil { writeError(w, http.StatusBadRequest, "invalid cursor", err) return } filter.Cursor = id filter.CursorCreatedAt = &createdAt if q.Get("sort") == "rating" { filter.CursorRating = &rating } } result, err := h.placeSvc.List(r.Context(), filter) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list places", err) return } if result.Data == nil { result.Data = []*models.Place{} } writeJSON(w, http.StatusOK, result) } func (h *PlaceHandler) GetByID(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") place, err := h.placeSvc.GetByID(r.Context(), id, true) if err != nil { writeError(w, http.StatusInternalServerError, "failed to get place", err) return } if place == nil { writeError(w, http.StatusNotFound, "place not found", nil) return } writeJSON(w, http.StatusOK, place) } func (h *PlaceHandler) ListMy(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) places, err := h.placeSvc.List(r.Context(), models.PlaceFilter{ OwnerID: userID, IncludeTagsFeatures: true, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list places", err) return } if places.Data == nil { places.Data = []*models.Place{} } writeJSON(w, http.StatusOK, places) } type createPlaceRequest struct { Title string `json:"title" validate:"required,min=1,max=255"` Description *string `json:"description" validate:"omitempty,max=5000"` Address *string `json:"address" validate:"omitempty,max=500"` Lat float64 `json:"lat" validate:"required,latitude"` Lng float64 `json:"lng" validate:"required,longitude"` Type string `json:"type" validate:"required,place_type"` AccessInfo *string `json:"access_info" validate:"omitempty,max=2000"` CoverImage *string `json:"cover_image" validate:"omitempty,url,max=500"` Tags []string `json:"tags" validate:"dive,slug,max=50"` Features []string `json:"features" validate:"dive,slug,max=50"` HourlyRate *int `json:"hourly_rate" validate:"omitempty,min=0"` Currency string `json:"currency" validate:"omitempty,currency,len=3"` MinHours int `json:"min_hours" validate:"min=0,max=100"` Status string `json:"status" validate:"omitempty,place_status"` } type updatePlaceRequest struct { Title *string `json:"title" validate:"omitempty,min=1,max=255"` Description *string `json:"description" validate:"omitempty,max=5000"` Address *string `json:"address" validate:"omitempty,max=500"` Lat *float64 `json:"lat" validate:"omitempty,latitude"` Lng *float64 `json:"lng" validate:"omitempty,longitude"` AccessInfo *string `json:"access_info" validate:"omitempty,max=2000"` CoverImage *string `json:"cover_image" validate:"omitempty,url,max=500"` Tags []string `json:"tags" validate:"dive,slug,max=50"` Features []string `json:"features" validate:"dive,slug,max=50"` HourlyRate *int `json:"hourly_rate" validate:"omitempty,min=0"` Currency *string `json:"currency" validate:"omitempty,currency,len=3"` MinHours *int `json:"min_hours" validate:"omitempty,min=0,max=100"` } type moderatePlaceRequest struct { Action string `json:"action" validate:"required,oneof=approve reject rework revoke"` Comment *string `json:"comment" validate:"omitempty,max=1000"` } func (h *PlaceHandler) Create(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) role := middleware.GetUserRole(r.Context()) r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) var req createPlaceRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body", err) return } if err := validator.Validate(req); err != nil { writeValidationError(w, err) return } if req.Type == "studio" && role != "landlord" && role != "moderator" && role != "superadmin" { writeError(w, http.StatusForbidden, "only landlords can create studios", nil) return } if req.Type == "place" && role != "customer" && role != "moderator" && role != "superadmin" { writeError(w, http.StatusForbidden, "only customers can create places", nil) return } tags := make([]models.Tag, len(req.Tags)) for i, t := range req.Tags { tags[i] = models.Tag{ID: t} } features := make([]models.Feature, len(req.Features)) for i, f := range req.Features { features[i] = models.Feature{ID: f} } place, err := h.placeSvc.Create(r.Context(), services.CreatePlaceInput{ OwnerID: userID, Type: req.Type, Title: req.Title, Description: req.Description, Address: req.Address, Lat: req.Lat, Lng: req.Lng, CoverImage: req.CoverImage, AccessInfo: req.AccessInfo, Tags: tags, Features: features, HourlyRate: req.HourlyRate, Currency: req.Currency, MinHours: req.MinHours, Status: req.Status, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create place", err) return } writeJSON(w, http.StatusCreated, place) } func (h *PlaceHandler) Update(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") userID := middleware.GetUserID(r.Context()) role := middleware.GetUserRole(r.Context()) r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) var req updatePlaceRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body", err) return } if err := validator.Validate(req); err != nil { writeValidationError(w, err) return } isModerator := role == "moderator" || role == "superadmin" input := services.UpdatePlaceInput{ ID: id, OwnerID: userID, } if req.Title != nil { input.Title = req.Title } input.Description = req.Description input.Address = req.Address input.Lat = req.Lat input.Lng = req.Lng if req.CoverImage != nil { input.CoverImage = req.CoverImage } input.AccessInfo = req.AccessInfo input.HourlyRate = req.HourlyRate input.Currency = req.Currency input.MinHours = req.MinHours if req.Tags != nil { tags := make([]models.Tag, len(req.Tags)) for i, t := range req.Tags { tags[i] = models.Tag{ID: t} } input.Tags = tags } if req.Features != nil { features := make([]models.Feature, len(req.Features)) for i, f := range req.Features { features[i] = models.Feature{ID: f} } input.Features = features } place, err := h.placeSvc.Update(r.Context(), input, isModerator) if err != nil { if errors.Is(err, services.ErrNotYourPlace) { writeError(w, http.StatusForbidden, "not your place", nil) return } writeError(w, http.StatusInternalServerError, "failed to update place", err) return } if place == nil { writeError(w, http.StatusNotFound, "place not found", nil) return } writeJSON(w, http.StatusOK, place) } func (h *PlaceHandler) Moderate(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") userID := middleware.GetUserID(r.Context()) r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) var req moderatePlaceRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body", err) return } if err := validator.Validate(req); err != nil { writeValidationError(w, err) return } comment := "" if req.Comment != nil { comment = *req.Comment } if err := h.placeSvc.Moderate(r.Context(), id, req.Action, comment, userID); err != nil { writeError(w, http.StatusInternalServerError, "failed to moderate place", err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (h *PlaceHandler) Delete(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") userID := middleware.GetUserID(r.Context()) role := middleware.GetUserRole(r.Context()) place, err := h.placeSvc.GetByID(r.Context(), id, false) if err != nil { writeError(w, http.StatusInternalServerError, "failed to get place", err) return } if place == nil { writeError(w, http.StatusNotFound, "place not found", nil) return } if place.OwnerID != userID && role != "moderator" && role != "superadmin" { writeError(w, http.StatusForbidden, "not your place", nil) return } if err := h.placeSvc.Delete(r.Context(), id, userID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete place", err) return } w.WriteHeader(http.StatusNoContent) } func (h *PlaceHandler) HardDelete(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") userID := middleware.GetUserID(r.Context()) if err := h.placeSvc.HardDelete(r.Context(), id, userID); err != nil { writeError(w, http.StatusInternalServerError, "failed to hard-delete place", err) return } w.WriteHeader(http.StatusNoContent) } func (h *PlaceHandler) Restore(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") userID := middleware.GetUserID(r.Context()) if err := h.placeSvc.Restore(r.Context(), id, userID); err != nil { writeError(w, http.StatusInternalServerError, "failed to restore place", err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) }