Files
spsays-bff/internal/repository/broadcast_repo.go
T
2026-08-29 18:34:07 +05:30

152 lines
4.8 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
var ErrBroadcastGone = errors.New("broadcast not found")
type BroadcastRepo struct{ pg *Postgres }
func NewBroadcastRepo(pg *Postgres) *BroadcastRepo { return &BroadcastRepo{pg: pg} }
const broadcastColumns = `id, title, body, entities, banner_key, cta_label,
cta_url, published_at, expires_at, created_at`
// scanBroadcast decodes a row produced by broadcastColumns. bannerKey stays
// out of the model — the service turns it into a URL.
func scanBroadcast(row pgx.Row, b *model.Broadcast, bannerKey **string) error {
return row.Scan(&b.ID, &b.Title, &b.Body, &b.Entities, bannerKey,
&b.CTALabel, &b.CTAURL, &b.PublishedAt, &b.ExpiresAt, &b.CreatedAt)
}
type CreateBroadcastParams struct {
Title string
Body string
Entities []byte
BannerKey *string
CTALabel *string
CTAURL *string
ExpiresAt *time.Time
CreatedBy string
}
func (r *BroadcastRepo) Create(ctx context.Context, p CreateBroadcastParams) (*model.Broadcast, *string, error) {
var b model.Broadcast
var key *string
err := scanBroadcast(r.pg.Pool.QueryRow(ctx, `
INSERT INTO broadcasts (title, body, entities, banner_key, cta_label,
cta_url, expires_at, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING `+broadcastColumns,
p.Title, p.Body, p.Entities, p.BannerKey, p.CTALabel, p.CTAURL,
p.ExpiresAt, p.CreatedBy), &b, &key)
if err != nil {
return nil, nil, fmt.Errorf("create broadcast: %w", err)
}
return &b, key, nil
}
// Publish stamps published_at, making the broadcast deliverable. Idempotent:
// re-publishing keeps the original timestamp so the live window doesn't shift.
func (r *BroadcastRepo) Publish(ctx context.Context, id string) (*model.Broadcast, *string, error) {
var b model.Broadcast
var key *string
err := scanBroadcast(r.pg.Pool.QueryRow(ctx, `
UPDATE broadcasts
SET published_at = COALESCE(published_at, now()), updated_at = now()
WHERE id = $1
RETURNING `+broadcastColumns, id), &b, &key)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, ErrBroadcastGone
}
if err != nil {
return nil, nil, fmt.Errorf("publish broadcast: %w", err)
}
return &b, key, nil
}
func (r *BroadcastRepo) Delete(ctx context.Context, id string) error {
tag, err := r.pg.Pool.Exec(ctx, `DELETE FROM broadcasts WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("delete broadcast: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrBroadcastGone
}
return nil
}
// Pending returns the newest live broadcast this user has not dismissed.
func (r *BroadcastRepo) Pending(ctx context.Context, userID string) (*model.Broadcast, *string, error) {
var b model.Broadcast
var key *string
err := scanBroadcast(r.pg.Pool.QueryRow(ctx, `
SELECT `+broadcastColumns+`
FROM broadcasts b
WHERE b.published_at IS NOT NULL
AND b.published_at <= now()
AND (b.expires_at IS NULL OR b.expires_at > now())
AND NOT EXISTS (
SELECT 1 FROM broadcast_views v
WHERE v.broadcast_id = b.id AND v.user_id = $1)
-- Newest first. Announcements are time-sensitive, so a stale one
-- must not gate a fresh one; dismissing this reveals the next, so
-- nothing is skipped either way — only the order differs.
ORDER BY b.published_at DESC
LIMIT 1`, userID), &b, &key)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, nil // nothing waiting is not an error
}
if err != nil {
return nil, nil, fmt.Errorf("pending broadcast: %w", err)
}
return &b, key, nil
}
// MarkSeen records a dismissal. Idempotent — two tabs can race.
func (r *BroadcastRepo) MarkSeen(ctx context.Context, id, userID string) error {
_, err := r.pg.Pool.Exec(ctx, `
INSERT INTO broadcast_views (broadcast_id, user_id) VALUES ($1,$2)
ON CONFLICT DO NOTHING`, id, userID)
if err != nil {
return fmt.Errorf("mark broadcast seen: %w", err)
}
return nil
}
// List returns broadcasts newest first, with dismissal counts, for the admin.
func (r *BroadcastRepo) List(ctx context.Context, limit int) ([]model.Broadcast, []*string, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT `+broadcastColumns+`,
(SELECT count(*) FROM broadcast_views v WHERE v.broadcast_id = b.id)
FROM broadcasts b
ORDER BY COALESCE(b.published_at, b.created_at) DESC
LIMIT $1`, limit)
if err != nil {
return nil, nil, fmt.Errorf("list broadcasts: %w", err)
}
defer rows.Close()
out := []model.Broadcast{}
keys := []*string{}
for rows.Next() {
var b model.Broadcast
var key *string
if err := rows.Scan(&b.ID, &b.Title, &b.Body, &b.Entities, &key,
&b.CTALabel, &b.CTAURL, &b.PublishedAt, &b.ExpiresAt, &b.CreatedAt,
&b.SeenCount); err != nil {
return nil, nil, fmt.Errorf("scan broadcast: %w", err)
}
out = append(out, b)
keys = append(keys, key)
}
return out, keys, rows.Err()
}