dana-medical-site/internal/api/api.go

356 lines
18 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"
"errors"
"log/slog"
"net/http"
"reflect"
"strings"
"dana/internal/bitrix"
"dana/internal/crm"
"dana/internal/megafon"
"dana/internal/notify"
"dana/internal/onec"
"dana/internal/prodoctorov"
"dana/internal/sberhealth"
"dana/internal/storage"
)
type API struct {
store *storage.Storage
mediaDir string
notifier notify.Notifier
website *website
pbx *megafon.Client
pbxClinics map[string]*megafon.Client // АТС других клиник (clinics.go)
onec *onec.Client
onecHook string
bitrix *bitrix.Client
crm *crm.Client
crmMode string
smscLogin string
smscPassword string
smscCallbackToken string
prodoctorov *prodoctorov.Client
sberhealth *sberhealth.Client
limiter *rateLimiter
confirmKick chan struct{} // будит подтверждение записей (booking_confirm.go)
}
// WithBitrix включает разовую выгрузку номеров из Битрикс24 для «Отчёта по каналам».
// Без вебхука кнопка в отчёте просто не предлагается.
func (a *API) WithBitrix(c *bitrix.Client) { a.bitrix = c }
// WithCRM подключает интеграцию с новой CRM (создание сделок и синхронизация).
func (a *API) WithCRM(c *crm.Client, mode string) *API {
a.crm = c
a.crmMode = strings.ToLower(strings.TrimSpace(mode))
return a
}
func (a *API) bitrixEnabled() bool {
if a.bitrix == nil || !a.bitrix.Configured() {
return false
}
m := a.crmMode
return m == "" || m == "both" || m == "bitrix_only"
}
func (a *API) crmEnabled() bool {
if a.crm == nil || !a.crm.IsConfigured() {
return false
}
m := a.crmMode
return m == "" || m == "both" || m == "crm_only"
}
func (a *API) WithSMSC(login, password string) { a.smscLogin = login; a.smscPassword = password }
// WithSMSCCallbackToken включает приём обратных вызовов SMSC: без токена в адресе они отклоняются.
func (a *API) WithSMSCCallbackToken(t string) { a.smscCallbackToken = strings.TrimSpace(t) }
func New(store *storage.Storage, mediaDir string) *API {
return &API{store: store, mediaDir: mediaDir, limiter: newRateLimiter(), confirmKick: make(chan struct{}, 1)}
}
func (a *API) Routes() http.Handler {
mux := http.NewServeMux()
if a.website != nil {
mux.HandleFunc("GET /robots.txt", a.robots)
mux.HandleFunc("GET /sitemap.xml", a.sitemap)
mux.HandleFunc("GET /", a.websitePage)
}
mux.HandleFunc("GET /api/v1/health", a.health)
mux.HandleFunc("GET /api/v1/services", a.services)
mux.HandleFunc("GET /api/v1/services/{path...}", a.service)
mux.HandleFunc("GET /api/v1/specialties", a.specialties)
mux.HandleFunc("GET /api/v1/doctors", a.doctors)
mux.HandleFunc("GET /api/v1/doctors/{slug}", a.doctor)
mux.HandleFunc("GET /api/v1/doctors/{slug}/slots", a.doctorSlots)
mux.HandleFunc("GET /api/v1/prices", a.prices)
mux.HandleFunc("GET /api/v1/price-sections", a.priceSections)
mux.HandleFunc("GET /api/v1/checkups", a.checkups)
mux.HandleFunc("GET /api/v1/checkups/{slug}", a.checkup)
mux.HandleFunc("GET /api/v1/news", a.posts("news"))
mux.HandleFunc("GET /api/v1/news/{slug}", a.post("news"))
mux.HandleFunc("GET /api/v1/articles", a.posts("article"))
mux.HandleFunc("GET /api/v1/articles/{slug}", a.post("article"))
mux.HandleFunc("GET /api/v1/promos", a.posts("promo"))
mux.HandleFunc("GET /api/v1/promos/{slug}", a.post("promo"))
mux.HandleFunc("GET /api/v1/reviews", a.reviews)
mux.HandleFunc("GET /api/v1/faq", a.faq)
mux.HandleFunc("GET /api/v1/documents", a.documents)
mux.HandleFunc("GET /api/v1/partners", a.partners)
mux.HandleFunc("GET /api/v1/vacancies", a.vacancies)
mux.HandleFunc("GET /api/v1/branches", a.branches)
mux.HandleFunc("GET /api/v1/gallery", a.gallery)
mux.HandleFunc("GET /api/v1/settings", a.settings)
mux.HandleFunc("GET /api/v1/pages/{slug}", a.page)
mux.HandleFunc("GET /api/v1/banners", a.banners)
mux.HandleFunc("GET /api/v1/search", a.search)
// Формы и заявки с сайта.
mux.HandleFunc("GET /api/v1/forms", a.forms)
mux.HandleFunc("POST /api/v1/leads", a.createLead)
mux.HandleFunc("POST /api/v1/appointments", a.createAppointment)
mux.HandleFunc("POST /api/v1/auth/wait-call/init", a.waitCallInit)
mux.HandleFunc("GET /api/v1/auth/wait-call/status", a.waitCallStatus)
mux.HandleFunc("POST /api/v1/auth/smsc-callback", a.smscCallback)
mux.HandleFunc("GET /api/v1/auth/smsc-callback", a.smscCallback)
// --- админка ---
mux.HandleFunc("POST /api/v1/admin/login", a.adminLogin)
mux.HandleFunc("POST /api/v1/admin/logout", a.adminLogout)
mux.HandleFunc("GET /api/v1/admin/me", a.requireAuth(a.adminMe))
mux.HandleFunc("GET /api/v1/admin/services", a.requireAuth(a.adminServices))
mux.HandleFunc("POST /api/v1/admin/services", a.requireAuth(a.adminCreateService))
mux.HandleFunc("PUT /api/v1/admin/services/{id}", a.requireAuth(a.adminUpdateService))
mux.HandleFunc("POST /api/v1/onec/events", a.onecEvents)
mux.HandleFunc("POST /api/v1/onec/sync/all", a.onecSyncAll)
mux.HandleFunc("GET /api/v1/onec/appointments/pending", a.onecPendingAppointments)
mux.HandleFunc("POST /api/v1/onec/appointments/{id}/confirm", a.onecConfirmAppointment)
mux.HandleFunc("POST /api/v1/onec/appointments/{id}/reject", a.onecRejectAppointment)
mux.HandleFunc("POST /api/v1/onec/commands/{id}/ack", a.onecCommandAck)
mux.HandleFunc("GET /api/v1/admin/onec", a.requireAuth(a.adminOnec))
mux.HandleFunc("POST /api/v1/admin/onec/sync", a.requireAuth(a.adminOnecSync))
mux.HandleFunc("PUT /api/v1/admin/onec/doctors/{onec_id}/link", a.requireAuth(a.adminOnecLink))
mux.HandleFunc("PUT /api/v1/admin/onec/doctors/{onec_id}/booking", a.requireAuth(a.adminOnecBooking))
mux.HandleFunc("GET /api/v1/admin/onec/grid", a.requireAuth(a.adminOnecGrid))
mux.HandleFunc("GET /api/v1/admin/onec/appointments", a.requireAuth(a.adminOnecAppointments))
mux.HandleFunc("GET /api/v1/admin/booking-settings", a.requireAuth(a.adminBookingSettings))
mux.HandleFunc("PUT /api/v1/admin/booking-settings", a.requireAuth(a.adminUpdateBookingSettings))
mux.HandleFunc("GET /api/v1/admin/leads", a.requireAuth(a.adminLeads))
mux.HandleFunc("PUT /api/v1/admin/leads/{id}", a.requireAuth(a.adminUpdateLead))
mux.HandleFunc("GET /api/v1/admin/reviews", a.requireAuth(a.adminReviews))
mux.HandleFunc("POST /api/v1/admin/reviews", a.requireAuth(a.adminCreateReview))
mux.HandleFunc("PUT /api/v1/admin/reviews/{id}", a.requireAuth(a.adminUpdateReview))
mux.HandleFunc("DELETE /api/v1/admin/reviews/{id}", a.requireAuth(a.adminDeleteReview))
mux.HandleFunc("GET /api/v1/admin/banners", a.requireAuth(a.adminBanners))
mux.HandleFunc("POST /api/v1/admin/banners", a.requireAuth(a.adminCreateBanner))
mux.HandleFunc("PUT /api/v1/admin/banners/{id}", a.requireAuth(a.adminUpdateBanner))
mux.HandleFunc("DELETE /api/v1/admin/banners/{id}", a.requireAuth(a.adminDeleteBanner))
mux.HandleFunc("POST /api/v1/admin/media", a.requireAuth(a.adminUploadMedia))
mux.HandleFunc("GET /api/v1/admin/doctors", a.requireAuth(a.adminDoctors))
mux.HandleFunc("GET /api/v1/admin/doctors-quality", a.requireAuth(a.adminDoctorQuality))
mux.HandleFunc("POST /api/v1/admin/doctors", a.requireAuth(a.adminCreateDoctor))
mux.HandleFunc("PUT /api/v1/admin/doctors/{id}", a.requireAuth(a.adminUpdateDoctor))
mux.HandleFunc("GET /api/v1/admin/specialties", a.requireAuth(a.adminSpecialties))
mux.HandleFunc("POST /api/v1/admin/specialties", a.requireAuth(a.adminCreateSpecialty))
mux.HandleFunc("PUT /api/v1/admin/specialties/{id}", a.requireAuth(a.adminUpdateSpecialty))
mux.HandleFunc("DELETE /api/v1/admin/specialties/{id}", a.requireAuth(a.adminDeleteSpecialty))
mux.HandleFunc("PUT /api/v1/admin/doctors/{id}/links", a.requireAuth(a.adminSetDoctorLinks))
mux.HandleFunc("GET /api/v1/admin/doctors/{id}/bio", a.requireAuth(a.adminBioEntries))
mux.HandleFunc("POST /api/v1/admin/bio-entries", a.requireAuth(a.adminCreateBioEntry))
mux.HandleFunc("PUT /api/v1/admin/bio-entries/{id}", a.requireAuth(a.adminUpdateBioEntry))
mux.HandleFunc("DELETE /api/v1/admin/bio-entries/{id}", a.requireAuth(a.adminDeleteBioEntry))
mux.HandleFunc("GET /api/v1/admin/price-sections", a.requireAuth(a.adminPriceSections))
mux.HandleFunc("GET /api/v1/admin/price-items", a.requireAuth(a.adminPriceItems))
mux.HandleFunc("POST /api/v1/admin/price-items", a.requireAuth(a.adminCreatePriceItem))
mux.HandleFunc("PUT /api/v1/admin/price-items/{id}", a.requireAuth(a.adminUpdatePriceItem))
mux.HandleFunc("DELETE /api/v1/admin/price-items/{id}", a.requireAuth(a.adminDeletePriceItem))
mux.HandleFunc("GET /api/v1/admin/promos", a.requireAuth(a.adminPromos))
mux.HandleFunc("POST /api/v1/admin/promos", a.requireAuth(a.adminCreatePromo))
mux.HandleFunc("PUT /api/v1/admin/promos/{id}", a.requireAuth(a.adminUpdatePromo))
mux.HandleFunc("DELETE /api/v1/admin/promos/{id}", a.requireAuth(a.adminDeletePromo))
mux.HandleFunc("GET /api/v1/admin/checkups", a.requireAuth(a.adminCheckups))
mux.HandleFunc("POST /api/v1/admin/checkups", a.requireAuth(a.adminCreateCheckup))
mux.HandleFunc("PUT /api/v1/admin/checkups/{id}", a.requireAuth(a.adminUpdateCheckup))
// Новости (/admin/news) и статьи (/admin/articles) — одинаковые обработчики с разным видом записи.
for section, kind := range postKinds {
base := "/api/v1/admin/" + section
mux.HandleFunc("GET "+base, a.requireAuth(a.adminPosts(kind)))
mux.HandleFunc("POST "+base, a.requireAuth(a.adminCreatePost(kind)))
mux.HandleFunc("PUT "+base+"/{id}", a.requireAuth(a.adminUpdatePost(kind)))
mux.HandleFunc("DELETE "+base+"/{id}", a.requireAuth(a.adminDeletePost(kind)))
}
mux.HandleFunc("GET /api/v1/admin/vacancies", a.requireAuth(a.adminVacancies))
mux.HandleFunc("POST /api/v1/admin/vacancies", a.requireAuth(a.adminCreateVacancy))
mux.HandleFunc("PUT /api/v1/admin/vacancies/{id}", a.requireAuth(a.adminUpdateVacancy))
mux.HandleFunc("DELETE /api/v1/admin/vacancies/{id}", a.requireAuth(a.adminDeleteVacancy))
mux.HandleFunc("GET /api/v1/admin/documents", a.requireAuth(a.adminDocuments))
mux.HandleFunc("POST /api/v1/admin/documents", a.requireAuth(a.adminCreateDocument))
mux.HandleFunc("PUT /api/v1/admin/documents/{id}", a.requireAuth(a.adminUpdateDocument))
mux.HandleFunc("DELETE /api/v1/admin/documents/{id}", a.requireAuth(a.adminDeleteDocument))
mux.HandleFunc("GET /api/v1/admin/partners", a.requireAuth(a.adminPartners))
mux.HandleFunc("POST /api/v1/admin/partners", a.requireAuth(a.adminCreatePartner))
mux.HandleFunc("PUT /api/v1/admin/partners/{id}", a.requireAuth(a.adminUpdatePartner))
mux.HandleFunc("DELETE /api/v1/admin/partners/{id}", a.requireAuth(a.adminDeletePartner))
mux.HandleFunc("GET /api/v1/admin/dashboard", a.requireAuth(a.adminDashboard))
mux.HandleFunc("GET /api/v1/admin/stats", a.requireAuth(a.adminStats))
mux.HandleFunc("GET /api/v1/admin/clinics", a.requireAuth(a.adminClinics))
mux.HandleFunc("GET /api/v1/admin/share-link", a.requireAuth(a.adminShareLink))
mux.HandleFunc("POST /api/v1/admin/share-link", a.requireAuth(a.adminCreateShareLink))
mux.HandleFunc("DELETE /api/v1/admin/share-link", a.requireAuth(a.adminDeleteShareLink))
mux.HandleFunc("GET /api/v1/share/weekly-reports", a.shareWeeklyReports)
mux.HandleFunc("GET /api/v1/admin/pbx/status", a.requireAuth(a.adminPBXStatus))
mux.HandleFunc("POST /api/v1/admin/pbx/check", a.requireAuth(a.adminPBXCheck))
mux.HandleFunc("POST /api/v1/admin/pbx/sync", a.requireAuth(a.adminPBXSync))
mux.HandleFunc("GET /api/v1/admin/pbx/lines", a.requireAuth(a.adminPBXLines))
mux.HandleFunc("PUT /api/v1/admin/pbx/lines", a.requireAuth(a.adminSetPBXLine))
mux.HandleFunc("GET /api/v1/admin/pbx/report", a.requireAuth(a.adminPBXReport))
mux.HandleFunc("GET /api/v1/admin/reports", a.requireAuth(a.adminReports))
mux.HandleFunc("POST /api/v1/admin/reports", a.requireAuth(a.adminCreateReport))
mux.HandleFunc("POST /api/v1/admin/reports/period", a.requireAuth(a.adminReportPeriod))
mux.HandleFunc("GET /api/v1/admin/reports/{id}", a.requireAuth(a.adminReport))
mux.HandleFunc("POST /api/v1/admin/reports/{id}/cancel", a.requireAuth(a.adminCancelReport))
mux.HandleFunc("DELETE /api/v1/admin/reports/{id}", a.requireAuth(a.adminDeleteReport))
mux.HandleFunc("GET /api/v1/admin/reports/{id}/download", a.requireAuth(a.adminDownloadReport))
mux.HandleFunc("GET /api/v1/admin/weekly-reports", a.requireAuth(a.adminWeeklyReports))
mux.HandleFunc("DELETE /api/v1/admin/weekly-reports/{id}", a.requireAuth(a.adminDeleteWeeklyReport))
mux.HandleFunc("PUT /api/v1/admin/weekly-reports/{id}/budget", a.requireAuth(a.adminSetWeeklyBudget))
mux.HandleFunc("GET /api/v1/admin/redirects", a.requireAuth(a.adminRedirects))
mux.HandleFunc("POST /api/v1/admin/redirects", a.requireAuth(a.adminCreateRedirect))
mux.HandleFunc("DELETE /api/v1/admin/redirects/{id}", a.requireAuth(a.adminDeleteRedirect))
mux.HandleFunc("DELETE /api/v1/admin/not-found", a.requireAuth(a.adminDeleteNotFound))
mux.HandleFunc("GET /api/v1/admin/pages", a.requireAuth(a.adminPages))
mux.HandleFunc("PUT /api/v1/admin/pages/{id}", a.requireAuth(a.adminUpdatePage))
mux.HandleFunc("GET /api/v1/admin/settings", a.requireAuth(a.adminSettings))
mux.HandleFunc("PUT /api/v1/admin/settings", a.requireAuth(a.adminUpdateSettings))
mux.HandleFunc("GET /api/v1/admin/branches", a.requireAuth(a.adminBranches))
mux.HandleFunc("POST /api/v1/admin/branches", a.requireAuth(a.adminCreateBranch))
mux.HandleFunc("PUT /api/v1/admin/branches/{id}", a.requireAuth(a.adminUpdateBranch))
mux.HandleFunc("GET /api/v1/admin/faq", a.requireAuth(a.adminFAQ))
mux.HandleFunc("POST /api/v1/admin/faq", a.requireAuth(a.adminCreateFAQ))
mux.HandleFunc("PUT /api/v1/admin/faq/{id}", a.requireAuth(a.adminUpdateFAQ))
mux.HandleFunc("DELETE /api/v1/admin/faq/{id}", a.requireAuth(a.adminDeleteFAQ))
// Интерфейс админки (встроен в бинарник).
mux.Handle("GET /admin/", http.StripPrefix("/admin/", http.FileServer(http.FS(adminFS()))))
mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/", http.StatusFound)
})
// Раздача загруженных картинок: /media/... -> файлы из mediaDir.
if a.mediaDir != "" {
mux.Handle("GET /media/", mediaHandler(a.mediaDir))
}
return logging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
// Заголовки безопасности на все ответы. CSP только frame-ancestors —
// защита от кликджекинга без риска сломать SPA (скрипты/стили не трогаем).
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "SAMEORIGIN")
// Безопасные директивы, не ломающие SPA: запрет плагинов, чужого <base>,
// отправки форм на сторонние адреса и встраивания сайта в чужие фреймы.
// Полный script-src/style-src добавляется отдельно (сначала в Report-Only).
h.Set("Content-Security-Policy", "frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Strict-Transport-Security", "max-age=31536000") // браузер учитывает только по HTTPS
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/admin" || strings.HasPrefix(r.URL.Path, "/admin/") {
h.Set("X-Robots-Tag", "noindex, nofollow")
}
mux.ServeHTTP(w, r)
}))
}
type envelope struct {
Data any `json:"data"`
Meta map[string]any `json:"meta,omitempty"`
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(payload); err != nil {
slog.Error("encode response", "err", err)
}
}
func ok(w http.ResponseWriter, data any, meta map[string]any) {
writeJSON(w, http.StatusOK, envelope{Data: emptyIfNil(data), Meta: meta})
}
// emptyIfNil превращает nil-срез в пустой: Go кодирует его как null, и фронт
// падал бы на пустой выборке (поиск без результатов, врачи без отзывов).
func emptyIfNil(data any) any {
v := reflect.ValueOf(data)
if v.Kind() == reflect.Slice && v.IsNil() {
return reflect.MakeSlice(v.Type(), 0, 0).Interface()
}
return data
}
type apiError struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func fail(w http.ResponseWriter, status int, code, message string) {
var e apiError
e.Error.Code = code
e.Error.Message = message
writeJSON(w, status, e)
}
// handleErr переводит ошибки хранилища в ответ API.
func handleErr(w http.ResponseWriter, err error) {
if errors.Is(err, storage.ErrNotFound) {
fail(w, http.StatusNotFound, "not_found", "Не найдено")
return
}
if errors.Is(err, storage.ErrBadReference) {
fail(w, http.StatusUnprocessableEntity, "bad_reference", "Связанная запись не найдена, обновите страницу")
return
}
if errors.Is(err, storage.ErrConflict) {
fail(w, http.StatusConflict, "conflict", "Такой адрес уже занят другой записью")
return
}
slog.Error("request failed", "err", err)
fail(w, http.StatusInternalServerError, "internal", "Внутренняя ошибка")
}
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slog.Debug("request", "method", r.Method, "path", r.URL.Path)
next.ServeHTTP(w, r)
})
}