From 0bccfc50d917de2dd0ca34931907f5ebc5c80b77 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 28 Mar 2026 17:36:35 -0500 Subject: [PATCH] Implemented the weather forecast discussion product from weatherfeeder v0.8.3 --- README.md | 4 + go.mod | 2 +- go.sum | 4 +- .../inbound/httpapi/discussion_endpoint.go | 28 +++ .../adapters/inbound/httpapi/endpoints.go | 1 + .../inbound/httpapi/endpoints_test.go | 199 ++++++++++++++++++ .../inbound/httpapi/presenter/discussion.go | 38 ++++ .../inbound/httpapi/presenter/payload_test.go | 37 ++++ .../adapters/inbound/httpapi/query_bind.go | 35 +++ internal/adapters/inbound/httpapi/service.go | 1 + .../outbound/postgres/discussion_mapper.go | 45 ++++ .../outbound/postgres/discussion_queries.go | 31 +++ .../outbound/postgres/discussion_read.go | 74 +++++++ .../outbound/postgres/discussion_rows.go | 28 +++ .../outbound/postgres/repository_test.go | 61 ++++++ internal/app/service.go | 5 + internal/app/service_test.go | 18 ++ templates/discussion.txt.tmpl | 43 ++++ 18 files changed, 651 insertions(+), 3 deletions(-) create mode 100644 internal/adapters/inbound/httpapi/discussion_endpoint.go create mode 100644 internal/adapters/inbound/httpapi/presenter/discussion.go create mode 100644 internal/adapters/outbound/postgres/discussion_mapper.go create mode 100644 internal/adapters/outbound/postgres/discussion_queries.go create mode 100644 internal/adapters/outbound/postgres/discussion_read.go create mode 100644 internal/adapters/outbound/postgres/discussion_rows.go create mode 100644 templates/discussion.txt.tmpl diff --git a/README.md b/README.md index 491f3aa..b04d154 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A small HTTP API that serves a variety of weather-related endpoints. - `GET /observations` - `GET /conditions/current` - `GET /alerts/active` +- `GET /discussion` - `GET /forecast/hourly` - `GET /forecast/hourly/today` - `GET /forecast/hourly/tomorrow` @@ -24,4 +25,7 @@ Shared weather query parameters: Forecast endpoint query parameters: - `precision` (`0`-`2`) + +Forecast and discussion endpoint query parameters: + - `tz` / `TZ` (IANA timezone, US abbreviation, or UTC offset) diff --git a/go.mod b/go.mod index 74bc188..e7e9f08 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.7.2 + gitea.maximumdirect.net/ejr/weatherfeeder v0.8.3 github.com/lib/pq v1.10.9 ) diff --git a/go.sum b/go.sum index 64fef22..cc88067 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.7.2 h1:GJdZ9x54HLTPidPGhD/dmq7efvGjBltK1ywCIRU1w6c= -gitea.maximumdirect.net/ejr/weatherfeeder v0.7.2/go.mod h1:P7rP7XftJjBFzNEIkAiX+z2YqXOtnZjy3BaF6kX+sO4= +gitea.maximumdirect.net/ejr/weatherfeeder v0.8.3 h1:vH5p8zKiJ6D7JnbpA43iWKNEdgQg/VwKKaWlwF3AXXs= +gitea.maximumdirect.net/ejr/weatherfeeder v0.8.3/go.mod h1:YeHGpmJihwutT+2t8La8e2pPCusdxqiUNyZZOPqE33I= 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/discussion_endpoint.go b/internal/adapters/inbound/httpapi/discussion_endpoint.go new file mode 100644 index 0000000..4905391 --- /dev/null +++ b/internal/adapters/inbound/httpapi/discussion_endpoint.go @@ -0,0 +1,28 @@ +// discussion_endpoint.go defines the /discussion endpoint behavior. +// Layer: adapters/inbound/httpapi discussion 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 discussionDefinition(svc Service) endpoint.Definition { + return endpoint.GET( + "/discussion", + bindTimezoneQuery, + func(ctx context.Context, req timezoneQueryRequest) (any, error) { + run, err := svc.LatestForecastDiscussion(ctx) + if err != nil { + return nil, err + } + return response.Envelope{Data: presenter.DiscussionPayload(run, req.Units, req.Timezone)}, nil + }, + endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), + endpoint.WithTemplate("discussion.txt.tmpl"), + ) +} diff --git a/internal/adapters/inbound/httpapi/endpoints.go b/internal/adapters/inbound/httpapi/endpoints.go index 3ceab54..ce4827e 100644 --- a/internal/adapters/inbound/httpapi/endpoints.go +++ b/internal/adapters/inbound/httpapi/endpoints.go @@ -8,6 +8,7 @@ func Definitions(svc Service) []endpoint.Definition { defs := []endpoint.Definition{ observationDefinition(svc), alertsDefinition(svc), + discussionDefinition(svc), conditionsDefinition(svc), } defs = append(defs, forecastDefinitions(svc)...) diff --git a/internal/adapters/inbound/httpapi/endpoints_test.go b/internal/adapters/inbound/httpapi/endpoints_test.go index 31291db..f859f5e 100644 --- a/internal/adapters/inbound/httpapi/endpoints_test.go +++ b/internal/adapters/inbound/httpapi/endpoints_test.go @@ -26,6 +26,7 @@ type fakeService struct { observation *model.WeatherObservation forecast *model.WeatherForecastRun narrativeForecast *model.WeatherForecastRun + discussion *model.WeatherForecastDiscussion alerts *model.WeatherAlertRun conditions *app.CurrentConditions err error @@ -43,6 +44,10 @@ func (s *fakeService) LatestNarrativeForecast(context.Context) (*model.WeatherFo return s.narrativeForecast, s.err } +func (s *fakeService) LatestForecastDiscussion(context.Context) (*model.WeatherForecastDiscussion, error) { + return s.discussion, s.err +} + func (s *fakeService) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) { return s.alerts, s.err } @@ -1257,6 +1262,186 @@ func TestAlertsRejectPrecisionQueryParameter(t *testing.T) { } } +func TestDiscussionNoDataReturnsNullEnvelopeData(t *testing.T) { + h := newHandler(t, &fakeService{}, "/discussion") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/discussion", 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 TestDiscussionJSONEnvelope(t *testing.T) { + issuedAt := time.Date(2026, 3, 29, 0, 24, 0, 0, time.UTC) + shortIssuedAt := issuedAt.Add(-5 * time.Minute) + h := newHandler(t, &fakeService{ + discussion: &model.WeatherForecastDiscussion{ + OfficeID: "LSX", + OfficeName: "National Weather Service Saint Louis MO", + Product: model.ForecastDiscussionProductAFD, + IssuedAt: issuedAt, + KeyMessages: []string{"msg one", "msg two"}, + ShortTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"}, + LongTerm: &model.WeatherForecastDiscussionSection{Text: "Long term text"}, + }, + }, "/discussion") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/discussion", 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"` + Product string `json:"product"` + KeyMessages []string `json:"keyMessages"` + ShortTerm *struct { + Text string `json:"text"` + } `json:"shortTerm"` + } `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 payload.Data.Product != "afd" { + t.Fatalf("expected product afd, got %q", payload.Data.Product) + } + if len(payload.Data.KeyMessages) != 2 { + t.Fatalf("expected 2 key messages, got %d", len(payload.Data.KeyMessages)) + } + if payload.Data.ShortTerm == nil || payload.Data.ShortTerm.Text != "Short term text" { + t.Fatalf("unexpected shortTerm payload: %+v", payload.Data.ShortTerm) + } +} + +func TestDiscussionSupportsTextAndXMLFormats(t *testing.T) { + hText := newHandler(t, &fakeService{ + discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()}, + }, "/discussion") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/discussion?format=TEXT", 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(), "Forecast Discussion") { + t.Fatalf("expected rendered text template body, got %q", w.Body.String()) + } + + hXML := newHandler(t, &fakeService{ + discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()}, + }, "/discussion") + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/discussion?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 TestDiscussionTimezoneQuery(t *testing.T) { + issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + updatedAt := issuedAt.Add(30 * time.Minute) + shortIssuedAt := issuedAt.Add(-15 * time.Minute) + longIssuedAt := issuedAt.Add(15 * time.Minute) + + for _, tc := range []struct { + name string + query string + want int + }{ + {name: "abbreviation", query: "/discussion?tz=CDT", want: -5 * 60 * 60}, + {name: "alias", query: "/discussion?tz=Chicago", want: -5 * 60 * 60}, + {name: "offset", query: "/discussion?TZ=-5", want: -5 * 60 * 60}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHandler(t, &fakeService{ + discussion: &model.WeatherForecastDiscussion{ + Product: model.ForecastDiscussionProductAFD, + IssuedAt: issuedAt, + UpdatedAt: &updatedAt, + ShortTerm: &model.WeatherForecastDiscussionSection{IssuedAt: &shortIssuedAt}, + LongTerm: &model.WeatherForecastDiscussionSection{IssuedAt: &longIssuedAt}, + }, + }, "/discussion") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, tc.query, nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var payload discussionTimePayload + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode discussion payload: %v", err) + } + assertOffsetSeconds(t, payload.Data.IssuedAt, tc.want) + assertOffsetSeconds(t, *payload.Data.UpdatedAt, tc.want) + assertOffsetSeconds(t, *payload.Data.ShortTerm.IssuedAt, tc.want) + assertOffsetSeconds(t, *payload.Data.LongTerm.IssuedAt, tc.want) + }) + } +} + +func TestDiscussionRejectsInvalidQueryParameters(t *testing.T) { + tests := []string{ + "/discussion?bogus=1", + "/discussion?precision=1", + "/discussion?tz=not-a-timezone", + "/discussion?tz=CDT&TZ=EST", + } + + for _, rawURL := range tests { + h := newHandler(t, &fakeService{ + discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()}, + }, "/discussion") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, rawURL, nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("%s: expected 400, got %d", rawURL, w.Code) + } + } +} + +func TestDefinitionsIncludeDiscussion(t *testing.T) { + _ = definitionForPath(t, Definitions(&fakeService{}), "/discussion") +} + func newHandler(t *testing.T, svc Service, path string) http.Handler { t.Helper() @@ -1292,6 +1477,7 @@ func testRenderers(t *testing.T) *render.Registry { tmplReg := templates.NewRegistry() for name, body := range map[string]string{ "observations.txt.tmpl": "Observation text", + "discussion.txt.tmpl": "Forecast Discussion", "forecast_hourly.txt.tmpl": "Forecast text", "forecast_narrative.txt.tmpl": "Narrative Forecast", "alerts_active.txt.tmpl": "Alerts text", @@ -1327,6 +1513,19 @@ type forecastTimePayload struct { } `json:"data"` } +type discussionTimePayload struct { + Data struct { + IssuedAt time.Time `json:"issuedAt"` + UpdatedAt *time.Time `json:"updatedAt"` + ShortTerm *struct { + IssuedAt *time.Time `json:"issuedAt"` + } `json:"shortTerm"` + LongTerm *struct { + IssuedAt *time.Time `json:"issuedAt"` + } `json:"longTerm"` + } `json:"data"` +} + func decodeForecastTimePayload(t *testing.T, w *httptest.ResponseRecorder) forecastTimePayload { t.Helper() diff --git a/internal/adapters/inbound/httpapi/presenter/discussion.go b/internal/adapters/inbound/httpapi/presenter/discussion.go new file mode 100644 index 0000000..63a2c23 --- /dev/null +++ b/internal/adapters/inbound/httpapi/presenter/discussion.go @@ -0,0 +1,38 @@ +// discussion.go presents forecast discussion payloads. +// Layer: adapters/inbound/httpapi/presenter discussion payload mapping. +package presenter + +import ( + "time" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +func DiscussionPayload(run *model.WeatherForecastDiscussion, _ Units, tz *time.Location) any { + if run == nil { + return nil + } + + out := model.WeatherForecastDiscussion{ + OfficeID: run.OfficeID, + OfficeName: run.OfficeName, + Product: run.Product, + IssuedAt: inLocationTime(run.IssuedAt, tz), + UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz), + KeyMessages: append([]string(nil), run.KeyMessages...), + ShortTerm: copyDiscussionSection(run.ShortTerm, tz), + LongTerm: copyDiscussionSection(run.LongTerm, tz), + } + return &out +} + +func copyDiscussionSection(in *model.WeatherForecastDiscussionSection, tz *time.Location) *model.WeatherForecastDiscussionSection { + if in == nil { + return nil + } + return &model.WeatherForecastDiscussionSection{ + Qualifier: in.Qualifier, + IssuedAt: inLocationTimePtr(in.IssuedAt, tz), + Text: in.Text, + } +} diff --git a/internal/adapters/inbound/httpapi/presenter/payload_test.go b/internal/adapters/inbound/httpapi/presenter/payload_test.go index 23bf2a0..b0e469d 100644 --- a/internal/adapters/inbound/httpapi/presenter/payload_test.go +++ b/internal/adapters/inbound/httpapi/presenter/payload_test.go @@ -175,6 +175,40 @@ func TestForecastPayloadNoTimezonePreservesUTCAndCopySemantics(t *testing.T) { assertOffsetSeconds(t, metric.Periods[0].StartTime, 0) } +func TestDiscussionPayloadTimezoneConversionAndCopySemantics(t *testing.T) { + loc := time.FixedZone("UTC-05:00", -5*60*60) + issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + updatedAt := issuedAt.Add(30 * time.Minute) + shortIssuedAt := issuedAt.Add(-15 * time.Minute) + run := &model.WeatherForecastDiscussion{ + OfficeID: "LSX", + Product: model.ForecastDiscussionProductAFD, + IssuedAt: issuedAt, + UpdatedAt: &updatedAt, + KeyMessages: []string{"msg one"}, + ShortTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"}, + } + + payload := DiscussionPayload(run, UnitsMetric, loc) + discussion, ok := payload.(*model.WeatherForecastDiscussion) + if !ok { + t.Fatalf("expected *model.WeatherForecastDiscussion payload, got %T", payload) + } + if discussion == run { + t.Fatalf("expected discussion payload to be copied") + } + if discussion.ShortTerm == run.ShortTerm { + t.Fatalf("expected shortTerm pointer to be copied") + } + assertOffsetSeconds(t, discussion.IssuedAt, -5*60*60) + assertOffsetSeconds(t, *discussion.UpdatedAt, -5*60*60) + assertOffsetSeconds(t, *discussion.ShortTerm.IssuedAt, -5*60*60) + if discussion.KeyMessages[0] != "msg one" { + t.Fatalf("expected key message preserved, got %#v", discussion.KeyMessages) + } + assertOffsetSeconds(t, run.IssuedAt, 0) +} + func TestMetricCopyAndNilHandling(t *testing.T) { obs := &model.WeatherObservation{ TemperatureC: float64Ptr(20.6), @@ -202,6 +236,9 @@ func TestMetricCopyAndNilHandling(t *testing.T) { if AlertsPayload(nil, UnitsUS) != nil { t.Fatalf("expected nil alerts input to return nil payload") } + if DiscussionPayload(nil, UnitsUS, nil) != nil { + t.Fatalf("expected nil discussion input to return nil payload") + } if CurrentConditionsPayload(nil, UnitsUS, 0) != nil { t.Fatalf("expected nil current conditions input to return nil payload") } diff --git a/internal/adapters/inbound/httpapi/query_bind.go b/internal/adapters/inbound/httpapi/query_bind.go index 55b1067..bb52a45 100644 --- a/internal/adapters/inbound/httpapi/query_bind.go +++ b/internal/adapters/inbound/httpapi/query_bind.go @@ -21,6 +21,11 @@ type precisionQueryRequest struct { Timezone *time.Location } +type timezoneQueryRequest struct { + Units presenter.Units + Timezone *time.Location +} + func bindQuery(r *http.Request) (queryRequest, error) { normalizeCommonQueryValue(r, "units") normalizeCommonQueryValue(r, "format") @@ -50,6 +55,36 @@ func bindForecastPrecisionQuery(r *http.Request) (precisionQueryRequest, error) return bindPrecisionQueryInternal(r, true) } +func bindTimezoneQuery(r *http.Request) (timezoneQueryRequest, error) { + normalizeCommonQueryValue(r, "units") + normalizeCommonQueryValue(r, "format") + + common, err := bind.CommonQueryParams(r, bind.QueryPolicy{ + AllowUnits: true, + AllowFormat: true, + DefaultUnits: string(presenter.UnitsMetric), + RejectUnknown: true, + }, "tz", "TZ") + if err != nil { + return timezoneQueryRequest{}, err + } + + units := presenter.Units(strings.ToLower(strings.TrimSpace(common.Units))) + if units == "" { + units = presenter.UnitsMetric + } + + tz, err := parseTimezoneQuery(r) + if err != nil { + return timezoneQueryRequest{}, err + } + + return timezoneQueryRequest{ + Units: units, + Timezone: tz, + }, nil +} + func bindPrecisionQueryInternal(r *http.Request, allowTimezone bool) (precisionQueryRequest, error) { normalizeCommonQueryValue(r, "units") normalizeCommonQueryValue(r, "format") diff --git a/internal/adapters/inbound/httpapi/service.go b/internal/adapters/inbound/httpapi/service.go index 7379171..a6c4656 100644 --- a/internal/adapters/inbound/httpapi/service.go +++ b/internal/adapters/inbound/httpapi/service.go @@ -14,6 +14,7 @@ type Service interface { LatestObservation(ctx context.Context) (*model.WeatherObservation, error) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error) + LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) CurrentConditions(ctx context.Context) (*app.CurrentConditions, error) } diff --git a/internal/adapters/outbound/postgres/discussion_mapper.go b/internal/adapters/outbound/postgres/discussion_mapper.go new file mode 100644 index 0000000..5431d87 --- /dev/null +++ b/internal/adapters/outbound/postgres/discussion_mapper.go @@ -0,0 +1,45 @@ +// discussion_mapper.go maps forecast discussion rows into weather model payloads. +// Layer: adapters/outbound/postgres discussion feature. +package postgres + +import ( + "database/sql" + "strings" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +func mapDiscussionParentRow(row discussionParentRow) model.WeatherForecastDiscussion { + return model.WeatherForecastDiscussion{ + OfficeID: stringValue(row.OfficeID), + OfficeName: stringValue(row.OfficeName), + Product: model.ForecastDiscussionProduct(strings.TrimSpace(row.Product)), + IssuedAt: row.IssuedAt.UTC(), + UpdatedAt: timePtr(row.UpdatedAt), + ShortTerm: discussionSectionPtr(row.ShortTermQualifier, row.ShortTermIssuedAt, row.ShortTermText), + LongTerm: discussionSectionPtr(row.LongTermQualifier, row.LongTermIssuedAt, row.LongTermText), + KeyMessages: nil, + } +} + +func mapDiscussionKeyMessageRow(row discussionKeyMessageRow) string { + return stringValue(row.MessageText) +} + +func discussionSectionPtr( + qualifier sql.NullString, + issuedAt sql.NullTime, + text sql.NullString, +) *model.WeatherForecastDiscussionSection { + q := stringValue(qualifier) + t := stringValue(text) + i := timePtr(issuedAt) + if q == "" && t == "" && i == nil { + return nil + } + return &model.WeatherForecastDiscussionSection{ + Qualifier: q, + IssuedAt: i, + Text: t, + } +} diff --git a/internal/adapters/outbound/postgres/discussion_queries.go b/internal/adapters/outbound/postgres/discussion_queries.go new file mode 100644 index 0000000..5210a78 --- /dev/null +++ b/internal/adapters/outbound/postgres/discussion_queries.go @@ -0,0 +1,31 @@ +// discussion_queries.go contains SQL text for forecast discussion reads. +// Layer: adapters/outbound/postgres discussion feature. +package postgres + +const ( + queryLatestForecastDiscussion = ` +SELECT + event_id, + office_id, + office_name, + issued_at, + updated_at, + product, + short_term_qualifier, + short_term_issued_at, + short_term_text, + long_term_qualifier, + long_term_issued_at, + long_term_text +FROM forecast_discussions +ORDER BY issued_at DESC, event_emitted_at DESC +LIMIT 1` + + queryForecastDiscussionKeyMessages = ` +SELECT + message_index, + message_text +FROM forecast_discussion_key_messages +WHERE run_event_id = $1 +ORDER BY message_index ASC` +) diff --git a/internal/adapters/outbound/postgres/discussion_read.go b/internal/adapters/outbound/postgres/discussion_read.go new file mode 100644 index 0000000..85180b4 --- /dev/null +++ b/internal/adapters/outbound/postgres/discussion_read.go @@ -0,0 +1,74 @@ +// discussion_read.go executes forecast discussion queries. +// Layer: adapters/outbound/postgres discussion feature. +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +func (r *Repository) LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error) { + if r == nil || r.db == nil { + return nil, fmt.Errorf("postgres repository is not configured") + } + + var row discussionParentRow + err := r.db.QueryRowContext(ctx, queryLatestForecastDiscussion).Scan( + &row.EventID, + &row.OfficeID, + &row.OfficeName, + &row.IssuedAt, + &row.UpdatedAt, + &row.Product, + &row.ShortTermQualifier, + &row.ShortTermIssuedAt, + &row.ShortTermText, + &row.LongTermQualifier, + &row.LongTermIssuedAt, + &row.LongTermText, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query latest forecast discussion: %w", err) + } + + run := mapDiscussionParentRow(row) + + keyMessages, err := r.loadForecastDiscussionKeyMessages(ctx, row.EventID) + if err != nil { + return nil, err + } + run.KeyMessages = keyMessages + + return &run, nil +} + +func (r *Repository) loadForecastDiscussionKeyMessages(ctx context.Context, eventID string) ([]string, error) { + rows, err := r.db.QueryContext(ctx, queryForecastDiscussionKeyMessages, eventID) + if err != nil { + return nil, fmt.Errorf("query forecast discussion key messages: %w", err) + } + defer rows.Close() + + out := make([]string, 0) + for rows.Next() { + var row discussionKeyMessageRow + if err := rows.Scan( + &row.MessageIndex, + &row.MessageText, + ); err != nil { + return nil, fmt.Errorf("scan forecast discussion key message row: %w", err) + } + out = append(out, mapDiscussionKeyMessageRow(row)) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate forecast discussion key message rows: %w", err) + } + return out, nil +} diff --git a/internal/adapters/outbound/postgres/discussion_rows.go b/internal/adapters/outbound/postgres/discussion_rows.go new file mode 100644 index 0000000..de4def2 --- /dev/null +++ b/internal/adapters/outbound/postgres/discussion_rows.go @@ -0,0 +1,28 @@ +// discussion_rows.go defines row DTOs for forecast discussion reads. +// Layer: adapters/outbound/postgres discussion feature. +package postgres + +import ( + "database/sql" + "time" +) + +type discussionParentRow struct { + EventID string + OfficeID sql.NullString + OfficeName sql.NullString + IssuedAt time.Time + UpdatedAt sql.NullTime + Product string + ShortTermQualifier sql.NullString + ShortTermIssuedAt sql.NullTime + ShortTermText sql.NullString + LongTermQualifier sql.NullString + LongTermIssuedAt sql.NullTime + LongTermText sql.NullString +} + +type discussionKeyMessageRow struct { + MessageIndex int + MessageText sql.NullString +} diff --git a/internal/adapters/outbound/postgres/repository_test.go b/internal/adapters/outbound/postgres/repository_test.go index d55d876..1c64170 100644 --- a/internal/adapters/outbound/postgres/repository_test.go +++ b/internal/adapters/outbound/postgres/repository_test.go @@ -6,6 +6,8 @@ import ( "database/sql" "testing" "time" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" ) func TestMapObservationParentRowNullables(t *testing.T) { @@ -84,6 +86,65 @@ func TestMapForecastPeriodRowNullables(t *testing.T) { } } +func TestMapDiscussionParentRowNullables(t *testing.T) { + issuedAt := time.Date(2026, 3, 28, 19, 24, 0, 0, time.FixedZone("CDT", -5*3600)) + updatedAt := issuedAt.Add(time.Hour) + shortIssuedAt := issuedAt.Add(-5 * time.Minute) + + discussion := mapDiscussionParentRow(discussionParentRow{ + OfficeID: sql.NullString{String: "LSX", Valid: true}, + OfficeName: sql.NullString{String: "National Weather Service Saint Louis MO", Valid: true}, + IssuedAt: issuedAt, + UpdatedAt: sql.NullTime{Time: updatedAt, Valid: true}, + Product: "afd", + ShortTermQualifier: sql.NullString{String: "(Tonight)", Valid: true}, + ShortTermIssuedAt: sql.NullTime{Time: shortIssuedAt, Valid: true}, + ShortTermText: sql.NullString{String: "Short term text", Valid: true}, + }) + + if discussion.OfficeID != "LSX" { + t.Fatalf("expected office id LSX, got %q", discussion.OfficeID) + } + if discussion.Product != model.ForecastDiscussionProductAFD { + t.Fatalf("expected product afd, got %q", discussion.Product) + } + if discussion.UpdatedAt == nil || discussion.UpdatedAt.Location().String() != "UTC" { + t.Fatalf("expected updatedAt to be UTC-normalized, got %v", discussion.UpdatedAt) + } + if discussion.ShortTerm == nil { + t.Fatalf("expected shortTerm section to be populated") + } + if discussion.ShortTerm.Qualifier != "(Tonight)" || discussion.ShortTerm.Text != "Short term text" { + t.Fatalf("unexpected shortTerm section: %+v", discussion.ShortTerm) + } + if discussion.LongTerm != nil { + t.Fatalf("expected longTerm nil, got %+v", discussion.LongTerm) + } +} + +func TestMapDiscussionSectionNilWhenAllFieldsMissing(t *testing.T) { + got := discussionSectionPtr(sql.NullString{}, sql.NullTime{}, sql.NullString{}) + if got != nil { + t.Fatalf("expected nil discussion section, got %+v", got) + } +} + +func TestMapDiscussionKeyMessagesPreserveOrder(t *testing.T) { + rows := []discussionKeyMessageRow{ + {MessageIndex: 0, MessageText: sql.NullString{String: "first", Valid: true}}, + {MessageIndex: 1, MessageText: sql.NullString{String: "second", Valid: true}}, + } + + got := make([]string, 0, len(rows)) + for _, row := range rows { + got = append(got, mapDiscussionKeyMessageRow(row)) + } + + if len(got) != 2 || got[0] != "first" || got[1] != "second" { + t.Fatalf("unexpected key message order: %#v", got) + } +} + 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/app/service.go b/internal/app/service.go index 53a488a..fc463e5 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -13,6 +13,7 @@ type Repository interface { LatestObservation(ctx context.Context) (*model.WeatherObservation, error) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error) + LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*CurrentConditions, error) } @@ -38,6 +39,10 @@ func (s *Service) LatestNarrativeForecast(ctx context.Context) (*model.WeatherFo return s.repo.LatestNarrativeForecast(ctx) } +func (s *Service) LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error) { + return s.repo.LatestForecastDiscussion(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 c98da92..99bd268 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -14,6 +14,7 @@ type fakeRepository struct { observation *model.WeatherObservation forecast *model.WeatherForecastRun narrative *model.WeatherForecastRun + discussion *model.WeatherForecastDiscussion alerts *model.WeatherAlertRun conditions *CurrentConditions err error @@ -33,6 +34,10 @@ func (r *fakeRepository) LatestNarrativeForecast(context.Context) (*model.Weathe return r.narrative, r.err } +func (r *fakeRepository) LatestForecastDiscussion(context.Context) (*model.WeatherForecastDiscussion, error) { + return r.discussion, r.err +} + func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) { return r.alerts, r.err } @@ -94,6 +99,19 @@ func TestServiceDelegatesAlerts(t *testing.T) { } } +func TestServiceDelegatesForecastDiscussion(t *testing.T) { + repo := &fakeRepository{discussion: &model.WeatherForecastDiscussion{OfficeID: "LSX"}} + svc := NewService(repo) + + run, err := svc.LatestForecastDiscussion(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if run == nil || run.OfficeID != "LSX" { + t.Fatalf("unexpected forecast discussion: %+v", run) + } +} + func TestServiceUsesDefaultCurrentConditionsWindow(t *testing.T) { repo := &fakeRepository{conditions: &CurrentConditions{ConditionCode: model.WMOUnknown}} svc := NewService(repo) diff --git a/templates/discussion.txt.tmpl b/templates/discussion.txt.tmpl new file mode 100644 index 0000000..99da98c --- /dev/null +++ b/templates/discussion.txt.tmpl @@ -0,0 +1,43 @@ +{{- if .Data -}} +Forecast Discussion +Office ID: {{if .Data.OfficeID}}{{.Data.OfficeID}}{{else}}n/a{{end}} +Office Name: {{if .Data.OfficeName}}{{.Data.OfficeName}}{{else}}n/a{{end}} +Product: {{.Data.Product}} +Issued At: {{.Data.IssuedAt}} +{{- if .Data.UpdatedAt}} +Updated At: {{.Data.UpdatedAt}} +{{- end}} +Key Messages: {{len .Data.KeyMessages}} +{{- range $i, $message := .Data.KeyMessages}} + +[{{$i}}] {{$message}} +{{- end}} +{{- if .Data.ShortTerm}} + +Short Term +{{- if .Data.ShortTerm.Qualifier}} +Qualifier: {{.Data.ShortTerm.Qualifier}} +{{- end}} +{{- if .Data.ShortTerm.IssuedAt}} +Issued At: {{.Data.ShortTerm.IssuedAt}} +{{- end}} +{{- if .Data.ShortTerm.Text}} +Text: {{.Data.ShortTerm.Text}} +{{- end}} +{{- end}} +{{- if .Data.LongTerm}} + +Long Term +{{- if .Data.LongTerm.Qualifier}} +Qualifier: {{.Data.LongTerm.Qualifier}} +{{- end}} +{{- if .Data.LongTerm.IssuedAt}} +Issued At: {{.Data.LongTerm.IssuedAt}} +{{- end}} +{{- if .Data.LongTerm.Text}} +Text: {{.Data.LongTerm.Text}} +{{- end}} +{{- end}} +{{- else -}} +No forecast discussion data available. +{{- end}}