Compare commits
1 Commits
v0.12.0
...
50215d2105
| Author | SHA1 | Date | |
|---|---|---|---|
| 50215d2105 |
@@ -41,9 +41,6 @@ Related child types include:
|
|||||||
- `WeatherOutlookDiscussion`
|
- `WeatherOutlookDiscussion`
|
||||||
- `WMOCode`
|
- `WMOCode`
|
||||||
|
|
||||||
`WeatherOutlookRun` includes `WeatherOutlookDiscussion` entries as run-level
|
|
||||||
SPC day discussions.
|
|
||||||
|
|
||||||
## Wire And Compatibility Rules
|
## Wire And Compatibility Rules
|
||||||
|
|
||||||
- JSON tags define canonical payload field names.
|
- JSON tags define canonical payload field names.
|
||||||
|
|||||||
@@ -211,8 +211,9 @@ Payload type: `WeatherAlertRun`.
|
|||||||
| `instruction` | string | no | Alert instruction. |
|
| `instruction` | string | no | Alert instruction. |
|
||||||
| `sent` | timestamp | no | Provider sent time. |
|
| `sent` | timestamp | no | Provider sent time. |
|
||||||
| `effective` | timestamp | no | Effective time. |
|
| `effective` | timestamp | no | Effective time. |
|
||||||
| `onset` | timestamp | no | Onset time. |
|
| `onset` | timestamp | no | Alert period start. |
|
||||||
| `expires` | timestamp | no | Expiration time. |
|
| `ends` | timestamp | no | Alert period end. |
|
||||||
|
| `expires` | timestamp | no | Provider expiration metadata; not necessarily the alert period end. |
|
||||||
| `areaDescription` | string | no | Affected area description. |
|
| `areaDescription` | string | no | Affected area description. |
|
||||||
| `senderName` | string | no | Provider sender name. |
|
| `senderName` | string | no | Provider sender name. |
|
||||||
| `references` | array | no | Related alerts. |
|
| `references` | array | no | Related alerts. |
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ normalizer uses fields under `properties` such as `stationId`, `stationName`,
|
|||||||
`nws_alerts` expects an alerts FeatureCollection. The normalizer uses the
|
`nws_alerts` expects an alerts FeatureCollection. The normalizer uses the
|
||||||
collection `updated` timestamp, `title`, each feature ID, alert classification
|
collection `updated` timestamp, `title`, each feature ID, alert classification
|
||||||
fields, narrative fields, timing fields, sender fields, and references.
|
fields, narrative fields, timing fields, sender fields, and references.
|
||||||
|
`properties.onset` and `properties.ends` map to the canonical alert period
|
||||||
|
start and end. `properties.expires` maps only to canonical `expires` provider
|
||||||
|
metadata and is not treated as the alert period end.
|
||||||
|
|
||||||
`nws_forecast_hourly` and `nws_forecast_narrative` expect gridpoint forecast
|
`nws_forecast_hourly` and `nws_forecast_narrative` expect gridpoint forecast
|
||||||
GeoJSON with `properties.generatedAt`, `properties.updateTime`, elevation,
|
GeoJSON with `properties.generatedAt`, `properties.updateTime`, elevation,
|
||||||
@@ -98,8 +101,9 @@ unset. Forecast temperatures are converted to Celsius when NWS supplies
|
|||||||
Fahrenheit, and wind speed strings are converted to kilometers per hour.
|
Fahrenheit, and wind speed strings are converted to kilometers per hour.
|
||||||
|
|
||||||
Alert timing fields are parsed best-effort. Invalid per-alert timestamps are
|
Alert timing fields are parsed best-effort. Invalid per-alert timestamps are
|
||||||
left unset rather than failing the whole alert run. Missing alert IDs are
|
left unset rather than failing the whole alert run. NWS `ends` is preserved
|
||||||
synthesized from the run snapshot time and array position.
|
separately from `expires`; `expires` does not fall back to `ends`. Missing alert
|
||||||
|
IDs are synthesized from the run snapshot time and array position.
|
||||||
|
|
||||||
Forecast discussion parsing requires an issue time. Weather story entries require
|
Forecast discussion parsing requires an issue time. Weather story entries require
|
||||||
start time, end time, and update time.
|
start time, end time, and update time.
|
||||||
|
|||||||
@@ -353,6 +353,7 @@ Indexes:
|
|||||||
| `sent` | `TIMESTAMPTZ` | yes | `payload.alerts[].sent` |
|
| `sent` | `TIMESTAMPTZ` | yes | `payload.alerts[].sent` |
|
||||||
| `effective` | `TIMESTAMPTZ` | yes | `payload.alerts[].effective` |
|
| `effective` | `TIMESTAMPTZ` | yes | `payload.alerts[].effective` |
|
||||||
| `onset` | `TIMESTAMPTZ` | yes | `payload.alerts[].onset` |
|
| `onset` | `TIMESTAMPTZ` | yes | `payload.alerts[].onset` |
|
||||||
|
| `ends` | `TIMESTAMPTZ` | yes | `payload.alerts[].ends` |
|
||||||
| `expires` | `TIMESTAMPTZ` | yes | `payload.alerts[].expires` |
|
| `expires` | `TIMESTAMPTZ` | yes | `payload.alerts[].expires` |
|
||||||
| `area_description` | `TEXT` | yes | `payload.alerts[].areaDescription` |
|
| `area_description` | `TEXT` | yes | `payload.alerts[].areaDescription` |
|
||||||
| `sender_name` | `TEXT` | yes | `payload.alerts[].senderName` |
|
| `sender_name` | `TEXT` | yes | `payload.alerts[].senderName` |
|
||||||
|
|||||||
@@ -93,12 +93,8 @@ func buildAlerts(parsed nwsAlertsResponse, fallbackAsOf time.Time) (model.Weathe
|
|||||||
sent := nwscommon.ParseTimePtr(p.Sent)
|
sent := nwscommon.ParseTimePtr(p.Sent)
|
||||||
effective := nwscommon.ParseTimePtr(p.Effective)
|
effective := nwscommon.ParseTimePtr(p.Effective)
|
||||||
onset := nwscommon.ParseTimePtr(p.Onset)
|
onset := nwscommon.ParseTimePtr(p.Onset)
|
||||||
|
ends := nwscommon.ParseTimePtr(p.Ends)
|
||||||
// Expires: prefer "expires"; fall back to "ends" if present.
|
|
||||||
expires := nwscommon.ParseTimePtr(p.Expires)
|
expires := nwscommon.ParseTimePtr(p.Expires)
|
||||||
if expires == nil {
|
|
||||||
expires = nwscommon.ParseTimePtr(p.Ends)
|
|
||||||
}
|
|
||||||
|
|
||||||
refs := parseNWSAlertReferences(p.References)
|
refs := parseNWSAlertReferences(p.References)
|
||||||
|
|
||||||
@@ -123,6 +119,7 @@ func buildAlerts(parsed nwsAlertsResponse, fallbackAsOf time.Time) (model.Weathe
|
|||||||
Sent: sent,
|
Sent: sent,
|
||||||
Effective: effective,
|
Effective: effective,
|
||||||
Onset: onset,
|
Onset: onset,
|
||||||
|
Ends: ends,
|
||||||
Expires: expires,
|
Expires: expires,
|
||||||
|
|
||||||
AreaDescription: strings.TrimSpace(p.AreaDesc),
|
AreaDescription: strings.TrimSpace(p.AreaDesc),
|
||||||
|
|||||||
136
internal/normalizers/nws/alerts_test.go
Normal file
136
internal/normalizers/nws/alerts_test.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package nws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/ejr/feedkit/event"
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertsNormalizerMapsEndsSeparatelyFromExpires(t *testing.T) {
|
||||||
|
raw := []byte(`{
|
||||||
|
"updated":"2026-06-16T10:00:00+00:00",
|
||||||
|
"title":"Current watches, warnings, and advisories for St. Louis",
|
||||||
|
"features":[{
|
||||||
|
"id":"https://api.weather.gov/alerts/alert-1",
|
||||||
|
"properties":{
|
||||||
|
"event":"Flood Warning",
|
||||||
|
"headline":"Flood Warning issued",
|
||||||
|
"sent":"2026-06-16T09:55:00+00:00",
|
||||||
|
"effective":"2026-06-16T10:00:00+00:00",
|
||||||
|
"onset":"2026-06-16T10:15:00+00:00",
|
||||||
|
"ends":"2026-06-16T14:00:00+00:00",
|
||||||
|
"expires":"2026-06-16T11:00:00+00:00"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := AlertsNormalizer{}.Normalize(context.Background(), alertRawEvent(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
run := decodeAlertRun(t, out)
|
||||||
|
if len(run.Alerts) != 1 {
|
||||||
|
t.Fatalf("expected 1 alert, got %d", len(run.Alerts))
|
||||||
|
}
|
||||||
|
alert := run.Alerts[0]
|
||||||
|
wantEnds := time.Date(2026, 6, 16, 14, 0, 0, 0, time.UTC)
|
||||||
|
wantExpires := time.Date(2026, 6, 16, 11, 0, 0, 0, time.UTC)
|
||||||
|
if alert.Ends == nil || !alert.Ends.Equal(wantEnds) {
|
||||||
|
t.Fatalf("ends = %v, want %s", alert.Ends, wantEnds)
|
||||||
|
}
|
||||||
|
if alert.Expires == nil || !alert.Expires.Equal(wantExpires) {
|
||||||
|
t.Fatalf("expires = %v, want %s", alert.Expires, wantExpires)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertsNormalizerDoesNotFallbackExpiresToEnds(t *testing.T) {
|
||||||
|
raw := []byte(`{
|
||||||
|
"updated":"2026-06-16T10:00:00+00:00",
|
||||||
|
"features":[{
|
||||||
|
"id":"alert-ends-only",
|
||||||
|
"properties":{
|
||||||
|
"event":"Heat Advisory",
|
||||||
|
"ends":"2026-06-16T22:00:00+00:00"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := AlertsNormalizer{}.Normalize(context.Background(), alertRawEvent(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
run := decodeAlertRun(t, out)
|
||||||
|
alert := run.Alerts[0]
|
||||||
|
if alert.Ends == nil {
|
||||||
|
t.Fatal("expected ends to be populated")
|
||||||
|
}
|
||||||
|
if alert.Expires != nil {
|
||||||
|
t.Fatalf("expected expires nil when upstream expires is absent, got %v", alert.Expires)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertsNormalizerIgnoresInvalidEnds(t *testing.T) {
|
||||||
|
raw := []byte(`{
|
||||||
|
"updated":"2026-06-16T10:00:00+00:00",
|
||||||
|
"features":[{
|
||||||
|
"id":"alert-invalid-ends",
|
||||||
|
"properties":{
|
||||||
|
"event":"Special Weather Statement",
|
||||||
|
"ends":"not-a-time",
|
||||||
|
"expires":"2026-06-16T11:00:00+00:00"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := AlertsNormalizer{}.Normalize(context.Background(), alertRawEvent(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
run := decodeAlertRun(t, out)
|
||||||
|
alert := run.Alerts[0]
|
||||||
|
if alert.Ends != nil {
|
||||||
|
t.Fatalf("expected invalid ends to map nil, got %v", alert.Ends)
|
||||||
|
}
|
||||||
|
if alert.Expires == nil {
|
||||||
|
t.Fatal("expected expires to remain populated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertRawEvent(raw []byte) event.Event {
|
||||||
|
emittedAt := time.Date(2026, 6, 16, 10, 5, 0, 0, time.UTC)
|
||||||
|
return event.Event{
|
||||||
|
ID: "raw-alerts",
|
||||||
|
Kind: event.Kind(standards.KindAlert),
|
||||||
|
Source: "NWSAlerts",
|
||||||
|
Schema: standards.SchemaRawNWSAlertsV1,
|
||||||
|
EmittedAt: emittedAt,
|
||||||
|
Payload: json.RawMessage(raw),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeAlertRun(t *testing.T, e *event.Event) model.WeatherAlertRun {
|
||||||
|
t.Helper()
|
||||||
|
if e == nil {
|
||||||
|
t.Fatal("expected normalized event")
|
||||||
|
}
|
||||||
|
if e.Schema != standards.SchemaWeatherAlertV1 {
|
||||||
|
t.Fatalf("schema = %q, want %q", e.Schema, standards.SchemaWeatherAlertV1)
|
||||||
|
}
|
||||||
|
var run model.WeatherAlertRun
|
||||||
|
raw, err := json.Marshal(e.Payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal alert payload: %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &run); err != nil {
|
||||||
|
t.Fatalf("decode alert payload: %v", err)
|
||||||
|
}
|
||||||
|
return run
|
||||||
|
}
|
||||||
@@ -198,6 +198,7 @@
|
|||||||
// - sent TIMESTAMPTZ NULL -> payload.alerts[i].sent
|
// - sent TIMESTAMPTZ NULL -> payload.alerts[i].sent
|
||||||
// - effective TIMESTAMPTZ NULL -> payload.alerts[i].effective
|
// - effective TIMESTAMPTZ NULL -> payload.alerts[i].effective
|
||||||
// - onset TIMESTAMPTZ NULL -> payload.alerts[i].onset
|
// - onset TIMESTAMPTZ NULL -> payload.alerts[i].onset
|
||||||
|
// - ends TIMESTAMPTZ NULL -> payload.alerts[i].ends
|
||||||
// - expires TIMESTAMPTZ NULL -> payload.alerts[i].expires
|
// - expires TIMESTAMPTZ NULL -> payload.alerts[i].expires
|
||||||
// - area_description TEXT NULL -> payload.alerts[i].areaDescription
|
// - area_description TEXT NULL -> payload.alerts[i].areaDescription
|
||||||
// - sender_name TEXT NULL -> payload.alerts[i].senderName
|
// - sender_name TEXT NULL -> payload.alerts[i].senderName
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ func mapAlertEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
|
|||||||
"sent": nullableTime(a.Sent),
|
"sent": nullableTime(a.Sent),
|
||||||
"effective": nullableTime(a.Effective),
|
"effective": nullableTime(a.Effective),
|
||||||
"onset": nullableTime(a.Onset),
|
"onset": nullableTime(a.Onset),
|
||||||
|
"ends": nullableTime(a.Ends),
|
||||||
"expires": nullableTime(a.Expires),
|
"expires": nullableTime(a.Expires),
|
||||||
"area_description": nullableString(a.AreaDescription),
|
"area_description": nullableString(a.AreaDescription),
|
||||||
"sender_name": nullableString(a.SenderName),
|
"sender_name": nullableString(a.SenderName),
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ func TestMapPostgresEventForecastStructPayload(t *testing.T) {
|
|||||||
|
|
||||||
func TestMapPostgresEventAlertStructPayload(t *testing.T) {
|
func TestMapPostgresEventAlertStructPayload(t *testing.T) {
|
||||||
sent := time.Date(2026, 3, 16, 17, 0, 0, 0, time.UTC)
|
sent := time.Date(2026, 3, 16, 17, 0, 0, 0, time.UTC)
|
||||||
|
ends := time.Date(2026, 3, 16, 20, 0, 0, 0, time.UTC)
|
||||||
|
expires := time.Date(2026, 3, 16, 18, 30, 0, 0, time.UTC)
|
||||||
run := model.WeatherAlertRun{
|
run := model.WeatherAlertRun{
|
||||||
AsOf: time.Date(2026, 3, 16, 18, 0, 0, 0, time.UTC),
|
AsOf: time.Date(2026, 3, 16, 18, 0, 0, 0, time.UTC),
|
||||||
Alerts: []model.WeatherAlert{
|
Alerts: []model.WeatherAlert{
|
||||||
@@ -110,6 +112,8 @@ func TestMapPostgresEventAlertStructPayload(t *testing.T) {
|
|||||||
ID: "urn:alert:1",
|
ID: "urn:alert:1",
|
||||||
Headline: "Winter Weather Advisory",
|
Headline: "Winter Weather Advisory",
|
||||||
Severity: "Moderate",
|
Severity: "Moderate",
|
||||||
|
Ends: &ends,
|
||||||
|
Expires: &expires,
|
||||||
References: []model.AlertReference{
|
References: []model.AlertReference{
|
||||||
{ID: "urn:ref:1", Sent: &sent},
|
{ID: "urn:ref:1", Sent: &sent},
|
||||||
{Identifier: "ref-two"},
|
{Identifier: "ref-two"},
|
||||||
@@ -145,6 +149,20 @@ func TestMapPostgresEventAlertStructPayload(t *testing.T) {
|
|||||||
if got := firstAlert.Values["reference_count"]; got != 2 {
|
if got := firstAlert.Values["reference_count"]; got != 2 {
|
||||||
t.Fatalf("alerts reference_count = %#v, want 2", got)
|
t.Fatalf("alerts reference_count = %#v, want 2", got)
|
||||||
}
|
}
|
||||||
|
if got := firstAlert.Values["ends"]; got != ends {
|
||||||
|
t.Fatalf("alerts ends = %#v, want %#v", got, ends)
|
||||||
|
}
|
||||||
|
if got := firstAlert.Values["expires"]; got != expires {
|
||||||
|
t.Fatalf("alerts expires = %#v, want %#v", got, expires)
|
||||||
|
}
|
||||||
|
|
||||||
|
alertWrites := writesForTable(writes, tableAlerts)
|
||||||
|
if len(alertWrites) != 2 {
|
||||||
|
t.Fatalf("alert writes len = %d, want 2", len(alertWrites))
|
||||||
|
}
|
||||||
|
if got := alertWrites[1].Values["ends"]; got != nil {
|
||||||
|
t.Fatalf("second alert ends = %#v, want nil", got)
|
||||||
|
}
|
||||||
|
|
||||||
assertAllWritesIncludeAllColumns(t, writes)
|
assertAllWritesIncludeAllColumns(t, writes)
|
||||||
}
|
}
|
||||||
@@ -758,6 +776,16 @@ func firstWriteForTable(writes []fksinks.PostgresWrite, table string) (fksinks.P
|
|||||||
return fksinks.PostgresWrite{}, false
|
return fksinks.PostgresWrite{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writesForTable(writes []fksinks.PostgresWrite, table string) []fksinks.PostgresWrite {
|
||||||
|
out := make([]fksinks.PostgresWrite, 0)
|
||||||
|
for _, w := range writes {
|
||||||
|
if w.Table == table {
|
||||||
|
out = append(out, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func assertAllWritesIncludeAllColumns(t *testing.T, writes []fksinks.PostgresWrite) {
|
func assertAllWritesIncludeAllColumns(t *testing.T, writes []fksinks.PostgresWrite) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
colCounts := tableColumnCounts()
|
colCounts := tableColumnCounts()
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ func PostgresSchema() fksinks.PostgresSchema {
|
|||||||
{Name: "sent", Type: "TIMESTAMPTZ", Nullable: true},
|
{Name: "sent", Type: "TIMESTAMPTZ", Nullable: true},
|
||||||
{Name: "effective", Type: "TIMESTAMPTZ", Nullable: true},
|
{Name: "effective", Type: "TIMESTAMPTZ", Nullable: true},
|
||||||
{Name: "onset", Type: "TIMESTAMPTZ", Nullable: true},
|
{Name: "onset", Type: "TIMESTAMPTZ", Nullable: true},
|
||||||
|
{Name: "ends", Type: "TIMESTAMPTZ", Nullable: true},
|
||||||
{Name: "expires", Type: "TIMESTAMPTZ", Nullable: true},
|
{Name: "expires", Type: "TIMESTAMPTZ", Nullable: true},
|
||||||
{Name: "area_description", Type: "TEXT", Nullable: true},
|
{Name: "area_description", Type: "TEXT", Nullable: true},
|
||||||
{Name: "sender_name", Type: "TEXT", Nullable: true},
|
{Name: "sender_name", Type: "TEXT", Nullable: true},
|
||||||
|
|||||||
@@ -91,6 +91,15 @@ func TestWeatherPostgresSchemaIncludesOutlookTables(t *testing.T) {
|
|||||||
assertTableUniqueIndex(t, tableOutlookDiscussions, "idx_wf_outlook_discussions_run_day", []string{"run_event_id", "day"})
|
assertTableUniqueIndex(t, tableOutlookDiscussions, "idx_wf_outlook_discussions_run_day", []string{"run_event_id", "day"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWeatherPostgresSchemaIncludesAlertEndsColumn(t *testing.T) {
|
||||||
|
alertColumns := columnsForTable(t, tableAlerts)
|
||||||
|
for _, col := range []string{"run_event_id", "alert_index", "as_of", "alert_id", "onset", "ends", "expires"} {
|
||||||
|
if !alertColumns[col] {
|
||||||
|
t.Fatalf("%s missing %s column", tableAlerts, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWeatherPostgresSchemaIncludesWeatherStoryColumns(t *testing.T) {
|
func TestWeatherPostgresSchemaIncludesWeatherStoryColumns(t *testing.T) {
|
||||||
runColumns := columnsForTable(t, tableWeatherStoryRuns)
|
runColumns := columnsForTable(t, tableWeatherStoryRuns)
|
||||||
if !runColumns["as_of"] {
|
if !runColumns["as_of"] {
|
||||||
|
|||||||
@@ -55,9 +55,11 @@ type WeatherAlert struct {
|
|||||||
Instruction string `json:"instruction,omitempty"`
|
Instruction string `json:"instruction,omitempty"`
|
||||||
|
|
||||||
// Timing (all optional; provider-dependent).
|
// Timing (all optional; provider-dependent).
|
||||||
|
// Onset and Ends describe the alert period. Expires is provider expiration metadata.
|
||||||
Sent *time.Time `json:"sent,omitempty"`
|
Sent *time.Time `json:"sent,omitempty"`
|
||||||
Effective *time.Time `json:"effective,omitempty"`
|
Effective *time.Time `json:"effective,omitempty"`
|
||||||
Onset *time.Time `json:"onset,omitempty"`
|
Onset *time.Time `json:"onset,omitempty"`
|
||||||
|
Ends *time.Time `json:"ends,omitempty"`
|
||||||
Expires *time.Time `json:"expires,omitempty"`
|
Expires *time.Time `json:"expires,omitempty"`
|
||||||
|
|
||||||
// Scope / affected area.
|
// Scope / affected area.
|
||||||
|
|||||||
Reference in New Issue
Block a user