318 lines
11 KiB
Go
318 lines
11 KiB
Go
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/url"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"dana/internal/storage"
|
||
)
|
||
|
||
// ---------- Страницы ----------
|
||
|
||
// Правовые страницы должны быть доступны всегда: на них ссылаются формы заявок
|
||
// (согласие на обработку персональных данных) и баннер cookie.
|
||
var requiredPages = map[string]bool{"legaldocs": true, "privacy": true, "consent": true, "cookie": true}
|
||
|
||
type pageRequest struct {
|
||
Title string `json:"title"`
|
||
HTML string `json:"html"`
|
||
Published bool `json:"published"`
|
||
SEOTitle string `json:"seo_title"`
|
||
SEODesc string `json:"seo_description"`
|
||
SEOH1 string `json:"seo_h1"`
|
||
}
|
||
|
||
func (a *API) adminPages(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
|
||
list, err := a.store.AdminPages(r.Context())
|
||
if err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
ok(w, list, map[string]any{"total": len(list)})
|
||
}
|
||
|
||
func (a *API) adminUpdatePage(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 req pageRequest
|
||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxPostBody)).Decode(&req); err != nil {
|
||
fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос или слишком большой текст")
|
||
return
|
||
}
|
||
current, err := a.store.AdminPage(r.Context(), id)
|
||
if err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
in := storage.PageInput{
|
||
Title: strings.TrimSpace(req.Title),
|
||
Published: req.Published,
|
||
SEOTitle: strings.TrimSpace(req.SEOTitle),
|
||
SEODesc: strings.TrimSpace(req.SEODesc),
|
||
SEOH1: strings.TrimSpace(req.SEOH1),
|
||
}
|
||
if in.Title == "" {
|
||
fail(w, http.StatusUnprocessableEntity, "validation", "Укажите заголовок страницы")
|
||
return
|
||
}
|
||
if requiredPages[current.Slug] && !in.Published {
|
||
fail(w, http.StatusUnprocessableEntity, "validation",
|
||
"Правовые документы нельзя скрыть: на них ссылаются формы заявок и баннер cookie")
|
||
return
|
||
}
|
||
html := strings.TrimSpace(req.HTML)
|
||
if requiredPages[current.Slug] && strings.TrimSpace(tagRE.ReplaceAllString(html, "")) == "" {
|
||
fail(w, http.StatusUnprocessableEntity, "validation", "Текст правового документа не может быть пустым")
|
||
return
|
||
}
|
||
// Блоки, которые админка не редактирует (например, фото на странице «О клинике»),
|
||
// сохраняются как были; текстовые заменяются одним блоком из редактора.
|
||
var blocks []json.RawMessage
|
||
var old []json.RawMessage
|
||
_ = json.Unmarshal(current.Blocks, &old)
|
||
for _, b := range old {
|
||
var head struct {
|
||
Type string `json:"type"`
|
||
HTML *string `json:"html"`
|
||
}
|
||
if json.Unmarshal(b, &head) == nil && head.HTML == nil && head.Type != "html" {
|
||
blocks = append(blocks, b)
|
||
}
|
||
}
|
||
if html != "" {
|
||
b, _ := json.Marshal(map[string]string{"type": "html", "html": html})
|
||
blocks = append(blocks, b)
|
||
}
|
||
if blocks == nil {
|
||
blocks = []json.RawMessage{}
|
||
}
|
||
in.Blocks, _ = json.Marshal(blocks)
|
||
if err := a.store.AdminUpdatePage(r.Context(), id, in); err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
a.store.Audit(r.Context(), u.ID, "update", "page", id, clientIP(r))
|
||
ok(w, map[string]any{"id": id}, nil)
|
||
}
|
||
|
||
// ---------- Настройки ----------
|
||
|
||
// Все настройки публичны (/api/v1/settings), поэтому админка пишет только известные
|
||
// ключи: пароли и ключи сервисов сюда попасть не должны, они живут в переменных окружения.
|
||
type legalSettings struct {
|
||
OrgName string `json:"org_name"`
|
||
OrgShort string `json:"org_short"`
|
||
INN string `json:"inn"`
|
||
OGRN string `json:"ogrn"`
|
||
Address string `json:"address"`
|
||
License string `json:"license"`
|
||
Email string `json:"email"`
|
||
}
|
||
|
||
type settingsRequest struct {
|
||
Email string `json:"email"`
|
||
MaxURL string `json:"max_url"`
|
||
Legal legalSettings `json:"legal"`
|
||
}
|
||
|
||
var (
|
||
emailRE = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
|
||
onlyDigitsRE = regexp.MustCompile(`^\d+$`)
|
||
)
|
||
|
||
func decodeSettings(w http.ResponseWriter, r *http.Request) (map[string]any, bool) {
|
||
var req settingsRequest
|
||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&req); err != nil {
|
||
fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
|
||
return nil, false
|
||
}
|
||
bad := func(msg string) (map[string]any, bool) {
|
||
fail(w, http.StatusUnprocessableEntity, "validation", msg)
|
||
return nil, false
|
||
}
|
||
trim := strings.TrimSpace
|
||
req.Email, req.MaxURL = trim(req.Email), trim(req.MaxURL)
|
||
l := &req.Legal
|
||
l.OrgName, l.OrgShort, l.INN, l.OGRN = trim(l.OrgName), trim(l.OrgShort), trim(l.INN), trim(l.OGRN)
|
||
l.Address, l.License, l.Email = trim(l.Address), trim(l.License), trim(l.Email)
|
||
|
||
if req.Email != "" && !emailRE.MatchString(req.Email) {
|
||
return bad("Проверьте адрес почты для сайта")
|
||
}
|
||
if req.MaxURL != "" {
|
||
u, err := url.Parse(req.MaxURL)
|
||
if err != nil || u.Scheme != "https" || !strings.Contains(u.Hostname(), ".") {
|
||
return bad("Ссылка на MAX — адрес вида https://max.ru/…")
|
||
}
|
||
}
|
||
// Реквизиты по закону должны быть на сайте медицинской организации — пустыми их не оставляем.
|
||
if l.OrgName == "" || l.OrgShort == "" || l.Address == "" || l.License == "" {
|
||
return bad("Заполните название организации, адрес и лицензию")
|
||
}
|
||
if !onlyDigitsRE.MatchString(l.INN) || (len(l.INN) != 10 && len(l.INN) != 12) {
|
||
return bad("ИНН — 10 или 12 цифр")
|
||
}
|
||
if !onlyDigitsRE.MatchString(l.OGRN) || (len(l.OGRN) != 13 && len(l.OGRN) != 15) {
|
||
return bad("ОГРН — 13 или 15 цифр")
|
||
}
|
||
if l.Email != "" && !emailRE.MatchString(l.Email) {
|
||
return bad("Проверьте почту в реквизитах")
|
||
}
|
||
return map[string]any{"email": req.Email, "max_url": req.MaxURL, "legal": req.Legal}, true
|
||
}
|
||
|
||
func (a *API) adminSettings(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
|
||
m, err := a.store.Settings(r.Context())
|
||
if err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
ok(w, m, nil)
|
||
}
|
||
|
||
func (a *API) adminUpdateSettings(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
|
||
values, valid := decodeSettings(w, r)
|
||
if !valid {
|
||
return
|
||
}
|
||
if err := a.store.AdminSetSettings(r.Context(), values); err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
a.store.Audit(r.Context(), u.ID, "update", "settings", 0, clientIP(r))
|
||
ok(w, values, nil)
|
||
}
|
||
|
||
// ---------- Филиалы ----------
|
||
|
||
type branchRequest struct {
|
||
Slug string `json:"slug"`
|
||
Name string `json:"name"`
|
||
Address string `json:"address"`
|
||
Phones []string `json:"phones"`
|
||
WorkHours []string `json:"work_hours"`
|
||
Lat *float64 `json:"lat"`
|
||
Lon *float64 `json:"lon"`
|
||
Sort int `json:"sort"`
|
||
Published bool `json:"published"`
|
||
}
|
||
|
||
func cleanList(items []string) []string {
|
||
out := []string{}
|
||
for _, s := range items {
|
||
if s = strings.TrimSpace(s); s != "" {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func decodeBranch(w http.ResponseWriter, r *http.Request) (storage.BranchInput, bool) {
|
||
var req branchRequest
|
||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil {
|
||
fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
|
||
return storage.BranchInput{}, false
|
||
}
|
||
in := storage.BranchInput{
|
||
Name: strings.TrimSpace(req.Name),
|
||
Address: strings.TrimSpace(req.Address),
|
||
Phones: cleanList(req.Phones),
|
||
WorkHours: cleanList(req.WorkHours),
|
||
Lat: req.Lat,
|
||
Lon: req.Lon,
|
||
Sort: req.Sort,
|
||
Published: req.Published,
|
||
}
|
||
if in.Sort == 0 {
|
||
in.Sort = 100
|
||
}
|
||
bad := func(msg string) (storage.BranchInput, bool) {
|
||
fail(w, http.StatusUnprocessableEntity, "validation", msg)
|
||
return in, false
|
||
}
|
||
if in.Name == "" || in.Address == "" {
|
||
return bad("Укажите название и адрес филиала")
|
||
}
|
||
slug, valid := resolveSlug(w, req.Slug, in.Name)
|
||
if !valid {
|
||
return in, false
|
||
}
|
||
in.Slug = slug
|
||
for _, p := range in.Phones {
|
||
// digitsRE (handlers_leads.go) совпадает с не-цифрами: после замены остаются только цифры
|
||
if n := len(digitsRE.ReplaceAllString(p, "")); n < 10 || n > 12 {
|
||
return bad("Телефон «" + p + "» — нужен номер целиком, например +7 928 962-00-94")
|
||
}
|
||
}
|
||
// Первый телефон первого филиала стоит в шапке сайта — без него показать нечего.
|
||
if in.Published && len(in.Phones) == 0 {
|
||
return bad("Укажите хотя бы один телефон филиала")
|
||
}
|
||
if (in.Lat == nil) != (in.Lon == nil) {
|
||
return bad("Укажите обе координаты или оставьте обе пустыми")
|
||
}
|
||
if in.Lat != nil && (*in.Lat < -90 || *in.Lat > 90 || *in.Lon < -180 || *in.Lon > 180) {
|
||
return bad("Координаты вне допустимого диапазона: широта −90…90, долгота −180…180")
|
||
}
|
||
return in, true
|
||
}
|
||
|
||
func (a *API) adminBranches(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
|
||
list, err := a.store.AdminBranches(r.Context())
|
||
if err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
ok(w, list, map[string]any{"total": len(list)})
|
||
}
|
||
|
||
func (a *API) adminCreateBranch(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
|
||
in, valid := decodeBranch(w, r)
|
||
if !valid {
|
||
return
|
||
}
|
||
id, err := a.store.AdminCreateBranch(r.Context(), in)
|
||
if err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
a.store.Audit(r.Context(), u.ID, "create", "branch", id, clientIP(r))
|
||
writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}})
|
||
}
|
||
|
||
func (a *API) adminUpdateBranch(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 := decodeBranch(w, r)
|
||
if !valid {
|
||
return
|
||
}
|
||
if err := a.store.AdminUpdateBranch(r.Context(), id, in); err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
a.store.Audit(r.Context(), u.ID, "update", "branch", id, clientIP(r))
|
||
ok(w, map[string]any{"id": id}, nil)
|
||
}
|
||
|
||
// ---------- Главный экран админки ----------
|
||
|
||
func (a *API) adminDashboard(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
|
||
d, err := a.store.Dashboard(r.Context(), 14)
|
||
if err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
ok(w, d, nil)
|
||
}
|