package api import ( "encoding/json" "net/http" "strconv" "strings" "time" "dana/internal/storage" ) // допустимые значения статусов — совпадают с CHECK в схеме var leadStatuses = map[string]bool{"new": true, "in_progress": true, "done": true, "spam": true} var reviewStatuses = map[string]bool{"pending": true, "published": true, "rejected": true} func pageParams(r *http.Request) (limit, offset int) { limit = queryInt(r, "limit", 50) if limit <= 0 || limit > 200 { limit = 50 } offset = queryInt(r, "offset", 0) if offset < 0 { offset = 0 } return limit, offset } // ---------- Заявки ---------- func (a *API) adminLeads(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { w.Header().Set("Cache-Control", "no-store") limit, offset := pageParams(r) q := r.URL.Query() var from, to *time.Time for _, filter := range []struct { key string out **time.Time }{{"from", &from}, {"to", &to}} { if value := q.Get(filter.key); value != "" { day, err := time.ParseInLocation("2006-01-02", value, time.Local) if err != nil { fail(w, http.StatusBadRequest, "bad_request", "Некорректная дата в фильтре") return } if filter.key == "to" { day = day.AddDate(0, 0, 1) } *filter.out = &day } } if from != nil && to != nil && !from.Before(*to) { fail(w, http.StatusBadRequest, "bad_request", "Дата начала позже даты окончания") return } list, total, err := a.store.AdminLeads(r.Context(), q.Get("status"), q.Get("q"), from, to, limit, offset) if err != nil { handleErr(w, err) return } counts, err := a.store.LeadCounts(r.Context()) if err != nil { handleErr(w, err) return } today, err := a.store.LeadTodayCount(r.Context()) if err != nil { handleErr(w, err) return } // Просмотр заявок — это доступ к персональным данным, фиксируем в журнале. a.store.Audit(r.Context(), u.ID, "view_leads", "lead", 0, clientIP(r)) ok(w, list, map[string]any{"total": total, "limit": limit, "offset": offset, "counts": counts, "today": today}) } func (a *API) adminUpdateLead(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") return } var in storage.LeadUpdate if err := json.NewDecoder(r.Body).Decode(&in); err != nil { fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") return } if !leadStatuses[in.Status] { fail(w, http.StatusUnprocessableEntity, "validation", "Недопустимый статус заявки") return } if err := a.store.AdminUpdateLead(r.Context(), id, in); err != nil { handleErr(w, err) return } a.store.Audit(r.Context(), u.ID, "update", "lead", id, clientIP(r)) ok(w, map[string]any{"id": id}, nil) } // ---------- Отзывы ---------- func (a *API) adminReviews(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { limit, offset := pageParams(r) q := r.URL.Query() list, total, err := a.store.AdminReviews(r.Context(), q.Get("status"), q.Get("q"), limit, offset) if err != nil { handleErr(w, err) return } counts, err := a.store.ReviewCounts(r.Context()) if err != nil { handleErr(w, err) return } ok(w, list, map[string]any{"total": total, "limit": limit, "offset": offset, "counts": counts}) } func decodeReview(w http.ResponseWriter, r *http.Request) (storage.ReviewUpdate, bool) { var in storage.ReviewUpdate if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil { fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") return in, false } in.AuthorName = strings.TrimSpace(in.AuthorName) in.Text = strings.TrimSpace(strings.ReplaceAll(in.Text, "\r", "")) in.Reply = strings.TrimSpace(strings.ReplaceAll(in.Reply, "\r", "")) if !reviewStatuses[in.Status] { fail(w, http.StatusUnprocessableEntity, "validation", "Недопустимый статус отзыва") return in, false } video, err := embedVideoURL(in.VideoURL) if err != nil { fail(w, http.StatusUnprocessableEntity, "validation", "Видео — ссылка на YouTube или Rutube, например https://youtu.be/…") return in, false } in.VideoURL = video // У видео-отзывов текста нет — содержимое там в ролике. if in.Text == "" && in.VideoURL == "" { fail(w, http.StatusUnprocessableEntity, "validation", "Отзыв должен содержать текст или ссылку на видео") return in, false } if in.AuthorName == "" { fail(w, http.StatusUnprocessableEntity, "validation", "Укажите имя автора отзыва") return in, false } if in.Rating != nil && (*in.Rating < 1 || *in.Rating > 5) { fail(w, http.StatusUnprocessableEntity, "validation", "Оценка должна быть от 1 до 5") return in, false } return in, true } // adminCreateReview — отзыв, записанный со слов пациента или видеоотзыв с YouTube. func (a *API) adminCreateReview(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { in, valid := decodeReview(w, r) if !valid { return } id, err := a.store.AdminCreateReview(r.Context(), in) if err != nil { handleErr(w, err) return } a.store.Audit(r.Context(), u.ID, "create", "review", id, clientIP(r)) writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}}) } func (a *API) adminUpdateReview(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") return } in, valid := decodeReview(w, r) if !valid { return } if err := a.store.AdminUpdateReview(r.Context(), id, in); err != nil { handleErr(w, err) return } a.store.Audit(r.Context(), u.ID, "update", "review", id, clientIP(r)) ok(w, map[string]any{"id": id}, nil) } func (a *API) adminDeleteReview(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") return } if err := a.store.AdminDeleteReview(r.Context(), id); err != nil { handleErr(w, err) return } a.store.Audit(r.Context(), u.ID, "delete", "review", id, clientIP(r)) ok(w, map[string]any{"id": id}, nil) }