From bb18bcfb15e08425ed86030688a2581fbdadddb6 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 20 Mar 2026 09:44:53 -0500 Subject: [PATCH] Refactored the application structure to better separate concerns between files and packages --- cmd/weatherapi/main.go | 6 +- .../inbound/httpapi/alerts_endpoint.go | 28 + .../inbound/httpapi/conditions_endpoint.go | 28 + .../adapters/inbound/httpapi/endpoints.go | 119 +-- .../inbound/httpapi/endpoints_test.go | 16 +- .../inbound/httpapi/forecast_endpoint.go | 28 + .../inbound/httpapi/observations_endpoint.go | 28 + .../inbound/httpapi/presenter/alerts.go | 12 + .../inbound/httpapi/presenter/conditions.go | 54 ++ .../inbound/httpapi/presenter}/constants.go | 16 +- .../inbound/httpapi/presenter/forecast.go} | 156 +--- .../inbound/httpapi/presenter/helpers.go | 55 ++ .../inbound/httpapi/presenter/observation.go | 57 ++ .../httpapi/presenter}/payload_test.go | 33 +- .../inbound/httpapi/presenter/units.go | 11 + .../adapters/inbound/httpapi/query_bind.go | 36 + .../inbound/httpapi/query_normalize.go | 23 + internal/adapters/inbound/httpapi/service.go | 18 + .../outbound/postgres/alerts_mapper.go | 70 ++ .../outbound/postgres/alerts_queries.go | 53 ++ .../adapters/outbound/postgres/alerts_read.go | 110 +++ .../adapters/outbound/postgres/alerts_rows.go | 59 ++ .../outbound/postgres/conditions_mapper.go | 30 + .../outbound/postgres/conditions_queries.go | 50 ++ .../outbound/postgres/conditions_read.go | 39 + .../outbound/postgres/conditions_rows.go | 17 + .../outbound/postgres/forecast_mapper.go | 49 ++ .../outbound/postgres/forecast_queries.go | 54 ++ .../outbound/postgres/forecast_read.go | 96 +++ .../outbound/postgres/forecast_rows.go | 50 ++ .../outbound/postgres/observations_mapper.go | 42 + .../outbound/postgres/observations_queries.go | 33 + .../outbound/postgres/observations_read.go | 79 ++ .../outbound/postgres/observations_rows.go | 32 + .../adapters/outbound/postgres/repository.go | 782 +----------------- .../outbound/postgres/repository_test.go | 2 + .../outbound/postgres/scan_helpers.go | 39 + internal/app/constants.go | 7 + internal/{core => app}/current_conditions.go | 4 +- internal/{core => app}/service.go | 10 +- internal/app/service_test.go | 100 +++ 41 files changed, 1445 insertions(+), 1086 deletions(-) create mode 100644 internal/adapters/inbound/httpapi/alerts_endpoint.go create mode 100644 internal/adapters/inbound/httpapi/conditions_endpoint.go create mode 100644 internal/adapters/inbound/httpapi/forecast_endpoint.go create mode 100644 internal/adapters/inbound/httpapi/observations_endpoint.go create mode 100644 internal/adapters/inbound/httpapi/presenter/alerts.go create mode 100644 internal/adapters/inbound/httpapi/presenter/conditions.go rename internal/{core => adapters/inbound/httpapi/presenter}/constants.go (62%) rename internal/{core/payload.go => adapters/inbound/httpapi/presenter/forecast.go} (50%) create mode 100644 internal/adapters/inbound/httpapi/presenter/helpers.go create mode 100644 internal/adapters/inbound/httpapi/presenter/observation.go rename internal/{core => adapters/inbound/httpapi/presenter}/payload_test.go (87%) create mode 100644 internal/adapters/inbound/httpapi/presenter/units.go create mode 100644 internal/adapters/inbound/httpapi/query_bind.go create mode 100644 internal/adapters/inbound/httpapi/query_normalize.go create mode 100644 internal/adapters/inbound/httpapi/service.go create mode 100644 internal/adapters/outbound/postgres/alerts_mapper.go create mode 100644 internal/adapters/outbound/postgres/alerts_queries.go create mode 100644 internal/adapters/outbound/postgres/alerts_read.go create mode 100644 internal/adapters/outbound/postgres/alerts_rows.go create mode 100644 internal/adapters/outbound/postgres/conditions_mapper.go create mode 100644 internal/adapters/outbound/postgres/conditions_queries.go create mode 100644 internal/adapters/outbound/postgres/conditions_read.go create mode 100644 internal/adapters/outbound/postgres/conditions_rows.go create mode 100644 internal/adapters/outbound/postgres/forecast_mapper.go create mode 100644 internal/adapters/outbound/postgres/forecast_queries.go create mode 100644 internal/adapters/outbound/postgres/forecast_read.go create mode 100644 internal/adapters/outbound/postgres/forecast_rows.go create mode 100644 internal/adapters/outbound/postgres/observations_mapper.go create mode 100644 internal/adapters/outbound/postgres/observations_queries.go create mode 100644 internal/adapters/outbound/postgres/observations_read.go create mode 100644 internal/adapters/outbound/postgres/observations_rows.go create mode 100644 internal/adapters/outbound/postgres/scan_helpers.go create mode 100644 internal/app/constants.go rename internal/{core => app}/current_conditions.go (76%) rename internal/{core => app}/service.go (75%) create mode 100644 internal/app/service_test.go diff --git a/cmd/weatherapi/main.go b/cmd/weatherapi/main.go index d22d4f0..ea39075 100644 --- a/cmd/weatherapi/main.go +++ b/cmd/weatherapi/main.go @@ -1,3 +1,5 @@ +// main.go wires configuration, dependencies, and HTTP runtime startup. +// Layer: cmd/weatherapi executable composition root. package main import ( @@ -15,7 +17,7 @@ import ( "gitea.maximumdirect.net/ejr/feedapi/db" httpapi "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi" wfpq "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/outbound/postgres" - "gitea.maximumdirect.net/ejr/weatherapi/internal/core" + "gitea.maximumdirect.net/ejr/weatherapi/internal/app" _ "github.com/lib/pq" ) @@ -60,7 +62,7 @@ func run(ctx context.Context, cfgPath string) error { } repo := wfpq.NewRepository(primary) - svc := core.NewService(repo) + svc := app.NewService(repo) defs := httpapi.Definitions(svc) a, err := feedapp.New(cfg, diff --git a/internal/adapters/inbound/httpapi/alerts_endpoint.go b/internal/adapters/inbound/httpapi/alerts_endpoint.go new file mode 100644 index 0000000..e71d367 --- /dev/null +++ b/internal/adapters/inbound/httpapi/alerts_endpoint.go @@ -0,0 +1,28 @@ +// alerts_endpoint.go defines the /alerts/active endpoint behavior. +// Layer: adapters/inbound/httpapi alerts 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 alertsDefinition(svc Service) endpoint.Definition { + return endpoint.GET( + "/alerts/active", + bindQuery, + func(ctx context.Context, req queryRequest) (any, error) { + run, err := svc.LatestAlertRun(ctx) + if err != nil { + return nil, err + } + return response.Envelope{Data: presenter.AlertsPayload(run, req.Units)}, nil + }, + endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), + endpoint.WithTemplate("alerts_active.txt.tmpl"), + ) +} diff --git a/internal/adapters/inbound/httpapi/conditions_endpoint.go b/internal/adapters/inbound/httpapi/conditions_endpoint.go new file mode 100644 index 0000000..69e401b --- /dev/null +++ b/internal/adapters/inbound/httpapi/conditions_endpoint.go @@ -0,0 +1,28 @@ +// conditions_endpoint.go defines the /conditions/current endpoint behavior. +// Layer: adapters/inbound/httpapi current-conditions 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 conditionsDefinition(svc Service) endpoint.Definition { + return endpoint.GET( + "/conditions/current", + bindQuery, + func(ctx context.Context, req queryRequest) (any, error) { + conditions, err := svc.CurrentConditions(ctx) + if err != nil { + return nil, err + } + return response.Envelope{Data: presenter.CurrentConditionsPayload(conditions, req.Units)}, nil + }, + endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), + endpoint.WithTemplate("conditions_current.txt.tmpl"), + ) +} diff --git a/internal/adapters/inbound/httpapi/endpoints.go b/internal/adapters/inbound/httpapi/endpoints.go index 8d8bc21..32f36bb 100644 --- a/internal/adapters/inbound/httpapi/endpoints.go +++ b/internal/adapters/inbound/httpapi/endpoints.go @@ -1,119 +1,14 @@ +// endpoints.go registers all HTTP endpoint definitions for weatherapi. +// Layer: adapters/inbound/httpapi endpoint registry only. package httpapi -import ( - "context" - "net/http" - "strings" - - "gitea.maximumdirect.net/ejr/feedapi/bind" - "gitea.maximumdirect.net/ejr/feedapi/endpoint" - "gitea.maximumdirect.net/ejr/feedapi/render" - "gitea.maximumdirect.net/ejr/feedapi/response" - "gitea.maximumdirect.net/ejr/weatherapi/internal/core" - "gitea.maximumdirect.net/ejr/weatherfeeder/model" -) - -// Service describes the weather use-cases needed by the HTTP adapter. -type Service interface { - LatestObservation(ctx context.Context) (*model.WeatherObservation, error) - LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) - LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error) - CurrentConditions(ctx context.Context) (*core.CurrentConditions, error) -} - -type queryRequest struct { - Units core.Units -} +import "gitea.maximumdirect.net/ejr/feedapi/endpoint" func Definitions(svc Service) []endpoint.Definition { return []endpoint.Definition{ - endpoint.GET( - "/observations", - bindQuery, - func(ctx context.Context, req queryRequest) (any, error) { - obs, err := svc.LatestObservation(ctx) - if err != nil { - return nil, err - } - return response.Envelope{Data: core.ObservationPayload(obs, req.Units)}, nil - }, - endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), - endpoint.WithTemplate("observations.txt.tmpl"), - ), - endpoint.GET( - "/forecast/hourly", - bindQuery, - func(ctx context.Context, req queryRequest) (any, error) { - run, err := svc.LatestHourlyForecast(ctx) - if err != nil { - return nil, err - } - return response.Envelope{Data: core.ForecastPayload(run, req.Units)}, nil - }, - endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), - endpoint.WithTemplate("forecast_hourly.txt.tmpl"), - ), - endpoint.GET( - "/alerts/active", - bindQuery, - func(ctx context.Context, req queryRequest) (any, error) { - run, err := svc.LatestActiveAlerts(ctx) - if err != nil { - return nil, err - } - return response.Envelope{Data: core.AlertsPayload(run, req.Units)}, nil - }, - endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), - endpoint.WithTemplate("alerts_active.txt.tmpl"), - ), - endpoint.GET( - "/conditions/current", - bindQuery, - func(ctx context.Context, req queryRequest) (any, error) { - conditions, err := svc.CurrentConditions(ctx) - if err != nil { - return nil, err - } - return response.Envelope{Data: core.CurrentConditionsPayload(conditions, req.Units)}, nil - }, - endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), - endpoint.WithTemplate("conditions_current.txt.tmpl"), - ), + observationDefinition(svc), + forecastDefinition(svc), + alertsDefinition(svc), + conditionsDefinition(svc), } } - -func bindQuery(r *http.Request) (queryRequest, error) { - normalizeCommonQueryValue(r, "units") - normalizeCommonQueryValue(r, "format") - - common, err := bind.CommonQueryParams(r, bind.QueryPolicy{ - AllowUnits: true, - AllowFormat: true, - DefaultUnits: string(core.UnitsMetric), - RejectUnknown: true, - }) - if err != nil { - return queryRequest{}, err - } - - units := core.Units(strings.ToLower(strings.TrimSpace(common.Units))) - if units == "" { - units = core.UnitsMetric - } - return queryRequest{Units: units}, nil -} - -func normalizeCommonQueryValue(r *http.Request, key string) { - q := r.URL.Query() - values, ok := q[key] - if !ok || len(values) == 0 { - return - } - - normalized := strings.ToLower(strings.TrimSpace(values[0])) - if normalized == values[0] { - return - } - q.Set(key, normalized) - r.URL.RawQuery = q.Encode() -} diff --git a/internal/adapters/inbound/httpapi/endpoints_test.go b/internal/adapters/inbound/httpapi/endpoints_test.go index 1d8e091..48b6f42 100644 --- a/internal/adapters/inbound/httpapi/endpoints_test.go +++ b/internal/adapters/inbound/httpapi/endpoints_test.go @@ -1,3 +1,5 @@ +// endpoints_test.go validates HTTP endpoint behavior and format negotiation. +// Layer: adapters/inbound/httpapi endpoint regression tests. package httpapi import ( @@ -16,7 +18,7 @@ import ( "gitea.maximumdirect.net/ejr/feedapi/render" "gitea.maximumdirect.net/ejr/feedapi/templates" "gitea.maximumdirect.net/ejr/feedapi/transport/httpx" - "gitea.maximumdirect.net/ejr/weatherapi/internal/core" + "gitea.maximumdirect.net/ejr/weatherapi/internal/app" "gitea.maximumdirect.net/ejr/weatherfeeder/model" ) @@ -24,7 +26,7 @@ type fakeService struct { observation *model.WeatherObservation forecast *model.WeatherForecastRun alerts *model.WeatherAlertRun - conditions *core.CurrentConditions + conditions *app.CurrentConditions err error } @@ -36,11 +38,11 @@ func (s *fakeService) LatestHourlyForecast(context.Context) (*model.WeatherForec return s.forecast, s.err } -func (s *fakeService) LatestActiveAlerts(context.Context) (*model.WeatherAlertRun, error) { +func (s *fakeService) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) { return s.alerts, s.err } -func (s *fakeService) CurrentConditions(context.Context) (*core.CurrentConditions, error) { +func (s *fakeService) CurrentConditions(context.Context) (*app.CurrentConditions, error) { return s.conditions, s.err } @@ -307,7 +309,7 @@ func TestCurrentConditionsNoDataReturnsNullEnvelopeData(t *testing.T) { func TestCurrentConditionsMetricDefaultJSON(t *testing.T) { h := newHandler(t, &fakeService{ - conditions: &core.CurrentConditions{ + conditions: &app.CurrentConditions{ TemperatureC: float64Ptr(10), ApparentTemperatureC: float64Ptr(9), DewpointC: float64Ptr(5), @@ -345,7 +347,7 @@ func TestCurrentConditionsMetricDefaultJSON(t *testing.T) { func TestCurrentConditionsUSJSON(t *testing.T) { h := newHandler(t, &fakeService{ - conditions: &core.CurrentConditions{ + conditions: &app.CurrentConditions{ TemperatureC: float64Ptr(10), ApparentTemperatureC: float64Ptr(9), DewpointC: float64Ptr(5), @@ -386,7 +388,7 @@ func TestCurrentConditionsUSJSON(t *testing.T) { func TestCurrentConditionsXMLAndTextFormats(t *testing.T) { h := newHandler(t, &fakeService{ - conditions: &core.CurrentConditions{ + conditions: &app.CurrentConditions{ TemperatureC: float64Ptr(10), WindSpeedKmh: float64Ptr(18), ConditionCode: 2, diff --git a/internal/adapters/inbound/httpapi/forecast_endpoint.go b/internal/adapters/inbound/httpapi/forecast_endpoint.go new file mode 100644 index 0000000..0a6bed8 --- /dev/null +++ b/internal/adapters/inbound/httpapi/forecast_endpoint.go @@ -0,0 +1,28 @@ +// forecast_endpoint.go defines the /forecast/hourly endpoint behavior. +// Layer: adapters/inbound/httpapi forecast 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 forecastDefinition(svc Service) endpoint.Definition { + return endpoint.GET( + "/forecast/hourly", + bindQuery, + func(ctx context.Context, req queryRequest) (any, error) { + run, err := svc.LatestHourlyForecast(ctx) + if err != nil { + return nil, err + } + return response.Envelope{Data: presenter.ForecastPayload(run, req.Units)}, nil + }, + endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), + endpoint.WithTemplate("forecast_hourly.txt.tmpl"), + ) +} diff --git a/internal/adapters/inbound/httpapi/observations_endpoint.go b/internal/adapters/inbound/httpapi/observations_endpoint.go new file mode 100644 index 0000000..e2f0222 --- /dev/null +++ b/internal/adapters/inbound/httpapi/observations_endpoint.go @@ -0,0 +1,28 @@ +// observations_endpoint.go defines the /observations endpoint behavior. +// Layer: adapters/inbound/httpapi observation 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 observationDefinition(svc Service) endpoint.Definition { + return endpoint.GET( + "/observations", + bindQuery, + func(ctx context.Context, req queryRequest) (any, error) { + obs, err := svc.LatestObservation(ctx) + if err != nil { + return nil, err + } + return response.Envelope{Data: presenter.ObservationPayload(obs, req.Units)}, nil + }, + endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), + endpoint.WithTemplate("observations.txt.tmpl"), + ) +} diff --git a/internal/adapters/inbound/httpapi/presenter/alerts.go b/internal/adapters/inbound/httpapi/presenter/alerts.go new file mode 100644 index 0000000..ea57ddd --- /dev/null +++ b/internal/adapters/inbound/httpapi/presenter/alerts.go @@ -0,0 +1,12 @@ +// alerts.go presents alert-run payloads. +// Layer: adapters/inbound/httpapi/presenter alerts payload mapping. +package presenter + +import "gitea.maximumdirect.net/ejr/weatherfeeder/model" + +func AlertsPayload(run *model.WeatherAlertRun, _ Units) any { + if run == nil { + return nil + } + return run +} diff --git a/internal/adapters/inbound/httpapi/presenter/conditions.go b/internal/adapters/inbound/httpapi/presenter/conditions.go new file mode 100644 index 0000000..1173780 --- /dev/null +++ b/internal/adapters/inbound/httpapi/presenter/conditions.go @@ -0,0 +1,54 @@ +// conditions.go presents current-conditions payloads in metric and US shapes. +// Layer: adapters/inbound/httpapi/presenter current-conditions payload mapping. +package presenter + +import ( + "gitea.maximumdirect.net/ejr/weatherapi/internal/app" + "gitea.maximumdirect.net/ejr/weatherfeeder/standards" +) + +// CurrentConditionsResponse is the response shape for /conditions/current. +// Unit-bearing fields are populated according to the requested unit mode. +type CurrentConditionsResponse struct { + TemperatureC *float64 `json:"temperatureC,omitempty" xml:"temperatureC,omitempty"` + ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty" xml:"apparentTemperatureC,omitempty"` + DewpointC *float64 `json:"dewpointC,omitempty" xml:"dewpointC,omitempty"` + WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty" xml:"windSpeedKmh,omitempty"` + TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"` + ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"` + DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"` + WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"` + RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"` + WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"` + ConditionText string `json:"conditionText,omitempty" xml:"conditionText,omitempty"` + IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"` + IsDayText string `json:"-" xml:"-"` +} + +func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units) any { + if conditions == nil { + return nil + } + + out := CurrentConditionsResponse{ + RelativeHumidityPercent: copyFloat64Ptr(conditions.RelativeHumidityPercent), + WindDirectionDegrees: copyFloat64Ptr(conditions.WindDirectionDegrees), + ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay), + IsDay: copyBoolPtr(conditions.IsDay), + IsDayText: boolText(conditions.IsDay), + } + + if units == UnitsUS { + out.TemperatureF = celsiusToFahrenheitPtr(conditions.TemperatureC) + out.ApparentTemperatureF = celsiusToFahrenheitPtr(conditions.ApparentTemperatureC) + out.DewpointF = celsiusToFahrenheitPtr(conditions.DewpointC) + out.WindSpeedMph = scalePtr(conditions.WindSpeedKmh, kmhToMphFactor) + return out + } + + out.TemperatureC = copyFloat64Ptr(conditions.TemperatureC) + out.ApparentTemperatureC = copyFloat64Ptr(conditions.ApparentTemperatureC) + out.DewpointC = copyFloat64Ptr(conditions.DewpointC) + out.WindSpeedKmh = copyFloat64Ptr(conditions.WindSpeedKmh) + return out +} diff --git a/internal/core/constants.go b/internal/adapters/inbound/httpapi/presenter/constants.go similarity index 62% rename from internal/core/constants.go rename to internal/adapters/inbound/httpapi/presenter/constants.go index 534a42b..a081902 100644 --- a/internal/core/constants.go +++ b/internal/adapters/inbound/httpapi/presenter/constants.go @@ -1,16 +1,6 @@ -package core - -// Units controls response-unit output formatting. -type Units string - -const ( - UnitsMetric Units = "metric" - UnitsUS Units = "us" -) - -const ( - ObservationWindowMinutesDefault = 30 -) +// constants.go defines unit conversion constants for payload presentation. +// Layer: adapters/inbound/httpapi/presenter conversion constants. +package presenter const ( celsiusToFahrenheitScale = 9.0 / 5.0 diff --git a/internal/core/payload.go b/internal/adapters/inbound/httpapi/presenter/forecast.go similarity index 50% rename from internal/core/payload.go rename to internal/adapters/inbound/httpapi/presenter/forecast.go index a275393..5da8e9e 100644 --- a/internal/core/payload.go +++ b/internal/adapters/inbound/httpapi/presenter/forecast.go @@ -1,32 +1,13 @@ -package core +// forecast.go presents hourly forecast payloads in metric and US shapes. +// Layer: adapters/inbound/httpapi/presenter forecast payload mapping. +package presenter import ( "time" "gitea.maximumdirect.net/ejr/weatherfeeder/model" - "gitea.maximumdirect.net/ejr/weatherfeeder/standards" ) -// WeatherObservationUS is the US-customary response shape for observations. -type WeatherObservationUS struct { - StationID string `json:"stationId,omitempty" xml:"stationId,omitempty"` - StationName string `json:"stationName,omitempty" xml:"stationName,omitempty"` - Timestamp time.Time `json:"timestamp" xml:"timestamp"` - ConditionCode model.WMOCode `json:"conditionCode" xml:"conditionCode"` - IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"` - TextDescription string `json:"textDescription,omitempty" xml:"textDescription,omitempty"` - TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"` - DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"` - WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"` - WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"` - WindGustMph *float64 `json:"windGustMph,omitempty" xml:"windGustMph,omitempty"` - BarometricPressureInHg *float64 `json:"barometricPressureInHg,omitempty" xml:"barometricPressureInHg,omitempty"` - VisibilityMiles *float64 `json:"visibilityMiles,omitempty" xml:"visibilityMiles,omitempty"` - RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"` - ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"` - PresentWeather []model.PresentWeather `json:"presentWeather,omitempty" xml:"presentWeather,omitempty"` -} - // WeatherForecastRunUS is the US-customary response shape for hourly forecasts. type WeatherForecastRunUS struct { LocationID string `json:"locationId,omitempty" xml:"locationId,omitempty"` @@ -70,52 +51,6 @@ type WeatherForecastPeriodUS struct { UVIndex *float64 `json:"uvIndex,omitempty" xml:"uvIndex,omitempty"` } -// CurrentConditionsResponse is the response shape for /conditions/current. -// Unit-bearing fields are populated according to the requested unit mode. -type CurrentConditionsResponse struct { - TemperatureC *float64 `json:"temperatureC,omitempty" xml:"temperatureC,omitempty"` - ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty" xml:"apparentTemperatureC,omitempty"` - DewpointC *float64 `json:"dewpointC,omitempty" xml:"dewpointC,omitempty"` - WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty" xml:"windSpeedKmh,omitempty"` - TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"` - ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"` - DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"` - WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"` - RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"` - WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"` - ConditionText string `json:"conditionText,omitempty" xml:"conditionText,omitempty"` - IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"` - IsDayText string `json:"-" xml:"-"` -} - -func ObservationPayload(obs *model.WeatherObservation, units Units) any { - if obs == nil { - return nil - } - if units == UnitsUS { - converted := WeatherObservationUS{ - StationID: obs.StationID, - StationName: obs.StationName, - Timestamp: obs.Timestamp, - ConditionCode: obs.ConditionCode, - IsDay: copyBoolPtr(obs.IsDay), - TextDescription: obs.TextDescription, - TemperatureF: celsiusToFahrenheitPtr(obs.TemperatureC), - DewpointF: celsiusToFahrenheitPtr(obs.DewpointC), - WindDirectionDegrees: copyFloat64Ptr(obs.WindDirectionDegrees), - WindSpeedMph: scalePtr(obs.WindSpeedKmh, kmhToMphFactor), - WindGustMph: scalePtr(obs.WindGustKmh, kmhToMphFactor), - BarometricPressureInHg: scalePtr(obs.BarometricPressurePa, paToInHgFactor), - VisibilityMiles: scalePtr(obs.VisibilityMeters, metersToMilesFactor), - RelativeHumidityPercent: copyFloat64Ptr(obs.RelativeHumidityPercent), - ApparentTemperatureF: celsiusToFahrenheitPtr(obs.ApparentTemperatureC), - PresentWeather: append([]model.PresentWeather(nil), obs.PresentWeather...), - } - return converted - } - return obs -} - func ForecastPayload(run *model.WeatherForecastRun, units Units) any { if run == nil { return nil @@ -166,88 +101,3 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units) any { } return run } - -func AlertsPayload(run *model.WeatherAlertRun, _ Units) any { - if run == nil { - return nil - } - return run -} - -func CurrentConditionsPayload(conditions *CurrentConditions, units Units) any { - if conditions == nil { - return nil - } - - out := CurrentConditionsResponse{ - RelativeHumidityPercent: copyFloat64Ptr(conditions.RelativeHumidityPercent), - WindDirectionDegrees: copyFloat64Ptr(conditions.WindDirectionDegrees), - ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay), - IsDay: copyBoolPtr(conditions.IsDay), - IsDayText: boolText(conditions.IsDay), - } - - if units == UnitsUS { - out.TemperatureF = celsiusToFahrenheitPtr(conditions.TemperatureC) - out.ApparentTemperatureF = celsiusToFahrenheitPtr(conditions.ApparentTemperatureC) - out.DewpointF = celsiusToFahrenheitPtr(conditions.DewpointC) - out.WindSpeedMph = scalePtr(conditions.WindSpeedKmh, kmhToMphFactor) - return out - } - - out.TemperatureC = copyFloat64Ptr(conditions.TemperatureC) - out.ApparentTemperatureC = copyFloat64Ptr(conditions.ApparentTemperatureC) - out.DewpointC = copyFloat64Ptr(conditions.DewpointC) - out.WindSpeedKmh = copyFloat64Ptr(conditions.WindSpeedKmh) - return out -} - -func celsiusToFahrenheitPtr(v *float64) *float64 { - if v == nil { - return nil - } - out := (*v * celsiusToFahrenheitScale) + celsiusToFahrenheitOffset - return &out -} - -func scalePtr(v *float64, factor float64) *float64 { - if v == nil { - return nil - } - out := *v * factor - return &out -} - -func copyFloat64Ptr(v *float64) *float64 { - if v == nil { - return nil - } - out := *v - return &out -} - -func copyBoolPtr(v *bool) *bool { - if v == nil { - return nil - } - out := *v - return &out -} - -func copyTimePtr(v *time.Time) *time.Time { - if v == nil { - return nil - } - out := *v - return &out -} - -func boolText(v *bool) string { - if v == nil { - return "" - } - if *v { - return "true" - } - return "false" -} diff --git a/internal/adapters/inbound/httpapi/presenter/helpers.go b/internal/adapters/inbound/httpapi/presenter/helpers.go new file mode 100644 index 0000000..8118255 --- /dev/null +++ b/internal/adapters/inbound/httpapi/presenter/helpers.go @@ -0,0 +1,55 @@ +// helpers.go contains shared pointer and scalar conversion helpers. +// Layer: adapters/inbound/httpapi/presenter helper functions. +package presenter + +import "time" + +func celsiusToFahrenheitPtr(v *float64) *float64 { + if v == nil { + return nil + } + out := (*v * celsiusToFahrenheitScale) + celsiusToFahrenheitOffset + return &out +} + +func scalePtr(v *float64, factor float64) *float64 { + if v == nil { + return nil + } + out := *v * factor + return &out +} + +func copyFloat64Ptr(v *float64) *float64 { + if v == nil { + return nil + } + out := *v + return &out +} + +func copyBoolPtr(v *bool) *bool { + if v == nil { + return nil + } + out := *v + return &out +} + +func copyTimePtr(v *time.Time) *time.Time { + if v == nil { + return nil + } + out := *v + return &out +} + +func boolText(v *bool) string { + if v == nil { + return "" + } + if *v { + return "true" + } + return "false" +} diff --git a/internal/adapters/inbound/httpapi/presenter/observation.go b/internal/adapters/inbound/httpapi/presenter/observation.go new file mode 100644 index 0000000..edc2207 --- /dev/null +++ b/internal/adapters/inbound/httpapi/presenter/observation.go @@ -0,0 +1,57 @@ +// observation.go presents observation payloads in metric and US shapes. +// Layer: adapters/inbound/httpapi/presenter observation payload mapping. +package presenter + +import ( + "time" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +// WeatherObservationUS is the US-customary response shape for observations. +type WeatherObservationUS struct { + StationID string `json:"stationId,omitempty" xml:"stationId,omitempty"` + StationName string `json:"stationName,omitempty" xml:"stationName,omitempty"` + Timestamp time.Time `json:"timestamp" xml:"timestamp"` + ConditionCode model.WMOCode `json:"conditionCode" xml:"conditionCode"` + IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"` + TextDescription string `json:"textDescription,omitempty" xml:"textDescription,omitempty"` + TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"` + DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"` + WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"` + WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"` + WindGustMph *float64 `json:"windGustMph,omitempty" xml:"windGustMph,omitempty"` + BarometricPressureInHg *float64 `json:"barometricPressureInHg,omitempty" xml:"barometricPressureInHg,omitempty"` + VisibilityMiles *float64 `json:"visibilityMiles,omitempty" xml:"visibilityMiles,omitempty"` + RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"` + ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"` + PresentWeather []model.PresentWeather `json:"presentWeather,omitempty" xml:"presentWeather,omitempty"` +} + +func ObservationPayload(obs *model.WeatherObservation, units Units) any { + if obs == nil { + return nil + } + if units == UnitsUS { + converted := WeatherObservationUS{ + StationID: obs.StationID, + StationName: obs.StationName, + Timestamp: obs.Timestamp, + ConditionCode: obs.ConditionCode, + IsDay: copyBoolPtr(obs.IsDay), + TextDescription: obs.TextDescription, + TemperatureF: celsiusToFahrenheitPtr(obs.TemperatureC), + DewpointF: celsiusToFahrenheitPtr(obs.DewpointC), + WindDirectionDegrees: copyFloat64Ptr(obs.WindDirectionDegrees), + WindSpeedMph: scalePtr(obs.WindSpeedKmh, kmhToMphFactor), + WindGustMph: scalePtr(obs.WindGustKmh, kmhToMphFactor), + BarometricPressureInHg: scalePtr(obs.BarometricPressurePa, paToInHgFactor), + VisibilityMiles: scalePtr(obs.VisibilityMeters, metersToMilesFactor), + RelativeHumidityPercent: copyFloat64Ptr(obs.RelativeHumidityPercent), + ApparentTemperatureF: celsiusToFahrenheitPtr(obs.ApparentTemperatureC), + PresentWeather: append([]model.PresentWeather(nil), obs.PresentWeather...), + } + return converted + } + return obs +} diff --git a/internal/core/payload_test.go b/internal/adapters/inbound/httpapi/presenter/payload_test.go similarity index 87% rename from internal/core/payload_test.go rename to internal/adapters/inbound/httpapi/presenter/payload_test.go index dfa0e57..59e1b15 100644 --- a/internal/core/payload_test.go +++ b/internal/adapters/inbound/httpapi/presenter/payload_test.go @@ -1,10 +1,13 @@ -package core +// payload_test.go validates presenter conversion and payload shaping behavior. +// Layer: adapters/inbound/httpapi/presenter unit and schema tests. +package presenter import ( "math" "testing" "time" + "gitea.maximumdirect.net/ejr/weatherapi/internal/app" "gitea.maximumdirect.net/ejr/weatherfeeder/model" ) @@ -48,19 +51,17 @@ func TestForecastPayloadUS(t *testing.T) { UpdatedAt: &updatedAt, Product: model.ForecastProductHourly, ElevationMeters: float64Ptr(1000), - Periods: []model.WeatherForecastPeriod{ - { - StartTime: issuedAt, - EndTime: issuedAt.Add(1 * time.Hour), - ConditionCode: 63, - TemperatureC: float64Ptr(0), - TemperatureCMin: float64Ptr(-5), - TemperatureCMax: float64Ptr(5), - WindSpeedKmh: float64Ptr(64.37376), - PrecipitationAmountMm: float64Ptr(25.4), - SnowfallDepthMM: float64Ptr(50.8), - }, - }, + Periods: []model.WeatherForecastPeriod{{ + StartTime: issuedAt, + EndTime: issuedAt.Add(1 * time.Hour), + ConditionCode: 63, + TemperatureC: float64Ptr(0), + TemperatureCMin: float64Ptr(-5), + TemperatureCMax: float64Ptr(5), + WindSpeedKmh: float64Ptr(64.37376), + PrecipitationAmountMm: float64Ptr(25.4), + SnowfallDepthMM: float64Ptr(50.8), + }}, } payload := ForecastPayload(run, UnitsUS) @@ -108,7 +109,7 @@ func TestMetricPassthroughAndNilHandling(t *testing.T) { } func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) { - conditions := &CurrentConditions{ + conditions := &app.CurrentConditions{ TemperatureC: float64Ptr(20), ApparentTemperatureC: float64Ptr(18), DewpointC: float64Ptr(10), @@ -147,7 +148,7 @@ func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) { func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) { night := false - payload := CurrentConditionsPayload(&CurrentConditions{ + payload := CurrentConditionsPayload(&app.CurrentConditions{ ConditionCode: 0, IsDay: &night, }, UnitsMetric) diff --git a/internal/adapters/inbound/httpapi/presenter/units.go b/internal/adapters/inbound/httpapi/presenter/units.go new file mode 100644 index 0000000..9148794 --- /dev/null +++ b/internal/adapters/inbound/httpapi/presenter/units.go @@ -0,0 +1,11 @@ +// units.go defines response unit modes for HTTP presentation. +// Layer: adapters/inbound/httpapi/presenter unit selection. +package presenter + +// Units controls response-unit output formatting. +type Units string + +const ( + UnitsMetric Units = "metric" + UnitsUS Units = "us" +) diff --git a/internal/adapters/inbound/httpapi/query_bind.go b/internal/adapters/inbound/httpapi/query_bind.go new file mode 100644 index 0000000..4ece724 --- /dev/null +++ b/internal/adapters/inbound/httpapi/query_bind.go @@ -0,0 +1,36 @@ +// query_bind.go binds endpoint query parameters into typed request config. +// Layer: adapters/inbound/httpapi request binding. +package httpapi + +import ( + "net/http" + "strings" + + "gitea.maximumdirect.net/ejr/feedapi/bind" + "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter" +) + +type queryRequest struct { + Units presenter.Units +} + +func bindQuery(r *http.Request) (queryRequest, error) { + normalizeCommonQueryValue(r, "units") + normalizeCommonQueryValue(r, "format") + + common, err := bind.CommonQueryParams(r, bind.QueryPolicy{ + AllowUnits: true, + AllowFormat: true, + DefaultUnits: string(presenter.UnitsMetric), + RejectUnknown: true, + }) + if err != nil { + return queryRequest{}, err + } + + units := presenter.Units(strings.ToLower(strings.TrimSpace(common.Units))) + if units == "" { + units = presenter.UnitsMetric + } + return queryRequest{Units: units}, nil +} diff --git a/internal/adapters/inbound/httpapi/query_normalize.go b/internal/adapters/inbound/httpapi/query_normalize.go new file mode 100644 index 0000000..e4ae0d9 --- /dev/null +++ b/internal/adapters/inbound/httpapi/query_normalize.go @@ -0,0 +1,23 @@ +// query_normalize.go normalizes common query values before binding. +// Layer: adapters/inbound/httpapi request pre-processing. +package httpapi + +import ( + "net/http" + "strings" +) + +func normalizeCommonQueryValue(r *http.Request, key string) { + q := r.URL.Query() + values, ok := q[key] + if !ok || len(values) == 0 { + return + } + + normalized := strings.ToLower(strings.TrimSpace(values[0])) + if normalized == values[0] { + return + } + q.Set(key, normalized) + r.URL.RawQuery = q.Encode() +} diff --git a/internal/adapters/inbound/httpapi/service.go b/internal/adapters/inbound/httpapi/service.go new file mode 100644 index 0000000..592b8f5 --- /dev/null +++ b/internal/adapters/inbound/httpapi/service.go @@ -0,0 +1,18 @@ +// service.go defines the inbound service contract consumed by HTTP handlers. +// Layer: adapters/inbound/httpapi boundary to application service. +package httpapi + +import ( + "context" + + "gitea.maximumdirect.net/ejr/weatherapi/internal/app" + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +// Service describes weather resource queries needed by the HTTP adapter. +type Service interface { + LatestObservation(ctx context.Context) (*model.WeatherObservation, error) + LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) + LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) + CurrentConditions(ctx context.Context) (*app.CurrentConditions, error) +} diff --git a/internal/adapters/outbound/postgres/alerts_mapper.go b/internal/adapters/outbound/postgres/alerts_mapper.go new file mode 100644 index 0000000..83630ee --- /dev/null +++ b/internal/adapters/outbound/postgres/alerts_mapper.go @@ -0,0 +1,70 @@ +// alerts_mapper.go maps alert rows into weather model payloads. +// Layer: adapters/outbound/postgres alerts feature. +package postgres + +import "gitea.maximumdirect.net/ejr/weatherfeeder/model" + +func mapAlertRunParentRow(row alertRunParentRow) model.WeatherAlertRun { + return model.WeatherAlertRun{ + LocationID: stringValue(row.LocationID), + LocationName: stringValue(row.LocationName), + AsOf: row.AsOf.UTC(), + Latitude: float64Ptr(row.Latitude), + Longitude: float64Ptr(row.Longitude), + } +} + +func mapAlertRow(row alertRow) indexedAlert { + return indexedAlert{ + Index: row.AlertIndex, + Alert: model.WeatherAlert{ + ID: row.AlertID, + Event: stringValue(row.Event), + Headline: stringValue(row.Headline), + Severity: stringValue(row.Severity), + Urgency: stringValue(row.Urgency), + Certainty: stringValue(row.Certainty), + Status: stringValue(row.Status), + MessageType: stringValue(row.MessageType), + Category: stringValue(row.Category), + Response: stringValue(row.Response), + Description: stringValue(row.Description), + Instruction: stringValue(row.Instruction), + Sent: timePtr(row.Sent), + Effective: timePtr(row.Effective), + Onset: timePtr(row.Onset), + Expires: timePtr(row.Expires), + AreaDescription: stringValue(row.AreaDescription), + SenderName: stringValue(row.SenderName), + }, + } +} + +func mapAlertReferenceRow(row alertReferenceRow) indexedAlertReference { + return indexedAlertReference{ + AlertIndex: row.AlertIndex, + Reference: model.AlertReference{ + ID: stringValue(row.ID), + Identifier: stringValue(row.Identifier), + Sender: stringValue(row.Sender), + Sent: timePtr(row.Sent), + }, + } +} + +func attachAlertReferences(alerts []indexedAlert, references []indexedAlertReference) []model.WeatherAlert { + refsByAlertIndex := make(map[int][]model.AlertReference, len(alerts)) + for _, ref := range references { + refsByAlertIndex[ref.AlertIndex] = append(refsByAlertIndex[ref.AlertIndex], ref.Reference) + } + + out := make([]model.WeatherAlert, 0, len(alerts)) + for _, alert := range alerts { + mapped := alert.Alert + if refs := refsByAlertIndex[alert.Index]; len(refs) > 0 { + mapped.References = refs + } + out = append(out, mapped) + } + return out +} diff --git a/internal/adapters/outbound/postgres/alerts_queries.go b/internal/adapters/outbound/postgres/alerts_queries.go new file mode 100644 index 0000000..dcb5de9 --- /dev/null +++ b/internal/adapters/outbound/postgres/alerts_queries.go @@ -0,0 +1,53 @@ +// alerts_queries.go contains SQL text for alert-run reads. +// Layer: adapters/outbound/postgres alerts feature. +package postgres + +const ( + queryLatestAlertRun = ` +SELECT + event_id, + location_id, + location_name, + as_of, + latitude, + longitude +FROM alert_runs +ORDER BY as_of DESC, event_emitted_at DESC +LIMIT 1` + + queryAlerts = ` +SELECT + alert_index, + alert_id, + event, + headline, + severity, + urgency, + certainty, + status, + message_type, + category, + response, + description, + instruction, + sent, + effective, + onset, + expires, + area_description, + sender_name +FROM alerts +WHERE run_event_id = $1 +ORDER BY alert_index ASC` + + queryAlertReferences = ` +SELECT + alert_index, + id, + identifier, + sender, + sent +FROM alert_references +WHERE run_event_id = $1 +ORDER BY alert_index ASC, reference_index ASC` +) diff --git a/internal/adapters/outbound/postgres/alerts_read.go b/internal/adapters/outbound/postgres/alerts_read.go new file mode 100644 index 0000000..3e99f30 --- /dev/null +++ b/internal/adapters/outbound/postgres/alerts_read.go @@ -0,0 +1,110 @@ +// alerts_read.go executes alert-run, alerts, and reference queries. +// Layer: adapters/outbound/postgres alerts feature. +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +func (r *Repository) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) { + if r == nil || r.db == nil { + return nil, fmt.Errorf("postgres repository is not configured") + } + + var row alertRunParentRow + err := r.db.QueryRowContext(ctx, queryLatestAlertRun).Scan( + &row.EventID, + &row.LocationID, + &row.LocationName, + &row.AsOf, + &row.Latitude, + &row.Longitude, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query latest alert run: %w", err) + } + + run := mapAlertRunParentRow(row) + + alerts, err := r.loadAlerts(ctx, row.EventID) + if err != nil { + return nil, err + } + run.Alerts = alerts + + return &run, nil +} + +func (r *Repository) loadAlerts(ctx context.Context, eventID string) ([]model.WeatherAlert, error) { + alertsRows, err := r.db.QueryContext(ctx, queryAlerts, eventID) + if err != nil { + return nil, fmt.Errorf("query alerts: %w", err) + } + defer alertsRows.Close() + + indexedAlerts := make([]indexedAlert, 0) + for alertsRows.Next() { + var row alertRow + if err := alertsRows.Scan( + &row.AlertIndex, + &row.AlertID, + &row.Event, + &row.Headline, + &row.Severity, + &row.Urgency, + &row.Certainty, + &row.Status, + &row.MessageType, + &row.Category, + &row.Response, + &row.Description, + &row.Instruction, + &row.Sent, + &row.Effective, + &row.Onset, + &row.Expires, + &row.AreaDescription, + &row.SenderName, + ); err != nil { + return nil, fmt.Errorf("scan alerts row: %w", err) + } + indexedAlerts = append(indexedAlerts, mapAlertRow(row)) + } + if err := alertsRows.Err(); err != nil { + return nil, fmt.Errorf("iterate alerts rows: %w", err) + } + + referenceRows, err := r.db.QueryContext(ctx, queryAlertReferences, eventID) + if err != nil { + return nil, fmt.Errorf("query alert references: %w", err) + } + defer referenceRows.Close() + + indexedReferences := make([]indexedAlertReference, 0) + for referenceRows.Next() { + var row alertReferenceRow + if err := referenceRows.Scan( + &row.AlertIndex, + &row.ID, + &row.Identifier, + &row.Sender, + &row.Sent, + ); err != nil { + return nil, fmt.Errorf("scan alert reference row: %w", err) + } + indexedReferences = append(indexedReferences, mapAlertReferenceRow(row)) + } + if err := referenceRows.Err(); err != nil { + return nil, fmt.Errorf("iterate alert reference rows: %w", err) + } + + return attachAlertReferences(indexedAlerts, indexedReferences), nil +} diff --git a/internal/adapters/outbound/postgres/alerts_rows.go b/internal/adapters/outbound/postgres/alerts_rows.go new file mode 100644 index 0000000..6f5f236 --- /dev/null +++ b/internal/adapters/outbound/postgres/alerts_rows.go @@ -0,0 +1,59 @@ +// alerts_rows.go defines row DTOs for alert-run, alert, and reference reads. +// Layer: adapters/outbound/postgres alerts feature. +package postgres + +import ( + "database/sql" + "time" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +type alertRunParentRow struct { + EventID string + LocationID sql.NullString + LocationName sql.NullString + AsOf time.Time + Latitude sql.NullFloat64 + Longitude sql.NullFloat64 +} + +type alertRow struct { + AlertIndex int + AlertID string + Event sql.NullString + Headline sql.NullString + Severity sql.NullString + Urgency sql.NullString + Certainty sql.NullString + Status sql.NullString + MessageType sql.NullString + Category sql.NullString + Response sql.NullString + Description sql.NullString + Instruction sql.NullString + Sent sql.NullTime + Effective sql.NullTime + Onset sql.NullTime + Expires sql.NullTime + AreaDescription sql.NullString + SenderName sql.NullString +} + +type indexedAlert struct { + Index int + Alert model.WeatherAlert +} + +type alertReferenceRow struct { + AlertIndex int + ID sql.NullString + Identifier sql.NullString + Sender sql.NullString + Sent sql.NullTime +} + +type indexedAlertReference struct { + AlertIndex int + Reference model.AlertReference +} diff --git a/internal/adapters/outbound/postgres/conditions_mapper.go b/internal/adapters/outbound/postgres/conditions_mapper.go new file mode 100644 index 0000000..7bd63fb --- /dev/null +++ b/internal/adapters/outbound/postgres/conditions_mapper.go @@ -0,0 +1,30 @@ +// conditions_mapper.go maps current-conditions DB rows into app models. +// Layer: adapters/outbound/postgres conditions feature. +package postgres + +import ( + "gitea.maximumdirect.net/ejr/weatherapi/internal/app" + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +func mapCurrentConditionsRow(row currentConditionsRow) *app.CurrentConditions { + if row.SampleCount == 0 { + return nil + } + + conditionCode := model.WMOUnknown + if row.ConditionCode.Valid { + conditionCode = model.WMOCode(row.ConditionCode.Int64) + } + + return &app.CurrentConditions{ + TemperatureC: float64Ptr(row.TemperatureC), + ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC), + DewpointC: float64Ptr(row.DewpointC), + RelativeHumidityPercent: float64Ptr(row.RelativeHumidityPercent), + WindSpeedKmh: float64Ptr(row.WindSpeedKmh), + WindDirectionDegrees: float64Ptr(row.WindDirectionDegrees), + ConditionCode: conditionCode, + IsDay: boolPtr(row.IsDay), + } +} diff --git a/internal/adapters/outbound/postgres/conditions_queries.go b/internal/adapters/outbound/postgres/conditions_queries.go new file mode 100644 index 0000000..e11a2e8 --- /dev/null +++ b/internal/adapters/outbound/postgres/conditions_queries.go @@ -0,0 +1,50 @@ +// conditions_queries.go contains SQL text for current-conditions reads. +// Layer: adapters/outbound/postgres conditions feature. +package postgres + +const ( + queryCurrentConditions = ` +WITH windowed AS ( + SELECT + temperature_c, + apparent_temperature_c, + dewpoint_c, + relative_humidity_percent, + wind_speed_kmh, + wind_direction_degrees, + condition_code, + is_day, + observed_at + FROM observations + WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1) +) +SELECT + COUNT(*) AS sample_count, + AVG(temperature_c) AS temperature_c, + AVG(apparent_temperature_c) AS apparent_temperature_c, + AVG(dewpoint_c) AS dewpoint_c, + AVG(relative_humidity_percent) AS relative_humidity_percent, + AVG(wind_speed_kmh) AS wind_speed_kmh, + CASE + WHEN atan2d( + AVG(sind(wind_direction_degrees)), + AVG(cosd(wind_direction_degrees)) + ) < 0 + THEN atan2d( + AVG(sind(wind_direction_degrees)), + AVG(cosd(wind_direction_degrees)) + ) + 360.0 + ELSE atan2d( + AVG(sind(wind_direction_degrees)), + AVG(cosd(wind_direction_degrees)) + ) + END AS wind_direction_degrees, + MAX(condition_code) AS condition_code, + ( + SELECT is_day + FROM windowed + ORDER BY observed_at DESC + LIMIT 1 + ) AS is_day +FROM windowed` +) diff --git a/internal/adapters/outbound/postgres/conditions_read.go b/internal/adapters/outbound/postgres/conditions_read.go new file mode 100644 index 0000000..75a2a71 --- /dev/null +++ b/internal/adapters/outbound/postgres/conditions_read.go @@ -0,0 +1,39 @@ +// conditions_read.go executes current-conditions queries. +// Layer: adapters/outbound/postgres conditions feature. +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "gitea.maximumdirect.net/ejr/weatherapi/internal/app" +) + +func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*app.CurrentConditions, error) { + if r == nil || r.db == nil { + return nil, fmt.Errorf("postgres repository is not configured") + } + + var row currentConditionsRow + err := r.db.QueryRowContext(ctx, queryCurrentConditions, observationWindowMinutes).Scan( + &row.SampleCount, + &row.TemperatureC, + &row.ApparentTemperatureC, + &row.DewpointC, + &row.RelativeHumidityPercent, + &row.WindSpeedKmh, + &row.WindDirectionDegrees, + &row.ConditionCode, + &row.IsDay, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query current conditions: %w", err) + } + + return mapCurrentConditionsRow(row), nil +} diff --git a/internal/adapters/outbound/postgres/conditions_rows.go b/internal/adapters/outbound/postgres/conditions_rows.go new file mode 100644 index 0000000..c3a4d52 --- /dev/null +++ b/internal/adapters/outbound/postgres/conditions_rows.go @@ -0,0 +1,17 @@ +// conditions_rows.go defines row DTOs for current-conditions reads. +// Layer: adapters/outbound/postgres conditions feature. +package postgres + +import "database/sql" + +type currentConditionsRow struct { + SampleCount int64 + TemperatureC sql.NullFloat64 + ApparentTemperatureC sql.NullFloat64 + DewpointC sql.NullFloat64 + RelativeHumidityPercent sql.NullFloat64 + WindSpeedKmh sql.NullFloat64 + WindDirectionDegrees sql.NullFloat64 + ConditionCode sql.NullInt64 + IsDay sql.NullBool +} diff --git a/internal/adapters/outbound/postgres/forecast_mapper.go b/internal/adapters/outbound/postgres/forecast_mapper.go new file mode 100644 index 0000000..ba19cf6 --- /dev/null +++ b/internal/adapters/outbound/postgres/forecast_mapper.go @@ -0,0 +1,49 @@ +// forecast_mapper.go maps forecast rows into weather model payloads. +// Layer: adapters/outbound/postgres forecast feature. +package postgres + +import "gitea.maximumdirect.net/ejr/weatherfeeder/model" + +func mapForecastParentRow(row forecastParentRow) model.WeatherForecastRun { + return model.WeatherForecastRun{ + LocationID: stringValue(row.LocationID), + LocationName: stringValue(row.LocationName), + IssuedAt: row.IssuedAt.UTC(), + UpdatedAt: timePtr(row.UpdatedAt), + Product: model.ForecastProduct(row.Product), + Latitude: float64Ptr(row.Latitude), + Longitude: float64Ptr(row.Longitude), + ElevationMeters: float64Ptr(row.ElevationMeters), + } +} + +func mapForecastPeriodRow(row forecastPeriodRow) model.WeatherForecastPeriod { + return model.WeatherForecastPeriod{ + StartTime: row.StartTime.UTC(), + EndTime: row.EndTime.UTC(), + Name: stringValue(row.Name), + IsDay: boolPtr(row.IsDay), + ConditionCode: model.WMOCode(row.ConditionCode), + ConditionText: stringValue(row.ConditionText), + ProviderRawDescription: stringValue(row.ProviderRawDescription), + TextDescription: stringValue(row.TextDescription), + DetailedText: stringValue(row.DetailedText), + IconURL: stringValue(row.IconURL), + TemperatureC: float64Ptr(row.TemperatureC), + TemperatureCMin: float64Ptr(row.TemperatureCMin), + TemperatureCMax: float64Ptr(row.TemperatureCMax), + DewpointC: float64Ptr(row.DewpointC), + RelativeHumidityPercent: float64Ptr(row.RelativeHumidityPercent), + WindDirectionDegrees: float64Ptr(row.WindDirectionDegrees), + WindSpeedKmh: float64Ptr(row.WindSpeedKmh), + WindGustKmh: float64Ptr(row.WindGustKmh), + BarometricPressurePa: float64Ptr(row.BarometricPressurePa), + VisibilityMeters: float64Ptr(row.VisibilityMeters), + ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC), + CloudCoverPercent: float64Ptr(row.CloudCoverPercent), + ProbabilityOfPrecipitationPercent: float64Ptr(row.ProbabilityOfPrecipitationPercent), + PrecipitationAmountMm: float64Ptr(row.PrecipitationAmountMM), + SnowfallDepthMM: float64Ptr(row.SnowfallDepthMM), + UVIndex: float64Ptr(row.UVIndex), + } +} diff --git a/internal/adapters/outbound/postgres/forecast_queries.go b/internal/adapters/outbound/postgres/forecast_queries.go new file mode 100644 index 0000000..7790a30 --- /dev/null +++ b/internal/adapters/outbound/postgres/forecast_queries.go @@ -0,0 +1,54 @@ +// forecast_queries.go contains SQL text for hourly forecast reads. +// Layer: adapters/outbound/postgres forecast feature. +package postgres + +const ( + queryLatestHourlyForecast = ` +SELECT + event_id, + location_id, + location_name, + issued_at, + updated_at, + product, + latitude, + longitude, + elevation_meters +FROM forecasts +WHERE product = 'hourly' +ORDER BY issued_at DESC, event_emitted_at DESC +LIMIT 1` + + queryForecastPeriods = ` +SELECT + period_index, + start_time, + end_time, + name, + is_day, + condition_code, + condition_text, + provider_raw_description, + text_description, + detailed_text, + icon_url, + temperature_c, + temperature_c_min, + temperature_c_max, + dewpoint_c, + relative_humidity_percent, + wind_direction_degrees, + wind_speed_kmh, + wind_gust_kmh, + barometric_pressure_pa, + visibility_meters, + apparent_temperature_c, + cloud_cover_percent, + probability_of_precipitation_percent, + precipitation_amount_mm, + snowfall_depth_mm, + uv_index +FROM forecast_periods +WHERE run_event_id = $1 +ORDER BY period_index ASC` +) diff --git a/internal/adapters/outbound/postgres/forecast_read.go b/internal/adapters/outbound/postgres/forecast_read.go new file mode 100644 index 0000000..0314991 --- /dev/null +++ b/internal/adapters/outbound/postgres/forecast_read.go @@ -0,0 +1,96 @@ +// forecast_read.go executes hourly forecast and period queries. +// Layer: adapters/outbound/postgres forecast feature. +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +func (r *Repository) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) { + if r == nil || r.db == nil { + return nil, fmt.Errorf("postgres repository is not configured") + } + + var row forecastParentRow + err := r.db.QueryRowContext(ctx, queryLatestHourlyForecast).Scan( + &row.EventID, + &row.LocationID, + &row.LocationName, + &row.IssuedAt, + &row.UpdatedAt, + &row.Product, + &row.Latitude, + &row.Longitude, + &row.ElevationMeters, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query latest hourly forecast: %w", err) + } + + run := mapForecastParentRow(row) + + periods, err := r.loadForecastPeriods(ctx, row.EventID) + if err != nil { + return nil, err + } + run.Periods = periods + + return &run, nil +} + +func (r *Repository) loadForecastPeriods(ctx context.Context, eventID string) ([]model.WeatherForecastPeriod, error) { + rows, err := r.db.QueryContext(ctx, queryForecastPeriods, eventID) + if err != nil { + return nil, fmt.Errorf("query forecast periods: %w", err) + } + defer rows.Close() + + out := make([]model.WeatherForecastPeriod, 0) + for rows.Next() { + var row forecastPeriodRow + if err := rows.Scan( + &row.PeriodIndex, + &row.StartTime, + &row.EndTime, + &row.Name, + &row.IsDay, + &row.ConditionCode, + &row.ConditionText, + &row.ProviderRawDescription, + &row.TextDescription, + &row.DetailedText, + &row.IconURL, + &row.TemperatureC, + &row.TemperatureCMin, + &row.TemperatureCMax, + &row.DewpointC, + &row.RelativeHumidityPercent, + &row.WindDirectionDegrees, + &row.WindSpeedKmh, + &row.WindGustKmh, + &row.BarometricPressurePa, + &row.VisibilityMeters, + &row.ApparentTemperatureC, + &row.CloudCoverPercent, + &row.ProbabilityOfPrecipitationPercent, + &row.PrecipitationAmountMM, + &row.SnowfallDepthMM, + &row.UVIndex, + ); err != nil { + return nil, fmt.Errorf("scan forecast period row: %w", err) + } + out = append(out, mapForecastPeriodRow(row)) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate forecast period rows: %w", err) + } + return out, nil +} diff --git a/internal/adapters/outbound/postgres/forecast_rows.go b/internal/adapters/outbound/postgres/forecast_rows.go new file mode 100644 index 0000000..7ce9c43 --- /dev/null +++ b/internal/adapters/outbound/postgres/forecast_rows.go @@ -0,0 +1,50 @@ +// forecast_rows.go defines row DTOs for forecast reads. +// Layer: adapters/outbound/postgres forecast feature. +package postgres + +import ( + "database/sql" + "time" +) + +type forecastParentRow struct { + EventID string + LocationID sql.NullString + LocationName sql.NullString + IssuedAt time.Time + UpdatedAt sql.NullTime + Product string + Latitude sql.NullFloat64 + Longitude sql.NullFloat64 + ElevationMeters sql.NullFloat64 +} + +type forecastPeriodRow struct { + PeriodIndex int + StartTime time.Time + EndTime time.Time + Name sql.NullString + IsDay sql.NullBool + ConditionCode int + ConditionText sql.NullString + ProviderRawDescription sql.NullString + TextDescription sql.NullString + DetailedText sql.NullString + IconURL sql.NullString + TemperatureC sql.NullFloat64 + TemperatureCMin sql.NullFloat64 + TemperatureCMax sql.NullFloat64 + DewpointC sql.NullFloat64 + RelativeHumidityPercent sql.NullFloat64 + WindDirectionDegrees sql.NullFloat64 + WindSpeedKmh sql.NullFloat64 + WindGustKmh sql.NullFloat64 + BarometricPressurePa sql.NullFloat64 + VisibilityMeters sql.NullFloat64 + ApparentTemperatureC sql.NullFloat64 + CloudCoverPercent sql.NullFloat64 + ProbabilityOfPrecipitationPercent sql.NullFloat64 + PrecipitationAmountMM sql.NullFloat64 + SnowfallDepthMM sql.NullFloat64 + UVIndex sql.NullFloat64 +} diff --git a/internal/adapters/outbound/postgres/observations_mapper.go b/internal/adapters/outbound/postgres/observations_mapper.go new file mode 100644 index 0000000..06f80f6 --- /dev/null +++ b/internal/adapters/outbound/postgres/observations_mapper.go @@ -0,0 +1,42 @@ +// observations_mapper.go maps observation rows into weather model payloads. +// Layer: adapters/outbound/postgres observations feature. +package postgres + +import ( + "encoding/json" + "strings" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +func mapObservationParentRow(row observationParentRow) model.WeatherObservation { + return model.WeatherObservation{ + StationID: stringValue(row.StationID), + StationName: stringValue(row.StationName), + Timestamp: row.ObservedAt.UTC(), + ConditionCode: model.WMOCode(row.ConditionCode), + IsDay: boolPtr(row.IsDay), + TextDescription: stringValue(row.TextDescription), + TemperatureC: float64Ptr(row.TemperatureC), + DewpointC: float64Ptr(row.DewpointC), + WindDirectionDegrees: float64Ptr(row.WindDirectionDegrees), + WindSpeedKmh: float64Ptr(row.WindSpeedKmh), + WindGustKmh: float64Ptr(row.WindGustKmh), + BarometricPressurePa: float64Ptr(row.BarometricPressurePa), + VisibilityMeters: float64Ptr(row.VisibilityMeters), + RelativeHumidityPercent: float64Ptr(row.RelativeHumidityPercent), + ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC), + } +} + +func mapObservationPresentWeatherRow(row observationPresentWeatherRow) (model.PresentWeather, error) { + if !row.RawText.Valid || strings.TrimSpace(row.RawText.String) == "" { + return model.PresentWeather{}, nil + } + + var raw map[string]any + if err := json.Unmarshal([]byte(row.RawText.String), &raw); err != nil { + return model.PresentWeather{}, err + } + return model.PresentWeather{Raw: raw}, nil +} diff --git a/internal/adapters/outbound/postgres/observations_queries.go b/internal/adapters/outbound/postgres/observations_queries.go new file mode 100644 index 0000000..6939bde --- /dev/null +++ b/internal/adapters/outbound/postgres/observations_queries.go @@ -0,0 +1,33 @@ +// observations_queries.go contains SQL text for observation reads. +// Layer: adapters/outbound/postgres observations feature. +package postgres + +const ( + queryLatestObservation = ` +SELECT + event_id, + station_id, + station_name, + observed_at, + condition_code, + is_day, + text_description, + temperature_c, + dewpoint_c, + wind_direction_degrees, + wind_speed_kmh, + wind_gust_kmh, + barometric_pressure_pa, + visibility_meters, + relative_humidity_percent, + apparent_temperature_c +FROM observations +ORDER BY observed_at DESC, event_emitted_at DESC +LIMIT 1` + + queryObservationPresentWeather = ` +SELECT weather_index, raw_text +FROM observation_present_weather +WHERE event_id = $1 +ORDER BY weather_index ASC` +) diff --git a/internal/adapters/outbound/postgres/observations_read.go b/internal/adapters/outbound/postgres/observations_read.go new file mode 100644 index 0000000..8b2c9bb --- /dev/null +++ b/internal/adapters/outbound/postgres/observations_read.go @@ -0,0 +1,79 @@ +// observations_read.go executes observation and present-weather queries. +// Layer: adapters/outbound/postgres observations feature. +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +func (r *Repository) LatestObservation(ctx context.Context) (*model.WeatherObservation, error) { + if r == nil || r.db == nil { + return nil, fmt.Errorf("postgres repository is not configured") + } + + var row observationParentRow + err := r.db.QueryRowContext(ctx, queryLatestObservation).Scan( + &row.EventID, + &row.StationID, + &row.StationName, + &row.ObservedAt, + &row.ConditionCode, + &row.IsDay, + &row.TextDescription, + &row.TemperatureC, + &row.DewpointC, + &row.WindDirectionDegrees, + &row.WindSpeedKmh, + &row.WindGustKmh, + &row.BarometricPressurePa, + &row.VisibilityMeters, + &row.RelativeHumidityPercent, + &row.ApparentTemperatureC, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query latest observation: %w", err) + } + + obs := mapObservationParentRow(row) + + presentWeather, err := r.loadObservationPresentWeather(ctx, row.EventID) + if err != nil { + return nil, err + } + obs.PresentWeather = presentWeather + + return &obs, nil +} + +func (r *Repository) loadObservationPresentWeather(ctx context.Context, eventID string) ([]model.PresentWeather, error) { + rows, err := r.db.QueryContext(ctx, queryObservationPresentWeather, eventID) + if err != nil { + return nil, fmt.Errorf("query observation present weather: %w", err) + } + defer rows.Close() + + out := make([]model.PresentWeather, 0) + for rows.Next() { + var row observationPresentWeatherRow + if err := rows.Scan(&row.WeatherIndex, &row.RawText); err != nil { + return nil, fmt.Errorf("scan observation present weather row: %w", err) + } + pw, err := mapObservationPresentWeatherRow(row) + if err != nil { + return nil, fmt.Errorf("decode observation present weather row (index=%d): %w", row.WeatherIndex, err) + } + out = append(out, pw) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate observation present weather rows: %w", err) + } + return out, nil +} diff --git a/internal/adapters/outbound/postgres/observations_rows.go b/internal/adapters/outbound/postgres/observations_rows.go new file mode 100644 index 0000000..625e518 --- /dev/null +++ b/internal/adapters/outbound/postgres/observations_rows.go @@ -0,0 +1,32 @@ +// observations_rows.go defines row DTOs for observation reads. +// Layer: adapters/outbound/postgres observations feature. +package postgres + +import ( + "database/sql" + "time" +) + +type observationParentRow struct { + EventID string + StationID sql.NullString + StationName sql.NullString + ObservedAt time.Time + ConditionCode int + IsDay sql.NullBool + TextDescription sql.NullString + TemperatureC sql.NullFloat64 + DewpointC sql.NullFloat64 + WindDirectionDegrees sql.NullFloat64 + WindSpeedKmh sql.NullFloat64 + WindGustKmh sql.NullFloat64 + BarometricPressurePa sql.NullFloat64 + VisibilityMeters sql.NullFloat64 + RelativeHumidityPercent sql.NullFloat64 + ApparentTemperatureC sql.NullFloat64 +} + +type observationPresentWeatherRow struct { + WeatherIndex int + RawText sql.NullString +} diff --git a/internal/adapters/outbound/postgres/repository.go b/internal/adapters/outbound/postgres/repository.go index 3fcb066..b3e1660 100644 --- a/internal/adapters/outbound/postgres/repository.go +++ b/internal/adapters/outbound/postgres/repository.go @@ -1,188 +1,11 @@ +// repository.go defines the Postgres repository shell and constructor. +// Layer: adapters/outbound/postgres repository root. package postgres import ( - "context" "database/sql" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - "gitea.maximumdirect.net/ejr/weatherapi/internal/core" - "gitea.maximumdirect.net/ejr/weatherfeeder/model" -) - -const ( - queryLatestObservation = ` -SELECT - event_id, - station_id, - station_name, - observed_at, - condition_code, - is_day, - text_description, - temperature_c, - dewpoint_c, - wind_direction_degrees, - wind_speed_kmh, - wind_gust_kmh, - barometric_pressure_pa, - visibility_meters, - relative_humidity_percent, - apparent_temperature_c -FROM observations -ORDER BY observed_at DESC, event_emitted_at DESC -LIMIT 1` - - queryCurrentConditions = ` -WITH windowed AS ( - SELECT - temperature_c, - apparent_temperature_c, - dewpoint_c, - relative_humidity_percent, - wind_speed_kmh, - wind_direction_degrees, - condition_code, - is_day, - observed_at - FROM observations - WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1) -) -SELECT - COUNT(*) AS sample_count, - AVG(temperature_c) AS temperature_c, - AVG(apparent_temperature_c) AS apparent_temperature_c, - AVG(dewpoint_c) AS dewpoint_c, - AVG(relative_humidity_percent) AS relative_humidity_percent, - AVG(wind_speed_kmh) AS wind_speed_kmh, - CASE - WHEN atan2d( - AVG(sind(wind_direction_degrees)), - AVG(cosd(wind_direction_degrees)) - ) < 0 - THEN atan2d( - AVG(sind(wind_direction_degrees)), - AVG(cosd(wind_direction_degrees)) - ) + 360.0 - ELSE atan2d( - AVG(sind(wind_direction_degrees)), - AVG(cosd(wind_direction_degrees)) - ) - END AS wind_direction_degrees, - MAX(condition_code) AS condition_code, - ( - SELECT is_day - FROM windowed - ORDER BY observed_at DESC - LIMIT 1 - ) AS is_day -FROM windowed` - - queryObservationPresentWeather = ` -SELECT weather_index, raw_text -FROM observation_present_weather -WHERE event_id = $1 -ORDER BY weather_index ASC` - - queryLatestHourlyForecast = ` -SELECT - event_id, - location_id, - location_name, - issued_at, - updated_at, - product, - latitude, - longitude, - elevation_meters -FROM forecasts -WHERE product = 'hourly' -ORDER BY issued_at DESC, event_emitted_at DESC -LIMIT 1` - - queryForecastPeriods = ` -SELECT - period_index, - start_time, - end_time, - name, - is_day, - condition_code, - condition_text, - provider_raw_description, - text_description, - detailed_text, - icon_url, - temperature_c, - temperature_c_min, - temperature_c_max, - dewpoint_c, - relative_humidity_percent, - wind_direction_degrees, - wind_speed_kmh, - wind_gust_kmh, - barometric_pressure_pa, - visibility_meters, - apparent_temperature_c, - cloud_cover_percent, - probability_of_precipitation_percent, - precipitation_amount_mm, - snowfall_depth_mm, - uv_index -FROM forecast_periods -WHERE run_event_id = $1 -ORDER BY period_index ASC` - - queryLatestAlertRun = ` -SELECT - event_id, - location_id, - location_name, - as_of, - latitude, - longitude -FROM alert_runs -ORDER BY as_of DESC, event_emitted_at DESC -LIMIT 1` - - queryAlerts = ` -SELECT - alert_index, - alert_id, - event, - headline, - severity, - urgency, - certainty, - status, - message_type, - category, - response, - description, - instruction, - sent, - effective, - onset, - expires, - area_description, - sender_name -FROM alerts -WHERE run_event_id = $1 -ORDER BY alert_index ASC` - - queryAlertReferences = ` -SELECT - alert_index, - id, - identifier, - sender, - sent -FROM alert_references -WHERE run_event_id = $1 -ORDER BY alert_index ASC, reference_index ASC` + "gitea.maximumdirect.net/ejr/weatherapi/internal/app" ) // Repository is a Postgres-backed implementation of weatherapi read ports. @@ -190,605 +13,8 @@ type Repository struct { db *sql.DB } -var _ core.Repository = (*Repository)(nil) +var _ app.Repository = (*Repository)(nil) func NewRepository(db *sql.DB) *Repository { return &Repository{db: db} } - -func (r *Repository) LatestObservation(ctx context.Context) (*model.WeatherObservation, error) { - if r == nil || r.db == nil { - return nil, fmt.Errorf("postgres repository is not configured") - } - - var row observationParentRow - err := r.db.QueryRowContext(ctx, queryLatestObservation).Scan( - &row.EventID, - &row.StationID, - &row.StationName, - &row.ObservedAt, - &row.ConditionCode, - &row.IsDay, - &row.TextDescription, - &row.TemperatureC, - &row.DewpointC, - &row.WindDirectionDegrees, - &row.WindSpeedKmh, - &row.WindGustKmh, - &row.BarometricPressurePa, - &row.VisibilityMeters, - &row.RelativeHumidityPercent, - &row.ApparentTemperatureC, - ) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - if err != nil { - return nil, fmt.Errorf("query latest observation: %w", err) - } - - obs := mapObservationParentRow(row) - - presentWeather, err := r.loadObservationPresentWeather(ctx, row.EventID) - if err != nil { - return nil, err - } - obs.PresentWeather = presentWeather - - return &obs, nil -} - -func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*core.CurrentConditions, error) { - if r == nil || r.db == nil { - return nil, fmt.Errorf("postgres repository is not configured") - } - - var row currentConditionsRow - err := r.db.QueryRowContext(ctx, queryCurrentConditions, observationWindowMinutes).Scan( - &row.SampleCount, - &row.TemperatureC, - &row.ApparentTemperatureC, - &row.DewpointC, - &row.RelativeHumidityPercent, - &row.WindSpeedKmh, - &row.WindDirectionDegrees, - &row.ConditionCode, - &row.IsDay, - ) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - if err != nil { - return nil, fmt.Errorf("query current conditions: %w", err) - } - - return mapCurrentConditionsRow(row), nil -} - -func (r *Repository) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) { - if r == nil || r.db == nil { - return nil, fmt.Errorf("postgres repository is not configured") - } - - var row forecastParentRow - err := r.db.QueryRowContext(ctx, queryLatestHourlyForecast).Scan( - &row.EventID, - &row.LocationID, - &row.LocationName, - &row.IssuedAt, - &row.UpdatedAt, - &row.Product, - &row.Latitude, - &row.Longitude, - &row.ElevationMeters, - ) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - if err != nil { - return nil, fmt.Errorf("query latest hourly forecast: %w", err) - } - - run := mapForecastParentRow(row) - - periods, err := r.loadForecastPeriods(ctx, row.EventID) - if err != nil { - return nil, err - } - run.Periods = periods - - return &run, nil -} - -func (r *Repository) LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error) { - if r == nil || r.db == nil { - return nil, fmt.Errorf("postgres repository is not configured") - } - - var row alertRunParentRow - err := r.db.QueryRowContext(ctx, queryLatestAlertRun).Scan( - &row.EventID, - &row.LocationID, - &row.LocationName, - &row.AsOf, - &row.Latitude, - &row.Longitude, - ) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - if err != nil { - return nil, fmt.Errorf("query latest alert run: %w", err) - } - - run := mapAlertRunParentRow(row) - - alerts, err := r.loadAlerts(ctx, row.EventID) - if err != nil { - return nil, err - } - run.Alerts = alerts - - return &run, nil -} - -func (r *Repository) loadObservationPresentWeather(ctx context.Context, eventID string) ([]model.PresentWeather, error) { - rows, err := r.db.QueryContext(ctx, queryObservationPresentWeather, eventID) - if err != nil { - return nil, fmt.Errorf("query observation present weather: %w", err) - } - defer rows.Close() - - out := make([]model.PresentWeather, 0) - for rows.Next() { - var row observationPresentWeatherRow - if err := rows.Scan(&row.WeatherIndex, &row.RawText); err != nil { - return nil, fmt.Errorf("scan observation present weather row: %w", err) - } - pw, err := mapObservationPresentWeatherRow(row) - if err != nil { - return nil, fmt.Errorf("decode observation present weather row (index=%d): %w", row.WeatherIndex, err) - } - out = append(out, pw) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate observation present weather rows: %w", err) - } - return out, nil -} - -func (r *Repository) loadForecastPeriods(ctx context.Context, eventID string) ([]model.WeatherForecastPeriod, error) { - rows, err := r.db.QueryContext(ctx, queryForecastPeriods, eventID) - if err != nil { - return nil, fmt.Errorf("query forecast periods: %w", err) - } - defer rows.Close() - - out := make([]model.WeatherForecastPeriod, 0) - for rows.Next() { - var row forecastPeriodRow - if err := rows.Scan( - &row.PeriodIndex, - &row.StartTime, - &row.EndTime, - &row.Name, - &row.IsDay, - &row.ConditionCode, - &row.ConditionText, - &row.ProviderRawDescription, - &row.TextDescription, - &row.DetailedText, - &row.IconURL, - &row.TemperatureC, - &row.TemperatureCMin, - &row.TemperatureCMax, - &row.DewpointC, - &row.RelativeHumidityPercent, - &row.WindDirectionDegrees, - &row.WindSpeedKmh, - &row.WindGustKmh, - &row.BarometricPressurePa, - &row.VisibilityMeters, - &row.ApparentTemperatureC, - &row.CloudCoverPercent, - &row.ProbabilityOfPrecipitationPercent, - &row.PrecipitationAmountMM, - &row.SnowfallDepthMM, - &row.UVIndex, - ); err != nil { - return nil, fmt.Errorf("scan forecast period row: %w", err) - } - out = append(out, mapForecastPeriodRow(row)) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate forecast period rows: %w", err) - } - return out, nil -} - -func (r *Repository) loadAlerts(ctx context.Context, eventID string) ([]model.WeatherAlert, error) { - alertsRows, err := r.db.QueryContext(ctx, queryAlerts, eventID) - if err != nil { - return nil, fmt.Errorf("query alerts: %w", err) - } - defer alertsRows.Close() - - indexedAlerts := make([]indexedAlert, 0) - for alertsRows.Next() { - var row alertRow - if err := alertsRows.Scan( - &row.AlertIndex, - &row.AlertID, - &row.Event, - &row.Headline, - &row.Severity, - &row.Urgency, - &row.Certainty, - &row.Status, - &row.MessageType, - &row.Category, - &row.Response, - &row.Description, - &row.Instruction, - &row.Sent, - &row.Effective, - &row.Onset, - &row.Expires, - &row.AreaDescription, - &row.SenderName, - ); err != nil { - return nil, fmt.Errorf("scan alerts row: %w", err) - } - indexedAlerts = append(indexedAlerts, mapAlertRow(row)) - } - if err := alertsRows.Err(); err != nil { - return nil, fmt.Errorf("iterate alerts rows: %w", err) - } - - referenceRows, err := r.db.QueryContext(ctx, queryAlertReferences, eventID) - if err != nil { - return nil, fmt.Errorf("query alert references: %w", err) - } - defer referenceRows.Close() - - indexedReferences := make([]indexedAlertReference, 0) - for referenceRows.Next() { - var row alertReferenceRow - if err := referenceRows.Scan( - &row.AlertIndex, - &row.ID, - &row.Identifier, - &row.Sender, - &row.Sent, - ); err != nil { - return nil, fmt.Errorf("scan alert reference row: %w", err) - } - indexedReferences = append(indexedReferences, mapAlertReferenceRow(row)) - } - if err := referenceRows.Err(); err != nil { - return nil, fmt.Errorf("iterate alert reference rows: %w", err) - } - - return attachAlertReferences(indexedAlerts, indexedReferences), nil -} - -type observationParentRow struct { - EventID string - StationID sql.NullString - StationName sql.NullString - ObservedAt time.Time - ConditionCode int - IsDay sql.NullBool - TextDescription sql.NullString - TemperatureC sql.NullFloat64 - DewpointC sql.NullFloat64 - WindDirectionDegrees sql.NullFloat64 - WindSpeedKmh sql.NullFloat64 - WindGustKmh sql.NullFloat64 - BarometricPressurePa sql.NullFloat64 - VisibilityMeters sql.NullFloat64 - RelativeHumidityPercent sql.NullFloat64 - ApparentTemperatureC sql.NullFloat64 -} - -type currentConditionsRow struct { - SampleCount int64 - TemperatureC sql.NullFloat64 - ApparentTemperatureC sql.NullFloat64 - DewpointC sql.NullFloat64 - RelativeHumidityPercent sql.NullFloat64 - WindSpeedKmh sql.NullFloat64 - WindDirectionDegrees sql.NullFloat64 - ConditionCode sql.NullInt64 - IsDay sql.NullBool -} - -func mapCurrentConditionsRow(row currentConditionsRow) *core.CurrentConditions { - if row.SampleCount == 0 { - return nil - } - - conditionCode := model.WMOUnknown - if row.ConditionCode.Valid { - conditionCode = model.WMOCode(row.ConditionCode.Int64) - } - - return &core.CurrentConditions{ - TemperatureC: float64Ptr(row.TemperatureC), - ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC), - DewpointC: float64Ptr(row.DewpointC), - RelativeHumidityPercent: float64Ptr(row.RelativeHumidityPercent), - WindSpeedKmh: float64Ptr(row.WindSpeedKmh), - WindDirectionDegrees: float64Ptr(row.WindDirectionDegrees), - ConditionCode: conditionCode, - IsDay: boolPtr(row.IsDay), - } -} - -func mapObservationParentRow(row observationParentRow) model.WeatherObservation { - return model.WeatherObservation{ - StationID: stringValue(row.StationID), - StationName: stringValue(row.StationName), - Timestamp: row.ObservedAt.UTC(), - ConditionCode: model.WMOCode(row.ConditionCode), - IsDay: boolPtr(row.IsDay), - TextDescription: stringValue(row.TextDescription), - TemperatureC: float64Ptr(row.TemperatureC), - DewpointC: float64Ptr(row.DewpointC), - WindDirectionDegrees: float64Ptr(row.WindDirectionDegrees), - WindSpeedKmh: float64Ptr(row.WindSpeedKmh), - WindGustKmh: float64Ptr(row.WindGustKmh), - BarometricPressurePa: float64Ptr(row.BarometricPressurePa), - VisibilityMeters: float64Ptr(row.VisibilityMeters), - RelativeHumidityPercent: float64Ptr(row.RelativeHumidityPercent), - ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC), - } -} - -type observationPresentWeatherRow struct { - WeatherIndex int - RawText sql.NullString -} - -func mapObservationPresentWeatherRow(row observationPresentWeatherRow) (model.PresentWeather, error) { - if !row.RawText.Valid || strings.TrimSpace(row.RawText.String) == "" { - return model.PresentWeather{}, nil - } - - var raw map[string]any - if err := json.Unmarshal([]byte(row.RawText.String), &raw); err != nil { - return model.PresentWeather{}, err - } - return model.PresentWeather{Raw: raw}, nil -} - -type forecastParentRow struct { - EventID string - LocationID sql.NullString - LocationName sql.NullString - IssuedAt time.Time - UpdatedAt sql.NullTime - Product string - Latitude sql.NullFloat64 - Longitude sql.NullFloat64 - ElevationMeters sql.NullFloat64 -} - -func mapForecastParentRow(row forecastParentRow) model.WeatherForecastRun { - return model.WeatherForecastRun{ - LocationID: stringValue(row.LocationID), - LocationName: stringValue(row.LocationName), - IssuedAt: row.IssuedAt.UTC(), - UpdatedAt: timePtr(row.UpdatedAt), - Product: model.ForecastProduct(row.Product), - Latitude: float64Ptr(row.Latitude), - Longitude: float64Ptr(row.Longitude), - ElevationMeters: float64Ptr(row.ElevationMeters), - } -} - -type forecastPeriodRow struct { - PeriodIndex int - StartTime time.Time - EndTime time.Time - Name sql.NullString - IsDay sql.NullBool - ConditionCode int - ConditionText sql.NullString - ProviderRawDescription sql.NullString - TextDescription sql.NullString - DetailedText sql.NullString - IconURL sql.NullString - TemperatureC sql.NullFloat64 - TemperatureCMin sql.NullFloat64 - TemperatureCMax sql.NullFloat64 - DewpointC sql.NullFloat64 - RelativeHumidityPercent sql.NullFloat64 - WindDirectionDegrees sql.NullFloat64 - WindSpeedKmh sql.NullFloat64 - WindGustKmh sql.NullFloat64 - BarometricPressurePa sql.NullFloat64 - VisibilityMeters sql.NullFloat64 - ApparentTemperatureC sql.NullFloat64 - CloudCoverPercent sql.NullFloat64 - ProbabilityOfPrecipitationPercent sql.NullFloat64 - PrecipitationAmountMM sql.NullFloat64 - SnowfallDepthMM sql.NullFloat64 - UVIndex sql.NullFloat64 -} - -func mapForecastPeriodRow(row forecastPeriodRow) model.WeatherForecastPeriod { - return model.WeatherForecastPeriod{ - StartTime: row.StartTime.UTC(), - EndTime: row.EndTime.UTC(), - Name: stringValue(row.Name), - IsDay: boolPtr(row.IsDay), - ConditionCode: model.WMOCode(row.ConditionCode), - ConditionText: stringValue(row.ConditionText), - ProviderRawDescription: stringValue(row.ProviderRawDescription), - TextDescription: stringValue(row.TextDescription), - DetailedText: stringValue(row.DetailedText), - IconURL: stringValue(row.IconURL), - TemperatureC: float64Ptr(row.TemperatureC), - TemperatureCMin: float64Ptr(row.TemperatureCMin), - TemperatureCMax: float64Ptr(row.TemperatureCMax), - DewpointC: float64Ptr(row.DewpointC), - RelativeHumidityPercent: float64Ptr(row.RelativeHumidityPercent), - WindDirectionDegrees: float64Ptr(row.WindDirectionDegrees), - WindSpeedKmh: float64Ptr(row.WindSpeedKmh), - WindGustKmh: float64Ptr(row.WindGustKmh), - BarometricPressurePa: float64Ptr(row.BarometricPressurePa), - VisibilityMeters: float64Ptr(row.VisibilityMeters), - ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC), - CloudCoverPercent: float64Ptr(row.CloudCoverPercent), - ProbabilityOfPrecipitationPercent: float64Ptr(row.ProbabilityOfPrecipitationPercent), - PrecipitationAmountMm: float64Ptr(row.PrecipitationAmountMM), - SnowfallDepthMM: float64Ptr(row.SnowfallDepthMM), - UVIndex: float64Ptr(row.UVIndex), - } -} - -type alertRunParentRow struct { - EventID string - LocationID sql.NullString - LocationName sql.NullString - AsOf time.Time - Latitude sql.NullFloat64 - Longitude sql.NullFloat64 -} - -func mapAlertRunParentRow(row alertRunParentRow) model.WeatherAlertRun { - return model.WeatherAlertRun{ - LocationID: stringValue(row.LocationID), - LocationName: stringValue(row.LocationName), - AsOf: row.AsOf.UTC(), - Latitude: float64Ptr(row.Latitude), - Longitude: float64Ptr(row.Longitude), - } -} - -type alertRow struct { - AlertIndex int - AlertID string - Event sql.NullString - Headline sql.NullString - Severity sql.NullString - Urgency sql.NullString - Certainty sql.NullString - Status sql.NullString - MessageType sql.NullString - Category sql.NullString - Response sql.NullString - Description sql.NullString - Instruction sql.NullString - Sent sql.NullTime - Effective sql.NullTime - Onset sql.NullTime - Expires sql.NullTime - AreaDescription sql.NullString - SenderName sql.NullString -} - -type indexedAlert struct { - Index int - Alert model.WeatherAlert -} - -func mapAlertRow(row alertRow) indexedAlert { - return indexedAlert{ - Index: row.AlertIndex, - Alert: model.WeatherAlert{ - ID: row.AlertID, - Event: stringValue(row.Event), - Headline: stringValue(row.Headline), - Severity: stringValue(row.Severity), - Urgency: stringValue(row.Urgency), - Certainty: stringValue(row.Certainty), - Status: stringValue(row.Status), - MessageType: stringValue(row.MessageType), - Category: stringValue(row.Category), - Response: stringValue(row.Response), - Description: stringValue(row.Description), - Instruction: stringValue(row.Instruction), - Sent: timePtr(row.Sent), - Effective: timePtr(row.Effective), - Onset: timePtr(row.Onset), - Expires: timePtr(row.Expires), - AreaDescription: stringValue(row.AreaDescription), - SenderName: stringValue(row.SenderName), - }, - } -} - -type alertReferenceRow struct { - AlertIndex int - ID sql.NullString - Identifier sql.NullString - Sender sql.NullString - Sent sql.NullTime -} - -type indexedAlertReference struct { - AlertIndex int - Reference model.AlertReference -} - -func mapAlertReferenceRow(row alertReferenceRow) indexedAlertReference { - return indexedAlertReference{ - AlertIndex: row.AlertIndex, - Reference: model.AlertReference{ - ID: stringValue(row.ID), - Identifier: stringValue(row.Identifier), - Sender: stringValue(row.Sender), - Sent: timePtr(row.Sent), - }, - } -} - -func attachAlertReferences(alerts []indexedAlert, references []indexedAlertReference) []model.WeatherAlert { - refsByAlertIndex := make(map[int][]model.AlertReference, len(alerts)) - for _, ref := range references { - refsByAlertIndex[ref.AlertIndex] = append(refsByAlertIndex[ref.AlertIndex], ref.Reference) - } - - out := make([]model.WeatherAlert, 0, len(alerts)) - for _, alert := range alerts { - mapped := alert.Alert - if refs := refsByAlertIndex[alert.Index]; len(refs) > 0 { - mapped.References = refs - } - out = append(out, mapped) - } - return out -} - -func stringValue(v sql.NullString) string { - if !v.Valid { - return "" - } - return v.String -} - -func boolPtr(v sql.NullBool) *bool { - if !v.Valid { - return nil - } - b := v.Bool - return &b -} - -func float64Ptr(v sql.NullFloat64) *float64 { - if !v.Valid { - return nil - } - f := v.Float64 - return &f -} - -func timePtr(v sql.NullTime) *time.Time { - if !v.Valid { - return nil - } - t := v.Time.UTC() - return &t -} diff --git a/internal/adapters/outbound/postgres/repository_test.go b/internal/adapters/outbound/postgres/repository_test.go index 9695d55..d55d876 100644 --- a/internal/adapters/outbound/postgres/repository_test.go +++ b/internal/adapters/outbound/postgres/repository_test.go @@ -1,3 +1,5 @@ +// repository_test.go validates Postgres row mapping and attachment helpers. +// Layer: adapters/outbound/postgres mapper regression tests. package postgres import ( diff --git a/internal/adapters/outbound/postgres/scan_helpers.go b/internal/adapters/outbound/postgres/scan_helpers.go new file mode 100644 index 0000000..c8e8cf4 --- /dev/null +++ b/internal/adapters/outbound/postgres/scan_helpers.go @@ -0,0 +1,39 @@ +// scan_helpers.go provides shared sql.Null* conversion helpers. +// Layer: adapters/outbound/postgres helper utilities. +package postgres + +import ( + "database/sql" + "time" +) + +func stringValue(v sql.NullString) string { + if !v.Valid { + return "" + } + return v.String +} + +func boolPtr(v sql.NullBool) *bool { + if !v.Valid { + return nil + } + b := v.Bool + return &b +} + +func float64Ptr(v sql.NullFloat64) *float64 { + if !v.Valid { + return nil + } + f := v.Float64 + return &f +} + +func timePtr(v sql.NullTime) *time.Time { + if !v.Valid { + return nil + } + t := v.Time.UTC() + return &t +} diff --git a/internal/app/constants.go b/internal/app/constants.go new file mode 100644 index 0000000..7158731 --- /dev/null +++ b/internal/app/constants.go @@ -0,0 +1,7 @@ +// constants.go defines shared application-level constants. +// Layer: internal/app service behavior defaults. +package app + +const ( + ObservationWindowMinutesDefault = 30 +) diff --git a/internal/core/current_conditions.go b/internal/app/current_conditions.go similarity index 76% rename from internal/core/current_conditions.go rename to internal/app/current_conditions.go index 645fda0..dd1dc9b 100644 --- a/internal/core/current_conditions.go +++ b/internal/app/current_conditions.go @@ -1,4 +1,6 @@ -package core +// current_conditions.go defines the current-conditions aggregate model. +// Layer: internal/app domain-adjacent read model. +package app import "gitea.maximumdirect.net/ejr/weatherfeeder/model" diff --git a/internal/core/service.go b/internal/app/service.go similarity index 75% rename from internal/core/service.go rename to internal/app/service.go index ca522c4..d6ac485 100644 --- a/internal/core/service.go +++ b/internal/app/service.go @@ -1,4 +1,6 @@ -package core +// service.go defines application read ports and use-case orchestration. +// Layer: internal/app core business-facing API. +package app import ( "context" @@ -10,7 +12,7 @@ import ( type Repository interface { LatestObservation(ctx context.Context) (*model.WeatherObservation, error) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) - LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error) + LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*CurrentConditions, error) } @@ -31,8 +33,8 @@ func (s *Service) LatestHourlyForecast(ctx context.Context) (*model.WeatherForec return s.repo.LatestHourlyForecast(ctx) } -func (s *Service) LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error) { - return s.repo.LatestActiveAlerts(ctx) +func (s *Service) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) { + return s.repo.LatestAlertRun(ctx) } func (s *Service) CurrentConditions(ctx context.Context) (*CurrentConditions, error) { diff --git a/internal/app/service_test.go b/internal/app/service_test.go new file mode 100644 index 0000000..5dd5705 --- /dev/null +++ b/internal/app/service_test.go @@ -0,0 +1,100 @@ +// service_test.go validates application service delegation behavior. +// Layer: internal/app tests for read use-case orchestration. +package app + +import ( + "context" + "errors" + "testing" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +type fakeRepository struct { + observation *model.WeatherObservation + forecast *model.WeatherForecastRun + alerts *model.WeatherAlertRun + conditions *CurrentConditions + err error + + currentConditionsWindow int +} + +func (r *fakeRepository) LatestObservation(context.Context) (*model.WeatherObservation, error) { + return r.observation, r.err +} + +func (r *fakeRepository) LatestHourlyForecast(context.Context) (*model.WeatherForecastRun, error) { + return r.forecast, r.err +} + +func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) { + return r.alerts, r.err +} + +func (r *fakeRepository) CurrentConditions(_ context.Context, observationWindowMinutes int) (*CurrentConditions, error) { + r.currentConditionsWindow = observationWindowMinutes + return r.conditions, r.err +} + +func TestServiceDelegatesObservation(t *testing.T) { + repo := &fakeRepository{observation: &model.WeatherObservation{StationID: "KSTL"}} + svc := NewService(repo) + + obs, err := svc.LatestObservation(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if obs == nil || obs.StationID != "KSTL" { + t.Fatalf("unexpected observation: %+v", obs) + } +} + +func TestServiceDelegatesForecast(t *testing.T) { + repo := &fakeRepository{forecast: &model.WeatherForecastRun{LocationID: "stl"}} + svc := NewService(repo) + + run, err := svc.LatestHourlyForecast(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if run == nil || run.LocationID != "stl" { + t.Fatalf("unexpected forecast: %+v", run) + } +} + +func TestServiceDelegatesAlerts(t *testing.T) { + repo := &fakeRepository{alerts: &model.WeatherAlertRun{LocationID: "stl"}} + svc := NewService(repo) + + run, err := svc.LatestAlertRun(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if run == nil || run.LocationID != "stl" { + t.Fatalf("unexpected alert run: %+v", run) + } +} + +func TestServiceUsesDefaultCurrentConditionsWindow(t *testing.T) { + repo := &fakeRepository{conditions: &CurrentConditions{ConditionCode: model.WMOUnknown}} + svc := NewService(repo) + + _, err := svc.CurrentConditions(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if repo.currentConditionsWindow != ObservationWindowMinutesDefault { + t.Fatalf("expected observation window %d, got %d", ObservationWindowMinutesDefault, repo.currentConditionsWindow) + } +} + +func TestServicePropagatesErrors(t *testing.T) { + want := errors.New("boom") + repo := &fakeRepository{err: want} + svc := NewService(repo) + + if _, err := svc.LatestObservation(context.Background()); !errors.Is(err, want) { + t.Fatalf("expected error %v, got %v", want, err) + } +}