added ability to broadcast
This commit is contained in:
parent
7d41cbeb1e
commit
2491088e81
31 changed files with 1607 additions and 18 deletions
44
internal/store/broadcast.go
Normal file
44
internal/store/broadcast.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// BroadcastStore persists system broadcast campaigns and their durable
|
||||
// per-recipient delivery outbox.
|
||||
type BroadcastStore interface {
|
||||
// CreateBroadcast inserts the broadcast row and one pending recipient row
|
||||
// per id in recipientUserIDs, in a single transaction: a broadcast with
|
||||
// zero recipients (an empty "selected" list, or an "all" snapshot taken
|
||||
// when there happen to be no eligible users) is rejected with
|
||||
// domain.ErrBroadcastNoRecipients rather than created empty.
|
||||
CreateBroadcast(ctx context.Context, message string, targetMode domain.BroadcastTargetMode, recipientUserIDs []int64, createdBy string) (domain.Broadcast, error)
|
||||
// PendingBroadcastRecipients returns undelivered outbox rows across every
|
||||
// broadcast, oldest first, each carrying its broadcast's message text so
|
||||
// the worker can send without a second round trip per row.
|
||||
PendingBroadcastRecipients(ctx context.Context, limit int) ([]PendingBroadcastRecipient, error)
|
||||
// MarkBroadcastRecipientSent closes a recipient row as delivered.
|
||||
MarkBroadcastRecipientSent(ctx context.Context, recipientID int64) error
|
||||
// MarkBroadcastRecipientFailed records a failed attempt. The row stays
|
||||
// 'pending' (retried on the next cycle) until attempts reaches
|
||||
// domain.MaxBroadcastRecipientAttempts, at which point it becomes the
|
||||
// terminal 'failed' status.
|
||||
MarkBroadcastRecipientFailed(ctx context.Context, recipientID int64, reason string) error
|
||||
// ListBroadcasts pages broadcasts newest-first, each with sent/failed
|
||||
// counts derived live from its recipient rows.
|
||||
ListBroadcasts(ctx context.Context, beforeID int64, limit int) ([]domain.Broadcast, bool, error)
|
||||
// BroadcastByID returns one broadcast with derived counts.
|
||||
BroadcastByID(ctx context.Context, id int64) (domain.Broadcast, bool, error)
|
||||
}
|
||||
|
||||
// PendingBroadcastRecipient is one undelivered outbox row, joined with its
|
||||
// broadcast's message text.
|
||||
type PendingBroadcastRecipient struct {
|
||||
RecipientID int64
|
||||
BroadcastID int64
|
||||
UserID int64
|
||||
Attempts int
|
||||
Message string
|
||||
}
|
||||
188
internal/store/memory/broadcast.go
Normal file
188
internal/store/memory/broadcast.go
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// BroadcastStore is the in-memory implementation of store.BroadcastStore,
|
||||
// used by admin/app unit tests.
|
||||
type BroadcastStore struct {
|
||||
mu sync.Mutex
|
||||
broadcasts map[int64]domain.Broadcast
|
||||
recipients map[int64]*memBroadcastRecipient
|
||||
nextBID int64
|
||||
nextRID int64
|
||||
}
|
||||
|
||||
type memBroadcastRecipient struct {
|
||||
domain.BroadcastRecipient
|
||||
message string
|
||||
}
|
||||
|
||||
func NewBroadcastStore() *BroadcastStore {
|
||||
return &BroadcastStore{
|
||||
broadcasts: make(map[int64]domain.Broadcast),
|
||||
recipients: make(map[int64]*memBroadcastRecipient),
|
||||
}
|
||||
}
|
||||
|
||||
var _ store.BroadcastStore = (*BroadcastStore)(nil)
|
||||
|
||||
func (s *BroadcastStore) CreateBroadcast(_ context.Context, message string, targetMode domain.BroadcastTargetMode, recipientUserIDs []int64, createdBy string) (domain.Broadcast, error) {
|
||||
if len(recipientUserIDs) == 0 {
|
||||
return domain.Broadcast{}, domain.ErrBroadcastNoRecipients
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.nextBID++
|
||||
b := domain.Broadcast{
|
||||
ID: s.nextBID,
|
||||
Message: message,
|
||||
TargetMode: targetMode,
|
||||
CreatedBy: createdBy,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
seen := make(map[int64]bool, len(recipientUserIDs))
|
||||
for _, userID := range recipientUserIDs {
|
||||
if seen[userID] {
|
||||
continue
|
||||
}
|
||||
seen[userID] = true
|
||||
s.nextRID++
|
||||
s.recipients[s.nextRID] = &memBroadcastRecipient{
|
||||
BroadcastRecipient: domain.BroadcastRecipient{
|
||||
ID: s.nextRID,
|
||||
BroadcastID: b.ID,
|
||||
UserID: userID,
|
||||
Status: domain.BroadcastRecipientPending,
|
||||
},
|
||||
message: message,
|
||||
}
|
||||
b.TotalCount++
|
||||
}
|
||||
s.broadcasts[b.ID] = b
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (s *BroadcastStore) PendingBroadcastRecipients(_ context.Context, limit int) ([]store.PendingBroadcastRecipient, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// Iteration order over a map is unspecified; sort by recipient id (assigned
|
||||
// in creation order) so this matches the postgres backend's "oldest first".
|
||||
ids := make([]int64, 0, len(s.recipients))
|
||||
for id, r := range s.recipients {
|
||||
if r.Status == domain.BroadcastRecipientPending {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sortInt64s(ids)
|
||||
out := make([]store.PendingBroadcastRecipient, 0, limit)
|
||||
for _, id := range ids {
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
r := s.recipients[id]
|
||||
out = append(out, store.PendingBroadcastRecipient{
|
||||
RecipientID: r.ID, BroadcastID: r.BroadcastID, UserID: r.UserID, Attempts: r.Attempts, Message: r.message,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *BroadcastStore) MarkBroadcastRecipientSent(_ context.Context, recipientID int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
r, ok := s.recipients[recipientID]
|
||||
if !ok || r.Status != domain.BroadcastRecipientPending {
|
||||
return nil
|
||||
}
|
||||
r.Status = domain.BroadcastRecipientSent
|
||||
now := time.Now().UTC()
|
||||
r.SentAt = &now
|
||||
r.LastError = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BroadcastStore) MarkBroadcastRecipientFailed(_ context.Context, recipientID int64, reason string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
r, ok := s.recipients[recipientID]
|
||||
if !ok || r.Status != domain.BroadcastRecipientPending {
|
||||
return nil
|
||||
}
|
||||
r.Attempts++
|
||||
r.LastError = reason
|
||||
if r.Attempts >= domain.MaxBroadcastRecipientAttempts {
|
||||
r.Status = domain.BroadcastRecipientFailed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BroadcastStore) countsFor(broadcastID int64) (sent, failed int) {
|
||||
for _, r := range s.recipients {
|
||||
if r.BroadcastID != broadcastID {
|
||||
continue
|
||||
}
|
||||
switch r.Status {
|
||||
case domain.BroadcastRecipientSent:
|
||||
sent++
|
||||
case domain.BroadcastRecipientFailed:
|
||||
failed++
|
||||
}
|
||||
}
|
||||
return sent, failed
|
||||
}
|
||||
|
||||
func (s *BroadcastStore) ListBroadcasts(_ context.Context, beforeID int64, limit int) ([]domain.Broadcast, bool, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ids := make([]int64, 0, len(s.broadcasts))
|
||||
for id := range s.broadcasts {
|
||||
if beforeID == 0 || id < beforeID {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sortInt64sDesc(ids)
|
||||
hasMore := len(ids) > limit
|
||||
if hasMore {
|
||||
ids = ids[:limit]
|
||||
}
|
||||
out := make([]domain.Broadcast, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
b := s.broadcasts[id]
|
||||
b.SentCount, b.FailedCount = s.countsFor(id)
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
func (s *BroadcastStore) BroadcastByID(_ context.Context, id int64) (domain.Broadcast, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
b, ok := s.broadcasts[id]
|
||||
if !ok {
|
||||
return domain.Broadcast{}, false, nil
|
||||
}
|
||||
b.SentCount, b.FailedCount = s.countsFor(id)
|
||||
return b, true, nil
|
||||
}
|
||||
|
||||
func sortInt64s(v []int64) {
|
||||
sort.Slice(v, func(i, j int) bool { return v[i] < v[j] })
|
||||
}
|
||||
|
||||
func sortInt64sDesc(v []int64) {
|
||||
sort.Slice(v, func(i, j int) bool { return v[i] > v[j] })
|
||||
}
|
||||
193
internal/store/postgres/broadcast.go
Normal file
193
internal/store/postgres/broadcast.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
// BroadcastStore persists system broadcast campaigns (see
|
||||
// deploy/migrations/20260714003131_system_broadcasts.up.sql).
|
||||
type BroadcastStore struct {
|
||||
db sqlcgen.DBTX
|
||||
}
|
||||
|
||||
// NewBroadcastStore builds the store on a pgx pool or transaction.
|
||||
func NewBroadcastStore(db sqlcgen.DBTX) *BroadcastStore {
|
||||
return &BroadcastStore{db: db}
|
||||
}
|
||||
|
||||
var _ store.BroadcastStore = (*BroadcastStore)(nil)
|
||||
|
||||
// CreateBroadcast inserts the broadcast row and one pending recipient row per
|
||||
// id, deduplicating recipientUserIDs (a "selected" list built by hand in the
|
||||
// panel could otherwise carry a repeat) via ON CONFLICT DO NOTHING against
|
||||
// the (broadcast_id, user_id) unique constraint.
|
||||
func (s *BroadcastStore) CreateBroadcast(ctx context.Context, message string, targetMode domain.BroadcastTargetMode, recipientUserIDs []int64, createdBy string) (domain.Broadcast, error) {
|
||||
if len(recipientUserIDs) == 0 {
|
||||
return domain.Broadcast{}, domain.ErrBroadcastNoRecipients
|
||||
}
|
||||
var out domain.Broadcast
|
||||
err := withTx(ctx, s.db, "create broadcast", func(tx pgx.Tx) error {
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO broadcasts (message, target_mode, total_count, created_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, message, target_mode, total_count, created_by, created_at`,
|
||||
message, string(targetMode), len(recipientUserIDs), createdBy,
|
||||
).Scan(&out.ID, &out.Message, &out.TargetMode, &out.TotalCount, &out.CreatedBy, &out.CreatedAt); err != nil {
|
||||
return fmt.Errorf("insert broadcast: %w", err)
|
||||
}
|
||||
batch := &pgx.Batch{}
|
||||
for _, userID := range recipientUserIDs {
|
||||
batch.Queue(`
|
||||
INSERT INTO broadcast_recipients (broadcast_id, user_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (broadcast_id, user_id) DO NOTHING`, out.ID, userID)
|
||||
}
|
||||
results := tx.SendBatch(ctx, batch)
|
||||
defer results.Close()
|
||||
for range recipientUserIDs {
|
||||
if _, err := results.Exec(); err != nil {
|
||||
return fmt.Errorf("insert broadcast recipient: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Broadcast{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PendingBroadcastRecipients returns undelivered outbox rows, oldest first,
|
||||
// each carrying its broadcast's message text.
|
||||
func (s *BroadcastStore) PendingBroadcastRecipients(ctx context.Context, limit int) ([]store.PendingBroadcastRecipient, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT r.id, r.broadcast_id, r.user_id, r.attempts, b.message
|
||||
FROM broadcast_recipients r
|
||||
JOIN broadcasts b ON b.id = r.broadcast_id
|
||||
WHERE r.status = 'pending'
|
||||
ORDER BY r.id
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list pending broadcast recipients: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]store.PendingBroadcastRecipient, 0, limit)
|
||||
for rows.Next() {
|
||||
var item store.PendingBroadcastRecipient
|
||||
if err := rows.Scan(&item.RecipientID, &item.BroadcastID, &item.UserID, &item.Attempts, &item.Message); err != nil {
|
||||
return nil, fmt.Errorf("scan pending broadcast recipient: %w", err)
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate pending broadcast recipients: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MarkBroadcastRecipientSent closes a recipient row as delivered. Closing an
|
||||
// already-closed row is a no-op: the outbox is exactly-once, not
|
||||
// at-least-once.
|
||||
func (s *BroadcastStore) MarkBroadcastRecipientSent(ctx context.Context, recipientID int64) error {
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
UPDATE broadcast_recipients
|
||||
SET status = 'sent', sent_at = now(), last_error = ''
|
||||
WHERE id = $1 AND status = 'pending'`, recipientID); err != nil {
|
||||
return fmt.Errorf("mark broadcast recipient sent: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkBroadcastRecipientFailed records a failed delivery attempt. The row
|
||||
// stays 'pending' (retried on the next cycle) until attempts reaches
|
||||
// domain.MaxBroadcastRecipientAttempts, at which point it becomes the
|
||||
// terminal 'failed' status so a permanently blocked/deleted recipient
|
||||
// doesn't spin forever alongside real deliveries.
|
||||
func (s *BroadcastStore) MarkBroadcastRecipientFailed(ctx context.Context, recipientID int64, reason string) error {
|
||||
if len(reason) > 500 {
|
||||
reason = reason[:500]
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
UPDATE broadcast_recipients
|
||||
SET attempts = attempts + 1,
|
||||
last_error = $2,
|
||||
status = CASE WHEN attempts + 1 >= $3 THEN 'failed' ELSE 'pending' END
|
||||
WHERE id = $1 AND status = 'pending'`, recipientID, reason, domain.MaxBroadcastRecipientAttempts); err != nil {
|
||||
return fmt.Errorf("mark broadcast recipient failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const broadcastSelectColumns = `
|
||||
b.id, b.message, b.target_mode, b.total_count, b.created_by, b.created_at,
|
||||
count(*) FILTER (WHERE r.status = 'sent')::int AS sent_count,
|
||||
count(*) FILTER (WHERE r.status = 'failed')::int AS failed_count`
|
||||
|
||||
func scanBroadcastRow(row interface{ Scan(...any) error }, item *domain.Broadcast) error {
|
||||
return row.Scan(&item.ID, &item.Message, &item.TargetMode, &item.TotalCount, &item.CreatedBy, &item.CreatedAt,
|
||||
&item.SentCount, &item.FailedCount)
|
||||
}
|
||||
|
||||
// ListBroadcasts pages broadcasts newest-first, each with sent/failed counts
|
||||
// derived live from its recipient rows (never stored, so they can't drift).
|
||||
func (s *BroadcastStore) ListBroadcasts(ctx context.Context, beforeID int64, limit int) ([]domain.Broadcast, bool, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT `+broadcastSelectColumns+`
|
||||
FROM broadcasts b
|
||||
LEFT JOIN broadcast_recipients r ON r.broadcast_id = b.id
|
||||
WHERE $1::bigint = 0 OR b.id < $1
|
||||
GROUP BY b.id
|
||||
ORDER BY b.id DESC
|
||||
LIMIT $2`, beforeID, limit+1)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("list broadcasts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.Broadcast, 0, limit+1)
|
||||
for rows.Next() {
|
||||
var item domain.Broadcast
|
||||
if err := scanBroadcastRow(rows, &item); err != nil {
|
||||
return nil, false, fmt.Errorf("scan broadcast: %w", err)
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, fmt.Errorf("iterate broadcasts: %w", err)
|
||||
}
|
||||
hasMore := len(out) > limit
|
||||
if hasMore {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
// BroadcastByID returns one broadcast with derived counts.
|
||||
func (s *BroadcastStore) BroadcastByID(ctx context.Context, id int64) (domain.Broadcast, bool, error) {
|
||||
var item domain.Broadcast
|
||||
err := scanBroadcastRow(s.db.QueryRow(ctx, `
|
||||
SELECT `+broadcastSelectColumns+`
|
||||
FROM broadcasts b
|
||||
LEFT JOIN broadcast_recipients r ON r.broadcast_id = b.id
|
||||
WHERE b.id = $1
|
||||
GROUP BY b.id`, id), &item)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return domain.Broadcast{}, false, nil
|
||||
}
|
||||
return domain.Broadcast{}, false, fmt.Errorf("get broadcast: %w", err)
|
||||
}
|
||||
return item, true, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue