package storage import ( "context" "strings" "dana/internal/domain" ) var likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) // likePattern готовит подстроку для ILIKE ... ESCAPE '\': символы % и _ из запроса // ищутся буквально, а не работают как шаблон. func likePattern(q string) string { return "%" + likeEscaper.Replace(q) + "%" } func prefixPattern(q string) string { return likeEscaper.Replace(q) + "%" } // Search ищет по услугам, врачам, прайсу и чек-апам. types ограничивает, где искать // (пустая карта — везде); limit — сколько находок на каждый тип. func (s *Storage) Search(ctx context.Context, q string, types map[string]bool, limit int) (*domain.SearchResult, error) { res := &domain.SearchResult{ Query: q, Services: []domain.SearchHit{}, Doctors: []domain.SearchHit{}, Prices: []domain.SearchHit{}, Checkups: []domain.SearchHit{}, } want := func(t string) bool { return len(types) == 0 || types[t] } like, prefix := likePattern(q), prefixPattern(q) // Совпадения с начала названия показываются выше совпадений в середине. if want("services") { rows, err := s.pool.Query(ctx, ` SELECT s.name, COALESCE(p.name, ''), s.path, COALESCE(m.path, '') FROM services s LEFT JOIN services p ON p.id = s.parent_id LEFT JOIN media m ON m.id = s.image_id WHERE s.published AND s.deleted_at IS NULL AND (s.name ILIKE $1 ESCAPE '\' OR s.short_name ILIKE $1 ESCAPE '\') ORDER BY (s.name ILIKE $2 ESCAPE '\') DESC, s.parent_id NULLS FIRST, s.name LIMIT $3`, like, prefix, limit) if err != nil { return nil, err } for rows.Next() { h := domain.SearchHit{Type: "service"} if err := rows.Scan(&h.Title, &h.Subtitle, &h.Path, &h.Image); err != nil { rows.Close() return nil, err } res.Services = append(res.Services, h) } rows.Close() if err := rows.Err(); err != nil { return nil, err } } if want("doctors") { rows, err := s.pool.Query(ctx, ` SELECT concat_ws(' ', d.last_name, d.first_name, d.middle_name), d.position, d.slug, COALESCE(m.path, '') FROM doctors d LEFT JOIN media m ON m.id = d.photo_id WHERE d.published AND d.deleted_at IS NULL AND (concat_ws(' ', d.last_name, d.first_name, d.middle_name) ILIKE $1 ESCAPE '\' OR d.position ILIKE $1 ESCAPE '\') ORDER BY (d.last_name ILIKE $2 ESCAPE '\') DESC, d.sort, d.last_name LIMIT $3`, like, prefix, limit) if err != nil { return nil, err } for rows.Next() { h := domain.SearchHit{Type: "doctor"} if err := rows.Scan(&h.Title, &h.Subtitle, &h.Slug, &h.Image); err != nil { rows.Close() return nil, err } res.Doctors = append(res.Doctors, h) } rows.Close() if err := rows.Err(); err != nil { return nil, err } } if want("prices") { rows, err := s.pool.Query(ctx, ` SELECT pi.name, ps.name, pi.price FROM price_items pi JOIN price_sections ps ON ps.id = pi.section_id WHERE pi.published AND ps.published AND pi.name ILIKE $1 ESCAPE '\' ORDER BY (pi.name ILIKE $2 ESCAPE '\') DESC, pi.name LIMIT $3`, like, prefix, limit) if err != nil { return nil, err } for rows.Next() { h := domain.SearchHit{Type: "price"} var price float64 if err := rows.Scan(&h.Title, &h.Subtitle, &price); err != nil { rows.Close() return nil, err } h.Price = &price res.Prices = append(res.Prices, h) } rows.Close() if err := rows.Err(); err != nil { return nil, err } } if want("checkups") { rows, err := s.pool.Query(ctx, ` SELECT c.name, c.lead, c.slug, COALESCE(m.path, '') FROM checkups c LEFT JOIN media m ON m.id = c.image_id WHERE c.published AND c.deleted_at IS NULL AND c.name ILIKE $1 ESCAPE '\' ORDER BY (c.name ILIKE $2 ESCAPE '\') DESC, c.sort, c.name LIMIT $3`, like, prefix, limit) if err != nil { return nil, err } for rows.Next() { h := domain.SearchHit{Type: "checkup"} if err := rows.Scan(&h.Title, &h.Subtitle, &h.Slug, &h.Image); err != nil { rows.Close() return nil, err } res.Checkups = append(res.Checkups, h) } rows.Close() if err := rows.Err(); err != nil { return nil, err } } return res, nil }