Implemented weather stories support
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful

This commit is contained in:
2026-05-30 07:06:29 -05:00
parent bde515d146
commit ecea856e8e
18 changed files with 760 additions and 4 deletions

View File

@@ -160,6 +160,63 @@ func TestMapDiscussionKeyMessagesPreserveOrder(t *testing.T) {
}
}
func TestMapWeatherStoryRunParentRowNullables(t *testing.T) {
asOf := time.Date(2026, 5, 30, 9, 0, 34, 0, time.FixedZone("CDT", -5*3600))
run := mapWeatherStoryRunParentRow(weatherStoryRunParentRow{
EventID: "evt-story-run",
OfficeID: sql.NullString{String: "LSX", Valid: true},
AsOf: asOf,
})
if run.OfficeID != "LSX" {
t.Fatalf("expected office id LSX, got %q", run.OfficeID)
}
if run.AsOf.Location().String() != "UTC" {
t.Fatalf("expected asOf to be UTC-normalized, got %v", run.AsOf)
}
if run.Stories != nil {
t.Fatalf("expected nil stories before child load, got %+v", run.Stories)
}
}
func TestMapWeatherStoryRowMapsFields(t *testing.T) {
start := time.Date(2026, 5, 30, 8, 46, 0, 0, time.FixedZone("CDT", -5*3600))
end := time.Date(2026, 5, 31, 11, 0, 0, 0, time.FixedZone("CDT", -5*3600))
updated := time.Date(2026, 5, 30, 9, 0, 34, 0, time.FixedZone("CDT", -5*3600))
story := mapWeatherStoryRow(weatherStoryRow{
StoryIndex: 2,
OfficeID: sql.NullString{String: "LSX", Valid: true},
StartTime: start,
EndTime: end,
UpdatedAt: updated,
Title: sql.NullString{String: "Several Chances for Rain Through Monday", Valid: true},
Description: sql.NullString{String: "Scattered showers and thunderstorms.", Valid: true},
AltText: sql.NullString{String: "Forecast slide.", Valid: true},
Priority: true,
StoryOrder: 1,
DownloadURL: sql.NullString{String: "https://api.weather.gov/offices/LSX/weatherstories/download/story-1", Valid: true},
})
if story.OfficeID != "LSX" {
t.Fatalf("expected office id LSX, got %q", story.OfficeID)
}
if story.Title != "Several Chances for Rain Through Monday" {
t.Fatalf("unexpected title: %q", story.Title)
}
if !story.Priority {
t.Fatalf("expected priority true")
}
if story.Order != 1 {
t.Fatalf("expected order 1, got %d", story.Order)
}
if story.DownloadURL == "" {
t.Fatalf("expected download URL")
}
if story.StartTime.Location().String() != "UTC" || story.EndTime.Location().String() != "UTC" || story.UpdatedAt.Location().String() != "UTC" {
t.Fatalf("expected story timestamps to be UTC-normalized, got %s %s %s", story.StartTime, story.EndTime, story.UpdatedAt)
}
}
func TestAttachAlertReferencesPreservesOrder(t *testing.T) {
sent1 := time.Date(2026, 3, 20, 1, 0, 0, 0, time.UTC)
sent2 := sent1.Add(10 * time.Minute)

View File

@@ -0,0 +1,28 @@
// weatherstories_mapper.go maps weather story rows into weather model payloads.
// Layer: adapters/outbound/postgres weather stories feature.
package postgres
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
func mapWeatherStoryRunParentRow(row weatherStoryRunParentRow) model.WeatherStoryRun {
return model.WeatherStoryRun{
OfficeID: stringValue(row.OfficeID),
AsOf: row.AsOf.UTC(),
Stories: nil,
}
}
func mapWeatherStoryRow(row weatherStoryRow) model.WeatherStory {
return model.WeatherStory{
OfficeID: stringValue(row.OfficeID),
StartTime: row.StartTime.UTC(),
EndTime: row.EndTime.UTC(),
UpdatedAt: row.UpdatedAt.UTC(),
Title: stringValue(row.Title),
Description: stringValue(row.Description),
AltText: stringValue(row.AltText),
Priority: row.Priority,
Order: row.StoryOrder,
DownloadURL: stringValue(row.DownloadURL),
}
}

View File

@@ -0,0 +1,48 @@
// weatherstories_queries.go contains SQL text for weather story reads.
// Layer: adapters/outbound/postgres weather stories feature.
package postgres
const (
queryLatestWeatherStoryRun = `
SELECT
event_id,
office_id,
as_of
FROM weather_story_runs
ORDER BY as_of DESC, event_emitted_at DESC
LIMIT 1`
queryWeatherStoriesForRun = `
SELECT
story_index,
office_id,
start_time,
end_time,
updated_at,
title,
description,
alt_text,
priority,
story_order,
download_url
FROM weather_stories
WHERE run_event_id = $1
ORDER BY story_index ASC`
queryLatestWeatherStory = `
SELECT
story_index,
office_id,
start_time,
end_time,
updated_at,
title,
description,
alt_text,
priority,
story_order,
download_url
FROM weather_stories
ORDER BY updated_at DESC, as_of DESC, story_order ASC, story_index ASC
LIMIT 1`
)

View File

@@ -0,0 +1,103 @@
// weatherstories_read.go executes weather story queries.
// Layer: adapters/outbound/postgres weather stories feature.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func (r *Repository) LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row weatherStoryRunParentRow
err := r.db.QueryRowContext(ctx, queryLatestWeatherStoryRun).Scan(
&row.EventID,
&row.OfficeID,
&row.AsOf,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest weather story run: %w", err)
}
run := mapWeatherStoryRunParentRow(row)
stories, err := r.loadWeatherStories(ctx, row.EventID)
if err != nil {
return nil, err
}
run.Stories = stories
return &run, nil
}
func (r *Repository) LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row weatherStoryRow
err := r.db.QueryRowContext(ctx, queryLatestWeatherStory).Scan(
&row.StoryIndex,
&row.OfficeID,
&row.StartTime,
&row.EndTime,
&row.UpdatedAt,
&row.Title,
&row.Description,
&row.AltText,
&row.Priority,
&row.StoryOrder,
&row.DownloadURL,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest weather story: %w", err)
}
story := mapWeatherStoryRow(row)
return &story, nil
}
func (r *Repository) loadWeatherStories(ctx context.Context, eventID string) ([]model.WeatherStory, error) {
rows, err := r.db.QueryContext(ctx, queryWeatherStoriesForRun, eventID)
if err != nil {
return nil, fmt.Errorf("query weather stories: %w", err)
}
defer rows.Close()
out := make([]model.WeatherStory, 0)
for rows.Next() {
var row weatherStoryRow
if err := rows.Scan(
&row.StoryIndex,
&row.OfficeID,
&row.StartTime,
&row.EndTime,
&row.UpdatedAt,
&row.Title,
&row.Description,
&row.AltText,
&row.Priority,
&row.StoryOrder,
&row.DownloadURL,
); err != nil {
return nil, fmt.Errorf("scan weather story row: %w", err)
}
out = append(out, mapWeatherStoryRow(row))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate weather story rows: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,28 @@
// weatherstories_rows.go defines row DTOs for weather story reads.
// Layer: adapters/outbound/postgres weather stories feature.
package postgres
import (
"database/sql"
"time"
)
type weatherStoryRunParentRow struct {
EventID string
OfficeID sql.NullString
AsOf time.Time
}
type weatherStoryRow struct {
StoryIndex int
OfficeID sql.NullString
StartTime time.Time
EndTime time.Time
UpdatedAt time.Time
Title sql.NullString
Description sql.NullString
AltText sql.NullString
Priority bool
StoryOrder int
DownloadURL sql.NullString
}