diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md new file mode 100644 index 0000000..ad8f58c --- /dev/null +++ b/docs/roadmap/implementation.md @@ -0,0 +1,232 @@ +# 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. diff --git a/docs/roadmap/outlook.md b/docs/roadmap/outlook.md new file mode 100644 index 0000000..454d1d7 --- /dev/null +++ b/docs/roadmap/outlook.md @@ -0,0 +1,128 @@ +# SPC Convective Outlook API Roadmap + +## Summary + +Add `weatherapi` support for SPC convective outlook data stored by `weatherfeeder`. + +This is roadmap-only content. Do not document these endpoints in `README.md`, `docs/api.md`, or other current-behavior docs until the endpoints are implemented in the same change. + +Target endpoints: + +- `GET /outlooks/convective` +- `GET /outlooks/convective/active` +- `GET /outlooks/convective/location` + +Each endpoint returns the standard weatherapi response envelope. Missing latest data returns `{"data": null}`. When a latest run exists but filters match no outlooks, return the run metadata with `outlooks: []`. + +## Architecture And Boundary Decisions + +- `weatherapi` remains read-only. It reads `weatherfeeder` Postgres tables and does not ingest SPC data, call SPC provider APIs, create tables, or run migrations. +- The application service owns latest-run filtering policy. The Postgres adapter reconstructs the latest stored canonical run; it should not encode HTTP query semantics. +- HTTP adapters own route registration, query binding, request validation, and filter construction. +- Presenters own timezone conversion and copy semantics. Repository methods normalize database timestamps to UTC. +- Geometry is returned as stored GeoJSON. `weatherapi` must not simplify, transform, or recompute polygons. + +## Public API Contract + +### Routes + +- `/outlooks/convective`: latest convective outlook run with all stored outlook polygons. +- `/outlooks/convective/active`: latest run filtered to outlooks where `validFrom <= now < validTo`. +- `/outlooks/convective/location`: latest run filtered to currently active outlooks where `containsLocation=true`. + +### Query Parameters + +All routes support: + +- `format=json|xml|text` +- `units=metric|us`, accepted for consistency and with no payload effect +- `tz` / `TZ`, affecting response timestamp rendering only +- `day=1|2|3` +- `outlookType=categorical|tornado|hail|wind` + +`/outlooks/convective` and `/outlooks/convective/active` also support: + +- `containsLocation=true|false` + +Reject with `400 Bad Request`: + +- `precision` +- unknown query parameters +- invalid `day`, `outlookType`, `containsLocation`, or timezone values +- conflicting `tz` and `TZ` +- `containsLocation` on `/outlooks/convective/location` + +### Response Shape + +Return a `model.WeatherOutlookRun`-compatible payload: + +- run fields: `locationId`, `locationName`, `latitude`, `longitude`, `asOf`, `issuedAt`, `outlooks` +- outlook fields: `id`, `provider`, `product`, `day`, `outlookType`, `label`, `labelText`, `severityRank`, `validFrom`, `validTo`, `issuedAt`, `expiresAt`, `forecaster`, `headline`, `summary`, `discussion`, `sourceUrl`, `imageUrl`, `containsLocation`, `geometry` + +Timezone conversion applies to run `asOf`, run `issuedAt`, and outlook `validFrom`, `validTo`, `issuedAt`, and `expiresAt`. Active filtering compares instants and is not timezone-dependent. + +## Implementation Stages + +### Stage 1: Application Use Case + +- Extend `internal/app.Repository` with `LatestConvectiveOutlookRun(ctx)`. +- Add `app.OutlookFilter` with optional `Day`, `OutlookType`, `ContainsLocation`, and `ActiveAt` fields. +- Add `LatestConvectiveOutlook(ctx, filter)` to `app.Service`. +- Implement filtering by cloning the latest repository run and filtering the copied outlook slice while preserving order. +- Use an injectable request-time value from the HTTP adapter for active/location filters. + +### Stage 2: Postgres Read Adapter + +- Add outlook SQL, row DTOs, mapper, and read methods under `internal/adapters/outbound/postgres`, following the weather stories read pattern. +- Query the latest parent row from `outlook_runs` by `as_of DESC, event_emitted_at DESC`. +- Load child rows from `outlooks` by `run_event_id`, ordered by `outlook_index ASC`. +- Map all canonical outlook columns, including `outlook_id`, `provider`, `contains_location`, and `geometry_json`. +- Normalize timestamps to UTC and preserve `geometry_json` as `json.RawMessage`. +- Return `nil, nil` when no latest parent row exists. + +### Stage 3: HTTP Adapter And Presenter + +- Extend `internal/adapters/inbound/httpapi.Service` with `LatestConvectiveOutlook(ctx, app.OutlookFilter)`. +- Register the three outlook routes with JSON, XML, and text support. +- Add an outlook query binder for common query params plus `day`, `outlookType`, and `containsLocation` validation. +- Add `outlookNow`, defaulting to `time.Now`, for deterministic active/location endpoint tests. +- Add presenter helpers that copy the model, convert timestamps to the requested timezone, preserve geometry bytes, and never mutate repository-returned values. +- Add `templates/outlooks_convective.txt.tmpl` for all three outlook routes. + +### Stage 4: Documentation After Implementation + +Update current-behavior docs only in the same change that implements the routes: + +- `README.md`: endpoint list and short query-parameter summary. +- `docs/api.md`: route family, query params, validation behavior, response fields, examples, `containsLocation` semantics, active filtering semantics, and GeoJSON longitude/latitude coordinate order. +- Internal or integration docs only if implementation changes repository assumptions or adapter boundaries beyond the planned latest-run reads. + +## Test Plan + +- App tests: delegation, no-data behavior, active/day/type/location filters, combined filters, and non-mutating clone behavior. +- Postgres tests: parent and child row mapping, nullable fields, UTC normalization, geometry preservation, child ordering, and no-row behavior. +- HTTP tests: route registration, JSON/XML/text responses, null data, empty filtered outlooks, timezone conversion, valid filters, rejected query params, invalid filter values, invalid timezone, and conflicting `tz`/`TZ`. +- Presenter tests: nil input, timezone conversion, copy semantics, and exact geometry preservation. + +Verification commands: + +```sh +go test ./internal/app +go test ./internal/adapters/outbound/postgres +go test ./internal/adapters/inbound/httpapi +go test ./internal/adapters/inbound/httpapi/presenter +go test ./... +``` + +## Assumptions And Defaults + +- `weatherfeeder v0.10.0` or the active workspace module provides `model.WeatherOutlookRun` and `model.WeatherOutlook`. +- The first implementation serves only latest-run views; historical browsing remains future work. +- `/outlooks/convective` returns all stored latest-run polygons by default, including polygons that do not contain the configured location. +- `/outlooks/convective/location` means active and `containsLocation=true`. +- `units` is accepted but does not alter outlook payload values or field names. +- No `weatherapi` database migration is required. + +## Open Questions + +None. Route names, filtering semantics, adapter ownership, documentation timing, and verification expectations are decision-complete for implementation.