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

241 lines
8.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package storage
import (
"context"
"encoding/json"
"time"
)
// ---------- Заявки ----------
type AdminLead struct {
ID int64 `json:"id"`
FormCode string `json:"form_code,omitempty"`
FormTitle string `json:"form_title,omitempty"`
Name string `json:"name"`
Phone string `json:"phone"`
Email string `json:"email,omitempty"`
ServiceName string `json:"service_name,omitempty"`
DoctorName string `json:"doctor_name,omitempty"`
Message string `json:"message,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
Status string `json:"status"`
Comment string `json:"comment,omitempty"`
PageURL string `json:"page_url,omitempty"`
UTM json.RawMessage `json:"utm,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// AdminLeads: постранично, с фильтрами по статусу, тексту и календарным датам.
func (s *Storage) AdminLeads(ctx context.Context, status, q string, from, to *time.Time, limit, offset int) ([]AdminLead, int, error) {
var total int
if err := s.pool.QueryRow(ctx, `
SELECT count(*) FROM leads l
WHERE ($1 = '' OR l.status = $1)
AND ($2 = '' OR l.name ILIKE '%' || $2 || '%' OR l.phone ILIKE '%' || $2 || '%' OR l.message ILIKE '%' || $2 || '%')
AND ($3::timestamptz IS NULL OR l.created_at >= $3)
AND ($4::timestamptz IS NULL OR l.created_at < $4)`,
status, q, from, to).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.pool.Query(ctx, `
SELECT l.id, COALESCE(f.code, ''), COALESCE(f.title, ''),
l.name, l.phone, l.email,
COALESCE(sv.name, ''), COALESCE(d.last_name || ' ' || d.first_name, ''),
l.message, l.payload, l.status, l.comment, l.page_url, l.utm, l.created_at
FROM leads l
LEFT JOIN forms f ON f.id = l.form_id
LEFT JOIN services sv ON sv.id = l.service_id
LEFT JOIN doctors d ON d.id = l.doctor_id
WHERE ($1 = '' OR l.status = $1)
AND ($2 = '' OR l.name ILIKE '%' || $2 || '%' OR l.phone ILIKE '%' || $2 || '%' OR l.message ILIKE '%' || $2 || '%')
AND ($3::timestamptz IS NULL OR l.created_at >= $3)
AND ($4::timestamptz IS NULL OR l.created_at < $4)
ORDER BY l.created_at DESC
LIMIT $5 OFFSET $6`, status, q, from, to, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []AdminLead
for rows.Next() {
var a AdminLead
if err := rows.Scan(&a.ID, &a.FormCode, &a.FormTitle, &a.Name, &a.Phone, &a.Email,
&a.ServiceName, &a.DoctorName, &a.Message, &a.Payload,
&a.Status, &a.Comment, &a.PageURL, &a.UTM, &a.CreatedAt); err != nil {
return nil, 0, err
}
out = append(out, a)
}
return out, total, rows.Err()
}
// LeadCounts — сколько заявок в каждом статусе, для бейджей в интерфейсе.
func (s *Storage) LeadCounts(ctx context.Context) (map[string]int, error) {
rows, err := s.pool.Query(ctx, `SELECT status, count(*) FROM leads GROUP BY status`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]int{}
for rows.Next() {
var st string
var n int
if err := rows.Scan(&st, &n); err != nil {
return nil, err
}
out[st] = n
}
return out, rows.Err()
}
func (s *Storage) LeadTodayCount(ctx context.Context) (int, error) {
var count int
now := time.Now().In(time.Local)
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
err := s.pool.QueryRow(ctx, `SELECT count(*) FROM leads WHERE created_at >= $1 AND created_at < $2`, start, start.AddDate(0, 0, 1)).Scan(&count)
return count, err
}
type LeadUpdate struct {
Status string `json:"status"`
Comment string `json:"comment"`
}
func (s *Storage) AdminUpdateLead(ctx context.Context, id int64, in LeadUpdate) error {
tag, err := s.pool.Exec(ctx, `
UPDATE leads SET status=$2, comment=$3, updated_at=now() WHERE id=$1`,
id, in.Status, in.Comment)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ---------- Отзывы (включая непроверенные) ----------
type AdminReview struct {
ID int64 `json:"id"`
AuthorName string `json:"author_name"`
Rating *int `json:"rating"`
Text string `json:"text"`
VideoURL string `json:"video_url"`
DoctorID *int64 `json:"doctor_id"`
DoctorName string `json:"doctor_name,omitempty"`
Source string `json:"source"`
Status string `json:"status"`
Reply string `json:"reply"`
CreatedAt time.Time `json:"created_at"`
}
func (s *Storage) AdminReviews(ctx context.Context, status, q string, limit, offset int) ([]AdminReview, int, error) {
var total int
if err := s.pool.QueryRow(ctx, `
SELECT count(*) FROM reviews r
WHERE ($1 = '' OR r.status = $1)
AND ($2 = '' OR r.author_name ILIKE '%' || $2 || '%' OR r.text ILIKE '%' || $2 || '%')`,
status, q).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.pool.Query(ctx, `
SELECT r.id, r.author_name, r.rating, r.text, r.video_url, r.doctor_id,
COALESCE(d.last_name || ' ' || d.first_name, ''),
r.source, r.status, r.reply, r.created_at
FROM reviews r
LEFT JOIN doctors d ON d.id = r.doctor_id
WHERE ($1 = '' OR r.status = $1)
AND ($2 = '' OR r.author_name ILIKE '%' || $2 || '%' OR r.text ILIKE '%' || $2 || '%')
ORDER BY
CASE r.status WHEN 'pending' THEN 0 ELSE 1 END, -- непроверенные сверху
r.created_at DESC
LIMIT $3 OFFSET $4`, status, q, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []AdminReview
for rows.Next() {
var a AdminReview
if err := rows.Scan(&a.ID, &a.AuthorName, &a.Rating, &a.Text, &a.VideoURL, &a.DoctorID,
&a.DoctorName, &a.Source, &a.Status, &a.Reply, &a.CreatedAt); err != nil {
return nil, 0, err
}
out = append(out, a)
}
return out, total, rows.Err()
}
func (s *Storage) ReviewCounts(ctx context.Context) (map[string]int, error) {
rows, err := s.pool.Query(ctx, `SELECT status, count(*) FROM reviews GROUP BY status`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]int{}
for rows.Next() {
var st string
var n int
if err := rows.Scan(&st, &n); err != nil {
return nil, err
}
out[st] = n
}
return out, rows.Err()
}
type ReviewUpdate struct {
AuthorName string `json:"author_name"`
Rating *int `json:"rating"`
Text string `json:"text"`
VideoURL string `json:"video_url"`
DoctorID *int64 `json:"doctor_id"`
Status string `json:"status"`
Reply string `json:"reply"`
}
func (s *Storage) AdminUpdateReview(ctx context.Context, id int64, in ReviewUpdate) error {
// moderated_at проставляется, когда отзыв уходит из ожидания проверки
tag, err := s.pool.Exec(ctx, `
UPDATE reviews SET author_name=$2, rating=$3, text=$4, video_url=$5, doctor_id=$6,
status=$7, reply=$8,
moderated_at = CASE WHEN $7 <> 'pending' THEN now() ELSE moderated_at END,
updated_at = now()
WHERE id=$1`,
id, in.AuthorName, in.Rating, in.Text, in.VideoURL, in.DoctorID, in.Status, in.Reply)
if err != nil {
return wrapConflict(err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Storage) AdminCreateReview(ctx context.Context, in ReviewUpdate) (int64, error) {
var id int64
err := s.pool.QueryRow(ctx, `
INSERT INTO reviews (author_name, rating, text, video_url, doctor_id, source, status, reply, moderated_at)
VALUES ($1,$2,$3,$4,$5,'manual',$6,$7, CASE WHEN $6 <> 'pending' THEN now() END)
RETURNING id`,
in.AuthorName, in.Rating, in.Text, in.VideoURL, in.DoctorID, in.Status, in.Reply).Scan(&id)
return id, wrapConflict(err)
}
func (s *Storage) AdminDeleteReview(ctx context.Context, id int64) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM reviews WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}