query.go 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. // M13a W5: DLQ query helpers, used by the per-channel admin
  2. // endpoints in deliverd-fcm and deliverd-telegram. The
  3. // shape mirrors what admind does for its global /v1/dlq
  4. // endpoint, but the package is shared so the SQL lives in
  5. // one place.
  6. package dlq
  7. import (
  8. "context"
  9. "encoding/json"
  10. "errors"
  11. "fmt"
  12. "strings"
  13. "time"
  14. "git3.techno-world.net/lrosales/broad-announce/internal/postgres"
  15. "github.com/jackc/pgx/v5"
  16. )
  17. // Row is the row shape returned by List / Get. Mirrors the
  18. // columns of deliveries_dlq (payload excluded from the list
  19. // view; Get returns it inline).
  20. type Row struct {
  21. ID int64 `json:"id"`
  22. AlertID string `json:"alert_id"`
  23. CompanyID string `json:"company_id"`
  24. IndividualID string `json:"individual_id"`
  25. Channel string `json:"channel"`
  26. Target string `json:"target"`
  27. OriginalSubject string `json:"original_subject"`
  28. Attempts int `json:"attempts"`
  29. LastError string `json:"last_error"`
  30. Discarded bool `json:"discarded"`
  31. DiscardedAt *time.Time `json:"discarded_at,omitempty"`
  32. DiscardedBy *string `json:"discarded_by,omitempty"`
  33. CreatedAt time.Time `json:"created_at"`
  34. Payload json.RawMessage `json:"payload,omitempty"`
  35. }
  36. // ListFilters is the input to List. Empty fields mean "no
  37. // filter on that field". Limit / Offset paginate.
  38. type ListFilters struct {
  39. Channel string // fcm | telegram | … (deliverd-* binaries always set this)
  40. CompanyID string
  41. AlertID string
  42. IncludeDiscarded bool
  43. Limit int
  44. Offset int
  45. }
  46. // List returns rows matching the filters, ordered by created_at
  47. // DESC. Excludes the payload column from the list view;
  48. // callers fetch /v1/admin/dlq/{id} for the full row.
  49. func List(ctx context.Context, pool *postgres.Pool, f ListFilters) ([]Row, error) {
  50. conds := []string{"created_at > now() - INTERVAL '30 days'"}
  51. args := []any{}
  52. if f.Channel != "" {
  53. args = append(args, f.Channel)
  54. conds = append(conds, fmt.Sprintf("channel = $%d", len(args)))
  55. }
  56. if f.CompanyID != "" {
  57. args = append(args, f.CompanyID)
  58. conds = append(conds, fmt.Sprintf("company_id = $%d", len(args)))
  59. }
  60. if f.AlertID != "" {
  61. args = append(args, f.AlertID)
  62. conds = append(conds, fmt.Sprintf("alert_id = $%d", len(args)))
  63. }
  64. if !f.IncludeDiscarded {
  65. conds = append(conds, "discarded = false")
  66. }
  67. where := strings.Join(conds, " AND ")
  68. limit := f.Limit
  69. if limit <= 0 || limit > 500 {
  70. limit = 50
  71. }
  72. offset := f.Offset
  73. if offset < 0 {
  74. offset = 0
  75. }
  76. args = append(args, limit, offset)
  77. q := fmt.Sprintf(`
  78. SELECT id, alert_id, company_id, individual_id, channel, target,
  79. original_subject, attempts, last_error, discarded,
  80. discarded_at, discarded_by, created_at
  81. FROM deliveries_dlq
  82. WHERE %s
  83. ORDER BY created_at DESC
  84. LIMIT $%d OFFSET $%d
  85. `, where, len(args)-1, len(args))
  86. rows, err := pool.Query(ctx, q, args...)
  87. if err != nil {
  88. return nil, fmt.Errorf("dlq.List: %w", err)
  89. }
  90. defer rows.Close()
  91. var out []Row
  92. for rows.Next() {
  93. var r Row
  94. var dAt *time.Time
  95. var dBy *string
  96. if err := rows.Scan(
  97. &r.ID, &r.AlertID, &r.CompanyID, &r.IndividualID,
  98. &r.Channel, &r.Target, &r.OriginalSubject, &r.Attempts,
  99. &r.LastError, &r.Discarded, &dAt, &dBy, &r.CreatedAt,
  100. ); err != nil {
  101. return nil, fmt.Errorf("dlq.List scan: %w", err)
  102. }
  103. r.DiscardedAt = dAt
  104. r.DiscardedBy = dBy
  105. out = append(out, r)
  106. }
  107. if err := rows.Err(); err != nil {
  108. return nil, fmt.Errorf("dlq.List rows: %w", err)
  109. }
  110. return out, nil
  111. }
  112. // Get returns one row by id, including the payload. Returns
  113. // (nil, nil) when the row doesn't exist (idempotent).
  114. func Get(ctx context.Context, pool *postgres.Pool, id int64) (*Row, error) {
  115. q := `
  116. SELECT id, alert_id, company_id, individual_id, channel, target,
  117. original_subject, attempts, last_error, discarded,
  118. discarded_at, discarded_by, created_at, payload
  119. FROM deliveries_dlq
  120. WHERE id = $1
  121. LIMIT 1
  122. `
  123. var r Row
  124. var dAt *time.Time
  125. var dBy *string
  126. err := pool.QueryRow(ctx, q, id).Scan(
  127. &r.ID, &r.AlertID, &r.CompanyID, &r.IndividualID,
  128. &r.Channel, &r.Target, &r.OriginalSubject, &r.Attempts,
  129. &r.LastError, &r.Discarded, &dAt, &dBy, &r.CreatedAt, &r.Payload,
  130. )
  131. if err != nil {
  132. if errors.Is(err, pgx.ErrNoRows) {
  133. return nil, nil
  134. }
  135. return nil, fmt.Errorf("dlq.Get: %w", err)
  136. }
  137. r.DiscardedAt = dAt
  138. r.DiscardedBy = dBy
  139. return &r, nil
  140. }