places.go 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. // Package handlers
  2. package handlers
  3. import (
  4. "encoding/json"
  5. "fmt"
  6. "net/http"
  7. "strconv"
  8. "strings"
  9. "github.com/go-chi/chi/v5"
  10. "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/middleware"
  11. "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models"
  12. "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/services"
  13. "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/validator"
  14. )
  15. type PlaceHandler struct {
  16. placeSvc *services.PlaceService
  17. }
  18. func NewPlaceHandler(placeSvc *services.PlaceService) *PlaceHandler {
  19. return &PlaceHandler{placeSvc: placeSvc}
  20. }
  21. func (h *PlaceHandler) List(w http.ResponseWriter, r *http.Request) {
  22. q := r.URL.Query()
  23. filter := models.PlaceFilter{
  24. Type: q.Get("type"),
  25. Status: "published",
  26. }
  27. if tags := q["tags"]; len(tags) > 0 {
  28. filter.TagIDs = tags
  29. }
  30. if features := q["features"]; len(features) > 0 {
  31. filter.FeatureIDs = features
  32. }
  33. if mr := q.Get("min_rating"); mr != "" {
  34. if v, err := strconv.ParseFloat(mr, 64); err == nil {
  35. filter.MinRating = v
  36. }
  37. }
  38. if pmin := q.Get("price_min"); pmin != "" {
  39. if v, err := strconv.Atoi(pmin); err == nil {
  40. filter.PriceMin = &v
  41. }
  42. }
  43. if pmax := q.Get("price_max"); pmax != "" {
  44. if v, err := strconv.Atoi(pmax); err == nil {
  45. filter.PriceMax = &v
  46. }
  47. }
  48. if bounds := q.Get("bounds"); bounds != "" {
  49. var b models.Bounds
  50. if _, err := fmt.Sscanf(bounds, "%f,%f,%f,%f", &b.SWLat, &b.SWLng, &b.NELat, &b.NELng); err == nil {
  51. filter.Bounds = &b
  52. }
  53. }
  54. if limit := q.Get("limit"); limit != "" {
  55. if v, err := strconv.Atoi(limit); err == nil {
  56. filter.Limit_ = v
  57. }
  58. }
  59. filter.Sort = q.Get("sort")
  60. if q.Get("user_lat") != "" && q.Get("user_lng") != "" {
  61. if lat, err := strconv.ParseFloat(q.Get("user_lat"), 64); err == nil {
  62. filter.UserLat = &lat
  63. }
  64. if lng, err := strconv.ParseFloat(q.Get("user_lng"), 64); err == nil {
  65. filter.UserLng = &lng
  66. }
  67. }
  68. // Include tags and features if requested (e.g., ?include=tags,features)
  69. if include := q.Get("include"); include != "" {
  70. for _, v := range strings.Split(include, ",") {
  71. if v == "tags" || v == "features" {
  72. filter.IncludeTagsFeatures = true
  73. break
  74. }
  75. }
  76. }
  77. if cursor := q.Get("cursor"); cursor != "" {
  78. id, createdAt, rating, err := services.DecodeCursor(cursor)
  79. if err != nil {
  80. writeError(w, http.StatusBadRequest, "invalid cursor", err)
  81. return
  82. }
  83. filter.Cursor = id
  84. filter.CursorCreatedAt = &createdAt
  85. if q.Get("sort") == "rating" {
  86. filter.CursorRating = &rating
  87. }
  88. }
  89. result, err := h.placeSvc.List(r.Context(), filter)
  90. if err != nil {
  91. writeError(w, http.StatusInternalServerError, "failed to list places", err)
  92. return
  93. }
  94. if result.Data == nil {
  95. result.Data = []*models.Place{}
  96. }
  97. writeJSON(w, http.StatusOK, result)
  98. }
  99. func (h *PlaceHandler) GetByID(w http.ResponseWriter, r *http.Request) {
  100. id := chi.URLParam(r, "id")
  101. place, err := h.placeSvc.GetByID(r.Context(), id, true)
  102. if err != nil {
  103. writeError(w, http.StatusInternalServerError, "failed to get place", err)
  104. return
  105. }
  106. if place == nil {
  107. writeError(w, http.StatusNotFound, "place not found", nil)
  108. return
  109. }
  110. writeJSON(w, http.StatusOK, place)
  111. }
  112. func (h *PlaceHandler) ListMy(w http.ResponseWriter, r *http.Request) {
  113. userID := middleware.GetUserID(r.Context())
  114. places, err := h.placeSvc.List(r.Context(), models.PlaceFilter{
  115. OwnerID: userID,
  116. IncludeTagsFeatures: true,
  117. })
  118. if err != nil {
  119. writeError(w, http.StatusInternalServerError, "failed to list places", err)
  120. return
  121. }
  122. writeJSON(w, http.StatusOK, places)
  123. }
  124. type createPlaceRequest struct {
  125. Title string `json:"title" validate:"required,min=1,max=255"`
  126. Description *string `json:"description" validate:"omitempty,max=5000"`
  127. Address *string `json:"address" validate:"omitempty,max=500"`
  128. Lat float64 `json:"lat" validate:"required,latitude"`
  129. Lng float64 `json:"lng" validate:"required,longitude"`
  130. Type string `json:"type" validate:"required,place_type"`
  131. AccessInfo *string `json:"access_info" validate:"omitempty,max=2000"`
  132. CoverImage *string `json:"cover_image" validate:"omitempty,url,max=500"`
  133. Tags []string `json:"tags" validate:"dive,slug,max=50"`
  134. Features []string `json:"features" validate:"dive,slug,max=50"`
  135. HourlyRate *int `json:"hourly_rate" validate:"omitempty,min=0"`
  136. Currency string `json:"currency" validate:"omitempty,currency,len=3"`
  137. MinHours int `json:"min_hours" validate:"min=0,max=100"`
  138. }
  139. type updatePlaceRequest struct {
  140. Title *string `json:"title" validate:"omitempty,min=1,max=255"`
  141. Description *string `json:"description" validate:"omitempty,max=5000"`
  142. Address *string `json:"address" validate:"omitempty,max=500"`
  143. Lat *float64 `json:"lat" validate:"omitempty,latitude"`
  144. Lng *float64 `json:"lng" validate:"omitempty,longitude"`
  145. AccessInfo *string `json:"access_info" validate:"omitempty,max=2000"`
  146. CoverImage *string `json:"cover_image" validate:"omitempty,url,max=500"`
  147. Tags []string `json:"tags" validate:"dive,slug,max=50"`
  148. Features []string `json:"features" validate:"dive,slug,max=50"`
  149. HourlyRate *int `json:"hourly_rate" validate:"omitempty,min=0"`
  150. Currency *string `json:"currency" validate:"omitempty,currency,len=3"`
  151. MinHours *int `json:"min_hours" validate:"omitempty,min=0,max=100"`
  152. }
  153. type moderatePlaceRequest struct {
  154. Action string `json:"action" validate:"required,oneof=approve reject"`
  155. Comment *string `json:"comment" validate:"omitempty,max=1000"`
  156. }
  157. func (h *PlaceHandler) Create(w http.ResponseWriter, r *http.Request) {
  158. userID := middleware.GetUserID(r.Context())
  159. role := middleware.GetUserRole(r.Context())
  160. r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
  161. var req createPlaceRequest
  162. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  163. writeError(w, http.StatusBadRequest, "invalid request body", err)
  164. return
  165. }
  166. if err := validator.Validate(req); err != nil {
  167. writeValidationError(w, err)
  168. return
  169. }
  170. if req.Type == "studio" && role != "landlord" && role != "moderator" && role != "superadmin" {
  171. writeError(w, http.StatusForbidden, "only landlords can create studios", nil)
  172. return
  173. }
  174. if req.Type == "place" && role != "customer" && role != "moderator" && role != "superadmin" {
  175. writeError(w, http.StatusForbidden, "only customers can create places", nil)
  176. return
  177. }
  178. tags := make([]models.Tag, len(req.Tags))
  179. for i, t := range req.Tags {
  180. tags[i] = models.Tag{ID: t}
  181. }
  182. features := make([]models.Feature, len(req.Features))
  183. for i, f := range req.Features {
  184. features[i] = models.Feature{ID: f}
  185. }
  186. place, err := h.placeSvc.Create(r.Context(), services.CreatePlaceInput{
  187. OwnerID: userID,
  188. Type: req.Type,
  189. Title: req.Title,
  190. Description: req.Description,
  191. Address: req.Address,
  192. Lat: req.Lat,
  193. Lng: req.Lng,
  194. CoverImage: req.CoverImage,
  195. AccessInfo: req.AccessInfo,
  196. Tags: tags,
  197. Features: features,
  198. HourlyRate: req.HourlyRate,
  199. Currency: req.Currency,
  200. MinHours: req.MinHours,
  201. })
  202. if err != nil {
  203. writeError(w, http.StatusInternalServerError, "failed to create place", err)
  204. return
  205. }
  206. writeJSON(w, http.StatusCreated, place)
  207. }
  208. func (h *PlaceHandler) Update(w http.ResponseWriter, r *http.Request) {
  209. id := chi.URLParam(r, "id")
  210. userID := middleware.GetUserID(r.Context())
  211. role := middleware.GetUserRole(r.Context())
  212. r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
  213. var req updatePlaceRequest
  214. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  215. writeError(w, http.StatusBadRequest, "invalid request body", err)
  216. return
  217. }
  218. if err := validator.Validate(req); err != nil {
  219. writeValidationError(w, err)
  220. return
  221. }
  222. isModerator := role == "moderator" || role == "superadmin"
  223. input := services.UpdatePlaceInput{
  224. ID: id,
  225. OwnerID: userID,
  226. }
  227. if req.Title != nil { input.Title = req.Title }
  228. input.Description = req.Description
  229. input.Address = req.Address
  230. input.Lat = req.Lat
  231. input.Lng = req.Lng
  232. input.AccessInfo = req.AccessInfo
  233. input.HourlyRate = req.HourlyRate
  234. input.Currency = req.Currency
  235. input.MinHours = req.MinHours
  236. place, err := h.placeSvc.Update(r.Context(), input, isModerator)
  237. if err != nil {
  238. writeError(w, http.StatusInternalServerError, "failed to update place", err)
  239. return
  240. }
  241. if place == nil {
  242. writeError(w, http.StatusNotFound, "place not found", nil)
  243. return
  244. }
  245. writeJSON(w, http.StatusOK, place)
  246. }
  247. func (h *PlaceHandler) Moderate(w http.ResponseWriter, r *http.Request) {
  248. id := chi.URLParam(r, "id")
  249. userID := middleware.GetUserID(r.Context())
  250. r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
  251. var req moderatePlaceRequest
  252. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  253. writeError(w, http.StatusBadRequest, "invalid request body", err)
  254. return
  255. }
  256. if err := validator.Validate(req); err != nil {
  257. writeValidationError(w, err)
  258. return
  259. }
  260. comment := ""
  261. if req.Comment != nil {
  262. comment = *req.Comment
  263. }
  264. if err := h.placeSvc.Moderate(r.Context(), id, req.Action, comment, userID); err != nil {
  265. writeError(w, http.StatusInternalServerError, "failed to moderate place", err)
  266. return
  267. }
  268. writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
  269. }
  270. func (h *PlaceHandler) Delete(w http.ResponseWriter, r *http.Request) {
  271. id := chi.URLParam(r, "id")
  272. userID := middleware.GetUserID(r.Context())
  273. role := middleware.GetUserRole(r.Context())
  274. place, err := h.placeSvc.GetByID(r.Context(), id, false)
  275. if err != nil {
  276. writeError(w, http.StatusInternalServerError, "failed to get place", err)
  277. return
  278. }
  279. if place == nil {
  280. writeError(w, http.StatusNotFound, "place not found", nil)
  281. return
  282. }
  283. if place.OwnerID != userID && role != "moderator" && role != "superadmin" {
  284. writeError(w, http.StatusForbidden, "not your place", nil)
  285. return
  286. }
  287. if err := h.placeSvc.Delete(r.Context(), id); err != nil {
  288. writeError(w, http.StatusInternalServerError, "failed to delete place", err)
  289. return
  290. }
  291. w.WriteHeader(http.StatusNoContent)
  292. }