# SPC Convective Outlook API Implementation Plan ## Summary Implement the `weatherapi` SPC convective outlook API described in [`docs/roadmap/outlook.md`](outlook.md). This plan is execution-oriented. Implement stages in order. Keep `weatherapi` read-only: it must read `weatherfeeder` Postgres tables, reconstruct canonical model payloads, apply API filters, and present results over HTTP. Do not add ingestion, upstream SPC calls, table creation, migrations, or historical browsing. Target routes: - `GET /outlooks/convective` - `GET /outlooks/convective/active` - `GET /outlooks/convective/location` All routes return the existing `data` envelope, support JSON/XML/text, and serve latest-run views only. ## Stage 1: Application Use Case And Filtering Implement the core use case before touching HTTP or SQL. Required changes: - Extend `internal/app.Repository` with: - `LatestConvectiveOutlookRun(context.Context) (*model.WeatherOutlookRun, error)` - Add `internal/app.OutlookFilter` with: - `Day *int` - `OutlookType string` - `ContainsLocation *bool` - `ActiveAt *time.Time` - Add `LatestConvectiveOutlook(ctx context.Context, filter OutlookFilter) (*model.WeatherOutlookRun, error)` to `internal/app.Service`. - Implement `LatestConvectiveOutlook` by calling `repo.LatestConvectiveOutlookRun(ctx)`, returning `nil, nil` for no run, cloning the run, and filtering the cloned `Outlooks` slice. - Filtering rules: - `Day`: keep outlooks with `outlook.Day == *Day`. - `OutlookType`: keep outlooks with exact normalized value matching `outlook.OutlookType`. - `ContainsLocation`: keep outlooks with `outlook.ContainsLocation == *ContainsLocation`. - `ActiveAt`: keep outlooks where `!ActiveAt.Before(outlook.ValidFrom)` and `ActiveAt.Before(outlook.ValidTo)`. - Preserve original outlook order. - Do not mutate the repository-returned run, slice, geometry, or pointer fields. Tests: - Add fake repository fields/methods in `internal/app/service_test.go`. - Test repository delegation and no-data behavior. - Test each filter independently. - Test combined filters. - Test active boundary behavior: includes exact `validFrom`, excludes exact `validTo`. - Test filtered result keeps run metadata and can return `outlooks: []`. - Test filtering does not mutate the original run or outlook slice. Run: ```sh go test ./internal/app ``` ## Stage 2: Postgres Outlook Read Adapter Add latest-run reconstruction from `weatherfeeder` outlook tables. Required changes: - Add `outlooks_queries.go`, `outlooks_rows.go`, `outlooks_mapper.go`, and `outlooks_read.go` under `internal/adapters/outbound/postgres`. - Query latest parent from `outlook_runs`: - selected columns: `event_id`, `location_id`, `location_name`, `latitude`, `longitude`, `as_of`, `issued_at` - order: `as_of DESC, event_emitted_at DESC` - limit: `1` - Query child rows from `outlooks` by `run_event_id = $1`, ordered by `outlook_index ASC`. - Select and map all canonical child columns: - `outlook_index`, `outlook_id`, `provider`, `product`, `day`, `outlook_type`, `label`, `label_text`, `severity_rank`, `valid_from`, `valid_to`, `issued_at`, `expires_at`, `forecaster`, `headline`, `summary`, `discussion`, `source_url`, `image_url`, `contains_location`, `geometry_json` - Map parent row into `model.WeatherOutlookRun` with UTC-normalized `AsOf` and `IssuedAt` pointer. - Map child row into `model.WeatherOutlook` with UTC-normalized timestamps. - Convert `geometry_json` into `json.RawMessage` without parsing or reserializing. - Return `nil, nil` when the latest parent query returns `sql.ErrNoRows`. - Wrap query, scan, iteration, and geometry decode/conversion failures with operation context. - Confirm `var _ app.Repository = (*Repository)(nil)` still compiles. Tests: - Extend `internal/adapters/outbound/postgres/repository_test.go` or add outlook-specific mapper tests in the same package. - Test parent nullable mapping and UTC normalization. - Test child mapping for every canonical field, including `outlook_id`, `provider`, `contains_location`, and `geometry_json`. - Test nullable fields map to nil/zero omitted model values consistently with existing helpers. - Test geometry bytes are preserved exactly. - Add read-path tests following existing repository test style if the package already has DB-backed query tests available; otherwise keep mapper-focused tests and rely on compile-time interface satisfaction. Run: ```sh go test ./internal/adapters/outbound/postgres ``` ## Stage 3: HTTP Route Definitions And Query Binding Add route wiring and strict query validation. Required changes: - Extend `internal/adapters/inbound/httpapi.Service` with: - `LatestConvectiveOutlook(context.Context, app.OutlookFilter) (*model.WeatherOutlookRun, error)` - Update HTTP test fakes to implement the new method. - Add `outlooks_endpoint.go` under `internal/adapters/inbound/httpapi`. - Register outlook definitions from `Definitions(svc)` after weather stories or before discussion; route order should not affect behavior. - Add an outlook query binder that accepts common `format`, `units`, `tz`, `TZ`, plus outlook-specific filters. - Reuse existing timezone parsing and common query normalization patterns. - Validate: - `day` must be integer `1`, `2`, or `3`. - `outlookType` must be one of `categorical`, `tornado`, `hail`, `wind`; normalize case/space like other common values. - `containsLocation` must parse as boolean when allowed. - `precision` and unknown params are rejected. - `containsLocation` is rejected on `/outlooks/convective/location`. - Add package-level `outlookNow = time.Now` for active/location filters. - Route filter construction: - `/outlooks/convective`: use user filters only. - `/outlooks/convective/active`: add `ActiveAt=outlookNow().UTC()` plus user filters. - `/outlooks/convective/location`: add `ActiveAt=outlookNow().UTC()` and `ContainsLocation=true`; reject explicit `containsLocation`. - Return `response.Envelope{Data: presenter.OutlookRunPayload(run, req.Units, req.Timezone)}`. Tests: - Route registration for all three paths. - JSON success for each route. - No repository data returns `data: null`. - Filtered no-match run returns `outlooks: []`. - `day`, `outlookType`, and `containsLocation` query params construct expected filters. - Active and location endpoints use deterministic `outlookNow` in tests. - `precision`, unknown params, invalid day/type/bool, invalid timezone, conflicting `tz`/`TZ`, and `containsLocation` on `/location` return `400 Bad Request`. Run: ```sh go test ./internal/adapters/inbound/httpapi ``` ## Stage 4: Presenter And Text Template Add outlook response presentation while preserving canonical shape. Required changes: - Add `presenter/outlook.go`. - Implement `OutlookRunPayload(run *model.WeatherOutlookRun, _ Units, tz *time.Location) any`. - Return nil for nil input. - Copy run fields and outlook entries before timestamp conversion. - Convert run `AsOf`, run `IssuedAt`, outlook `ValidFrom`, `ValidTo`, `IssuedAt`, and `ExpiresAt` with existing timezone helper behavior. - Preserve `Latitude`, `Longitude`, `SeverityRank`, and `Geometry` without mutating source pointers or slices. - Keep `units` accepted but ignored. - Add `templates/outlooks_convective.txt.tmpl`. - Template should render a clear no-data message when `.Data` is nil and concise outlook summaries when data exists. Tests: - Presenter nil input returns nil. - Timezone conversion affects all run/outlook timestamps and preserves instant. - Source model is not mutated. - Geometry bytes are preserved exactly. - Endpoint text response uses the new template. - XML response renders without handler error. Run: ```sh go test ./internal/adapters/inbound/httpapi/presenter go test ./internal/adapters/inbound/httpapi ``` ## Stage 5: Current-Behavior Documentation Update docs only after the endpoint behavior is implemented in code. Required changes: - Update `README.md` endpoint list with: - `GET /outlooks/convective` - `GET /outlooks/convective/active` - `GET /outlooks/convective/location` - Update `README.md` query parameter summary to mention outlook filters: `day`, `outlookType`, and `containsLocation`. - Update `docs/api.md` with an Outlook endpoints section covering: - route behavior - supported query params and rejected params - active filtering semantics - `containsLocation` semantics - response fields and nullability - GeoJSON geometry coordinate order - examples for full and location-filtered responses - text format note - Update `docs/internal/http-adapter.md`, `docs/internal/presenters.md`, `docs/internal/postgres-repository.md`, and `docs/integrations/weatherfeeder-postgres.md` only if the implementation adds details not already covered by existing internal/integration docs. - Do not move implemented behavior into roadmap docs; roadmap docs should remain future/planning context until retired. Tests: - No documentation-specific tests are currently required unless existing tests check docs. - Run full test suite after docs updates to catch template or route registration regressions. Run: ```sh go test ./... ``` ## Stage 6: Final Verification And Cleanup Before committing the implementation: - Run focused tests: ```sh go test ./internal/app go test ./internal/adapters/outbound/postgres go test ./internal/adapters/inbound/httpapi go test ./internal/adapters/inbound/httpapi/presenter ``` - Run full tests: ```sh go test ./... ``` - Check status and diff: ```sh git status --short git diff --stat ``` - Confirm no unrelated files are included. - Confirm no weatherfeeder ingestion behavior, schema creation, migration logic, or upstream SPC calls were added. - Confirm current-behavior docs no longer describe outlook endpoints as future work once implementation is complete, or leave roadmap docs clearly marked as historical/planning if they remain. ## Open Questions None. The feature roadmap defines the route names, latest-run semantics, filters, adapter ownership, documentation timing, and validation behavior needed for implementation.