dana-medical-site/internal/api/admin_site.go
2026-10-14 13:33:13 +00:00

318 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}