places.go 12 KB

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