package storage import ( "context" "dana/internal/domain" ) // ---------- Публикации: новости, статьи, акции ---------- // Posts возвращает публикации заданного вида. Для акций отсеиваются просроченные. func (s *Storage) Posts(ctx context.Context, kind string, limit int) ([]domain.Post, error) { if limit <= 0 || limit > 100 { limit = 20 } rows, err := s.pool.Query(ctx, ` SELECT p.id, p.kind, p.slug, p.title, p.lead, COALESCE(m.path, ''), p.starts_at, p.ends_at, p.published_at, p.seo_title, p.seo_description, p.seo_h1, p.discount_percent, p.terms, COALESCE(c.slug, '') FROM posts p LEFT JOIN media m ON m.id = p.image_id LEFT JOIN checkups c ON c.id = p.checkup_id AND c.published AND c.deleted_at IS NULL WHERE p.published AND p.deleted_at IS NULL AND ($1 = '' OR p.kind = $1) AND (p.kind <> 'promo' OR p.ends_at IS NULL OR p.ends_at >= current_date) ORDER BY p.published_at DESC LIMIT $2`, kind, limit) if err != nil { return nil, err } defer rows.Close() var out []domain.Post for rows.Next() { var p domain.Post if err := rows.Scan(&p.ID, &p.Kind, &p.Slug, &p.Title, &p.Lead, &p.Image, &p.StartsAt, &p.EndsAt, &p.PublishedAt, &p.SEO.Title, &p.SEO.Description, &p.SEO.H1, &p.DiscountPercent, &p.Terms, &p.CheckupSlug); err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } func (s *Storage) PostBySlug(ctx context.Context, kind, slug string) (*domain.Post, error) { var p domain.Post err := s.pool.QueryRow(ctx, ` SELECT p.id, p.kind, p.slug, p.title, p.lead, p.blocks, COALESCE(m.path, ''), p.starts_at, p.ends_at, p.published_at, p.seo_title, p.seo_description, p.seo_h1, p.discount_percent, p.terms, COALESCE(c.slug, '') FROM posts p LEFT JOIN media m ON m.id = p.image_id LEFT JOIN checkups c ON c.id = p.checkup_id AND c.published AND c.deleted_at IS NULL WHERE p.kind = $1 AND p.slug = $2 AND p.published AND p.deleted_at IS NULL`, kind, slug).Scan(&p.ID, &p.Kind, &p.Slug, &p.Title, &p.Lead, &p.Blocks, &p.Image, &p.StartsAt, &p.EndsAt, &p.PublishedAt, &p.SEO.Title, &p.SEO.Description, &p.SEO.H1, &p.DiscountPercent, &p.Terms, &p.CheckupSlug) if err != nil { return nil, wrapNotFound(err) } return &p, nil } // ---------- Отзывы ---------- // Reviews отдаёт только опубликованные (прошедшие модерацию) отзывы. func (s *Storage) Reviews(ctx context.Context, doctorSlug string, limit int) ([]domain.Review, error) { if limit <= 0 || limit > 200 { limit = 50 } rows, err := s.pool.Query(ctx, ` SELECT r.id, r.author_name, COALESCE(m.path, ''), r.rating, r.text, r.video_url, COALESCE(d.slug, ''), COALESCE(d.last_name || ' ' || d.first_name, ''), r.source, r.reply, r.created_at FROM reviews r LEFT JOIN media m ON m.id = r.author_photo_id LEFT JOIN doctors d ON d.id = r.doctor_id WHERE r.status = 'published' AND ($1 = '' OR d.slug = $1) ORDER BY r.created_at DESC, r.id DESC LIMIT $2`, doctorSlug, limit) if err != nil { return nil, err } defer rows.Close() var out []domain.Review for rows.Next() { var r domain.Review if err := rows.Scan(&r.ID, &r.AuthorName, &r.AuthorPhoto, &r.Rating, &r.Text, &r.VideoURL, &r.DoctorSlug, &r.DoctorName, &r.Source, &r.Reply, &r.CreatedAt); err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } // ---------- Вопрос-ответ ---------- func (s *Storage) FAQ(ctx context.Context, servicePath string) ([]domain.FAQItem, error) { rows, err := s.pool.Query(ctx, ` SELECT f.id, f.question, f.answer, f.service_id FROM faq f LEFT JOIN services s ON s.id = f.service_id WHERE f.published AND ($1 = '' OR s.path = $1) ORDER BY f.sort, f.id`, servicePath) if err != nil { return nil, err } defer rows.Close() var out []domain.FAQItem for rows.Next() { var f domain.FAQItem if err := rows.Scan(&f.ID, &f.Question, &f.Answer, &f.ServiceID); err != nil { return nil, err } out = append(out, f) } return out, rows.Err() } // ---------- Документы: лицензии, сертификаты, надзорные органы ---------- func (s *Storage) Documents(ctx context.Context, kind string) ([]domain.Document, error) { rows, err := s.pool.Query(ctx, ` SELECT d.id, d.kind, d.title, d.body, COALESCE(f.path, ''), COALESCE(p.path, ''), d.issued_at FROM documents d LEFT JOIN media f ON f.id = d.file_id LEFT JOIN media p ON p.id = d.preview_id WHERE d.published AND ($1 = '' OR d.kind = $1) ORDER BY d.sort, d.id`, kind) if err != nil { return nil, err } defer rows.Close() var out []domain.Document for rows.Next() { var d domain.Document if err := rows.Scan(&d.ID, &d.Kind, &d.Title, &d.Body, &d.File, &d.Preview, &d.IssuedAt); err != nil { return nil, err } out = append(out, d) } return out, rows.Err() } // ---------- Партнёры ---------- func (s *Storage) Partners(ctx context.Context) ([]domain.Partner, error) { rows, err := s.pool.Query(ctx, ` SELECT p.id, p.name, p.url, COALESCE(m.path, '') FROM partners p LEFT JOIN media m ON m.id = p.logo_id WHERE p.published ORDER BY p.sort, p.name`) if err != nil { return nil, err } defer rows.Close() var out []domain.Partner for rows.Next() { var p domain.Partner if err := rows.Scan(&p.ID, &p.Name, &p.URL, &p.Logo); err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } // ---------- Вакансии ---------- func (s *Storage) Vacancies(ctx context.Context) ([]domain.Vacancy, error) { rows, err := s.pool.Query(ctx, ` SELECT id, slug, title, description, duties, requirements, conditions, salary, branch_id FROM vacancies WHERE published ORDER BY sort, title`) if err != nil { return nil, err } defer rows.Close() var out []domain.Vacancy for rows.Next() { var v domain.Vacancy if err := rows.Scan(&v.ID, &v.Slug, &v.Title, &v.Description, &v.Duties, &v.Requirements, &v.Conditions, &v.Salary, &v.BranchID); err != nil { return nil, err } out = append(out, v) } return out, rows.Err() } // ---------- Филиалы ---------- func (s *Storage) Branches(ctx context.Context) ([]domain.Branch, error) { rows, err := s.pool.Query(ctx, ` SELECT id, slug, name, address, phones, work_hours, lat, lon FROM branches WHERE published ORDER BY sort, name`) if err != nil { return nil, err } defer rows.Close() var out []domain.Branch for rows.Next() { var b domain.Branch if err := rows.Scan(&b.ID, &b.Slug, &b.Name, &b.Address, &b.Phones, &b.WorkHours, &b.Lat, &b.Lon); err != nil { return nil, err } out = append(out, b) } return out, rows.Err() } // ---------- Чек-апы ---------- func (s *Storage) Checkups(ctx context.Context, audience string) ([]domain.Checkup, error) { rows, err := s.pool.Query(ctx, ` SELECT c.id, c.slug, c.audience, c.name, c.lead, c.price, c.old_price, COALESCE(m.path, ''), c.featured, c.seo_title, c.seo_description, c.seo_h1 FROM checkups c LEFT JOIN media m ON m.id = c.image_id WHERE c.published AND c.deleted_at IS NULL AND ($1 = '' OR c.audience = $1) ORDER BY c.sort, c.name`, audience) if err != nil { return nil, err } defer rows.Close() var out []domain.Checkup for rows.Next() { var c domain.Checkup if err := rows.Scan(&c.ID, &c.Slug, &c.Audience, &c.Name, &c.Lead, &c.Price, &c.OldPrice, &c.Image, &c.Featured, &c.SEO.Title, &c.SEO.Description, &c.SEO.H1); err != nil { return nil, err } out = append(out, c) } return out, rows.Err() } func (s *Storage) CheckupBySlug(ctx context.Context, slug string) (*domain.Checkup, error) { var c domain.Checkup err := s.pool.QueryRow(ctx, ` SELECT c.id, c.slug, c.audience, c.name, c.lead, c.blocks, c.price, c.old_price, COALESCE(m.path, ''), c.featured, c.seo_title, c.seo_description, c.seo_h1 FROM checkups c LEFT JOIN media m ON m.id = c.image_id WHERE c.slug = $1 AND c.published AND c.deleted_at IS NULL`, slug). Scan(&c.ID, &c.Slug, &c.Audience, &c.Name, &c.Lead, &c.Blocks, &c.Price, &c.OldPrice, &c.Image, &c.Featured, &c.SEO.Title, &c.SEO.Description, &c.SEO.H1) if err != nil { return nil, wrapNotFound(err) } itemRows, err := s.pool.Query(ctx, ` SELECT name FROM checkup_items WHERE checkup_id = $1 ORDER BY sort, id`, c.ID) if err != nil { return nil, err } defer itemRows.Close() for itemRows.Next() { var name string if err := itemRows.Scan(&name); err != nil { return nil, err } c.Items = append(c.Items, name) } return &c, itemRows.Err() } // ---------- Произвольные страницы ---------- func (s *Storage) PageBySlug(ctx context.Context, slug string) (*domain.Page, error) { var p domain.Page err := s.pool.QueryRow(ctx, ` SELECT id, slug, title, blocks, seo_title, seo_description, seo_h1 FROM pages WHERE slug = $1 AND published`, slug). Scan(&p.ID, &p.Slug, &p.Title, &p.Blocks, &p.SEO.Title, &p.SEO.Description, &p.SEO.H1) if err != nil { return nil, wrapNotFound(err) } return &p, nil } // ---------- Галерея ---------- func (s *Storage) Gallery(ctx context.Context) ([]domain.GalleryAlbum, error) { rows, err := s.pool.Query(ctx, ` SELECT a.id, a.slug, a.title, COALESCE(p.id, 0), COALESCE(m.path, ''), COALESCE(p.caption, '') FROM gallery_albums a LEFT JOIN gallery_photos p ON p.album_id = a.id LEFT JOIN media m ON m.id = p.media_id WHERE a.published ORDER BY a.sort, a.title, p.sort, p.id`) if err != nil { return nil, err } defer rows.Close() var out []domain.GalleryAlbum byID := map[int64]int{} // album id -> индекс в out for rows.Next() { var a domain.GalleryAlbum var ph domain.GalleryPhoto if err := rows.Scan(&a.ID, &a.Slug, &a.Title, &ph.ID, &ph.Path, &ph.Caption); err != nil { return nil, err } idx, seen := byID[a.ID] if !seen { out = append(out, a) idx = len(out) - 1 byID[a.ID] = idx } if ph.ID != 0 { out[idx].Photos = append(out[idx].Photos, ph) } } return out, rows.Err() } // ---------- Настройки сайта ---------- // Settings отдаёт публичные настройки как плоскую карту key -> value. func (s *Storage) Settings(ctx context.Context) (map[string]any, error) { rows, err := s.pool.Query(ctx, `SELECT key, value FROM settings ORDER BY key`) if err != nil { return nil, err } defer rows.Close() out := map[string]any{} for rows.Next() { var k string var v any if err := rows.Scan(&k, &v); err != nil { return nil, err } out[k] = v } return out, rows.Err() }