199 lines
6.5 KiB
Go
199 lines
6.5 KiB
Go
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) {
|
||
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)
|
||
}
|