package notificationstore import ( "context" "database/sql" "fmt" "time" "galaxy/notification/internal/api/intentstream" pgtable "galaxy/notification/internal/adapters/postgres/jet/notification/table" "galaxy/notification/internal/service/acceptintent" pg "github.com/go-jet/jet/v2/postgres" ) // routeSelectColumns is the canonical SELECT list for the routes table, // matching scanRoute's column order. var routeSelectColumns = pg.ColumnList{ pgtable.Routes.NotificationID, pgtable.Routes.RouteID, pgtable.Routes.Channel, pgtable.Routes.RecipientRef, pgtable.Routes.Status, pgtable.Routes.AttemptCount, pgtable.Routes.MaxAttempts, pgtable.Routes.NextAttemptAt, pgtable.Routes.ResolvedEmail, pgtable.Routes.ResolvedLocale, pgtable.Routes.LastErrorClassification, pgtable.Routes.LastErrorMessage, pgtable.Routes.LastErrorAt, pgtable.Routes.CreatedAt, pgtable.Routes.UpdatedAt, pgtable.Routes.PublishedAt, pgtable.Routes.DeadLetteredAt, pgtable.Routes.SkippedAt, } // scanRoute scans one routes row from rs. func scanRoute(rs rowScanner) (acceptintent.NotificationRoute, error) { var ( notificationID string routeID string channel string recipientRef string status string attemptCount int maxAttempts int nextAttemptAt sql.NullTime resolvedEmail string resolvedLocale string lastErrorClassification string lastErrorMessage string lastErrorAt sql.NullTime createdAt time.Time updatedAt time.Time publishedAt sql.NullTime deadLetteredAt sql.NullTime skippedAt sql.NullTime ) if err := rs.Scan( ¬ificationID, &routeID, &channel, &recipientRef, &status, &attemptCount, &maxAttempts, &nextAttemptAt, &resolvedEmail, &resolvedLocale, &lastErrorClassification, &lastErrorMessage, &lastErrorAt, &createdAt, &updatedAt, &publishedAt, &deadLetteredAt, &skippedAt, ); err != nil { return acceptintent.NotificationRoute{}, err } return acceptintent.NotificationRoute{ NotificationID: notificationID, RouteID: routeID, Channel: intentstream.Channel(channel), RecipientRef: recipientRef, Status: acceptintent.RouteStatus(status), AttemptCount: attemptCount, MaxAttempts: maxAttempts, NextAttemptAt: timeFromNullable(nextAttemptAt), ResolvedEmail: resolvedEmail, ResolvedLocale: resolvedLocale, LastErrorClassification: lastErrorClassification, LastErrorMessage: lastErrorMessage, LastErrorAt: timeFromNullable(lastErrorAt), CreatedAt: createdAt.UTC(), UpdatedAt: updatedAt.UTC(), PublishedAt: timeFromNullable(publishedAt), DeadLetteredAt: timeFromNullable(deadLetteredAt), SkippedAt: timeFromNullable(skippedAt), }, nil } // insertRoute writes one route row inside an open transaction. func insertRoute(ctx context.Context, tx *sql.Tx, route acceptintent.NotificationRoute) error { if err := route.Validate(); err != nil { return fmt.Errorf("insert route: %w", err) } stmt := pgtable.Routes.INSERT( pgtable.Routes.NotificationID, pgtable.Routes.RouteID, pgtable.Routes.Channel, pgtable.Routes.RecipientRef, pgtable.Routes.Status, pgtable.Routes.AttemptCount, pgtable.Routes.MaxAttempts, pgtable.Routes.NextAttemptAt, pgtable.Routes.ResolvedEmail, pgtable.Routes.ResolvedLocale, pgtable.Routes.LastErrorClassification, pgtable.Routes.LastErrorMessage, pgtable.Routes.LastErrorAt, pgtable.Routes.CreatedAt, pgtable.Routes.UpdatedAt, pgtable.Routes.PublishedAt, pgtable.Routes.DeadLetteredAt, pgtable.Routes.SkippedAt, ).VALUES( route.NotificationID, route.RouteID, string(route.Channel), route.RecipientRef, string(route.Status), route.AttemptCount, route.MaxAttempts, nullableTime(route.NextAttemptAt), route.ResolvedEmail, route.ResolvedLocale, route.LastErrorClassification, route.LastErrorMessage, nullableTime(route.LastErrorAt), route.CreatedAt.UTC(), route.UpdatedAt.UTC(), nullableTime(route.PublishedAt), nullableTime(route.DeadLetteredAt), nullableTime(route.SkippedAt), ) query, args := stmt.Sql() if _, err := tx.ExecContext(ctx, query, args...); err != nil { return err } return nil } // loadRoute returns one route row by composite key. found is false when no // matching row exists. func loadRoute(ctx context.Context, db *sql.DB, notificationID string, routeID string) (acceptintent.NotificationRoute, bool, error) { stmt := pg.SELECT(routeSelectColumns). FROM(pgtable.Routes). WHERE(pg.AND( pgtable.Routes.NotificationID.EQ(pg.String(notificationID)), pgtable.Routes.RouteID.EQ(pg.String(routeID)), )) query, args := stmt.Sql() row := db.QueryRowContext(ctx, query, args...) route, err := scanRoute(row) if isNoRows(err) { return acceptintent.NotificationRoute{}, false, nil } if err != nil { return acceptintent.NotificationRoute{}, false, fmt.Errorf("load notification route: %w", err) } return route, true, nil } // loadRouteTx returns one route row by composite key inside an open // transaction. func loadRouteTx(ctx context.Context, tx *sql.Tx, notificationID string, routeID string) (acceptintent.NotificationRoute, bool, error) { stmt := pg.SELECT(routeSelectColumns). FROM(pgtable.Routes). WHERE(pg.AND( pgtable.Routes.NotificationID.EQ(pg.String(notificationID)), pgtable.Routes.RouteID.EQ(pg.String(routeID)), )) query, args := stmt.Sql() row := tx.QueryRowContext(ctx, query, args...) route, err := scanRoute(row) if isNoRows(err) { return acceptintent.NotificationRoute{}, false, nil } if err != nil { return acceptintent.NotificationRoute{}, false, fmt.Errorf("load notification route: %w", err) } return route, true, nil } // updateRouteIfMatching writes the route columns back inside an open // transaction, gated on `updated_at = expectedUpdatedAt`. Returns the // number of rows actually updated; zero indicates an optimistic-concurrency // loss. func updateRouteIfMatching(ctx context.Context, tx *sql.Tx, route acceptintent.NotificationRoute, expectedUpdatedAt time.Time) (int64, error) { stmt := pgtable.Routes.UPDATE( pgtable.Routes.Status, pgtable.Routes.AttemptCount, pgtable.Routes.NextAttemptAt, pgtable.Routes.ResolvedEmail, pgtable.Routes.ResolvedLocale, pgtable.Routes.LastErrorClassification, pgtable.Routes.LastErrorMessage, pgtable.Routes.LastErrorAt, pgtable.Routes.UpdatedAt, pgtable.Routes.PublishedAt, pgtable.Routes.DeadLetteredAt, pgtable.Routes.SkippedAt, ).SET( string(route.Status), route.AttemptCount, nullableTime(route.NextAttemptAt), route.ResolvedEmail, route.ResolvedLocale, route.LastErrorClassification, route.LastErrorMessage, nullableTime(route.LastErrorAt), route.UpdatedAt.UTC(), nullableTime(route.PublishedAt), nullableTime(route.DeadLetteredAt), nullableTime(route.SkippedAt), ).WHERE(pg.AND( pgtable.Routes.NotificationID.EQ(pg.String(route.NotificationID)), pgtable.Routes.RouteID.EQ(pg.String(route.RouteID)), pgtable.Routes.UpdatedAt.EQ(pg.TimestampzT(expectedUpdatedAt.UTC())), )) query, args := stmt.Sql() result, err := tx.ExecContext(ctx, query, args...) if err != nil { return 0, err } rows, err := result.RowsAffected() if err != nil { return 0, err } return rows, nil }