feat: backend service
This commit is contained in:
@@ -0,0 +1,606 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/postgres/jet/backend/model"
|
||||
"galaxy/backend/internal/postgres/jet/backend/table"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Store is the Postgres-backed query surface for notifications,
|
||||
// notification_routes, notification_dead_letters, and
|
||||
// notification_malformed_intents. All queries are built through go-jet
|
||||
// against the generated table bindings under
|
||||
// `backend/internal/postgres/jet/backend/table`.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore constructs a Store wrapping db.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// BeginTx exposes the transaction handle to the worker so the
|
||||
// claim-dispatch-mark cycle stays within a single commit boundary.
|
||||
func (s *Store) BeginTx(ctx context.Context) (*sql.Tx, error) {
|
||||
return s.db.BeginTx(ctx, nil)
|
||||
}
|
||||
|
||||
// RouteSeed describes one freshly-materialised route destined for an
|
||||
// `INSERT INTO notification_routes` inside InsertNotification.
|
||||
type RouteSeed struct {
|
||||
RouteID uuid.UUID
|
||||
Channel string
|
||||
Status string
|
||||
MaxAttempts int32
|
||||
NextAttemptAt *time.Time
|
||||
ResolvedEmail string
|
||||
ResolvedLocale string
|
||||
UserID *uuid.UUID
|
||||
DeviceSessionID *uuid.UUID
|
||||
SkippedAt *time.Time
|
||||
LastError string
|
||||
}
|
||||
|
||||
// InsertNotificationArgs aggregates the inputs to InsertNotification.
|
||||
type InsertNotificationArgs struct {
|
||||
NotificationID uuid.UUID
|
||||
Kind string
|
||||
IdempotencyKey string
|
||||
UserID *uuid.UUID
|
||||
Payload map[string]any
|
||||
Routes []RouteSeed
|
||||
}
|
||||
|
||||
// InsertNotification persists a notification row together with its
|
||||
// route rows in a single transaction. The (kind, idempotency_key)
|
||||
// UNIQUE constraint serves the idempotency contract: the second
|
||||
// caller observes inserted=false and the existing notification_id is
|
||||
// returned. On the duplicate path no route rows are inserted and the
|
||||
// transaction rolls back so an orphan notification cannot exist.
|
||||
func (s *Store) InsertNotification(ctx context.Context, args InsertNotificationArgs) (uuid.UUID, bool, error) {
|
||||
payload, err := encodePayload(args.Payload)
|
||||
if err != nil {
|
||||
return uuid.Nil, false, fmt.Errorf("encode payload: %w", err)
|
||||
}
|
||||
var (
|
||||
storedID uuid.UUID
|
||||
inserted bool
|
||||
)
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
insertStmt := table.Notifications.INSERT(
|
||||
table.Notifications.NotificationID,
|
||||
table.Notifications.Kind,
|
||||
table.Notifications.IdempotencyKey,
|
||||
table.Notifications.UserID,
|
||||
table.Notifications.Payload,
|
||||
).VALUES(
|
||||
args.NotificationID, args.Kind, args.IdempotencyKey, args.UserID, string(payload),
|
||||
).
|
||||
ON_CONFLICT(table.Notifications.Kind, table.Notifications.IdempotencyKey).
|
||||
DO_NOTHING().
|
||||
RETURNING(table.Notifications.NotificationID)
|
||||
|
||||
var freshRow model.Notifications
|
||||
err := insertStmt.QueryContext(ctx, tx, &freshRow)
|
||||
switch {
|
||||
case errors.Is(err, qrm.ErrNoRows):
|
||||
// Idempotent re-submit. Look up the existing row id and bail.
|
||||
lookupStmt := postgres.SELECT(table.Notifications.NotificationID).
|
||||
FROM(table.Notifications).
|
||||
WHERE(
|
||||
table.Notifications.Kind.EQ(postgres.String(args.Kind)).
|
||||
AND(table.Notifications.IdempotencyKey.EQ(postgres.String(args.IdempotencyKey))),
|
||||
).
|
||||
LIMIT(1)
|
||||
var existing model.Notifications
|
||||
if scanErr := lookupStmt.QueryContext(ctx, tx, &existing); scanErr != nil {
|
||||
return fmt.Errorf("lookup existing notification: %w", scanErr)
|
||||
}
|
||||
storedID = existing.NotificationID
|
||||
return errIdempotentNoop
|
||||
case err != nil:
|
||||
return fmt.Errorf("insert notification: %w", err)
|
||||
}
|
||||
storedID = freshRow.NotificationID
|
||||
inserted = true
|
||||
for _, r := range args.Routes {
|
||||
routeStmt := table.NotificationRoutes.INSERT(
|
||||
table.NotificationRoutes.RouteID,
|
||||
table.NotificationRoutes.NotificationID,
|
||||
table.NotificationRoutes.Channel,
|
||||
table.NotificationRoutes.Status,
|
||||
table.NotificationRoutes.MaxAttempts,
|
||||
table.NotificationRoutes.NextAttemptAt,
|
||||
table.NotificationRoutes.ResolvedEmail,
|
||||
table.NotificationRoutes.ResolvedLocale,
|
||||
table.NotificationRoutes.LastError,
|
||||
table.NotificationRoutes.SkippedAt,
|
||||
).VALUES(
|
||||
r.RouteID, args.NotificationID, r.Channel, r.Status,
|
||||
r.MaxAttempts, r.NextAttemptAt,
|
||||
r.ResolvedEmail, r.ResolvedLocale, r.LastError,
|
||||
r.SkippedAt,
|
||||
)
|
||||
if _, err := routeStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert route %s: %w", r.RouteID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, errIdempotentNoop) {
|
||||
return storedID, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
return storedID, inserted, nil
|
||||
}
|
||||
|
||||
// errIdempotentNoop tells withTx to roll back the transaction without
|
||||
// surfacing an error to the caller. It must never escape this package.
|
||||
var errIdempotentNoop = errors.New("notification store: idempotent noop")
|
||||
|
||||
// MarkRoutePublished flips a route to status='published', clears the
|
||||
// retry schedule, stamps published_at and last_attempt_at, and clears
|
||||
// last_error.
|
||||
func (s *Store) MarkRoutePublished(ctx context.Context, tx *sql.Tx, routeID uuid.UUID, at time.Time) error {
|
||||
r := table.NotificationRoutes
|
||||
stmt := r.UPDATE().
|
||||
SET(
|
||||
r.Status.SET(postgres.String(RouteStatusPublished)),
|
||||
r.Attempts.SET(r.Attempts.ADD(postgres.Int(1))),
|
||||
r.LastAttemptAt.SET(postgres.TimestampzT(at)),
|
||||
r.PublishedAt.SET(postgres.TimestampzT(at)),
|
||||
r.NextAttemptAt.SET(postgres.TimestampzExp(postgres.NULL)),
|
||||
r.LastError.SET(postgres.String("")),
|
||||
r.UpdatedAt.SET(postgres.TimestampzT(at)),
|
||||
).
|
||||
WHERE(r.RouteID.EQ(postgres.UUID(routeID)))
|
||||
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("mark route published: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScheduleRouteRetry flips a route to status='retrying', bumps
|
||||
// attempts, arms next_attempt_at, and stamps the diagnostic message.
|
||||
func (s *Store) ScheduleRouteRetry(ctx context.Context, tx *sql.Tx, routeID uuid.UUID, at time.Time, nextAt time.Time, errMsg string) error {
|
||||
r := table.NotificationRoutes
|
||||
stmt := r.UPDATE().
|
||||
SET(
|
||||
r.Status.SET(postgres.String(RouteStatusRetrying)),
|
||||
r.Attempts.SET(r.Attempts.ADD(postgres.Int(1))),
|
||||
r.LastAttemptAt.SET(postgres.TimestampzT(at)),
|
||||
r.NextAttemptAt.SET(postgres.TimestampzT(nextAt)),
|
||||
r.LastError.SET(postgres.String(errMsg)),
|
||||
r.UpdatedAt.SET(postgres.TimestampzT(at)),
|
||||
).
|
||||
WHERE(r.RouteID.EQ(postgres.UUID(routeID)))
|
||||
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("schedule route retry: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkRouteDeadLettered moves the route to the terminal `dead_lettered`
|
||||
// state and inserts a notification_dead_letters row under the same
|
||||
// transaction.
|
||||
func (s *Store) MarkRouteDeadLettered(ctx context.Context, tx *sql.Tx, notificationID, routeID uuid.UUID, at time.Time, reason string) error {
|
||||
r := table.NotificationRoutes
|
||||
updateStmt := r.UPDATE().
|
||||
SET(
|
||||
r.Status.SET(postgres.String(RouteStatusDeadLettered)),
|
||||
r.Attempts.SET(r.Attempts.ADD(postgres.Int(1))),
|
||||
r.LastAttemptAt.SET(postgres.TimestampzT(at)),
|
||||
r.NextAttemptAt.SET(postgres.TimestampzExp(postgres.NULL)),
|
||||
r.DeadLetteredAt.SET(postgres.TimestampzT(at)),
|
||||
r.LastError.SET(postgres.String(reason)),
|
||||
r.UpdatedAt.SET(postgres.TimestampzT(at)),
|
||||
).
|
||||
WHERE(r.RouteID.EQ(postgres.UUID(routeID)))
|
||||
if _, err := updateStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("mark route dead-lettered: %w", err)
|
||||
}
|
||||
|
||||
dl := table.NotificationDeadLetters
|
||||
insertStmt := dl.INSERT(
|
||||
dl.DeadLetterID, dl.NotificationID, dl.RouteID, dl.ArchivedAt, dl.Reason,
|
||||
).VALUES(uuid.New(), notificationID, routeID, at, reason)
|
||||
if _, err := insertStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert notification dead-letter: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClaimedRoute bundles a locked route row with its parent notification
|
||||
// so the worker has every field it needs in one trip.
|
||||
type ClaimedRoute struct {
|
||||
Route Route
|
||||
Notification Notification
|
||||
}
|
||||
|
||||
// ClaimDueRoutes locks up to `limit` due routes with FOR UPDATE SKIP
|
||||
// LOCKED, joins the parent notification to surface kind/payload, and
|
||||
// returns them. exclude is the list of route_ids already handled in
|
||||
// the current tick — they are filtered out so the same row cannot
|
||||
// chew through MaxAttempts inside a single tick when its retry
|
||||
// schedule lands at <= now().
|
||||
func (s *Store) ClaimDueRoutes(ctx context.Context, tx *sql.Tx, limit int, exclude ...uuid.UUID) ([]ClaimedRoute, error) {
|
||||
r := table.NotificationRoutes
|
||||
n := table.Notifications
|
||||
|
||||
condition := r.Status.IN(postgres.String(RouteStatusPending), postgres.String(RouteStatusRetrying)).
|
||||
AND(r.NextAttemptAt.IS_NULL().OR(r.NextAttemptAt.LT_EQ(postgres.NOW())))
|
||||
if len(exclude) > 0 {
|
||||
excludeExprs := make([]postgres.Expression, 0, len(exclude))
|
||||
for _, id := range exclude {
|
||||
excludeExprs = append(excludeExprs, postgres.UUID(id))
|
||||
}
|
||||
condition = condition.AND(r.RouteID.NOT_IN(excludeExprs...))
|
||||
}
|
||||
|
||||
stmt := postgres.SELECT(
|
||||
r.AllColumns,
|
||||
n.Kind, n.IdempotencyKey, n.UserID, n.Payload, n.CreatedAt,
|
||||
).
|
||||
FROM(r.INNER_JOIN(n, n.NotificationID.EQ(r.NotificationID))).
|
||||
WHERE(condition).
|
||||
ORDER_BY(postgres.COALESCE(r.NextAttemptAt, r.CreatedAt).ASC()).
|
||||
LIMIT(int64(limit)).
|
||||
FOR(postgres.UPDATE().OF(r).SKIP_LOCKED())
|
||||
|
||||
var rows []struct {
|
||||
model.NotificationRoutes
|
||||
Notifications struct {
|
||||
Kind string
|
||||
IdempotencyKey string
|
||||
UserID *uuid.UUID
|
||||
Payload *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
}
|
||||
if err := stmt.QueryContext(ctx, tx, &rows); err != nil {
|
||||
return nil, fmt.Errorf("claim due routes: %w", err)
|
||||
}
|
||||
|
||||
out := make([]ClaimedRoute, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
route := modelToRoute(row.NotificationRoutes)
|
||||
route.UserID = row.Notifications.UserID
|
||||
notif := Notification{
|
||||
NotificationID: row.NotificationRoutes.NotificationID,
|
||||
Kind: row.Notifications.Kind,
|
||||
IdempotencyKey: row.Notifications.IdempotencyKey,
|
||||
UserID: row.Notifications.UserID,
|
||||
CreatedAt: row.Notifications.CreatedAt,
|
||||
}
|
||||
decoded, err := decodePayload(payloadBytesFromPtr(row.Notifications.Payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode notification payload: %w", err)
|
||||
}
|
||||
notif.Payload = decoded
|
||||
out = append(out, ClaimedRoute{Route: route, Notification: notif})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListNotificationsResult bundles a page of notifications and the
|
||||
// total-row count. Layout mirrors `mail.AdminListDeliveriesPage`.
|
||||
type ListNotificationsResult struct {
|
||||
Items []Notification
|
||||
Total int64
|
||||
}
|
||||
|
||||
// ListNotifications returns the page newest-first.
|
||||
func (s *Store) ListNotifications(ctx context.Context, offset, limit int) (ListNotificationsResult, error) {
|
||||
total, err := countAll(ctx, s.db, table.Notifications)
|
||||
if err != nil {
|
||||
return ListNotificationsResult{}, fmt.Errorf("count notifications: %w", err)
|
||||
}
|
||||
n := table.Notifications
|
||||
stmt := postgres.SELECT(
|
||||
n.NotificationID, n.Kind, n.IdempotencyKey, n.UserID,
|
||||
n.Payload, n.CreatedAt,
|
||||
).
|
||||
FROM(n).
|
||||
ORDER_BY(n.CreatedAt.DESC(), n.NotificationID.DESC()).
|
||||
LIMIT(int64(limit)).OFFSET(int64(offset))
|
||||
|
||||
var rows []model.Notifications
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return ListNotificationsResult{}, fmt.Errorf("list notifications: %w", err)
|
||||
}
|
||||
items := make([]Notification, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
notif, err := modelToNotification(row)
|
||||
if err != nil {
|
||||
return ListNotificationsResult{}, err
|
||||
}
|
||||
items = append(items, notif)
|
||||
}
|
||||
return ListNotificationsResult{Items: items, Total: total}, nil
|
||||
}
|
||||
|
||||
// GetNotification loads a notification by primary key. The sentinel
|
||||
// ErrNotificationNotFound is returned when no row matches.
|
||||
func (s *Store) GetNotification(ctx context.Context, id uuid.UUID) (Notification, error) {
|
||||
n := table.Notifications
|
||||
stmt := postgres.SELECT(
|
||||
n.NotificationID, n.Kind, n.IdempotencyKey, n.UserID,
|
||||
n.Payload, n.CreatedAt,
|
||||
).
|
||||
FROM(n).
|
||||
WHERE(n.NotificationID.EQ(postgres.UUID(id))).
|
||||
LIMIT(1)
|
||||
|
||||
var row model.Notifications
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Notification{}, ErrNotificationNotFound
|
||||
}
|
||||
return Notification{}, fmt.Errorf("get notification: %w", err)
|
||||
}
|
||||
return modelToNotification(row)
|
||||
}
|
||||
|
||||
// ListDeadLettersResult bundles a page of dead-letters and the total
|
||||
// row count.
|
||||
type ListDeadLettersResult struct {
|
||||
Items []DeadLetter
|
||||
Total int64
|
||||
}
|
||||
|
||||
// ListDeadLetters returns the dead-letter page newest-first.
|
||||
func (s *Store) ListDeadLetters(ctx context.Context, offset, limit int) (ListDeadLettersResult, error) {
|
||||
total, err := countAll(ctx, s.db, table.NotificationDeadLetters)
|
||||
if err != nil {
|
||||
return ListDeadLettersResult{}, fmt.Errorf("count dead-letters: %w", err)
|
||||
}
|
||||
dl := table.NotificationDeadLetters
|
||||
stmt := postgres.SELECT(
|
||||
dl.DeadLetterID, dl.NotificationID, dl.RouteID, dl.ArchivedAt, dl.Reason,
|
||||
).
|
||||
FROM(dl).
|
||||
ORDER_BY(dl.ArchivedAt.DESC(), dl.DeadLetterID.DESC()).
|
||||
LIMIT(int64(limit)).OFFSET(int64(offset))
|
||||
|
||||
var rows []model.NotificationDeadLetters
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return ListDeadLettersResult{}, fmt.Errorf("list dead-letters: %w", err)
|
||||
}
|
||||
items := make([]DeadLetter, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, DeadLetter{
|
||||
DeadLetterID: row.DeadLetterID,
|
||||
NotificationID: row.NotificationID,
|
||||
RouteID: row.RouteID,
|
||||
ArchivedAt: row.ArchivedAt,
|
||||
Reason: row.Reason,
|
||||
})
|
||||
}
|
||||
return ListDeadLettersResult{Items: items, Total: total}, nil
|
||||
}
|
||||
|
||||
// ListMalformedResult bundles a page of malformed intents and the
|
||||
// total row count.
|
||||
type ListMalformedResult struct {
|
||||
Items []MalformedIntent
|
||||
Total int64
|
||||
}
|
||||
|
||||
// ListMalformed returns the malformed page newest-first.
|
||||
func (s *Store) ListMalformed(ctx context.Context, offset, limit int) (ListMalformedResult, error) {
|
||||
total, err := countAll(ctx, s.db, table.NotificationMalformedIntents)
|
||||
if err != nil {
|
||||
return ListMalformedResult{}, fmt.Errorf("count malformed intents: %w", err)
|
||||
}
|
||||
m := table.NotificationMalformedIntents
|
||||
stmt := postgres.SELECT(m.ID, m.ReceivedAt, m.Payload, m.Reason).
|
||||
FROM(m).
|
||||
ORDER_BY(m.ReceivedAt.DESC(), m.ID.DESC()).
|
||||
LIMIT(int64(limit)).OFFSET(int64(offset))
|
||||
|
||||
var rows []model.NotificationMalformedIntents
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return ListMalformedResult{}, fmt.Errorf("list malformed intents: %w", err)
|
||||
}
|
||||
items := make([]MalformedIntent, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
decoded, err := decodePayload([]byte(row.Payload))
|
||||
if err != nil {
|
||||
return ListMalformedResult{}, fmt.Errorf("decode malformed payload: %w", err)
|
||||
}
|
||||
items = append(items, MalformedIntent{
|
||||
ID: row.ID,
|
||||
ReceivedAt: row.ReceivedAt,
|
||||
Payload: decoded,
|
||||
Reason: row.Reason,
|
||||
})
|
||||
}
|
||||
return ListMalformedResult{Items: items, Total: total}, nil
|
||||
}
|
||||
|
||||
// InsertMalformed records a producer-supplied intent that failed
|
||||
// validation. The payload is best-effort JSON-encoded by the caller;
|
||||
// the row never blocks the producer.
|
||||
func (s *Store) InsertMalformed(ctx context.Context, payload map[string]any, reason string) error {
|
||||
encoded, err := encodePayload(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode malformed payload: %w", err)
|
||||
}
|
||||
m := table.NotificationMalformedIntents
|
||||
stmt := m.INSERT(m.ID, m.Payload, m.Reason).
|
||||
VALUES(uuid.New(), string(encoded), reason)
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("insert malformed intent: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SkipPendingRoutesForUser flips every pending or retrying route owned
|
||||
// by userID to status='skipped'. The `OnUserDeleted` cascade calls it so
|
||||
// the worker stops trying to deliver notifications to a vanished
|
||||
// account; published rows are kept as audit trail.
|
||||
func (s *Store) SkipPendingRoutesForUser(ctx context.Context, userID uuid.UUID, at time.Time) (int64, error) {
|
||||
r := table.NotificationRoutes
|
||||
n := table.Notifications
|
||||
|
||||
notifSubquery := postgres.SELECT(n.NotificationID).
|
||||
FROM(n).
|
||||
WHERE(n.UserID.EQ(postgres.UUID(userID)))
|
||||
|
||||
stmt := r.UPDATE().
|
||||
SET(
|
||||
r.Status.SET(postgres.String(RouteStatusSkipped)),
|
||||
r.NextAttemptAt.SET(postgres.TimestampzExp(postgres.NULL)),
|
||||
r.SkippedAt.SET(postgres.TimestampzT(at)),
|
||||
r.UpdatedAt.SET(postgres.TimestampzT(at)),
|
||||
r.LastError.SET(postgres.String("recipient soft-deleted")),
|
||||
).
|
||||
WHERE(
|
||||
r.Status.IN(postgres.String(RouteStatusPending), postgres.String(RouteStatusRetrying)).
|
||||
AND(r.NotificationID.IN(notifSubquery)),
|
||||
)
|
||||
res, err := stmt.ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("skip pending routes: %w", err)
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("rows affected: %w", err)
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// withTx wraps fn in a Postgres transaction. fn's return value
|
||||
// determines commit (nil) vs rollback (non-nil). Rollback errors are
|
||||
// swallowed when fn already returned an error, since the latter is
|
||||
// more actionable.
|
||||
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notification store: begin tx: %w", err)
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("notification store: commit tx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// modelToRoute projects a generated model row onto the public Route
|
||||
// struct (without the user-id which lives on the parent notification).
|
||||
func modelToRoute(row model.NotificationRoutes) Route {
|
||||
r := Route{
|
||||
RouteID: row.RouteID,
|
||||
NotificationID: row.NotificationID,
|
||||
Channel: row.Channel,
|
||||
Status: row.Status,
|
||||
Attempts: row.Attempts,
|
||||
MaxAttempts: row.MaxAttempts,
|
||||
LastError: row.LastError,
|
||||
ResolvedEmail: row.ResolvedEmail,
|
||||
ResolvedLocale: row.ResolvedLocale,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
if row.NextAttemptAt != nil {
|
||||
t := *row.NextAttemptAt
|
||||
r.NextAttemptAt = &t
|
||||
}
|
||||
if row.LastAttemptAt != nil {
|
||||
t := *row.LastAttemptAt
|
||||
r.LastAttemptAt = &t
|
||||
}
|
||||
if row.PublishedAt != nil {
|
||||
t := *row.PublishedAt
|
||||
r.PublishedAt = &t
|
||||
}
|
||||
if row.DeadLetteredAt != nil {
|
||||
t := *row.DeadLetteredAt
|
||||
r.DeadLetteredAt = &t
|
||||
}
|
||||
if row.SkippedAt != nil {
|
||||
t := *row.SkippedAt
|
||||
r.SkippedAt = &t
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// modelToNotification decodes a generated model row into the public
|
||||
// Notification struct, including the JSON payload.
|
||||
func modelToNotification(row model.Notifications) (Notification, error) {
|
||||
decoded, err := decodePayload(payloadBytesFromPtr(row.Payload))
|
||||
if err != nil {
|
||||
return Notification{}, fmt.Errorf("decode payload: %w", err)
|
||||
}
|
||||
return Notification{
|
||||
NotificationID: row.NotificationID,
|
||||
Kind: row.Kind,
|
||||
IdempotencyKey: row.IdempotencyKey,
|
||||
UserID: row.UserID,
|
||||
Payload: decoded,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// payloadBytesFromPtr converts the nullable string from the generated
|
||||
// jsonb-as-text model into the byte slice expected by decodePayload.
|
||||
func payloadBytesFromPtr(p *string) []byte {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return []byte(*p)
|
||||
}
|
||||
|
||||
// encodePayload renders a map[string]any to JSON for storage in
|
||||
// jsonb columns. A nil map encodes as JSON null; this is harmless on
|
||||
// the read path because decodePayload returns nil for it.
|
||||
func encodePayload(payload map[string]any) ([]byte, error) {
|
||||
if payload == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
|
||||
// decodePayload parses a jsonb column back into the producer's map.
|
||||
// A NULL or empty buffer round-trips to nil.
|
||||
func decodePayload(buf []byte) (map[string]any, error) {
|
||||
if len(buf) == 0 || strings.EqualFold(strings.TrimSpace(string(buf)), "null") {
|
||||
return nil, nil
|
||||
}
|
||||
out := map[string]any{}
|
||||
if err := json.Unmarshal(buf, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// countAll runs `SELECT COUNT(*) FROM <tbl>` through jet and returns
|
||||
// the result. The destination uses an alias-tagged scalar so QRM can
|
||||
// map the un-prefixed alias produced by AS("count").
|
||||
func countAll(ctx context.Context, db qrm.DB, tbl postgres.ReadableTable) (int64, error) {
|
||||
stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).FROM(tbl)
|
||||
var dest struct {
|
||||
Count int64 `alias:"count"`
|
||||
}
|
||||
if err := stmt.QueryContext(ctx, db, &dest); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return dest.Count, nil
|
||||
}
|
||||
Reference in New Issue
Block a user