From ecea856e8e4815a460a6f15f26c2508713a2f446 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 30 May 2026 07:06:29 -0500 Subject: [PATCH] Implemented weather stories support --- README.md | 4 +- docs/api.md | 87 +++++++ go.mod | 2 +- go.sum | 4 +- .../adapters/inbound/httpapi/endpoints.go | 1 + .../inbound/httpapi/endpoints_test.go | 216 ++++++++++++++++++ .../httpapi/presenter/weatherstories.go | 48 ++++ internal/adapters/inbound/httpapi/service.go | 2 + .../httpapi/weatherstories_endpoint.go | 43 ++++ .../outbound/postgres/repository_test.go | 57 +++++ .../postgres/weatherstories_mapper.go | 28 +++ .../postgres/weatherstories_queries.go | 48 ++++ .../outbound/postgres/weatherstories_read.go | 103 +++++++++ .../outbound/postgres/weatherstories_rows.go | 28 +++ internal/app/service.go | 10 + internal/app/service_test.go | 36 +++ templates/weatherstories.txt.tmpl | 26 +++ templates/weatherstories_latest.txt.tmpl | 21 ++ 18 files changed, 760 insertions(+), 4 deletions(-) create mode 100644 internal/adapters/inbound/httpapi/presenter/weatherstories.go create mode 100644 internal/adapters/inbound/httpapi/weatherstories_endpoint.go create mode 100644 internal/adapters/outbound/postgres/weatherstories_mapper.go create mode 100644 internal/adapters/outbound/postgres/weatherstories_queries.go create mode 100644 internal/adapters/outbound/postgres/weatherstories_read.go create mode 100644 internal/adapters/outbound/postgres/weatherstories_rows.go create mode 100644 templates/weatherstories.txt.tmpl create mode 100644 templates/weatherstories_latest.txt.tmpl diff --git a/README.md b/README.md index e37f7f5..25f739c 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ A small HTTP API that serves a variety of weather-related endpoints. - `GET /discussion/key-messages` - `GET /discussion/short-term` - `GET /discussion/long-term` +- `GET /weatherstories` +- `GET /weatherstories/latest` - `GET /forecast/hourly` - `GET /forecast/hourly/today` - `GET /forecast/hourly/tomorrow` @@ -29,6 +31,6 @@ Forecast endpoint query parameters: - `precision` (`0`-`2`) -Forecast and discussion endpoint query parameters: +Forecast, discussion, and weather stories endpoint query parameters: - `tz` / `TZ` (IANA timezone, US abbreviation, or UTC offset) diff --git a/docs/api.md b/docs/api.md index 038f60b..00d5297 100644 --- a/docs/api.md +++ b/docs/api.md @@ -63,6 +63,7 @@ Supported where documented: - if both are provided, they must match exactly or the request fails Timezone affects datetime rendering and day-slice filtering for `/today` and `/tomorrow` forecast routes. +It also affects weather story and discussion datetime rendering. ### Query validation @@ -223,6 +224,42 @@ Discussion section object fields: - `narrative` (string, optional) - `issuedAt` (RFC3339 datetime, optional) +## Weather Stories endpoints + +- `GET /weatherstories` +- `GET /weatherstories/latest` + +Query parameters: + +- `units`: `metric` | `us` (accepted; does not materially alter weather story payloads) +- `format`: `json` | `xml` | `text` +- `tz` or `TZ`: timezone selector + +Response behavior: + +- `/weatherstories` returns the latest stored weather story run and its ordered stories. +- `/weatherstories/latest` returns the single story with the greatest `updatedAt`. +- When no weather story data exists, `data` is `null`. + +`/weatherstories` response `data` fields: + +- `officeId` (string, optional) +- `asOf` (RFC3339 datetime, required) +- `stories` (array, required) + +Weather story fields: + +- `officeId` (string, optional) +- `startTime` (RFC3339 datetime, required) +- `endTime` (RFC3339 datetime, required) +- `updatedAt` (RFC3339 datetime, required) +- `title` (string, optional) +- `description` (string, optional) +- `altText` (string, optional) +- `priority` (boolean, required) +- `order` (integer, required) +- `downloadUrl` (string, optional) + ## Examples ### Observation (JSON, metric) @@ -338,6 +375,56 @@ GET /discussion/key-messages?format=json&tz=Chicago } ``` +### Weather stories (JSON) + +```http +GET /weatherstories?format=json&tz=America/Chicago +``` + +```json +{ + "data": { + "officeId": "LSX", + "asOf": "2026-05-30T04:00:34-05:00", + "stories": [ + { + "officeId": "LSX", + "startTime": "2026-05-30T03:46:00-05:00", + "endTime": "2026-05-31T06:00:00-05:00", + "updatedAt": "2026-05-30T04:00:34-05:00", + "title": "Several Chances for Rain Through Monday", + "description": "Scattered showers and thunderstorms remain possible.", + "altText": "This slide shows the forecast for today through Tuesday.", + "priority": false, + "order": 1, + "downloadUrl": "https://api.weather.gov/offices/LSX/weatherstories/download/3228e499-2aae-45a8-9ff9-1c060311026f" + } + ] + } +} +``` + +### Latest weather story (JSON) + +```http +GET /weatherstories/latest?format=json&tz=CDT +``` + +```json +{ + "data": { + "officeId": "LSX", + "startTime": "2026-05-30T03:46:00-05:00", + "endTime": "2026-05-31T06:00:00-05:00", + "updatedAt": "2026-05-30T04:00:34-05:00", + "title": "Several Chances for Rain Through Monday", + "description": "Scattered showers and thunderstorms remain possible.", + "priority": false, + "order": 1 + } +} +``` + ### Invalid timezone error example ```http diff --git a/go.mod b/go.mod index e7e9f08..b8cdae3 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.5 require ( gitea.maximumdirect.net/ejr/feedapi v0.1.0 - gitea.maximumdirect.net/ejr/weatherfeeder v0.8.3 + gitea.maximumdirect.net/ejr/weatherfeeder v0.10.0 github.com/lib/pq v1.10.9 ) diff --git a/go.sum b/go.sum index cc88067..8e4105b 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ gitea.maximumdirect.net/ejr/feedapi v0.1.0 h1:ZB5QWKD5DPFV3P7vyeJqXPMcSWN9qHkDUHw1LgN9hwY= gitea.maximumdirect.net/ejr/feedapi v0.1.0/go.mod h1:3fIaFFx4ywt0TWbN8DIIBAHJn7ZQUm6PNcceqRgy3bw= -gitea.maximumdirect.net/ejr/weatherfeeder v0.8.3 h1:vH5p8zKiJ6D7JnbpA43iWKNEdgQg/VwKKaWlwF3AXXs= -gitea.maximumdirect.net/ejr/weatherfeeder v0.8.3/go.mod h1:YeHGpmJihwutT+2t8La8e2pPCusdxqiUNyZZOPqE33I= +gitea.maximumdirect.net/ejr/weatherfeeder v0.10.0 h1:C2LQehq1A74Ufem8szL0kKxLRR7+h4kHcbyPefwRXWI= +gitea.maximumdirect.net/ejr/weatherfeeder v0.10.0/go.mod h1:VVtuwrbddWdUu21ovCSSojhH5J9P6kk0/dfnFqC4/Lw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/adapters/inbound/httpapi/endpoints.go b/internal/adapters/inbound/httpapi/endpoints.go index 210c7af..5686159 100644 --- a/internal/adapters/inbound/httpapi/endpoints.go +++ b/internal/adapters/inbound/httpapi/endpoints.go @@ -10,6 +10,7 @@ func Definitions(svc Service) []endpoint.Definition { alertsDefinition(svc), conditionsDefinition(svc), } + defs = append(defs, weatherStoriesDefinitions(svc)...) defs = append(defs, discussionDefinitions(svc)...) defs = append(defs, forecastDefinitions(svc)...) return defs diff --git a/internal/adapters/inbound/httpapi/endpoints_test.go b/internal/adapters/inbound/httpapi/endpoints_test.go index ee2b2e2..c4cc455 100644 --- a/internal/adapters/inbound/httpapi/endpoints_test.go +++ b/internal/adapters/inbound/httpapi/endpoints_test.go @@ -27,6 +27,8 @@ type fakeService struct { forecast *model.WeatherForecastRun narrativeForecast *model.WeatherForecastRun discussion *model.WeatherForecastDiscussion + weatherStoryRun *model.WeatherStoryRun + weatherStory *model.WeatherStory alerts *model.WeatherAlertRun conditions *app.CurrentConditions err error @@ -48,6 +50,14 @@ func (s *fakeService) LatestForecastDiscussion(context.Context) (*model.WeatherF return s.discussion, s.err } +func (s *fakeService) LatestWeatherStoryRun(context.Context) (*model.WeatherStoryRun, error) { + return s.weatherStoryRun, s.err +} + +func (s *fakeService) LatestWeatherStory(context.Context) (*model.WeatherStory, error) { + return s.weatherStory, s.err +} + func (s *fakeService) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) { return s.alerts, s.err } @@ -1445,6 +1455,177 @@ func TestDefinitionsIncludeDiscussion(t *testing.T) { _ = definitionForPath(t, Definitions(&fakeService{}), "/discussion/long-term") } +func TestDefinitionsIncludeWeatherStories(t *testing.T) { + _ = definitionForPath(t, Definitions(&fakeService{}), "/weatherstories") + _ = definitionForPath(t, Definitions(&fakeService{}), "/weatherstories/latest") +} + +func TestWeatherStoriesNoDataReturnsNullEnvelopeData(t *testing.T) { + for _, path := range []string{"/weatherstories", "/weatherstories/latest"} { + t.Run(path, func(t *testing.T) { + h := newHandler(t, &fakeService{}, path) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var payload struct { + Data *json.RawMessage `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + if payload.Data != nil { + t.Fatalf("expected data null, got %s", string(*payload.Data)) + } + }) + } +} + +func TestWeatherStoriesJSONEnvelope(t *testing.T) { + h := newHandler(t, &fakeService{ + weatherStoryRun: sampleWeatherStoryRun(), + }, "/weatherstories") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/weatherstories?tz=CDT", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var payload struct { + Data struct { + OfficeID string `json:"officeId"` + AsOf string `json:"asOf"` + Stories []struct { + Title string `json:"title"` + UpdatedAt string `json:"updatedAt"` + } `json:"stories"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + if payload.Data.OfficeID != "LSX" { + t.Fatalf("expected officeId LSX, got %q", payload.Data.OfficeID) + } + if len(payload.Data.Stories) != 2 { + t.Fatalf("expected 2 stories, got %d", len(payload.Data.Stories)) + } + if payload.Data.Stories[0].Title != "Rain Chances" { + t.Fatalf("unexpected first story title: %q", payload.Data.Stories[0].Title) + } + if !strings.Contains(payload.Data.AsOf, "-05:00") || !strings.Contains(payload.Data.Stories[0].UpdatedAt, "-05:00") { + t.Fatalf("expected CDT offset in weather story times, got asOf=%q updatedAt=%q", payload.Data.AsOf, payload.Data.Stories[0].UpdatedAt) + } +} + +func TestWeatherStoriesLatestJSONEnvelope(t *testing.T) { + run := sampleWeatherStoryRun() + h := newHandler(t, &fakeService{ + weatherStory: &run.Stories[1], + }, "/weatherstories/latest") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/weatherstories/latest?tz=Chicago", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var payload struct { + Data struct { + Title string `json:"title"` + UpdatedAt string `json:"updatedAt"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + if payload.Data.Title != "More Rain" { + t.Fatalf("expected latest story title More Rain, got %q", payload.Data.Title) + } + if !strings.Contains(payload.Data.UpdatedAt, "-05:00") { + t.Fatalf("expected Chicago offset in updatedAt, got %q", payload.Data.UpdatedAt) + } +} + +func TestWeatherStoriesFormatNegotiation(t *testing.T) { + hText := newHandler(t, &fakeService{ + weatherStoryRun: sampleWeatherStoryRun(), + }, "/weatherstories") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/weatherstories?format=TEXT&units=US", nil) + hText.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for text request, got %d", w.Code) + } + if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") { + t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type")) + } + if !strings.Contains(w.Body.String(), "Weather Stories") { + t.Fatalf("expected rendered weather stories template, got %q", w.Body.String()) + } + + run := sampleWeatherStoryRun() + hXML := newHandler(t, &fakeService{ + weatherStory: &run.Stories[0], + }, "/weatherstories/latest") + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/weatherstories/latest?format=XML", nil) + hXML.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for xml request, got %d", w.Code) + } + if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") { + t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type")) + } +} + +func TestWeatherStoriesRejectInvalidQuery(t *testing.T) { + tests := []struct { + path string + query string + }{ + {path: "/weatherstories", query: "/weatherstories?bogus=1"}, + {path: "/weatherstories", query: "/weatherstories?precision=1"}, + {path: "/weatherstories", query: "/weatherstories?tz=not-a-timezone"}, + {path: "/weatherstories", query: "/weatherstories?tz=CDT&TZ=EST"}, + {path: "/weatherstories/latest", query: "/weatherstories/latest?bogus=1"}, + {path: "/weatherstories/latest", query: "/weatherstories/latest?precision=1"}, + {path: "/weatherstories/latest", query: "/weatherstories/latest?tz=not-a-timezone"}, + {path: "/weatherstories/latest", query: "/weatherstories/latest?tz=CDT&TZ=EST"}, + } + + for _, tt := range tests { + t.Run(tt.query, func(t *testing.T) { + run := sampleWeatherStoryRun() + h := newHandler(t, &fakeService{ + weatherStoryRun: run, + weatherStory: &run.Stories[0], + }, tt.path) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, tt.query, nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } + }) + } +} + func TestDiscussionSubresourcesNoDataReturnsNullEnvelopeData(t *testing.T) { for _, path := range []string{ "/discussion/key-messages", @@ -1784,6 +1965,8 @@ func testRenderers(t *testing.T) *render.Registry { "discussion_long_term.txt.tmpl": "Forecast Discussion Long Term", "forecast_hourly.txt.tmpl": "Forecast text", "forecast_narrative.txt.tmpl": "Narrative Forecast", + "weatherstories.txt.tmpl": "Weather Stories", + "weatherstories_latest.txt.tmpl": "Latest Weather Story", "alerts_active.txt.tmpl": "Alerts text", "conditions_current.txt.tmpl": "Conditions text", } { @@ -1811,6 +1994,39 @@ func wmoCodePtr(v model.WMOCode) *model.WMOCode { return &out } +func sampleWeatherStoryRun() *model.WeatherStoryRun { + return &model.WeatherStoryRun{ + OfficeID: "LSX", + AsOf: time.Date(2026, 5, 30, 16, 0, 34, 0, time.UTC), + Stories: []model.WeatherStory{ + { + OfficeID: "LSX", + StartTime: time.Date(2026, 5, 30, 13, 46, 0, 0, time.UTC), + EndTime: time.Date(2026, 5, 31, 16, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 5, 30, 14, 0, 34, 0, time.UTC), + Title: "Rain Chances", + Description: "Several chances for rain through Monday.", + AltText: "Forecast graphic.", + Priority: false, + Order: 1, + DownloadURL: "https://api.weather.gov/offices/LSX/weatherstories/download/story-1", + }, + { + OfficeID: "LSX", + StartTime: time.Date(2026, 5, 30, 15, 46, 0, 0, time.UTC), + EndTime: time.Date(2026, 5, 31, 18, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 5, 30, 16, 0, 34, 0, time.UTC), + Title: "More Rain", + Description: "Showers remain possible.", + AltText: "Another forecast graphic.", + Priority: true, + Order: 2, + DownloadURL: "https://api.weather.gov/offices/LSX/weatherstories/download/story-2", + }, + }, + } +} + type forecastTimePayload struct { Data struct { IssuedAt time.Time `json:"issuedAt"` diff --git a/internal/adapters/inbound/httpapi/presenter/weatherstories.go b/internal/adapters/inbound/httpapi/presenter/weatherstories.go new file mode 100644 index 0000000..862c611 --- /dev/null +++ b/internal/adapters/inbound/httpapi/presenter/weatherstories.go @@ -0,0 +1,48 @@ +// weatherstories.go presents weather story payloads. +// Layer: adapters/inbound/httpapi/presenter weather stories payload mapping. +package presenter + +import ( + "time" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +func WeatherStoryRunPayload(run *model.WeatherStoryRun, _ Units, tz *time.Location) any { + if run == nil { + return nil + } + + out := model.WeatherStoryRun{ + OfficeID: run.OfficeID, + AsOf: inLocationTime(run.AsOf, tz), + Stories: make([]model.WeatherStory, 0, len(run.Stories)), + } + for _, story := range run.Stories { + out.Stories = append(out.Stories, copyWeatherStory(story, tz)) + } + return &out +} + +func WeatherStoryPayload(story *model.WeatherStory, _ Units, tz *time.Location) any { + if story == nil { + return nil + } + out := copyWeatherStory(*story, tz) + return &out +} + +func copyWeatherStory(story model.WeatherStory, tz *time.Location) model.WeatherStory { + return model.WeatherStory{ + OfficeID: story.OfficeID, + StartTime: inLocationTime(story.StartTime, tz), + EndTime: inLocationTime(story.EndTime, tz), + UpdatedAt: inLocationTime(story.UpdatedAt, tz), + Title: story.Title, + Description: story.Description, + AltText: story.AltText, + Priority: story.Priority, + Order: story.Order, + DownloadURL: story.DownloadURL, + } +} diff --git a/internal/adapters/inbound/httpapi/service.go b/internal/adapters/inbound/httpapi/service.go index a6c4656..bd5b7a7 100644 --- a/internal/adapters/inbound/httpapi/service.go +++ b/internal/adapters/inbound/httpapi/service.go @@ -15,6 +15,8 @@ type Service interface { LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error) LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error) + LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error) + LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) CurrentConditions(ctx context.Context) (*app.CurrentConditions, error) } diff --git a/internal/adapters/inbound/httpapi/weatherstories_endpoint.go b/internal/adapters/inbound/httpapi/weatherstories_endpoint.go new file mode 100644 index 0000000..efe9523 --- /dev/null +++ b/internal/adapters/inbound/httpapi/weatherstories_endpoint.go @@ -0,0 +1,43 @@ +// weatherstories_endpoint.go defines the /weatherstories endpoint behavior. +// Layer: adapters/inbound/httpapi weather stories route. +package httpapi + +import ( + "context" + + "gitea.maximumdirect.net/ejr/feedapi/endpoint" + "gitea.maximumdirect.net/ejr/feedapi/render" + "gitea.maximumdirect.net/ejr/feedapi/response" + "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter" +) + +func weatherStoriesDefinitions(svc Service) []endpoint.Definition { + return []endpoint.Definition{ + endpoint.GET( + "/weatherstories", + bindTimezoneQuery, + func(ctx context.Context, req timezoneQueryRequest) (any, error) { + run, err := svc.LatestWeatherStoryRun(ctx) + if err != nil { + return nil, err + } + return response.Envelope{Data: presenter.WeatherStoryRunPayload(run, req.Units, req.Timezone)}, nil + }, + endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), + endpoint.WithTemplate("weatherstories.txt.tmpl"), + ), + endpoint.GET( + "/weatherstories/latest", + bindTimezoneQuery, + func(ctx context.Context, req timezoneQueryRequest) (any, error) { + story, err := svc.LatestWeatherStory(ctx) + if err != nil { + return nil, err + } + return response.Envelope{Data: presenter.WeatherStoryPayload(story, req.Units, req.Timezone)}, nil + }, + endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), + endpoint.WithTemplate("weatherstories_latest.txt.tmpl"), + ), + } +} diff --git a/internal/adapters/outbound/postgres/repository_test.go b/internal/adapters/outbound/postgres/repository_test.go index 64b56b1..b370ecb 100644 --- a/internal/adapters/outbound/postgres/repository_test.go +++ b/internal/adapters/outbound/postgres/repository_test.go @@ -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) diff --git a/internal/adapters/outbound/postgres/weatherstories_mapper.go b/internal/adapters/outbound/postgres/weatherstories_mapper.go new file mode 100644 index 0000000..f52b523 --- /dev/null +++ b/internal/adapters/outbound/postgres/weatherstories_mapper.go @@ -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), + } +} diff --git a/internal/adapters/outbound/postgres/weatherstories_queries.go b/internal/adapters/outbound/postgres/weatherstories_queries.go new file mode 100644 index 0000000..e8e3796 --- /dev/null +++ b/internal/adapters/outbound/postgres/weatherstories_queries.go @@ -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` +) diff --git a/internal/adapters/outbound/postgres/weatherstories_read.go b/internal/adapters/outbound/postgres/weatherstories_read.go new file mode 100644 index 0000000..c299b9d --- /dev/null +++ b/internal/adapters/outbound/postgres/weatherstories_read.go @@ -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 +} diff --git a/internal/adapters/outbound/postgres/weatherstories_rows.go b/internal/adapters/outbound/postgres/weatherstories_rows.go new file mode 100644 index 0000000..168beb9 --- /dev/null +++ b/internal/adapters/outbound/postgres/weatherstories_rows.go @@ -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 +} diff --git a/internal/app/service.go b/internal/app/service.go index fc463e5..4df60be 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -14,6 +14,8 @@ type Repository interface { LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error) LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error) + LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error) + LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*CurrentConditions, error) } @@ -43,6 +45,14 @@ func (s *Service) LatestForecastDiscussion(ctx context.Context) (*model.WeatherF return s.repo.LatestForecastDiscussion(ctx) } +func (s *Service) LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error) { + return s.repo.LatestWeatherStoryRun(ctx) +} + +func (s *Service) LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error) { + return s.repo.LatestWeatherStory(ctx) +} + func (s *Service) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) { return s.repo.LatestAlertRun(ctx) } diff --git a/internal/app/service_test.go b/internal/app/service_test.go index 99bd268..c53bfba 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -15,6 +15,8 @@ type fakeRepository struct { forecast *model.WeatherForecastRun narrative *model.WeatherForecastRun discussion *model.WeatherForecastDiscussion + storyRun *model.WeatherStoryRun + story *model.WeatherStory alerts *model.WeatherAlertRun conditions *CurrentConditions err error @@ -38,6 +40,14 @@ func (r *fakeRepository) LatestForecastDiscussion(context.Context) (*model.Weath return r.discussion, r.err } +func (r *fakeRepository) LatestWeatherStoryRun(context.Context) (*model.WeatherStoryRun, error) { + return r.storyRun, r.err +} + +func (r *fakeRepository) LatestWeatherStory(context.Context) (*model.WeatherStory, error) { + return r.story, r.err +} + func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) { return r.alerts, r.err } @@ -112,6 +122,32 @@ func TestServiceDelegatesForecastDiscussion(t *testing.T) { } } +func TestServiceDelegatesWeatherStoryRun(t *testing.T) { + repo := &fakeRepository{storyRun: &model.WeatherStoryRun{OfficeID: "LSX"}} + svc := NewService(repo) + + run, err := svc.LatestWeatherStoryRun(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if run == nil || run.OfficeID != "LSX" { + t.Fatalf("unexpected weather story run: %+v", run) + } +} + +func TestServiceDelegatesWeatherStory(t *testing.T) { + repo := &fakeRepository{story: &model.WeatherStory{OfficeID: "LSX", Title: "Rain chances"}} + svc := NewService(repo) + + story, err := svc.LatestWeatherStory(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if story == nil || story.Title != "Rain chances" { + t.Fatalf("unexpected weather story: %+v", story) + } +} + func TestServiceUsesDefaultCurrentConditionsWindow(t *testing.T) { repo := &fakeRepository{conditions: &CurrentConditions{ConditionCode: model.WMOUnknown}} svc := NewService(repo) diff --git a/templates/weatherstories.txt.tmpl b/templates/weatherstories.txt.tmpl new file mode 100644 index 0000000..d13ea4f --- /dev/null +++ b/templates/weatherstories.txt.tmpl @@ -0,0 +1,26 @@ +{{- if .Data -}} +Weather Stories +Office ID: {{if .Data.OfficeID}}{{.Data.OfficeID}}{{else}}n/a{{end}} +As Of: {{.Data.AsOf}} +Stories: {{len .Data.Stories}} +{{- range $i, $story := .Data.Stories}} + +[{{$i}}] {{if $story.Title}}{{$story.Title}}{{else}}Untitled{{end}} +Start: {{$story.StartTime}} +End: {{$story.EndTime}} +Updated: {{$story.UpdatedAt}} +Priority: {{$story.Priority}} +Order: {{$story.Order}} +{{- if $story.Description}} +Description: {{$story.Description}} +{{- end}} +{{- if $story.AltText}} +Alt Text: {{$story.AltText}} +{{- end}} +{{- if $story.DownloadURL}} +Download URL: {{$story.DownloadURL}} +{{- end}} +{{- end}} +{{- else -}} +No weather stories data available. +{{- end}} diff --git a/templates/weatherstories_latest.txt.tmpl b/templates/weatherstories_latest.txt.tmpl new file mode 100644 index 0000000..64d7576 --- /dev/null +++ b/templates/weatherstories_latest.txt.tmpl @@ -0,0 +1,21 @@ +{{- if .Data -}} +Latest Weather Story +Office ID: {{if .Data.OfficeID}}{{.Data.OfficeID}}{{else}}n/a{{end}} +Title: {{if .Data.Title}}{{.Data.Title}}{{else}}Untitled{{end}} +Start: {{.Data.StartTime}} +End: {{.Data.EndTime}} +Updated: {{.Data.UpdatedAt}} +Priority: {{.Data.Priority}} +Order: {{.Data.Order}} +{{- if .Data.Description}} +Description: {{.Data.Description}} +{{- end}} +{{- if .Data.AltText}} +Alt Text: {{.Data.AltText}} +{{- end}} +{{- if .Data.DownloadURL}} +Download URL: {{.Data.DownloadURL}} +{{- end}} +{{- else -}} +No weather stories data available. +{{- end}}