places.go 12 KB

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