diff --git a/internal/api/admin_doctors.go b/internal/api/admin_doctors.go index dc98af1..69fc712 100644 --- a/internal/api/admin_doctors.go +++ b/internal/api/admin_doctors.go @@ -9,7 +9,7 @@ import ( "dana/internal/storage" ) -var bookingProviders = map[string]bool{"none": true, "native": true, "medflex": true} +var bookingProviders = map[string]bool{"none": true, "native": true} var bioKinds = map[string]bool{ "education": true, "experience": true, "training": true, "award": true, "conference": true, @@ -54,12 +54,6 @@ func (a *API) adminUpdateDoctor(w http.ResponseWriter, r *http.Request, u *stora fail(w, http.StatusUnprocessableEntity, "validation", "Рейтинг должен быть от 1,0 до 5,0") return } - // Medflex без ссылки работать не будет — не даём сохранить заведомо мёртвую кнопку. - if in.Booking == "medflex" && strings.TrimSpace(in.MedflexUser) == "" { - fail(w, http.StatusUnprocessableEntity, "validation", - "Для записи через Medflex нужна ссылка") - return - } oldSlug, err := a.store.PublishedDoctorSlug(r.Context(), id) if err != nil { handleErr(w, err) diff --git a/internal/api/adminui/doctors-dash.js b/internal/api/adminui/doctors-dash.js index 44dface..3a0cd5c 100644 --- a/internal/api/adminui/doctors-dash.js +++ b/internal/api/adminui/doctors-dash.js @@ -93,7 +93,7 @@ const DoctorDash = { ['Основные сведения', ({ d }) => !!(d.last_name && d.first_name && d.position && d.specialties)], ['Фото', ({ d }) => !!d.photo], ['Стаж', ({ d }) => d.practice_start_year != null], - ['Онлайн-запись', ({ d }) => d.booking_provider === 'medflex'], + ['Онлайн-запись', ({ d }) => d.booking_provider === 'native'], ['Категория', ({ d }) => !!d.category], ['«О враче»', ({ d }) => !!d.bio], ], @@ -172,7 +172,7 @@ const DoctorDash = { ['Образование', cnt(({ q }) => q.education > 0), (v) => `заполнено у ${v}`], ['Награды', cnt(({ q }) => q.awards > 0), (v) => `заполнено у ${v}`], ['Конференции', cnt(({ q }) => q.conferences > 0), (v) => `заполнено у ${v}`], - ['Онлайн-запись Medflex', cnt(({ d }) => d.booking_provider === 'medflex'), (v) => `подключена у ${v}`, 'no-medflex'], + ['Онлайн-запись 1С', cnt(({ d }) => d.booking_provider === 'native'), (v) => `подключена у ${v}`, 'no-booking'], ['Услуги врача', cnt(({ d }) => d.service_ids.length > 0), (v) => v ? `указаны у ${v}` : 'не указаны', 'no-services'], ['Филиалы приёма', cnt(({ d }) => d.branch_ids.length > 0), (v) => v ? `указаны у ${v}` : 'не заполнены', 'no-branches'], ['Отзывы', withReviews, () => (pending + live @@ -208,7 +208,7 @@ const DoctorDash = { 'Онлайн-запись', 'Услуг', 'Филиалов', 'Отзывов на проверке', 'Отзывов опубликовано', 'Заявок']]; for (const { d, q } of DoctorDash.rows()) { rows.push([fullName(d), d.specialties, yes(d.photo), d.practice_start_year ? new Date().getFullYear() - d.practice_start_year + ' лет' : 'нет', - d.category || 'нет', yes(d.bio), q.education || 0, q.awards || 0, q.conferences || 0, d.booking_provider === 'medflex' ? 'Medflex' : 'заявка', + d.category || 'нет', yes(d.bio), q.education || 0, q.awards || 0, q.conferences || 0, d.booking_provider === 'native' ? '1С' : 'заявка', d.service_ids.length, d.branch_ids.length, q.reviews_pending || 0, q.reviews_published || 0, q.leads || 0]); } const esc = (v) => { const x = String(v ?? ''); return /[";\n]/.test(x) ? '"' + x.replace(/"/g, '""') + '"' : x; }; @@ -224,7 +224,7 @@ $('#dd-spec').onchange = () => DoctorDash.render(); window.addEventListener('resize', debounce(() => { if (DoctorDash.q && !$('#tab-doctors-dash').hidden) DoctorDash.chart(DoctorDash.rows()); }, 150)); { const acts = [['users', 'Все врачи', () => DoctorDash.toList('')], ['check', 'Проверить карточки', () => DoctorDash.toList('incomplete')], - ['chat', 'Отзывы', () => go('reviews')], ['calendar', 'Онлайн-запись', () => DoctorDash.toList('no-medflex')], + ['chat', 'Отзывы', () => go('reviews')], ['calendar', 'Онлайн-запись', () => DoctorDash.toList('no-booking')], ['download', 'Экспорт отчёта', () => DoctorDash.exportCSV()]]; const bar = $('#dd-actions'); for (const [icon, label, fn] of acts) { diff --git a/internal/api/adminui/doctors.js b/internal/api/adminui/doctors.js index d4575b9..f957334 100644 --- a/internal/api/adminui/doctors.js +++ b/internal/api/adminui/doctors.js @@ -44,7 +44,7 @@ const Doctors = { (f !== 'no-services' || !d.service_ids.length) && (f !== 'no-branches' || !d.branch_ids.length) && (f !== 'no-photo' || !d.photo) && (f !== 'no-experience' || d.practice_start_year == null) && (f !== 'no-category' || !d.category) && - (f !== 'no-bio' || !d.bio) && (f !== 'no-medflex' || d.booking_provider !== 'medflex') && + (f !== 'no-bio' || !d.bio) && (f !== 'no-booking' || d.booking_provider !== 'native') && (f !== 'incomplete' || !d.photo || d.practice_start_year == null || (!d.bio && !d.bio_count)) && (!f.startsWith('sp:') || String(d.specialty_id) === f.slice(3))); const { key, dir } = Doctors.sort; @@ -150,7 +150,7 @@ const Doctors = { const body = { slug: d.slug, last_name: d.last_name, first_name: d.first_name, middle_name: d.middle_name, position: d.position, category: d.category, degree: d.degree, practice_start_year: d.practice_start_year, rating: d.rating, - photo_id: d.photo_id, quote: d.quote, bio: d.bio, booking_provider: d.booking_provider, medflex_user: d.medflex_user, + photo_id: d.photo_id, quote: d.quote, bio: d.bio, booking_provider: d.booking_provider, sort: d.sort, published: d.published, absent: d.absent, show_on_home: d.show_on_home, seo_title: d.seo_title, seo_description: d.seo_description, seo_h1: d.seo_h1, ...change, }; @@ -192,7 +192,7 @@ const Doctors = { const base = [['', 'Все врачи'], ['published', 'На сайте'], ['hidden', 'Скрытые'], ['absent', 'Не принимают'], ['no-services', 'Без услуг'], ['no-branches', 'Без филиала'], ['no-photo', 'Без фото'], ['incomplete', 'Неполные карточки'], ['no-experience', 'Без стажа'], ['no-category', 'Без категории'], - ['no-bio', 'Без «О враче»'], ['no-medflex', 'Без онлайн-записи']]; + ['no-bio', 'Без «О враче»'], ['no-booking', 'Без онлайн-записи 1С']]; for (const [v, t] of base) sel.append(el('option', { value: v, textContent: t })); const grp = el('optgroup', { label: 'Специальность' }); for (const s of specialtiesCache.filter((s) => s.doctors)) grp.append(el('option', { value: 'sp:' + s.id, textContent: `${s.name_plural} (${s.doctors})` })); diff --git a/internal/api/adminui/index.html b/internal/api/adminui/index.html index 92502d5..66fe08e 100644 --- a/internal/api/adminui/index.html +++ b/internal/api/adminui/index.html @@ -1717,8 +1717,7 @@ function editDoctor(d) { { key: 'bio', label: 'О враче — абзацы через пустую строку, пункты списка начинайте с «•»', value: d.bio, type: 'textarea', rows: 8 }, { key: 'quote', label: 'Цитата врача (необязательно)', value: d.quote, type: 'textarea', rows: 2 }, { key: 'booking_provider', label: 'Запись на приём', value: d.booking_provider || 'none', type: 'select', - options: [{ value: 'none', label: 'Заявка на сайте (перезвонит администратор)' }, { value: 'medflex', label: 'Онлайн-запись Medflex' }, { value: 'native', label: 'Своя запись' }] }, - { key: 'medflex_user', label: 'Ссылка Medflex (для онлайн-записи)', value: d.medflex_user }, + options: [{ value: 'none', label: 'Заявка на сайте (перезвонит администратор)' }, { value: 'native', label: 'Онлайн-запись через 1С' }] }, { key: 'absent', label: 'Временно не принимает (кнопка записи скрывается)', value: d.absent, type: 'checkbox' }, { key: 'show_on_home', label: 'Показывать на главной', value: d.show_on_home, type: 'checkbox' }, { key: 'sort', label: 'Порядок (меньше — выше в списке)', value: d.sort ?? 100, type: 'number' }, diff --git a/internal/api/adminui/onec.js b/internal/api/adminui/onec.js index 3f6df52..19353a5 100644 --- a/internal/api/adminui/onec.js +++ b/internal/api/adminui/onec.js @@ -165,7 +165,7 @@ const Onec = { if (!r.doctor_id) { way.append(ocEl('span', { className: 'muted', textContent: 'сначала привяжите карточку' })); } else { - const label = { native: '1С', medflex: 'Medflex', none: 'заявка', '': 'заявка' }[r.booking] || r.booking; + const label = { native: '1С', none: 'заявка', '': 'заявка' }[r.booking] || 'заявка'; way.append(ocEl('span', { className: 'pill ' + (bookingNative ? '' : 'grey'), textContent: label })); const b = ocEl('button', { type: 'button', className: 'btn ghost oc-small', textContent: bookingNative ? 'Отключить' : 'Включить запись через 1С' }); b.onclick = () => Onec.setBooking(r, !bookingNative); diff --git a/internal/api/booking_providers_test.go b/internal/api/booking_providers_test.go new file mode 100644 index 0000000..89617eb --- /dev/null +++ b/internal/api/booking_providers_test.go @@ -0,0 +1,24 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "dana/internal/storage" +) + +func TestDoctorRejectsRemovedExternalBookingProvider(t *testing.T) { + app := &API{} + r := httptest.NewRequest(http.MethodPut, "/api/v1/admin/doctors/1", strings.NewReader(`{"last_name":"Тест","slug":"test","booking_provider":"medflex","medflex_user":"https://booking.medflex.ru/example"}`)) + r.SetPathValue("id", "1") + w := httptest.NewRecorder() + app.adminUpdateDoctor(w, r, &storage.AdminUser{}) + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("removed provider accepted: %d %s", w.Code, w.Body.String()) + } + if len(bookingProviders) != 2 || !bookingProviders["native"] || !bookingProviders["none"] { + t.Fatal("only on-site appointment methods should remain") + } +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go index b55aab6..e430084 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -80,8 +80,7 @@ type DoctorService struct { } type Booking struct { - Provider string `json:"provider"` // none | native | medflex - URL string `json:"url,omitempty"` + Provider string `json:"provider"` // none | native } type BioEntry struct { diff --git a/internal/storage/admin_doctors.go b/internal/storage/admin_doctors.go index dfebc52..62105d3 100644 --- a/internal/storage/admin_doctors.go +++ b/internal/storage/admin_doctors.go @@ -22,7 +22,6 @@ type AdminDoctor struct { SpecialtyID *int64 `json:"specialty_id"` SpecialtySlug string `json:"specialty_slug"` Booking string `json:"booking_provider"` - MedflexUser string `json:"medflex_user"` Sort int `json:"sort"` Published bool `json:"published"` Absent bool `json:"absent"` @@ -48,7 +47,7 @@ func (s *Storage) AdminDoctors(ctx context.Context, q string) ([]AdminDoctor, er WHERE ds.doctor_id = d.id ), ''), ps.id, COALESCE(ps.slug, ''), - d.booking_provider, d.medflex_user, d.sort, d.published, + d.booking_provider, d.sort, d.published, d.absent, d.show_on_home, (SELECT count(*) FROM doctor_bio_entries e WHERE e.doctor_id = d.id), d.seo_title, d.seo_description, d.seo_h1, @@ -79,11 +78,14 @@ func (s *Storage) AdminDoctors(ctx context.Context, q string) ([]AdminDoctor, er &a.Position, &a.Category, &a.Degree, &a.PracticeYear, &a.Rating, &a.PhotoID, &a.Photo, &a.Quote, &a.Bio, &a.Specialties, &a.SpecialtyID, &a.SpecialtySlug, - &a.Booking, &a.MedflexUser, &a.Sort, &a.Published, + &a.Booking, &a.Sort, &a.Published, &a.Absent, &a.ShowOnHome, &a.BioCount, &a.SEOTitle, &a.SEODesc, &a.SEOH1, &a.ServiceIDs, &a.BranchIDs); err != nil { return nil, err } + if a.Booking != "native" { + a.Booking = "none" + } out = append(out, a) } return out, rows.Err() @@ -103,7 +105,6 @@ type DoctorUpdate struct { Quote string `json:"quote"` Bio string `json:"bio"` Booking string `json:"booking_provider"` - MedflexUser string `json:"medflex_user"` Sort int `json:"sort"` Published bool `json:"published"` Absent bool `json:"absent"` @@ -123,7 +124,7 @@ func (s *Storage) AdminUpdateDoctor(ctx context.Context, id int64, in DoctorUpda WHERE id=$1 AND deleted_at IS NULL`, id, in.Slug, in.LastName, in.FirstName, in.MiddleName, in.Position, in.Category, in.Degree, in.PracticeYear, in.Rating, - in.Quote, in.Bio, in.Booking, in.MedflexUser, + in.Quote, in.Bio, in.Booking, "", in.Sort, in.Published, in.Absent, in.ShowOnHome, in.SEOTitle, in.SEODesc, in.SEOH1, in.PhotoID) if err != nil { diff --git a/internal/storage/doctor_booking_cleanup_test.go b/internal/storage/doctor_booking_cleanup_test.go new file mode 100644 index 0000000..fb7846b --- /dev/null +++ b/internal/storage/doctor_booking_cleanup_test.go @@ -0,0 +1,74 @@ +package storage + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + "time" +) + +func TestDoctorExternalBookingCannotLeakOrReturn(t *testing.T) { + dsn := os.Getenv("DANA_AUTONOMY_TEST_DSN") + if dsn == "" { + t.Skip("requires disposable autonomy database") + } + ctx := context.Background() + s, err := New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err = s.Migrate(ctx); err != nil { + t.Fatal(err) + } + slug := fmt.Sprintf("legacy-external-%d", time.Now().UnixNano()) + var id int64 + if err = s.Pool().QueryRow(ctx, `INSERT INTO doctors(slug,last_name,first_name,booking_provider,medflex_user) VALUES($1,'Тест','Врач','medflex','https://booking.medflex.ru/legacy') RETURNING id`, slug).Scan(&id); err != nil { + t.Fatal(err) + } + d, err := s.DoctorBySlug(ctx, slug) + if err != nil { + t.Fatal(err) + } + public, _ := json.Marshal(d) + if d.Booking.Provider != "none" || strings.Contains(string(public), "medflex") { + t.Fatalf("legacy link leaked: %s", public) + } + rows, err := s.AdminDoctors(ctx, slug) + if err != nil { + t.Fatal(err) + } + if len(rows) != 0 { + t.Fatal("slug must not match the name filter") + } + rows, err = s.AdminDoctors(ctx, "") + if err != nil { + t.Fatal(err) + } + found := false + for _, row := range rows { + if row.ID == id { + found = true + admin, _ := json.Marshal(row) + if row.Booking != "none" || strings.Contains(string(admin), "medflex") { + t.Fatalf("legacy option leaked in admin: %s", admin) + } + } + } + if !found { + t.Fatal("doctor missing from admin") + } + if err = s.AdminUpdateDoctor(ctx, id, DoctorUpdate{Slug: slug, LastName: "Тест", FirstName: "Врач", Booking: "none", Published: true}); err != nil { + t.Fatal(err) + } + var link string + if err = s.Pool().QueryRow(ctx, `SELECT medflex_user FROM doctors WHERE id=$1`, id).Scan(&link); err != nil { + t.Fatal(err) + } + if link != "" { + t.Fatal("editing doctor restored an external link") + } +} diff --git a/internal/storage/doctors.go b/internal/storage/doctors.go index 8f05630..7d1df53 100644 --- a/internal/storage/doctors.go +++ b/internal/storage/doctors.go @@ -16,7 +16,7 @@ import ( // приоритет, а при пустом значении используется средняя оценка отзывов. 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.languages, d.accepts_kids_from, d.booking_provider, 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 @@ -24,15 +24,12 @@ const doctorColumns = `d.id, d.slug, d.last_name, d.first_name, d.middle_name, d (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.Languages, &d.KidsFrom, &d.Booking.Provider, &d.Absent, &d.SEO.Title, &d.SEO.Description, &d.SEO.H1, &d.Rating, &d.ReviewCount) if err != nil { @@ -46,14 +43,8 @@ func scanDoctor(row pgx.Row) (domain.Doctor, error) { 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 - } + if d.Booking.Provider != "native" { + d.Booking.Provider = "none" } return d, nil } diff --git a/web/src/components/DoctorCard.jsx b/web/src/components/DoctorCard.jsx index 2ffb22e..a8d32ba 100644 --- a/web/src/components/DoctorCard.jsx +++ b/web/src/components/DoctorCard.jsx @@ -9,16 +9,9 @@ export function roleLabel(doctor) { return doctor.position || doctor.specialties?.[0]?.name || ''; } -// Запись к врачу: у кого настроен Medflex — ведём туда, у остальных открываем форму заявки. +// Запись всегда остаётся на сайте: окна 1С либо обычная форма заявки. export function BookButton({ doctor, className = '', children = 'Записаться' }) { const { open } = useLeadModal(); - if (doctor.booking?.provider === 'medflex' && doctor.booking.url) { - return ( - - {children} - - ); - } return (