323 lines
9.4 KiB
Go
323 lines
9.4 KiB
Go
package storage
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
|
||
"dana/internal/domain"
|
||
)
|
||
|
||
// Число отзывов считается по опубликованным отзывам. Рейтинг из админки имеет
|
||
// приоритет, а при пустом значении используется средняя оценка отзывов.
|
||
const doctorColumns = `d.id, d.slug, d.last_name, d.first_name, d.middle_name, d.position,
|
||
d.category, d.degree, d.practice_start_year, COALESCE(m.path, ''), d.quote,
|
||
d.languages, d.accepts_kids_from, d.booking_provider, d.medflex_user, d.absent,
|
||
d.seo_title, d.seo_description, d.seo_h1,
|
||
COALESCE(d.rating_override::float8,
|
||
(SELECT round(avg(r.rating)::numeric, 1)::float8 FROM reviews r
|
||
WHERE r.doctor_id = d.id AND r.status = 'published' AND r.rating IS NOT NULL)),
|
||
(SELECT count(*) FROM reviews r
|
||
WHERE r.doctor_id = d.id AND r.status = 'published')`
|
||
|
||
const medflexBase = "https://booking.medflex.ru/?user="
|
||
|
||
func scanDoctor(row pgx.Row) (domain.Doctor, error) {
|
||
var d domain.Doctor
|
||
var startYear *int
|
||
var medflexUser string
|
||
err := row.Scan(&d.ID, &d.Slug, &d.LastName, &d.FirstName, &d.MiddleName, &d.Position,
|
||
&d.Category, &d.Degree, &startYear, &d.Photo, &d.Quote,
|
||
&d.Languages, &d.KidsFrom, &d.Booking.Provider, &medflexUser, &d.Absent,
|
||
&d.SEO.Title, &d.SEO.Description, &d.SEO.H1,
|
||
&d.Rating, &d.ReviewCount)
|
||
if err != nil {
|
||
return d, err
|
||
}
|
||
d.FullName = strings.TrimSpace(strings.Join([]string{d.LastName, d.FirstName, d.MiddleName}, " "))
|
||
if startYear != nil && *startYear > 0 {
|
||
// Стаж считается, а не хранится текстом — иначе он устаревает.
|
||
years := time.Now().Year() - *startYear
|
||
if years >= 0 {
|
||
d.Experience = &years
|
||
}
|
||
}
|
||
if d.Booking.Provider == "medflex" && medflexUser != "" {
|
||
// При переносе со старого сайта в поле попала ссылка целиком;
|
||
// через админку можно ввести и только идентификатор.
|
||
if strings.HasPrefix(medflexUser, "http://") || strings.HasPrefix(medflexUser, "https://") {
|
||
d.Booking.URL = medflexUser
|
||
} else {
|
||
d.Booking.URL = medflexBase + medflexUser
|
||
}
|
||
}
|
||
return d, nil
|
||
}
|
||
|
||
func (s *Storage) collectDoctors(ctx context.Context, sql string, args ...any) ([]domain.Doctor, error) {
|
||
rows, err := s.pool.Query(ctx, sql, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []domain.Doctor
|
||
for rows.Next() {
|
||
d, err := scanDoctor(rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, d)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// DoctorFilter — необязательные фильтры списка врачей. Пустое поле не фильтрует.
|
||
type DoctorFilter struct {
|
||
Specialty string // slug специальности
|
||
Service string // path услуги
|
||
Branch string // slug филиала
|
||
Query string // поиск по ФИО и должности
|
||
}
|
||
|
||
func (s *Storage) Doctors(ctx context.Context, f DoctorFilter) ([]domain.Doctor, error) {
|
||
sql := `
|
||
SELECT ` + doctorColumns + `
|
||
FROM doctors d
|
||
LEFT JOIN media m ON m.id = d.photo_id
|
||
WHERE d.published AND d.deleted_at IS NULL`
|
||
var args []any
|
||
arg := func(v any) string {
|
||
args = append(args, v)
|
||
return "$" + strconv.Itoa(len(args))
|
||
}
|
||
|
||
if f.Specialty != "" {
|
||
sql += `
|
||
AND EXISTS (
|
||
SELECT 1 FROM doctor_specialties ds
|
||
JOIN specialties sp ON sp.id = ds.specialty_id
|
||
WHERE ds.doctor_id = d.id AND sp.slug = ` + arg(f.Specialty) + `)`
|
||
}
|
||
if f.Service != "" {
|
||
sql += `
|
||
AND EXISTS (
|
||
SELECT 1 FROM doctor_services dsv
|
||
JOIN services sv ON sv.id = dsv.service_id
|
||
WHERE dsv.doctor_id = d.id AND sv.path = ` + arg(f.Service) + `)`
|
||
}
|
||
if f.Branch != "" {
|
||
sql += `
|
||
AND EXISTS (
|
||
SELECT 1 FROM doctor_branches db
|
||
JOIN branches b ON b.id = db.branch_id
|
||
WHERE db.doctor_id = d.id AND b.slug = ` + arg(f.Branch) + `)`
|
||
}
|
||
if f.Query != "" {
|
||
p := arg(likePattern(f.Query))
|
||
sql += `
|
||
AND (concat_ws(' ', d.last_name, d.first_name, d.middle_name) ILIKE ` + p + ` ESCAPE '\'
|
||
OR d.position ILIKE ` + p + ` ESCAPE '\')`
|
||
}
|
||
sql += `
|
||
ORDER BY d.sort, d.last_name, d.id`
|
||
|
||
doctors, err := s.collectDoctors(ctx, sql, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.attachSpecialties(ctx, doctors)
|
||
}
|
||
|
||
func (s *Storage) DoctorsByService(ctx context.Context, serviceID int64) ([]domain.Doctor, error) {
|
||
doctors, err := s.collectDoctors(ctx, `
|
||
SELECT `+doctorColumns+`
|
||
FROM doctors d
|
||
JOIN doctor_services ds ON ds.doctor_id = d.id
|
||
LEFT JOIN media m ON m.id = d.photo_id
|
||
WHERE ds.service_id = $1 AND d.published AND d.deleted_at IS NULL
|
||
ORDER BY d.sort, d.last_name, d.id`, serviceID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Cards and JSON-LD must use the same primary-specialty URL as the directory.
|
||
return s.attachSpecialties(ctx, doctors)
|
||
}
|
||
|
||
func (s *Storage) DoctorBySlug(ctx context.Context, slug string) (*domain.Doctor, error) {
|
||
row := s.pool.QueryRow(ctx, `
|
||
SELECT `+doctorColumns+`
|
||
FROM doctors d
|
||
LEFT JOIN media m ON m.id = d.photo_id
|
||
WHERE d.slug = $1 AND d.published AND d.deleted_at IS NULL`, slug)
|
||
|
||
d, err := scanDoctor(row)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if err := s.pool.QueryRow(ctx, `SELECT bio FROM doctors WHERE id = $1`, d.ID).Scan(&d.Bio); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
entries, err := s.doctorBio(ctx, d.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
d.BioEntries = entries
|
||
|
||
services, err := s.doctorServices(ctx, d.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
d.Services = services
|
||
|
||
branches, err := s.doctorBranches(ctx, d.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
d.Branches = branches
|
||
|
||
withSpec, err := s.attachSpecialties(ctx, []domain.Doctor{d})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &withSpec[0], nil
|
||
}
|
||
|
||
func (s *Storage) doctorBio(ctx context.Context, doctorID int64) ([]domain.BioEntry, error) {
|
||
rows, err := s.pool.Query(ctx, `
|
||
SELECT kind, year_from, year_to, title, description
|
||
FROM doctor_bio_entries
|
||
WHERE doctor_id = $1
|
||
ORDER BY sort, year_from DESC NULLS LAST, id`, doctorID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []domain.BioEntry
|
||
for rows.Next() {
|
||
var e domain.BioEntry
|
||
if err := rows.Scan(&e.Kind, &e.YearFrom, &e.YearTo, &e.Title, &e.Description); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, e)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *Storage) doctorServices(ctx context.Context, doctorID int64) ([]domain.DoctorService, error) {
|
||
rows, err := s.pool.Query(ctx, `
|
||
SELECT sv.name, sv.path, sv.lead
|
||
FROM doctor_services ds
|
||
JOIN services sv ON sv.id = ds.service_id
|
||
WHERE ds.doctor_id = $1 AND sv.published AND sv.deleted_at IS NULL
|
||
ORDER BY sv.sort, sv.name, sv.id`, doctorID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []domain.DoctorService
|
||
for rows.Next() {
|
||
var ds domain.DoctorService
|
||
if err := rows.Scan(&ds.Name, &ds.Path, &ds.Lead); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, ds)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *Storage) doctorBranches(ctx context.Context, doctorID int64) ([]domain.DoctorBranch, error) {
|
||
rows, err := s.pool.Query(ctx, `
|
||
SELECT b.slug, b.name, b.address
|
||
FROM doctor_branches db
|
||
JOIN branches b ON b.id = db.branch_id
|
||
WHERE db.doctor_id = $1 AND b.published
|
||
ORDER BY b.sort, b.name`, doctorID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []domain.DoctorBranch
|
||
for rows.Next() {
|
||
var b domain.DoctorBranch
|
||
if err := rows.Scan(&b.Slug, &b.Name, &b.Address); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, b)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// attachSpecialties догружает специальности одним запросом, без N+1.
|
||
func (s *Storage) attachSpecialties(ctx context.Context, doctors []domain.Doctor) ([]domain.Doctor, error) {
|
||
if len(doctors) == 0 {
|
||
return doctors, nil
|
||
}
|
||
ids := make([]int64, len(doctors))
|
||
index := make(map[int64]int, len(doctors))
|
||
for i, d := range doctors {
|
||
ids[i] = d.ID
|
||
index[d.ID] = i
|
||
}
|
||
|
||
rows, err := s.pool.Query(ctx, `
|
||
SELECT ds.doctor_id, sp.id, sp.slug, sp.name, sp.name_plural
|
||
FROM doctor_specialties ds
|
||
JOIN specialties sp ON sp.id = ds.specialty_id
|
||
WHERE ds.doctor_id = ANY($1) AND sp.published
|
||
ORDER BY ds.doctor_id, ds.is_primary DESC, sp.sort, sp.id`, ids)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var doctorID int64
|
||
var sp domain.Specialty
|
||
if err := rows.Scan(&doctorID, &sp.ID, &sp.Slug, &sp.Name, &sp.NamePlural); err != nil {
|
||
return nil, err
|
||
}
|
||
if i, ok := index[doctorID]; ok {
|
||
doctors[i].Specialties = append(doctors[i].Specialties, sp)
|
||
}
|
||
}
|
||
return doctors, rows.Err()
|
||
}
|
||
|
||
func (s *Storage) Specialties(ctx context.Context) ([]domain.Specialty, error) {
|
||
rows, err := s.pool.Query(ctx, `
|
||
SELECT sp.id, sp.slug, sp.name, sp.name_plural, COUNT(d.id)
|
||
FROM specialties sp
|
||
LEFT JOIN doctor_specialties ds ON ds.specialty_id = sp.id
|
||
LEFT JOIN doctors d ON d.id = ds.doctor_id AND d.published AND d.deleted_at IS NULL
|
||
WHERE sp.published
|
||
GROUP BY sp.id
|
||
ORDER BY sp.sort, sp.name_plural, sp.id`)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []domain.Specialty
|
||
for rows.Next() {
|
||
var sp domain.Specialty
|
||
if err := rows.Scan(&sp.ID, &sp.Slug, &sp.Name, &sp.NamePlural, &sp.Count); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, sp)
|
||
}
|
||
return out, rows.Err()
|
||
}
|