dana-medical-site/internal/storage/content.go
2026-10-14 13:33:13 +00:00

368 lines
11 KiB
Go

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()
}