368 lines
11 KiB
Go
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()
|
|
}
|