diff --git a/.env.example b/.env.example
index f8ad35ef8f..5277962e36 100644
--- a/.env.example
+++ b/.env.example
@@ -67,4 +67,10 @@ GRIB_RETENTION_THRESHOLD=2
WX_OBJECT_STORE_SERVER=wx_object_store_server
WX_OBJECT_STORE_USER_ID=wx_object_store_server
WX_OBJECT_STORE_SECRET=wx_object_store_server
-WX_OBJECT_STORE_BUCKET=wx_object_store_server
\ No newline at end of file
+WX_OBJECT_STORE_BUCKET=wx_object_store_server
+CHES_TOKEN_URL=ches-token-url.com"
+CHES_CLIENT_ID=client
+CHES_CLIENT_SECRET=secret
+CHES_SENDER_EMAIL=ches@example.com
+CHES_EMAIL_MERGE_URL=ches-merge.com
+WEB_BASE_URL=base@url.com
\ No newline at end of file
diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml
index a6be5aba89..d9bb3e9470 100644
--- a/.github/workflows/deployment.yml
+++ b/.github/workflows/deployment.yml
@@ -109,7 +109,7 @@ jobs:
- name: NATS Message Queue
shell: bash
run: |
- MEMORY_REQUEST=250Mi MEMORY_LIMIT=500Mi CPU_REQUEST="250m" bash openshift/scripts/oc_provision_nats.sh ${SUFFIX} apply
+ MEMORY_REQUEST=250Mi MEMORY_LIMIT=500Mi CPU_REQUEST="250m" VANITY_DOMAIN="${SUFFIX}-dev-psu.apps.silver.devops.gov.bc.ca" bash openshift/scripts/oc_provision_nats.sh ${SUFFIX} apply
deploy-dev:
name: Deploy to Dev
diff --git a/backend/packages/wps-api/alembic/versions/3b9310ff54f5_spot_forecast_columns_issued_expired.py b/backend/packages/wps-api/alembic/versions/3b9310ff54f5_spot_forecast_columns_issued_expired.py
new file mode 100644
index 0000000000..9fcc52c59e
--- /dev/null
+++ b/backend/packages/wps-api/alembic/versions/3b9310ff54f5_spot_forecast_columns_issued_expired.py
@@ -0,0 +1,43 @@
+"""spot forecast columns - issued/expired
+
+Revision ID: 3b9310ff54f5
+Revises: 8ad2e0d77c9f
+Create Date: 2026-05-22 10:52:22.798252
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+from wps_shared.db.models.common import TZTimeStamp
+
+# revision identifiers, used by Alembic.
+revision = "3b9310ff54f5"
+down_revision = "8ad2e0d77c9f"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic ###
+ op.add_column("spot_forecast", sa.Column("issued_at", TZTimeStamp(), nullable=False))
+ op.add_column("spot_forecast", sa.Column("expires_at", TZTimeStamp(), nullable=True))
+ op.drop_column("spot_forecast", "for_date")
+ op.drop_column("spot_forecast", "updated_at")
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic ###
+
+ op.add_column(
+ "spot_forecast",
+ sa.Column("updated_at", TZTimeStamp(), autoincrement=False, nullable=True),
+ )
+ op.add_column(
+ "spot_forecast",
+ sa.Column("for_date", TZTimeStamp(), autoincrement=False, nullable=True),
+ )
+ op.drop_column("spot_forecast", "expires_at")
+ op.drop_column("spot_forecast", "issued_at")
+
+ # ### end Alembic commands ###
diff --git a/backend/packages/wps-api/alembic/versions/63b52513243e_smurfi_distribution_groups.py b/backend/packages/wps-api/alembic/versions/63b52513243e_smurfi_distribution_groups.py
new file mode 100644
index 0000000000..7788bbb3be
--- /dev/null
+++ b/backend/packages/wps-api/alembic/versions/63b52513243e_smurfi_distribution_groups.py
@@ -0,0 +1,47 @@
+"""SMURFI distribution groups
+
+Revision ID: 63b52513243e
+Revises: c2a830d3218e
+Create Date: 2026-05-26 00:00:00.000000
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+from wps_shared.db.models.common import TZTimeStamp
+
+# revision identifiers, used by Alembic.
+revision = "63b52513243e"
+down_revision = "c2a830d3218e"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ "smurfi_distribution_group",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("name", sa.String(), nullable=False),
+ sa.Column("emails", sa.ARRAY(sa.String()), nullable=False),
+ sa.Column("owner_idir", sa.String(), nullable=False),
+ sa.Column("created_at", TZTimeStamp(), nullable=False),
+ sa.Column("updated_at", TZTimeStamp(), nullable=False),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("name"),
+ comment="Named distribution groups for spot forecast email notifications.",
+ )
+ op.create_table(
+ "spot_request_distribution_group",
+ sa.Column("spot_request_id", sa.Integer(), nullable=False),
+ sa.Column("distribution_group_id", sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["distribution_group_id"], ["smurfi_distribution_group.id"], ondelete="CASCADE"
+ ),
+ sa.ForeignKeyConstraint(["spot_request_id"], ["spot_request_base.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("spot_request_id", "distribution_group_id"),
+ )
+
+
+def downgrade():
+ op.drop_table("spot_request_distribution_group")
+ op.drop_table("smurfi_distribution_group")
diff --git a/backend/packages/wps-api/alembic/versions/6f1d8d4c2a90_split_smurfi_request_instances.py b/backend/packages/wps-api/alembic/versions/6f1d8d4c2a90_split_smurfi_request_instances.py
new file mode 100644
index 0000000000..c59c888396
--- /dev/null
+++ b/backend/packages/wps-api/alembic/versions/6f1d8d4c2a90_split_smurfi_request_instances.py
@@ -0,0 +1,265 @@
+"""split smurfi request instances
+
+Revision ID: 6f1d8d4c2a90
+Revises: 3b9310ff54f5
+Create Date: 2026-05-26 10:24:00.000000
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+from geoalchemy2 import Geometry
+from wps_shared.db.models.common import TZTimeStamp
+
+# revision identifiers, used by Alembic.
+revision = "6f1d8d4c2a90"
+down_revision = "3b9310ff54f5"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.rename_table("spot_request", "spot_request_base")
+
+ op.create_table(
+ "spot_request_instance",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("spot_request_base_id", sa.Integer(), nullable=False),
+ sa.Column("latitude", sa.Float(), nullable=False),
+ sa.Column("longitude", sa.Float(), nullable=False),
+ sa.Column(
+ "geom",
+ Geometry(
+ geometry_type="POINT",
+ srid=3005,
+ dimension=2,
+ spatial_index=False,
+ from_text="ST_GeomFromEWKT",
+ name="geometry",
+ ),
+ nullable=False,
+ ),
+ sa.Column("geographic_description", sa.String(), nullable=False),
+ sa.Column("aspect", sa.String(), nullable=True),
+ sa.Column("elevation", sa.Integer(), nullable=True),
+ sa.Column("valley", sa.String(), nullable=True),
+ sa.Column("created_at", TZTimeStamp(), nullable=False),
+ sa.Column("updated_at", TZTimeStamp(), nullable=False),
+ sa.ForeignKeyConstraint(["spot_request_base_id"], ["spot_request_base.id"]),
+ sa.PrimaryKeyConstraint("id"),
+ comment="Tracks geographic instances used by spot requests and forecasts.",
+ )
+ op.create_index(
+ op.f("ix_spot_request_instance_spot_request_base_id"),
+ "spot_request_instance",
+ ["spot_request_base_id"],
+ unique=False,
+ )
+
+ op.execute(
+ """
+ INSERT INTO spot_request_instance (
+ spot_request_base_id,
+ latitude,
+ longitude,
+ geom,
+ geographic_description,
+ aspect,
+ elevation,
+ valley,
+ created_at,
+ updated_at
+ )
+ SELECT
+ id,
+ ST_Y(ST_Transform(geom, 4326)),
+ ST_X(ST_Transform(geom, 4326)),
+ geom,
+ geographic_description,
+ aspect,
+ elevation,
+ NULL,
+ created_at,
+ updated_at
+ FROM spot_request_base
+ """
+ )
+
+ op.drop_constraint("spot_forecast_spot_request_id_fkey", "spot_forecast", type_="foreignkey")
+ op.drop_index(op.f("ix_spot_forecast_spot_request_id"), table_name="spot_forecast")
+ op.execute(
+ """
+ ALTER TABLE spot_forecast
+ ALTER COLUMN fire_size TYPE FLOAT[]
+ USING CASE
+ WHEN fire_size IS NULL THEN NULL
+ ELSE ARRAY[fire_size]
+ END
+ """
+ )
+ op.add_column("spot_forecast", sa.Column("spot_request_base_id", sa.Integer(), nullable=True))
+ op.add_column(
+ "spot_forecast", sa.Column("spot_request_instance_id", sa.Integer(), nullable=True)
+ )
+ op.execute(
+ """
+ UPDATE spot_forecast forecast
+ SET
+ spot_request_base_id = forecast.spot_request_id,
+ spot_request_instance_id = instance.id
+ FROM spot_request_instance instance
+ WHERE instance.spot_request_base_id = forecast.spot_request_id
+ """
+ )
+ op.alter_column("spot_forecast", "spot_request_base_id", nullable=False)
+ op.alter_column("spot_forecast", "spot_request_instance_id", nullable=False)
+ op.create_foreign_key(
+ "spot_forecast_spot_request_base_id_fkey",
+ "spot_forecast",
+ "spot_request_base",
+ ["spot_request_base_id"],
+ ["id"],
+ )
+ op.create_foreign_key(
+ "spot_forecast_spot_request_instance_id_fkey",
+ "spot_forecast",
+ "spot_request_instance",
+ ["spot_request_instance_id"],
+ ["id"],
+ )
+ op.create_index(
+ op.f("ix_spot_forecast_spot_request_base_id"),
+ "spot_forecast",
+ ["spot_request_base_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_spot_forecast_spot_request_instance_id"),
+ "spot_forecast",
+ ["spot_request_instance_id"],
+ unique=False,
+ )
+ op.drop_column("spot_forecast", "spot_request_id")
+
+ op.drop_constraint(
+ "spot_subscriber_spot_request_id_fkey", "spot_subscriber", type_="foreignkey"
+ )
+ op.drop_index(op.f("ix_spot_subscriber_spot_request_id"), table_name="spot_subscriber")
+ op.alter_column("spot_subscriber", "spot_request_id", new_column_name="spot_request_base_id")
+ op.create_foreign_key(
+ "spot_subscriber_spot_request_base_id_fkey",
+ "spot_subscriber",
+ "spot_request_base",
+ ["spot_request_base_id"],
+ ["id"],
+ )
+ op.create_index(
+ op.f("ix_spot_subscriber_spot_request_base_id"),
+ "spot_subscriber",
+ ["spot_request_base_id"],
+ unique=False,
+ )
+
+ op.drop_constraint("chk_aspect_spot_request", "spot_request_base", type_="check")
+ op.drop_column("spot_request_base", "geom")
+ op.drop_column("spot_request_base", "geographic_description")
+ op.drop_column("spot_request_base", "elevation")
+ op.drop_column("spot_request_base", "aspect")
+
+
+def downgrade():
+ op.add_column("spot_request_base", sa.Column("aspect", sa.String(), nullable=True))
+ op.add_column("spot_request_base", sa.Column("elevation", sa.Integer(), nullable=True))
+ op.add_column(
+ "spot_request_base",
+ sa.Column("geographic_description", sa.String(), nullable=True),
+ )
+ op.add_column(
+ "spot_request_base",
+ sa.Column(
+ "geom",
+ Geometry(
+ geometry_type="POINT",
+ srid=3005,
+ dimension=2,
+ spatial_index=False,
+ from_text="ST_GeomFromEWKT",
+ name="geometry",
+ ),
+ nullable=True,
+ ),
+ )
+ op.execute(
+ """
+ UPDATE spot_request_base base
+ SET
+ aspect = instance.aspect,
+ elevation = instance.elevation,
+ geographic_description = instance.geographic_description,
+ geom = instance.geom
+ FROM spot_request_instance instance
+ WHERE instance.spot_request_base_id = base.id
+ """
+ )
+ op.alter_column("spot_request_base", "geographic_description", nullable=False)
+ op.alter_column("spot_request_base", "geom", nullable=False)
+
+ op.drop_index(op.f("ix_spot_subscriber_spot_request_base_id"), table_name="spot_subscriber")
+ op.drop_constraint(
+ "spot_subscriber_spot_request_base_id_fkey", "spot_subscriber", type_="foreignkey"
+ )
+ op.alter_column("spot_subscriber", "spot_request_base_id", new_column_name="spot_request_id")
+ op.create_foreign_key(
+ "spot_subscriber_spot_request_id_fkey",
+ "spot_subscriber",
+ "spot_request_base",
+ ["spot_request_id"],
+ ["id"],
+ )
+ op.create_index(
+ op.f("ix_spot_subscriber_spot_request_id"),
+ "spot_subscriber",
+ ["spot_request_id"],
+ unique=False,
+ )
+
+ op.add_column("spot_forecast", sa.Column("spot_request_id", sa.Integer(), nullable=True))
+ op.execute("UPDATE spot_forecast SET spot_request_id = spot_request_base_id")
+ op.alter_column("spot_forecast", "spot_request_id", nullable=False)
+ op.execute(
+ """
+ ALTER TABLE spot_forecast
+ ALTER COLUMN fire_size TYPE FLOAT
+ USING fire_size[1]
+ """
+ )
+ op.drop_index(op.f("ix_spot_forecast_spot_request_instance_id"), table_name="spot_forecast")
+ op.drop_index(op.f("ix_spot_forecast_spot_request_base_id"), table_name="spot_forecast")
+ op.drop_constraint(
+ "spot_forecast_spot_request_instance_id_fkey", "spot_forecast", type_="foreignkey"
+ )
+ op.drop_constraint(
+ "spot_forecast_spot_request_base_id_fkey", "spot_forecast", type_="foreignkey"
+ )
+ op.drop_column("spot_forecast", "spot_request_instance_id")
+ op.drop_column("spot_forecast", "spot_request_base_id")
+ op.create_foreign_key(
+ "spot_forecast_spot_request_id_fkey",
+ "spot_forecast",
+ "spot_request_base",
+ ["spot_request_id"],
+ ["id"],
+ )
+ op.create_index(
+ op.f("ix_spot_forecast_spot_request_id"),
+ "spot_forecast",
+ ["spot_request_id"],
+ unique=False,
+ )
+
+ op.drop_index(
+ op.f("ix_spot_request_instance_spot_request_base_id"),
+ table_name="spot_request_instance",
+ )
+ op.drop_table("spot_request_instance")
+ op.rename_table("spot_request_base", "spot_request")
diff --git a/backend/packages/wps-api/alembic/versions/8ad2e0d77c9f_add_spot_request_additional_information.py b/backend/packages/wps-api/alembic/versions/8ad2e0d77c9f_add_spot_request_additional_information.py
new file mode 100644
index 0000000000..56ec6f0367
--- /dev/null
+++ b/backend/packages/wps-api/alembic/versions/8ad2e0d77c9f_add_spot_request_additional_information.py
@@ -0,0 +1,24 @@
+"""Add spot request additional information
+
+Revision ID: 8ad2e0d77c9f
+Revises: f5bb9e85fd0a
+Create Date: 2026-05-19 00:00:00.000000
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "8ad2e0d77c9f"
+down_revision = "f5bb9e85fd0a"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.add_column("spot_request", sa.Column("additional_information", sa.Text(), nullable=True))
+
+
+def downgrade():
+ op.drop_column("spot_request", "additional_information")
diff --git a/backend/packages/wps-api/alembic/versions/c2a830d3218e_add_spot_forecast_type.py b/backend/packages/wps-api/alembic/versions/c2a830d3218e_add_spot_forecast_type.py
new file mode 100644
index 0000000000..7ba5e4e6d8
--- /dev/null
+++ b/backend/packages/wps-api/alembic/versions/c2a830d3218e_add_spot_forecast_type.py
@@ -0,0 +1,40 @@
+"""add spot forecast type
+
+Revision ID: c2a830d3218e
+Revises: 6f1d8d4c2a90
+Create Date: 2026-05-27 00:00:00.000000
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "c2a830d3218e"
+down_revision = "6f1d8d4c2a90"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.add_column("spot_forecast", sa.Column("forecast_type", sa.String(), nullable=True))
+ op.execute(
+ """
+ UPDATE spot_forecast forecast
+ SET forecast_type = COALESCE(request_base.request_type, 'Full')
+ FROM spot_request_base request_base
+ WHERE request_base.id = forecast.spot_request_base_id
+ """
+ )
+ op.execute("UPDATE spot_forecast SET forecast_type = 'Full' WHERE forecast_type IS NULL")
+ op.alter_column("spot_forecast", "forecast_type", nullable=False)
+ op.create_check_constraint(
+ "chk_forecast_type_spot_forecast",
+ "spot_forecast",
+ "forecast_type IN ('Full', 'Mini')",
+ )
+
+
+def downgrade():
+ op.drop_constraint("chk_forecast_type_spot_forecast", "spot_forecast", type_="check")
+ op.drop_column("spot_forecast", "forecast_type")
diff --git a/backend/packages/wps-api/alembic/versions/f5bb9e85fd0a_smurfi_models.py b/backend/packages/wps-api/alembic/versions/f5bb9e85fd0a_smurfi_models.py
new file mode 100644
index 0000000000..14b2b653f6
--- /dev/null
+++ b/backend/packages/wps-api/alembic/versions/f5bb9e85fd0a_smurfi_models.py
@@ -0,0 +1,214 @@
+"""SMURFI models
+
+Revision ID: f5bb9e85fd0a
+Revises: a4fb7e8a1d2c
+Create Date: 2026-05-19 08:52:27.220394
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+from geoalchemy2 import Geometry
+from sqlalchemy.dialects import postgresql
+from wps_shared.db.models.common import TZTimeStamp
+
+# revision identifiers, used by Alembic.
+revision = "f5bb9e85fd0a"
+down_revision = "a4fb7e8a1d2c"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ "spot_request",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("request_reference", sa.String(), nullable=False),
+ sa.Column("fire_number", sa.ARRAY(sa.String()), nullable=True),
+ sa.Column("fire_centre", sa.Integer(), nullable=False),
+ sa.Column("status", sa.String(), nullable=False),
+ sa.Column("requestor_name", sa.String(), nullable=False),
+ sa.Column("requestor_idir", sa.String(), nullable=False),
+ sa.Column("requestor_email", sa.String(), nullable=False),
+ sa.Column(
+ "request_frequency",
+ sa.ARRAY(
+ sa.Enum(
+ "Monday",
+ "Tuesday",
+ "Wednesday",
+ "Thursday",
+ "Friday",
+ "Saturday",
+ "Sunday",
+ name="frequencydayenum",
+ )
+ ),
+ nullable=True,
+ ),
+ sa.Column("request_type", sa.String(), nullable=False),
+ sa.Column("aspect", sa.String(), nullable=True),
+ sa.Column("elevation", sa.Integer(), nullable=True),
+ sa.Column("geographic_description", sa.String(), nullable=False),
+ sa.Column(
+ "geom",
+ Geometry(
+ geometry_type="POINT",
+ srid=3005,
+ dimension=2,
+ spatial_index=False,
+ from_text="ST_GeomFromEWKT",
+ name="geometry",
+ nullable=False,
+ ),
+ nullable=False,
+ ),
+ sa.Column("requested_at", TZTimeStamp(), nullable=False),
+ sa.Column("start_at", TZTimeStamp(), nullable=False),
+ sa.Column("end_at", TZTimeStamp(), nullable=False),
+ sa.Column("created_at", TZTimeStamp(), nullable=False),
+ sa.Column("updated_at", TZTimeStamp(), nullable=False),
+ sa.CheckConstraint(
+ "aspect IN ('North', 'Northwest', 'West', 'Southwest', 'South', 'Southeast', 'East', 'Northeast')",
+ name="chk_aspect_spot_request",
+ ),
+ sa.CheckConstraint(
+ "request_type IN ('Full', 'Mini')", name="chk_request_type_spot_request"
+ ),
+ sa.CheckConstraint(
+ "status IN ('Requested', 'Started', 'Suspended', 'Complete', 'Archived')",
+ name="chk_status_spot_request",
+ ),
+ sa.ForeignKeyConstraint(
+ ["fire_centre"],
+ ["fire_centres.id"],
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ comment="Tracks requests for spot weather forecasts.",
+ )
+ op.create_index(op.f("ix_spot_request_end_at"), "spot_request", ["end_at"], unique=False)
+ op.create_index(op.f("ix_spot_request_start_at"), "spot_request", ["start_at"], unique=False)
+ op.create_index(op.f("ix_spot_request_status"), "spot_request", ["status"], unique=False)
+ op.create_table(
+ "spot_forecast",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("spot_request_id", sa.Integer(), nullable=False),
+ sa.Column("forecaster_name", sa.String(), nullable=False),
+ sa.Column("forecaster_email", sa.String(), nullable=False),
+ sa.Column("forecaster_phone", sa.String(), nullable=True),
+ sa.Column("synopsis", sa.Text(), nullable=True),
+ sa.Column("inversion_and_venting", sa.Text(), nullable=True),
+ sa.Column("outlook", sa.Text(), nullable=True),
+ sa.Column("confidence", sa.Text(), nullable=True),
+ sa.Column("fire_size", sa.Float(), nullable=True),
+ sa.Column("representative_station_codes", sa.ARRAY(sa.Integer()), nullable=True),
+ sa.Column("created_at", TZTimeStamp(), nullable=False),
+ sa.Column("updated_at", TZTimeStamp(), nullable=True),
+ sa.Column("for_date", TZTimeStamp(), nullable=True),
+ sa.ForeignKeyConstraint(
+ ["spot_request_id"],
+ ["spot_request.id"],
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ comment="Spot forecasts for spot requests.",
+ )
+ op.create_index(
+ op.f("ix_spot_forecast_forecaster_name"), "spot_forecast", ["forecaster_name"], unique=False
+ )
+ op.create_index(
+ op.f("ix_spot_forecast_spot_request_id"), "spot_forecast", ["spot_request_id"], unique=False
+ )
+ op.create_table(
+ "spot_subscriber",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("spot_request_id", sa.Integer(), nullable=False),
+ sa.Column("email", sa.String(), nullable=False),
+ sa.Column("subscriber_status", sa.String(), nullable=False),
+ sa.Column("created_at", TZTimeStamp(), nullable=False),
+ sa.Column("updated_at", TZTimeStamp(), nullable=False),
+ sa.CheckConstraint(
+ "subscriber_status IN ('active', 'inactive')",
+ name="chk_subscriber_status_spot_subscriber",
+ ),
+ sa.ForeignKeyConstraint(
+ ["spot_request_id"],
+ ["spot_request.id"],
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ comment="Tracks email addresses subscribed to spot forecasts for a spot requests.",
+ )
+ op.create_index(op.f("ix_spot_subscriber_email"), "spot_subscriber", ["email"], unique=False)
+ op.create_index(
+ op.f("ix_spot_subscriber_spot_request_id"),
+ "spot_subscriber",
+ ["spot_request_id"],
+ unique=False,
+ )
+ op.create_table(
+ "spot_descriptive_weather",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("spot_forecast_id", sa.Integer(), nullable=False),
+ sa.Column("period", sa.String(), nullable=False),
+ sa.Column("temperature", sa.Float(), nullable=True),
+ sa.Column("relative_humidity", sa.Float(), nullable=True),
+ sa.Column("conditions", sa.String(), nullable=True),
+ sa.CheckConstraint(
+ "period IN ('Today', 'Tonight', 'Tomorrow')", name="chk_period_spot_descriptive_weather"
+ ),
+ sa.ForeignKeyConstraint(
+ ["spot_forecast_id"],
+ ["spot_forecast.id"],
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ comment="Represents a general text based forecast which includes a description of conditions, temperature and humidity. ",
+ )
+ op.create_index(
+ op.f("ix_spot_descriptive_weather_spot_forecast_id"),
+ "spot_descriptive_weather",
+ ["spot_forecast_id"],
+ unique=False,
+ )
+ op.create_table(
+ "spot_tabular_weather",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("spot_forecast_id", sa.Integer(), nullable=False),
+ sa.Column("forecast_time", TZTimeStamp(), nullable=False),
+ sa.Column("temperature", sa.Float(), nullable=True),
+ sa.Column("relative_humidity", sa.Float(), nullable=True),
+ sa.Column("wind", sa.String(), nullable=True),
+ sa.Column("probability_of_precipitation", sa.Float(), nullable=True),
+ sa.Column("precipitation_amount", sa.Float(), nullable=True),
+ sa.ForeignKeyConstraint(
+ ["spot_forecast_id"],
+ ["spot_forecast.id"],
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ comment="Detailed numerical forecasts for weather variable in a spot forecast.",
+ )
+ op.create_index(
+ op.f("ix_spot_tabular_weather_spot_forecast_id"),
+ "spot_tabular_weather",
+ ["spot_forecast_id"],
+ unique=False,
+ )
+
+
+def downgrade():
+ op.drop_index(
+ op.f("ix_spot_tabular_weather_spot_forecast_id"), table_name="spot_tabular_weather"
+ )
+ op.drop_table("spot_tabular_weather")
+ op.drop_index(
+ op.f("ix_spot_descriptive_weather_spot_forecast_id"), table_name="spot_descriptive_weather"
+ )
+ op.drop_table("spot_descriptive_weather")
+ op.drop_index(op.f("ix_spot_subscriber_spot_request_id"), table_name="spot_subscriber")
+ op.drop_index(op.f("ix_spot_subscriber_email"), table_name="spot_subscriber")
+ op.drop_table("spot_subscriber")
+ op.drop_index(op.f("ix_spot_forecast_spot_request_id"), table_name="spot_forecast")
+ op.drop_index(op.f("ix_spot_forecast_forecaster_name"), table_name="spot_forecast")
+ op.drop_table("spot_forecast")
+ op.drop_index(op.f("ix_spot_request_status"), table_name="spot_request")
+ op.drop_index(op.f("ix_spot_request_start_at"), table_name="spot_request")
+ op.drop_index(op.f("ix_spot_request_end_at"), table_name="spot_request")
+ op.drop_table("spot_request")
diff --git a/backend/packages/wps-api/src/app/main.py b/backend/packages/wps-api/src/app/main.py
index 6b14d6b268..8d2679bcb7 100644
--- a/backend/packages/wps-api/src/app/main.py
+++ b/backend/packages/wps-api/src/app/main.py
@@ -32,6 +32,7 @@
object_store_proxy,
psu,
sfms,
+ smurfi,
snow,
stations,
weather_models,
@@ -121,7 +122,7 @@ async def catch_exception_middleware(request: Request, call_next):
CORSMiddleware,
allow_origins=ORIGINS,
allow_credentials=True,
- allow_methods=["GET", "HEAD", "POST", "PATCH", "DELETE"],
+ allow_methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["*"],
)
api.middleware("http")(catch_exception_middleware)
@@ -138,6 +139,7 @@ async def catch_exception_middleware(request: Request, call_next):
api.include_router(snow.router, tags=["SFMS Insights"])
api.include_router(fire_watch.router, tags=["Fire Watch"])
api.include_router(psu.router, tags=["PSU"])
+api.include_router(smurfi.router, tags=["SMURFI"])
api.include_router(object_store_proxy.router, tags=["Object Store Proxy"])
api.include_router(object_store_proxy.wx_router, tags=["Object Store Proxy"])
api.include_router(fcm.router, tags=["Firebase Cloud Messaging"])
diff --git a/backend/packages/wps-api/src/app/routers/smurfi.py b/backend/packages/wps-api/src/app/routers/smurfi.py
new file mode 100644
index 0000000000..9798b3584f
--- /dev/null
+++ b/backend/packages/wps-api/src/app/routers/smurfi.py
@@ -0,0 +1,724 @@
+import logging
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from wps_shared.auth import (
+ auth_with_forecaster_role_or_spot_owner_required,
+ authentication_required,
+)
+from wps_shared.db.crud.smurfi import (
+ COORDINATE_MATCH_TOLERANCE,
+ create_distribution_group,
+ create_spot_descriptive_weather,
+ create_spot_forecast,
+ create_spot_request,
+ create_spot_request_instance,
+ create_spot_tabular_weather,
+ delete_distribution_group,
+ get_distribution_groups,
+ get_or_create_spot_request_instance,
+ get_spot_forecasts_for_request,
+ get_spot_request_by_id,
+ get_spot_requests_for_year,
+ get_subscribed_spot_request_ids,
+ start_requested_spot_request,
+ subscribe_to_spot_request,
+ sync_spot_request_distribution_groups,
+ sync_spot_subscribers,
+ unsubscribe_from_spot_request,
+ update_distribution_group,
+ update_spot_request_details,
+ update_spot_request_instance_details,
+ update_spot_request_status,
+ update_spot_subscriber_status,
+)
+from wps_shared.db.database import get_async_read_session_scope, get_async_write_session_scope
+from wps_shared.db.models.smurfi import (
+ SmurfiDistributionGroup,
+ SpotDescriptiveWeather,
+ SpotForecast,
+ SpotRequestBase,
+ SpotRequestInstance,
+ SpotRequestStatusEnum,
+ SpotSubscriberStatusEnum,
+ SpotTabularWeather,
+)
+from wps_shared.geospatial.geospatial import (
+ NAD83_BC_ALBERS,
+ PointTransformer,
+ SpatialReferenceSystem,
+)
+from wps_shared.schemas.smurfi import (
+ DistributionGroupInput,
+ DistributionGroupOutput,
+ SpotDescriptiveWeatherData,
+ SpotForecastData,
+ SpotForecastInput,
+ SpotForecastListResponse,
+ SpotForecastResponse,
+ SpotLatestForecastData,
+ SpotRequestData,
+ SpotRequestEditInput,
+ SpotRequestInput,
+ SpotRequestInstanceData,
+ SpotRequestInstanceInput,
+ SpotRequestListResponse,
+ SpotRequestResponse,
+ SpotRequestStatusUpdate,
+ SpotSubscriberData,
+ SpotTabularWeatherData,
+ SpotUpdatePayload,
+ SubscribeResponse,
+ SubscriptionsResponse,
+ UpdateSubscriberStatusData,
+)
+from wps_shared.utils.time import get_utc_now
+
+from app.nats_publish import publish
+from app.smurfi.nats_config import smurfi_spot_update_subject, stream_name, subjects
+
+logger = logging.getLogger(__name__)
+
+
+router = APIRouter(prefix="/smurfi", dependencies=[Depends(authentication_required)])
+
+MISSING_TOKEN_MESSAGE = "Token missing email claim"
+
+
+@dataclass(frozen=True)
+class SpotRequestor:
+ name: str
+ idir: str
+ email: str
+
+
+def _get_spot_user(token: dict) -> SpotRequestor:
+ requestor_idir = token.get("idir_username", None)
+ requestor_email = token.get("email", None)
+ first_name = token.get("given_name", None)
+ last_name = token.get("family_name", None)
+
+ # if either first or last name is missing, use the idir as the name
+ requestor_name = " ".join(name for name in [first_name, last_name] if name) or requestor_idir
+
+ return SpotRequestor(name=requestor_name, idir=requestor_idir, email=requestor_email)
+
+
+def _get_spot_request_subscriber_emails(
+ spot_request_input: SpotRequestInput | SpotRequestEditInput, required_emails: list[str]
+) -> list[str]:
+ seen = set()
+ unique_emails = []
+
+ for email in [s.email for s in spot_request_input.subscribers] + required_emails:
+ if not email:
+ continue
+
+ email = email.strip()
+ normalized_email = email.lower()
+ if normalized_email not in seen:
+ unique_emails.append(email)
+ seen.add(normalized_email)
+
+ return unique_emails
+
+
+def _get_bc_albers_point(latitude: float, longitude: float) -> str:
+ x, y = PointTransformer(
+ SpatialReferenceSystem.WGS84.code, NAD83_BC_ALBERS
+ ).transform_coordinate(latitude, longitude)
+ return f"POINT({x} {y})"
+
+
+def _build_spot_request_base(
+ spot_request_input: SpotRequestInput, requestor: SpotRequestor
+) -> SpotRequestBase:
+ now = get_utc_now()
+ return SpotRequestBase(
+ **spot_request_input.model_dump(
+ exclude={"id", "initial_instance", "subscribers", "distribution_group_ids"}
+ ),
+ requestor_name=requestor.name,
+ requestor_idir=requestor.idir,
+ requestor_email=requestor.email,
+ created_at=now,
+ updated_at=now,
+ )
+
+
+def _build_spot_request_update(
+ spot_request_base_id: int, spot_request_input: SpotRequestEditInput
+) -> SpotRequestBase:
+ return SpotRequestBase(
+ id=spot_request_base_id,
+ **spot_request_input.model_dump(
+ exclude={"request_instance", "subscribers", "distribution_group_ids"}
+ ),
+ )
+
+
+def _build_spot_request_instance(
+ spot_request_base_id: int, spot_request_instance_input: SpotRequestInstanceInput
+) -> SpotRequestInstance:
+ return SpotRequestInstance(
+ **spot_request_instance_input.model_dump(),
+ spot_request_base_id=spot_request_base_id,
+ geom=_get_bc_albers_point(
+ spot_request_instance_input.latitude, spot_request_instance_input.longitude
+ ),
+ )
+
+
+def _spot_request_instance_to_schema(
+ spot_request_instance: SpotRequestInstance,
+) -> SpotRequestInstanceData:
+ return SpotRequestInstanceData(
+ id=spot_request_instance.id,
+ geographic_description=spot_request_instance.geographic_description,
+ aspect=spot_request_instance.aspect,
+ elevation=spot_request_instance.elevation,
+ valley=spot_request_instance.valley,
+ latitude=spot_request_instance.latitude,
+ longitude=spot_request_instance.longitude,
+ created_at=spot_request_instance.created_at,
+ )
+
+
+def _get_initial_instance(spot_request: SpotRequestBase) -> SpotRequestInstance:
+ return min(spot_request.spot_request_instances, key=lambda instance: instance.created_at)
+
+
+def _get_request_instance(spot_request: SpotRequestBase) -> SpotRequestInstance:
+ return _get_initial_instance(spot_request)
+
+
+def _get_latest_forecast(spot_request: SpotRequestBase) -> SpotForecast | None:
+ return max(
+ spot_request.spot_forecasts,
+ key=lambda forecast: forecast.created_at,
+ default=None,
+ )
+
+
+def _coordinate_has_changed(existing: float, updated: float) -> bool:
+ return abs(existing - updated) > COORDINATE_MATCH_TOLERANCE
+
+
+def _spot_request_instance_has_changed(
+ existing: SpotRequestInstance, updated: SpotRequestInstanceInput
+) -> bool:
+ return (
+ existing.geographic_description != updated.geographic_description
+ or existing.aspect != updated.aspect
+ or existing.elevation != updated.elevation
+ or existing.valley != updated.valley
+ or _coordinate_has_changed(existing.latitude, updated.latitude)
+ or _coordinate_has_changed(existing.longitude, updated.longitude)
+ )
+
+
+def _latest_forecast_to_schema(spot_request_base: SpotRequestBase) -> SpotLatestForecastData | None:
+ latest_forecast = _get_latest_forecast(spot_request_base)
+ if latest_forecast is None:
+ return None
+
+ forecast_end_at = max(
+ (weather.forecast_time for weather in latest_forecast.tabular_weather),
+ default=None,
+ )
+ return SpotLatestForecastData(
+ id=latest_forecast.id,
+ created_at=latest_forecast.created_at,
+ issued_at=latest_forecast.issued_at,
+ expires_at=latest_forecast.expires_at,
+ forecast_end_at=forecast_end_at,
+ forecaster_name=latest_forecast.forecaster_name,
+ )
+
+
+@router.post("/spot_request", response_model=SpotRequestResponse)
+async def create_spot_request_endpoint(
+ spot_request_input: SpotRequestInput, token: Annotated[dict, Depends(authentication_required)]
+):
+ if spot_request_input.id is not None:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Use PATCH /spot_requests/{spot_request_id} to edit a spot request",
+ )
+
+ requestor = _get_spot_user(token)
+ spot_request_base = _build_spot_request_base(spot_request_input, requestor)
+
+ async with get_async_write_session_scope() as session:
+ logger.info("Creating a new SpotRequestBase.")
+ result = await create_spot_request(session, spot_request_base)
+ # creation always creates the request's editable location instance
+ await create_spot_request_instance(
+ session, _build_spot_request_instance(result.id, spot_request_input.initial_instance)
+ )
+
+ logger.info("Syncing subscribers for SpotRequestBase id: %s", result.id)
+ await sync_spot_subscribers(
+ session,
+ result.id,
+ _get_spot_request_subscriber_emails(spot_request_input, [requestor.email]),
+ )
+ await sync_spot_request_distribution_groups(
+ session, result.id, spot_request_input.distribution_group_ids or []
+ )
+ saved_spot_request = await get_spot_request_by_id(session, result.id)
+ if saved_spot_request is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"SpotRequestBase {result.id} not found",
+ )
+ spot_request = _spot_request_to_schema(saved_spot_request)
+
+ return SpotRequestResponse(spot_request=spot_request)
+
+
+@router.patch("/spot_requests/{spot_request_id}", response_model=SpotRequestResponse)
+async def update_spot_request_endpoint(
+ spot_request_id: int,
+ spot_request_input: SpotRequestEditInput,
+ token: Annotated[dict, Depends(authentication_required)],
+):
+ async with get_async_write_session_scope() as session:
+ existing_spot_request = await get_spot_request_by_id(session, spot_request_id)
+ if existing_spot_request is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"SpotRequestBase {spot_request_id} not found",
+ )
+
+ result = await update_spot_request_details(
+ session, _build_spot_request_update(spot_request_id, spot_request_input)
+ )
+ if result is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"SpotRequestBase {spot_request_id} not found",
+ )
+
+ request_instance = _get_request_instance(existing_spot_request)
+ # edits update the original request location; forecast locations remain immutable
+ if _spot_request_instance_has_changed(
+ request_instance, spot_request_input.request_instance
+ ):
+ await update_spot_request_instance_details(
+ session,
+ request_instance,
+ _build_spot_request_instance(spot_request_id, spot_request_input.request_instance),
+ )
+
+ logger.info("Syncing subscribers for SpotRequestBase id: %s", result.id)
+ await sync_spot_subscribers(
+ session,
+ result.id,
+ _get_spot_request_subscriber_emails(
+ spot_request_input, [existing_spot_request.requestor_email]
+ ),
+ )
+ await sync_spot_request_distribution_groups(
+ session, result.id, spot_request_input.distribution_group_ids or []
+ )
+ saved_spot_request = await get_spot_request_by_id(session, result.id)
+ if saved_spot_request is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"SpotRequestBase {spot_request_id} not found",
+ )
+ spot_request = _spot_request_to_schema(saved_spot_request)
+
+ return SpotRequestResponse(spot_request=spot_request)
+
+
+async def _create_descriptive_weather(
+ session, spot_forecast_id: int, data: SpotForecastInput
+) -> list[SpotDescriptiveWeatherData]:
+ records = []
+ for dw in data.descriptive_weather:
+ record = SpotDescriptiveWeather(
+ spot_forecast_id=spot_forecast_id,
+ period=dw.period,
+ temperature=dw.temperature,
+ relative_humidity=dw.relative_humidity,
+ conditions=dw.conditions,
+ )
+ logger.info(
+ "Creating a new SpotDescriptiveWeather for SpotForecast with id: %s.",
+ spot_forecast_id,
+ )
+ saved = await create_spot_descriptive_weather(session, record)
+ records.append(
+ SpotDescriptiveWeatherData(
+ id=saved.id,
+ period=saved.period,
+ temperature=saved.temperature,
+ relative_humidity=saved.relative_humidity,
+ conditions=saved.conditions,
+ )
+ )
+ return records
+
+
+async def _create_tabular_weather(
+ session, spot_forecast_id: int, data: SpotForecastInput
+) -> list[SpotTabularWeatherData]:
+ records = []
+ for tw in data.tabular_weather:
+ record = SpotTabularWeather(
+ spot_forecast_id=spot_forecast_id,
+ forecast_time=tw.forecast_time,
+ temperature=tw.temperature,
+ relative_humidity=tw.relative_humidity,
+ wind=tw.wind,
+ probability_of_precipitation=tw.probability_of_precipitation,
+ precipitation_amount=tw.precipitation_amount,
+ )
+ logger.info(
+ "Creating a new SpotTabularWeather for SpotForecast with id: %s.", spot_forecast_id
+ )
+ saved = await create_spot_tabular_weather(session, record)
+ records.append(
+ SpotTabularWeatherData(
+ id=saved.id,
+ forecast_time=saved.forecast_time,
+ temperature=saved.temperature,
+ relative_humidity=saved.relative_humidity,
+ wind=saved.wind,
+ probability_of_precipitation=saved.probability_of_precipitation,
+ precipitation_amount=saved.precipitation_amount,
+ )
+ )
+ return records
+
+
+@router.post("/spot_forecast", response_model=SpotForecastResponse)
+async def create_spot_forecast_endpoint(
+ spot_forecast_input: SpotForecastInput, token: Annotated[dict, Depends(authentication_required)]
+):
+ now = get_utc_now()
+ forecaster = _get_spot_user(token)
+ if not forecaster.email:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=MISSING_TOKEN_MESSAGE)
+ async with get_async_write_session_scope() as session:
+ # forecasts can reuse an identical location instance; request edits intentionally do not
+ spot_request_instance = await get_or_create_spot_request_instance(
+ session,
+ _build_spot_request_instance(
+ spot_forecast_input.spot_request_base_id, spot_forecast_input.spot_request_instance
+ ),
+ )
+ spot_forecast = SpotForecast(
+ spot_request_base_id=spot_forecast_input.spot_request_base_id,
+ spot_request_instance_id=spot_request_instance.id,
+ forecaster_name=forecaster.name,
+ forecaster_email=forecaster.email,
+ forecaster_phone=spot_forecast_input.forecaster_phone,
+ synopsis=spot_forecast_input.synopsis,
+ inversion_and_venting=spot_forecast_input.inversion_and_venting,
+ outlook=spot_forecast_input.outlook,
+ confidence=spot_forecast_input.confidence,
+ forecast_type=spot_forecast_input.forecast_type,
+ fire_size=spot_forecast_input.fire_size,
+ representative_station_codes=spot_forecast_input.representative_station_codes,
+ issued_at=spot_forecast_input.issued_at,
+ expires_at=spot_forecast_input.expires_at,
+ created_at=now,
+ )
+ logger.info("Creating a new SpotForecast.")
+ result = await create_spot_forecast(session, spot_forecast)
+
+ spot_forecast_id = result.id
+ descriptive_weather = await _create_descriptive_weather(
+ session, spot_forecast_id, spot_forecast_input
+ )
+ tabular_weather = await _create_tabular_weather(
+ session, spot_forecast_id, spot_forecast_input
+ )
+ await start_requested_spot_request(session, spot_forecast_input.spot_request_base_id)
+ spot_request_instance_id = spot_request_instance.id
+ spot_request_instance_data = _spot_request_instance_to_schema(spot_request_instance)
+
+ payload = SpotUpdatePayload(
+ spot_request_id=spot_forecast_input.spot_request_base_id, spot_forecast_id=spot_forecast_id
+ )
+ await publish(
+ stream=stream_name, subject=smurfi_spot_update_subject, payload=payload, subjects=subjects
+ )
+ return SpotForecastResponse(
+ spot_forecast=SpotForecastData(
+ **spot_forecast_input.model_dump(
+ exclude={
+ "descriptive_weather",
+ "tabular_weather",
+ "spot_request_instance",
+ "forecaster_phone",
+ }
+ ),
+ id=spot_forecast_id,
+ spot_request_instance_id=spot_request_instance_id,
+ spot_request_instance=spot_request_instance_data,
+ created_at=now,
+ forecaster_name=forecaster.name,
+ forecaster_email=forecaster.email,
+ forecaster_phone=spot_forecast_input.forecaster_phone,
+ descriptive_weather=descriptive_weather,
+ tabular_weather=tabular_weather,
+ )
+ )
+
+
+def _spot_forecast_to_schema(spot_forecast: SpotForecast) -> SpotForecastData:
+ period_order = {"Today": 0, "Tonight": 1, "Tomorrow": 2}
+ descriptive_weather = sorted(
+ spot_forecast.descriptive_weather, key=lambda item: period_order.get(item.period, 99)
+ )
+ tabular_weather = sorted(spot_forecast.tabular_weather, key=lambda item: item.forecast_time)
+
+ return SpotForecastData(
+ id=spot_forecast.id,
+ spot_request_base_id=spot_forecast.spot_request_base_id,
+ spot_request_instance_id=spot_forecast.spot_request_instance_id,
+ spot_request_instance=_spot_request_instance_to_schema(spot_forecast.spot_request_instance),
+ forecaster_name=spot_forecast.forecaster_name,
+ forecaster_email=spot_forecast.forecaster_email,
+ forecaster_phone=spot_forecast.forecaster_phone,
+ synopsis=spot_forecast.synopsis,
+ inversion_and_venting=spot_forecast.inversion_and_venting,
+ outlook=spot_forecast.outlook,
+ confidence=spot_forecast.confidence,
+ forecast_type=spot_forecast.forecast_type,
+ fire_size=spot_forecast.fire_size,
+ representative_station_codes=spot_forecast.representative_station_codes,
+ created_at=spot_forecast.created_at,
+ issued_at=spot_forecast.issued_at,
+ expires_at=spot_forecast.expires_at,
+ descriptive_weather=[
+ SpotDescriptiveWeatherData(
+ id=item.id,
+ period=item.period,
+ temperature=item.temperature,
+ relative_humidity=item.relative_humidity,
+ conditions=item.conditions,
+ )
+ for item in descriptive_weather
+ ],
+ tabular_weather=[
+ SpotTabularWeatherData(
+ id=item.id,
+ forecast_time=item.forecast_time,
+ temperature=item.temperature,
+ relative_humidity=item.relative_humidity,
+ wind=item.wind,
+ probability_of_precipitation=item.probability_of_precipitation,
+ precipitation_amount=item.precipitation_amount,
+ )
+ for item in tabular_weather
+ ],
+ )
+
+
+@router.patch("/spot_requests/{spot_request_id}/status", response_model=SpotRequestResponse)
+async def update_spot_request_status_endpoint(
+ spot_request_id: int,
+ data: SpotRequestStatusUpdate,
+ _: Annotated[dict, Depends(auth_with_forecaster_role_or_spot_owner_required)],
+):
+ try:
+ next_status = SpotRequestStatusEnum(data.status)
+ except ValueError:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid status: {data.status}"
+ )
+
+ # once work has started, requests should not move back to the initial Requested state
+ if next_status == SpotRequestStatusEnum.REQUESTED:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Spot request status cannot be changed back to Requested",
+ )
+
+ async with get_async_write_session_scope() as session:
+ spot_request = await get_spot_request_by_id(session, spot_request_id)
+ if spot_request is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"SpotRequestBase {spot_request_id} not found",
+ )
+
+ updated_spot_request = await update_spot_request_status(session, spot_request, next_status)
+ spot_request_data = _spot_request_to_schema(updated_spot_request)
+
+ return SpotRequestResponse(spot_request=spot_request_data)
+
+
+@router.get(
+ "/spot_requests/{spot_request_id}/spot_forecasts",
+ response_model=SpotForecastListResponse,
+)
+async def get_spot_forecasts(spot_request_id: int):
+ async with get_async_read_session_scope() as session:
+ spot_forecasts = await get_spot_forecasts_for_request(session, spot_request_id)
+ return SpotForecastListResponse(
+ spot_forecasts=[_spot_forecast_to_schema(forecast) for forecast in spot_forecasts]
+ )
+
+
+def _spot_request_to_schema(spot_request: SpotRequestBase) -> SpotRequestData:
+ request_instance = _get_request_instance(spot_request)
+ return SpotRequestData(
+ id=spot_request.id,
+ request_reference=spot_request.request_reference,
+ fire_number=spot_request.fire_number,
+ fire_centre=spot_request.fire_centre,
+ status=spot_request.status,
+ requestor_name=spot_request.requestor_name,
+ requestor_idir=spot_request.requestor_idir,
+ requestor_email=spot_request.requestor_email,
+ request_frequency=spot_request.request_frequency,
+ request_type=spot_request.request_type,
+ additional_information=spot_request.additional_information,
+ request_instance=_spot_request_instance_to_schema(request_instance),
+ requested_at=spot_request.requested_at,
+ start_at=spot_request.start_at,
+ end_at=spot_request.end_at,
+ latest_forecast=_latest_forecast_to_schema(spot_request),
+ subscribers=[
+ SpotSubscriberData(id=s.id, email=s.email, subscriber_status=s.subscriber_status)
+ for s in spot_request.spot_subscribers
+ if s.subscriber_status == SpotSubscriberStatusEnum.ACTIVE.value
+ ],
+ distribution_groups=[
+ DistributionGroupOutput.to_schema(g) for g in spot_request.distribution_groups
+ ],
+ distribution_group_ids=[g.id for g in spot_request.distribution_groups],
+ )
+
+
+@router.post("/update_subscriber", status_code=status.HTTP_204_NO_CONTENT)
+async def update_subscriber(data: UpdateSubscriberStatusData):
+ try:
+ status_enum = SpotSubscriberStatusEnum(data.status)
+ except ValueError:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid status: {data.status}"
+ )
+ async with get_async_write_session_scope() as session:
+ result = await update_spot_subscriber_status(session, data.subscriber_id, status_enum)
+ if result is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Subscriber {data.subscriber_id} not found",
+ )
+
+
+@router.get("/distribution_groups", response_model=list[DistributionGroupOutput])
+async def get_distribution_groups_endpoint(
+ token: Annotated[dict, Depends(authentication_required)],
+):
+ user = _get_spot_user(token)
+ async with get_async_read_session_scope() as session:
+ groups = await get_distribution_groups(session, user.idir)
+ return [DistributionGroupOutput.to_schema(g) for g in groups]
+
+
+@router.post(
+ "/distribution_groups",
+ response_model=DistributionGroupOutput,
+ status_code=status.HTTP_201_CREATED,
+)
+async def create_distribution_group_endpoint(
+ data: DistributionGroupInput, token: Annotated[dict, Depends(authentication_required)]
+):
+ user = _get_spot_user(token)
+ group = SmurfiDistributionGroup(name=data.name, emails=data.emails, owner_idir=user.idir)
+ async with get_async_write_session_scope() as session:
+ result = await create_distribution_group(session, group)
+ return DistributionGroupOutput.to_schema(result)
+
+
+@router.put("/distribution_groups/{group_id}", response_model=DistributionGroupOutput)
+async def update_distribution_group_endpoint(
+ group_id: int,
+ data: DistributionGroupInput,
+ token: Annotated[dict, Depends(authentication_required)],
+):
+ user = _get_spot_user(token)
+ async with get_async_write_session_scope() as session:
+ result = await update_distribution_group(
+ session, group_id, data.name, data.emails, user.idir
+ )
+ if result is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Distribution group {group_id} not found",
+ )
+ return DistributionGroupOutput.to_schema(result)
+
+
+@router.delete("/distribution_groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_distribution_group_endpoint(
+ group_id: int, token: Annotated[dict, Depends(authentication_required)]
+):
+ user = _get_spot_user(token)
+ async with get_async_write_session_scope() as session:
+ found = await delete_distribution_group(session, group_id, user.idir)
+ if not found:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Distribution group {group_id} not found",
+ )
+
+
+@router.get("/spot_requests", response_model=SpotRequestListResponse)
+async def get_spot_requests():
+ now = datetime.now()
+ logger.info("Getting SpotRequests for year: %s", now.year)
+ async with get_async_read_session_scope() as session:
+ spot_requests = await get_spot_requests_for_year(session, now.year)
+ return SpotRequestListResponse(
+ spot_requests=[_spot_request_to_schema(sr) for sr in spot_requests]
+ )
+
+
+@router.post("/spots/{spot_request_id}/subscribe", response_model=SubscribeResponse)
+async def subscribe_to_spot(
+ spot_request_id: int, token: Annotated[dict, Depends(authentication_required)]
+):
+ email = token.get("email", None)
+ if not email:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=MISSING_TOKEN_MESSAGE)
+ async with get_async_write_session_scope() as session:
+ subscriber = await subscribe_to_spot_request(session, spot_request_id, email)
+ subscriber_status = subscriber.subscriber_status
+ return SubscribeResponse(subscriber_status=subscriber_status)
+
+
+@router.delete("/spots/{spot_request_id}/subscribe", status_code=status.HTTP_204_NO_CONTENT)
+async def unsubscribe_from_spot(
+ spot_request_id: int, token: Annotated[dict, Depends(authentication_required)]
+):
+ email = token.get("email", None)
+ if not email:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=MISSING_TOKEN_MESSAGE)
+ async with get_async_write_session_scope() as session:
+ result = await unsubscribe_from_spot_request(session, spot_request_id, email)
+ if result is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Subscription for spot request {spot_request_id} not found",
+ )
+
+
+@router.get("/subscriptions", response_model=SubscriptionsResponse)
+async def get_subscriptions(token: Annotated[dict, Depends(authentication_required)]):
+ email = token.get("email", None)
+ if not email:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=MISSING_TOKEN_MESSAGE)
+ async with get_async_read_session_scope() as session:
+ ids = await get_subscribed_spot_request_ids(session, email)
+ return SubscriptionsResponse(spot_request_ids=ids)
diff --git a/backend/packages/wps-api/src/app/smurfi/__init__.py b/backend/packages/wps-api/src/app/smurfi/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/backend/packages/wps-api/src/app/smurfi/email.py b/backend/packages/wps-api/src/app/smurfi/email.py
new file mode 100644
index 0000000000..c44114e7cb
--- /dev/null
+++ b/backend/packages/wps-api/src/app/smurfi/email.py
@@ -0,0 +1,303 @@
+import html
+import logging
+from collections.abc import Sequence
+from datetime import date, datetime
+
+import httpx
+from wps_shared import config
+from wps_shared.db.models.smurfi import SpotForecast, SpotRequestInstance
+from wps_shared.utils.time import vancouver_tz
+
+logger = logging.getLogger(__name__)
+
+CHES_TOKEN_URL = config.get("CHES_TOKEN_URL")
+CHES_CLIENT_ID = config.get("CHES_CLIENT_ID")
+CHES_CLIENT_SECRET = config.get("CHES_CLIENT_SECRET")
+CHES_SENDER_EMAIL = config.get("CHES_SENDER_EMAIL")
+CHES_EMAIL_MERGE_URL = config.get("CHES_MERGE_URL")
+WEB_BASE_URL = config.get("WEB_BASE_URL")
+
+_TABLE_STYLE = "border-collapse:collapse;width:100%;font-size:12px;"
+_CELL_STYLE = "border:1px solid black;padding:3px 8px;"
+_HDR_STYLE = f"{_CELL_STYLE}font-weight:bold;"
+_NUM_STYLE = f"{_CELL_STYLE}font-weight:bold;text-align:center;"
+_DASH_STYLE = f"{_CELL_STYLE}text-align:center;"
+
+
+def _to_ddm(decimal: float) -> str:
+ abs_val = abs(decimal)
+ degrees = int(abs_val)
+ minutes = (abs_val - degrees) * 60
+ sign = "-" if decimal < 0 else ""
+ return f"{sign}{degrees} {minutes:.3f}"
+
+
+def _get_coords(spot_location: SpotRequestInstance) -> str:
+ latitude = getattr(spot_location, "latitude", None)
+ longitude = getattr(spot_location, "longitude", None)
+ if latitude is not None and longitude is not None:
+ return f"{_to_ddm(latitude)},{_to_ddm(longitude)}"
+
+ try:
+ from geoalchemy2.shape import to_shape
+ from wps_shared.geospatial.geospatial import (
+ NAD83_BC_ALBERS,
+ PointTransformer,
+ SpatialReferenceSystem,
+ )
+
+ shape = to_shape(spot_location.geom)
+ lat, lon = PointTransformer(
+ NAD83_BC_ALBERS, SpatialReferenceSystem.WGS84.code
+ ).transform_coordinate(shape.x, shape.y)
+ return f"{_to_ddm(lat)},{_to_ddm(lon)}"
+ except Exception:
+ return "—"
+
+
+def _format_date_label(forecast_time: datetime, issued_at: datetime) -> str:
+ ft = forecast_time.astimezone(vancouver_tz)
+ issued_day: date = issued_at.astimezone(vancouver_tz).date()
+ day_diff = (ft.date() - issued_day).days
+ time_str = ft.strftime("%H%M")
+
+ if day_diff == 0:
+ return f"Today {time_str}"
+ if day_diff == 1:
+ return "Tonight" if ft.hour == 0 else f"Tomorrow {time_str}"
+ if day_diff == 2:
+ return "Tomorrow night" if ft.hour == 0 else f"Next Day {time_str}"
+ return ft.strftime("%Y-%m-%d %H%M")
+
+
+def _ensure_period(text: str | None) -> str:
+ if not text:
+ return ""
+ return text if text.endswith(".") else f"{text}."
+
+
+def _format_fire_size(fire_size: Sequence[float | None] | float | None) -> str:
+ if not fire_size:
+ return ""
+
+ if isinstance(fire_size, Sequence) and not isinstance(fire_size, str):
+ fire_sizes = [str(size) for size in fire_size if size is not None]
+ return f"Size: {', '.join(fire_sizes)} ha" if fire_sizes else ""
+
+ return f"Size: {fire_size} ha"
+
+
+def build_spot_forecast_email(spot_forecast: SpotForecast, spot_detail_url: str) -> tuple[str, str]:
+ """Return (subject, html_body) for a spot forecast notification email."""
+ sr = spot_forecast.spot_request_base
+ instance = spot_forecast.spot_request_instance
+ fire_numbers = html.escape(", ".join(sr.fire_number)) if sr.fire_number else "N/A"
+ subject = f"Spot Forecast Update: Fire {fire_numbers}"
+
+ issued_dt = spot_forecast.issued_at.astimezone(vancouver_tz)
+ issued_str = (
+ issued_dt.strftime("%H%M")
+ + " "
+ + issued_dt.strftime("%Z")
+ + " "
+ + issued_dt.strftime("%A, %B %-d, %Y")
+ )
+
+ expiry_dt = (
+ spot_forecast.expires_at.astimezone(vancouver_tz) if spot_forecast.expires_at else None
+ )
+ expiry_str = expiry_dt.strftime("%A %B %-d") if expiry_dt else "—"
+
+ coords_str = _get_coords(instance)
+ aspect_str = html.escape(instance.aspect or "—")
+ elevation_str = f"{instance.elevation} m" if instance.elevation else "—"
+ geo_str = html.escape(instance.geographic_description or "")
+ forecaster_name = html.escape(spot_forecast.forecaster_name or "")
+ forecaster_email = html.escape(spot_forecast.forecaster_email or "")
+ forecaster_phone = html.escape(spot_forecast.forecaster_phone or "—")
+ requestor_name = html.escape(sr.requestor_name or "")
+ fire_size_str = _format_fire_size(spot_forecast.fire_size)
+
+ station_codes = spot_forecast.representative_station_codes
+ stations_str = ", ".join(str(c) for c in station_codes) if station_codes else "—"
+
+ # Descriptive weather
+ dw_by_period = {dw.period: dw for dw in spot_forecast.descriptive_weather}
+ forecast_lines = ""
+ afternoon = dw_by_period.get("Today")
+ tonight = dw_by_period.get("Tonight")
+ tomorrow = dw_by_period.get("Tomorrow")
+
+ if afternoon:
+ cond = html.escape(_ensure_period(afternoon.conditions))
+ forecast_lines += (
+ f"
AFTERNOON: "
+ f"{cond} MAX TEMP {afternoon.temperature}C, MIN RH {afternoon.relative_humidity}%
"
+ )
+ if tonight:
+ cond = html.escape(_ensure_period(tonight.conditions))
+ forecast_lines += (
+ f"TONIGHT: "
+ f"{cond} MIN TEMP {tonight.temperature}C. MAX RH {tonight.relative_humidity}%.
"
+ )
+ if tomorrow:
+ cond = html.escape(_ensure_period(tomorrow.conditions))
+ forecast_lines += (
+ f"TOMORROW: "
+ f"{cond} TEMP {tomorrow.temperature}C. MIN RH {tomorrow.relative_humidity}%.
"
+ )
+
+ # Tabular weather
+ sorted_tabular = sorted(spot_forecast.tabular_weather, key=lambda tw: tw.forecast_time)
+ tabular_rows = ""
+ for tw in sorted_tabular:
+ label = (
+ _format_date_label(tw.forecast_time, spot_forecast.issued_at)
+ if tw.forecast_time
+ else ""
+ )
+ tabular_rows += (
+ f""
+ f"| {html.escape(label)} | "
+ f"{tw.temperature if tw.temperature is not None else '-'} | "
+ f"{tw.relative_humidity if tw.relative_humidity is not None else '-'} | "
+ f"{html.escape(tw.wind or '-')} | "
+ f"{tw.precipitation_amount if tw.precipitation_amount is not None else '-'} | "
+ f"{tw.probability_of_precipitation if tw.probability_of_precipitation is not None else '-'} | "
+ f"
"
+ )
+
+ synopsis = html.escape(spot_forecast.synopsis or "")
+ inversion = html.escape(spot_forecast.inversion_and_venting or "")
+ outlook = html.escape(spot_forecast.outlook or "")
+ confidence = html.escape(spot_forecast.confidence or "")
+
+ outlook_block = (
+ f""
+ f"OUTLOOK (3-5 Day Outlook) {outlook}
"
+ if outlook
+ else ""
+ )
+
+ html_body = f"""
+
+
+
+ BC Wild Fire Service Spot Forecast
+
+
+
+ Date/time Issued: {issued_str}
+ Default Expiry: {expiry_str}
+
+
+
+
+ | Fire/Proj # |
+ {fire_numbers} |
+ Request by: {requestor_name} |
+
+
+ | Forecast by |
+ {forecaster_name} |
+ Email: {forecaster_email} |
+ Phone: {forecaster_phone} |
+
+
+ | Geographic |
+ {geo_str} |
+ Representative Stations: {stations_str} |
+
+
+ | Coordinates (approx) |
+ {coords_str} |
+ Slope/aspect: {aspect_str} |
+ |
+
+
+ | Elevation |
+ {elevation_str} |
+ {fire_size_str} |
+ |
+
+
+
+
+ SYNOPSIS: {synopsis}
+
+
+
+
FORECAST:
+ {forecast_lines}
+
+
+
+
+ | Date/Time (PDT) |
+ Temp (C) |
+ RH |
+ Wind (km/h) |
+ Rain (mm) |
+ Chance Rain |
+
+ {tabular_rows}
+
+
+
+ INVERSION & VENTING: {inversion}
+
+
+{outlook_block}
+
+
+ CONFIDENCE/DISCUSSION: {confidence}
+
+
+View Spot Forecast
+
+"""
+ return subject, html_body
+
+
+async def send_spot_forecast_emails(
+ subscriber_emails: list[str], subject: str, html_body: str
+) -> None:
+ """Fetch a CHES OAuth2 token and send bulk email via /email/merge."""
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ token_response = await client.post(
+ CHES_TOKEN_URL,
+ data={
+ "grant_type": "client_credentials",
+ "client_id": CHES_CLIENT_ID,
+ "client_secret": CHES_CLIENT_SECRET,
+ },
+ )
+ token_response.raise_for_status()
+ token_data = token_response.json()
+ access_token = token_data.get("access_token")
+ if not access_token:
+ raise ValueError(f"CHES token response missing access_token: {token_data}")
+
+ merge_payload = {
+ "subject": subject,
+ "from": CHES_SENDER_EMAIL,
+ "bodyType": "html",
+ "body": html_body,
+ "contexts": [{"to": [email], "context": {}} for email in subscriber_emails],
+ }
+
+ merge_response = await client.post(
+ CHES_EMAIL_MERGE_URL,
+ json=merge_payload,
+ headers={"Authorization": f"Bearer {access_token}"},
+ )
+ merge_response.raise_for_status()
+ logger.info("Sent spot forecast email to %d subscriber(s)", len(subscriber_emails))
+ except Exception:
+ logger.exception(
+ "Failed to send spot forecast email to %d subscriber(s) (subject: %s)",
+ len(subscriber_emails),
+ subject,
+ )
+ raise
diff --git a/backend/packages/wps-api/src/app/smurfi/files/spot_request_16821268-22f1-4a5c-bea9-db13f138b701.json b/backend/packages/wps-api/src/app/smurfi/files/spot_request_16821268-22f1-4a5c-bea9-db13f138b701.json
new file mode 100644
index 0000000000..813924ab3c
--- /dev/null
+++ b/backend/packages/wps-api/src/app/smurfi/files/spot_request_16821268-22f1-4a5c-bea9-db13f138b701.json
@@ -0,0 +1,37 @@
+{
+ "metadata": {
+ "submissionId": "16821268-22f1-4a5c-bea9-db13f138b701",
+ "confirmationId": "16821268",
+ "formName": "SPOT Forecast Request",
+ "version": 4,
+ "createdAt": "2026-01-20T22:43:35.874Z",
+ "fullName": "Pressney, Stewart WLRS:EX",
+ "username": "SPRESSNE",
+ "email": "stewart.pressney@gov.bc.ca",
+ "submittedAt": "2026-01-20T22:43:35.879Z",
+ "status": "SUBMITTED",
+ "assignee": null,
+ "assigneeEmail": null
+ },
+ "fire_number": "V12345",
+ "forecast_end_date": "2026-01-30T00:00:00-08:00",
+ "forecast_start_date": "2026-01-20T15:00:00-08:00",
+ "spot_forecast_type": "fullSpot",
+ "email_distribution_list": [
+ "STEWART.pressney@gov.bc.ca",
+ "stewartpressney@gmail.com"
+ ],
+ "additional_info": "More Info Please!",
+ "coordinates": {
+ "features": [
+ {
+ "type": "marker",
+ "coordinates": {
+ "lat": 50.402470152529034,
+ "lng": -121.09730131924152
+ }
+ }
+ ],
+ "selectedBaseLayer": "OpenStreetMap"
+ }
+}
\ No newline at end of file
diff --git a/backend/packages/wps-api/src/app/smurfi/files/spot_request_99bc3ba2-300e-48c8-833a-351bb5ffaadc.json b/backend/packages/wps-api/src/app/smurfi/files/spot_request_99bc3ba2-300e-48c8-833a-351bb5ffaadc.json
new file mode 100644
index 0000000000..8dd44ad41e
--- /dev/null
+++ b/backend/packages/wps-api/src/app/smurfi/files/spot_request_99bc3ba2-300e-48c8-833a-351bb5ffaadc.json
@@ -0,0 +1,36 @@
+{
+ "metadata": {
+ "submissionId": "99bc3ba2-300e-48c8-833a-351bb5ffaadc",
+ "confirmationId": "99BC3BA2",
+ "formName": "SPOT Forecast Request",
+ "version": 4,
+ "createdAt": "2026-01-20T23:20:57.374Z",
+ "fullName": "Williams, Andrea WLRS:EX",
+ "username": "AWILLIAM",
+ "email": "andrea.williams@gov.bc.ca",
+ "submittedAt": "2026-01-20T23:20:57.378Z",
+ "status": "SUBMITTED",
+ "assignee": null,
+ "assigneeEmail": null
+ },
+ "fire_number": "G82849",
+ "forecast_end_date": "2026-01-31T00:00:00-08:00",
+ "forecast_start_date": "2026-01-22T12:00:00-08:00",
+ "spot_forecast_type": "miniSpot",
+ "email_distribution_list": [
+ "andrea.williams@gov.bc.ca"
+ ],
+ "additional_info": "Help everything's on fire.",
+ "coordinates": {
+ "features": [
+ {
+ "type": "marker",
+ "coordinates": {
+ "lat": 49.69664476418803,
+ "lng": -123.20205688476564
+ }
+ }
+ ],
+ "selectedBaseLayer": "OpenStreetMap"
+ }
+}
\ No newline at end of file
diff --git a/backend/packages/wps-api/src/app/smurfi/nats_config.py b/backend/packages/wps-api/src/app/smurfi/nats_config.py
new file mode 100644
index 0000000000..29ec0bbcbb
--- /dev/null
+++ b/backend/packages/wps-api/src/app/smurfi/nats_config.py
@@ -0,0 +1,10 @@
+from typing import Final
+
+from wps_shared import config
+
+server: Final = config.get("NATS_SERVER")
+stream_prefix: Final = config.get("NATS_STREAM_PREFIX")
+stream_name: Final = f"{stream_prefix}smurfi"
+subjects: Final = ["smurfi.>"]
+smurfi_spot_update_subject: Final = "smurfi.spot.update"
+smurfi_spot_update_durable: Final = "smurfi_spot_update"
diff --git a/backend/packages/wps-api/src/app/smurfi/nats_consumer.py b/backend/packages/wps-api/src/app/smurfi/nats_consumer.py
new file mode 100644
index 0000000000..9f25dc5d6b
--- /dev/null
+++ b/backend/packages/wps-api/src/app/smurfi/nats_consumer.py
@@ -0,0 +1,117 @@
+"""NATS consumer for smurfi.spot.update — sends email to spot forecast subscribers."""
+
+import asyncio
+import json
+import logging
+
+import nats
+from nats.aio.msg import Msg
+from nats.js.api import AckPolicy, ConsumerConfig, RetentionPolicy, StreamConfig
+from wps_shared.db.crud.smurfi import get_all_notification_emails_for_spot, get_spot_forecast_with_weather
+from wps_shared.db.database import get_async_read_session_scope
+from wps_shared.wps_logging import configure_logging
+
+from app.smurfi.email import WEB_BASE_URL, build_spot_forecast_email, send_spot_forecast_emails
+from app.smurfi.nats_config import (
+ server,
+ smurfi_spot_update_durable,
+ smurfi_spot_update_subject,
+ stream_name,
+ subjects,
+)
+
+logger = logging.getLogger(__name__)
+
+
+async def process_message(msg: Msg) -> None:
+ try:
+ decoded = json.loads(json.loads(msg.data.decode()))
+ spot_request_id: int = decoded["spot_request_id"]
+ spot_forecast_id: int = decoded["spot_forecast_id"]
+ logger.info("Processing smurfi.spot.update for spot_request_id=%s", spot_request_id)
+
+ async with get_async_read_session_scope() as session:
+ emails = await get_all_notification_emails_for_spot(session, spot_request_id)
+ if not emails:
+ logger.info(
+ "No active subscribers for spot_request_id=%s — skipping email", spot_request_id
+ )
+ await msg.ack()
+ return
+ spot_forecast = await get_spot_forecast_with_weather(session, spot_forecast_id)
+
+ if spot_forecast is None:
+ logger.error("SpotForecast %s not found — cannot send email", spot_forecast_id)
+ await msg.nak(delay=60)
+ return
+
+ spot_detail_url = (
+ f"{WEB_BASE_URL}/smurfi/requests/{spot_request_id}/forecasts/{spot_forecast_id}"
+ )
+ subject, html_body = build_spot_forecast_email(
+ spot_forecast, spot_detail_url=spot_detail_url
+ )
+ await send_spot_forecast_emails(
+ subscriber_emails=emails, subject=subject, html_body=html_body
+ )
+ await msg.ack()
+ except Exception as exc:
+ logger.error("Error processing smurfi.spot.update: %s", msg.data, exc_info=exc)
+ try:
+ await msg.nak(delay=60)
+ except Exception:
+ logger.exception("Failed to nak message: %s", msg.data)
+
+
+async def run() -> None:
+ async def disconnected_cb():
+ logger.info("Got disconnected!")
+
+ async def reconnected_cb():
+ logger.info("Got reconnected!")
+
+ async def error_cb(error):
+ logger.error("NATS error: %s", error)
+
+ async def closed_cb():
+ logger.info("Connection closed")
+
+ nats_connection = await nats.connect(
+ server,
+ ping_interval=10,
+ max_reconnect_attempts=24,
+ disconnected_cb=disconnected_cb,
+ reconnected_cb=reconnected_cb,
+ error_cb=error_cb,
+ closed_cb=closed_cb,
+ )
+ jetstream = nats_connection.jetstream()
+ await jetstream.add_stream(
+ name=stream_name,
+ config=StreamConfig(retention=RetentionPolicy.WORK_QUEUE),
+ subjects=subjects,
+ )
+ consumer_config = ConsumerConfig(
+ durable_name=smurfi_spot_update_durable,
+ ack_policy=AckPolicy.EXPLICIT,
+ ack_wait=120,
+ max_deliver=5,
+ )
+ sub = await jetstream.pull_subscribe(
+ stream=stream_name,
+ subject=smurfi_spot_update_subject,
+ durable=smurfi_spot_update_durable,
+ config=consumer_config,
+ )
+ logger.info("Smurfi NATS consumer started, listening on %s", smurfi_spot_update_subject)
+ while True:
+ msgs = await sub.fetch(batch=1, timeout=None)
+ for msg in msgs:
+ await process_message(msg)
+
+
+if __name__ == "__main__":
+ configure_logging()
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ asyncio.run(run())
diff --git a/backend/packages/wps-api/src/app/smurfi/preview_email.py b/backend/packages/wps-api/src/app/smurfi/preview_email.py
new file mode 100644
index 0000000000..5c8ea386b4
--- /dev/null
+++ b/backend/packages/wps-api/src/app/smurfi/preview_email.py
@@ -0,0 +1,78 @@
+"""Run directly to preview the spot forecast email HTML in your browser.
+
+ python -m app.smurfi.preview_email
+"""
+import subprocess
+import tempfile
+from datetime import datetime, timezone
+
+from app.smurfi.email import build_spot_forecast_email
+
+
+class _DW:
+ def __init__(self, period, conditions, temperature, relative_humidity):
+ self.period = period
+ self.conditions = conditions
+ self.temperature = temperature
+ self.relative_humidity = relative_humidity
+
+
+class _TW:
+ def __init__(self, forecast_time, temperature, relative_humidity, wind, probability_of_precipitation, precipitation_amount):
+ self.forecast_time = forecast_time
+ self.temperature = temperature
+ self.relative_humidity = relative_humidity
+ self.wind = wind
+ self.probability_of_precipitation = probability_of_precipitation
+ self.precipitation_amount = precipitation_amount
+
+
+class _SR:
+ fire_number = ["V1234567"]
+ requestor_name = "Darren Boss"
+ geographic_description = "Strathcona"
+ aspect = "East"
+ elevation = 1100
+ geom = None # coord extraction falls back to "—" without a real DB geometry
+
+
+class _SF:
+ spot_request = _SR()
+ issued_at = datetime(2026, 5, 25, 21, 30, tzinfo=timezone.utc) # 1430 PDT
+ expires_at = datetime(2026, 5, 27, 7, 0, tzinfo=timezone.utc) # Wed May 27
+ forecaster_name = "Conor Brady"
+ forecaster_email = "conor.brady@gov.bc.ca"
+ forecaster_phone = None
+ synopsis = "A weak ridge of high pressure is maintaining mainly clear skies over the region."
+ inversion_and_venting = "A surface inversion is expected tonight with poor venting conditions."
+ outlook = "The ridge will weaken Thursday allowing a Pacific front to approach from the northwest."
+ confidence = "High confidence. Models are in good agreement."
+ fire_size = 2.5
+ representative_station_codes = None
+ descriptive_weather = [
+ _DW("Today", "Mainly sunny in the morning then increasing afternoon cloud", 28, 18),
+ _DW("Tonight", "Mainly clear", -2, 90),
+ _DW("Tomorrow", "Cloudy", 12, 40),
+ ]
+ tabular_weather = [
+ _TW(datetime(2026, 5, 25, 0, 0, tzinfo=timezone.utc), None, None, None, None, None),
+ _TW(datetime(2026, 5, 25, 23, 0, tzinfo=timezone.utc), 28, 18, "SW 20-30", 5, 0),
+ _TW(datetime(2026, 5, 26, 2, 0, tzinfo=timezone.utc), 25, 22, "SW 15", 5, 0),
+ _TW(datetime(2026, 5, 26, 6, 0, tzinfo=timezone.utc), None, None, None, None, None),
+ _TW(datetime(2026, 5, 26, 17, 0, tzinfo=timezone.utc), 12, 40, "W 10-20", 20, 2),
+ _TW(datetime(2026, 5, 26, 20, 0, tzinfo=timezone.utc), 10, 55, "W 10", 20, 2),
+ _TW(datetime(2026, 5, 26, 23, 0, tzinfo=timezone.utc), 8, 65, "Light", 30, 3),
+ _TW(datetime(2026, 5, 27, 2, 0, tzinfo=timezone.utc), 6, 75, "Light", 30, 3),
+ _TW(datetime(2026, 5, 27, 23, 0, tzinfo=timezone.utc), 14, 35, "NW 15", 10, 0),
+ ]
+
+
+if __name__ == "__main__":
+ _, html_body = build_spot_forecast_email(_SF(), spot_detail_url="http://localhost:3000/smurfi/spots/1")
+
+ with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w") as f:
+ f.write(html_body)
+ path = f.name
+
+ print(f"Preview written to: {path}")
+ subprocess.run(["open", path])
diff --git a/backend/packages/wps-api/src/app/tests/smurfi/test_email.py b/backend/packages/wps-api/src/app/tests/smurfi/test_email.py
new file mode 100644
index 0000000000..3b84170bd7
--- /dev/null
+++ b/backend/packages/wps-api/src/app/tests/smurfi/test_email.py
@@ -0,0 +1,170 @@
+"""Unit tests for smurfi CHES email builder."""
+
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from app.smurfi.email import build_spot_forecast_email, send_spot_forecast_emails
+
+_ISSUED_AT = datetime(2026, 5, 25, 21, 30, tzinfo=timezone.utc) # 1430 PDT
+
+
+def _make_spot_request(fire_number=None, geographic_description="Test Area"):
+ sr = MagicMock()
+ sr.fire_number = fire_number or ["V0800168"]
+ sr.geographic_description = geographic_description
+ sr.requestor_name = "Test Requestor"
+ sr.aspect = "East"
+ sr.elevation = 1100
+ sr.geom = None # coord extraction will fall back to "—" via try/except
+ return sr
+
+
+def _make_spot_request_instance(geographic_description="Test Area"):
+ instance = MagicMock()
+ instance.geographic_description = geographic_description
+ instance.aspect = "East"
+ instance.elevation = 1100
+ instance.latitude = 48.5
+ instance.longitude = -123.5
+ return instance
+
+
+def _make_spot_forecast(spot_request, descriptive_weather=None, tabular_weather=None):
+ sf = MagicMock()
+ sf.spot_request_base = spot_request
+ sf.spot_request_instance = _make_spot_request_instance(
+ geographic_description=getattr(spot_request, "geographic_description", "Test Area")
+ )
+ sf.descriptive_weather = descriptive_weather or []
+ sf.tabular_weather = tabular_weather or []
+ sf.issued_at = _ISSUED_AT
+ sf.expires_at = None
+ sf.forecaster_name = "Test Forecaster"
+ sf.forecaster_email = "test@example.com"
+ sf.forecaster_phone = None
+ sf.synopsis = "Test synopsis"
+ sf.inversion_and_venting = "Test inversion"
+ sf.outlook = None
+ sf.confidence = "Test confidence"
+ sf.fire_size = None
+ sf.representative_station_codes = None
+ return sf
+
+
+def _make_descriptive(period, conditions="Sunny", temperature=18.0, relative_humidity=40.0):
+ dw = MagicMock()
+ dw.period = period
+ dw.conditions = conditions
+ dw.temperature = temperature
+ dw.relative_humidity = relative_humidity
+ return dw
+
+
+def _make_tabular(
+ forecast_time,
+ temperature=18.0,
+ relative_humidity=40.0,
+ wind="NW 20",
+ probability_of_precipitation=10.0,
+ precipitation_amount=0.0,
+):
+ tw = MagicMock()
+ tw.forecast_time = forecast_time
+ tw.temperature = temperature
+ tw.relative_humidity = relative_humidity
+ tw.wind = wind
+ tw.probability_of_precipitation = probability_of_precipitation
+ tw.precipitation_amount = precipitation_amount
+ return tw
+
+
+def test_build_email_subject_includes_fire_number():
+ """Email subject contains the fire number."""
+ sr = _make_spot_request(fire_number=["V0800168"])
+ sf = _make_spot_forecast(sr)
+ subject, _ = build_spot_forecast_email(sf, spot_detail_url="http://example.com/smurfi/spots/1")
+ assert "V0800168" in subject
+
+
+def test_build_email_body_contains_geographic_description():
+ """Email body contains the geographic description."""
+ sr = _make_spot_request()
+ sf = _make_spot_forecast(sr)
+ sf.spot_request_instance.geographic_description = "Clearwater Valley"
+ _, html = build_spot_forecast_email(sf, spot_detail_url="http://example.com/smurfi/spots/1")
+ assert "Clearwater Valley" in html
+
+
+def test_build_email_body_contains_descriptive_period():
+ """Email body contains descriptive weather rendered as AFTERNOON/TONIGHT/TOMORROW labels."""
+ sr = _make_spot_request()
+ afternoon = _make_descriptive(
+ period="Today", conditions="Partly cloudy", temperature=22.0, relative_humidity=35.0
+ )
+ tonight = _make_descriptive(
+ period="Tonight", conditions="Clear", temperature=5.0, relative_humidity=80.0
+ )
+ tomorrow = _make_descriptive(
+ period="Tomorrow", conditions="Sunny", temperature=20.0, relative_humidity=30.0
+ )
+ sf = _make_spot_forecast(sr, descriptive_weather=[afternoon, tonight, tomorrow])
+ _, body = build_spot_forecast_email(sf, spot_detail_url="http://example.com/smurfi/spots/1")
+ assert "AFTERNOON:" in body
+ assert "Partly cloudy" in body
+ assert "MAX TEMP 22.0C, MIN RH 35.0%" in body
+ assert "TONIGHT:" in body
+ assert "MIN TEMP 5.0C. MAX RH 80.0%" in body
+ assert "TOMORROW:" in body
+ assert "TEMP 20.0C. MIN RH 30.0%" in body
+
+
+def test_build_email_body_contains_tabular_row():
+ """Email body contains a tabular weather row."""
+ sr = _make_spot_request()
+ tw = _make_tabular(
+ forecast_time=datetime(2026, 5, 19, 14, 0, tzinfo=timezone.utc),
+ temperature=21.0,
+ relative_humidity=38.0,
+ wind="SE 15",
+ )
+ sf = _make_spot_forecast(sr, tabular_weather=[tw])
+ _, html = build_spot_forecast_email(sf, spot_detail_url="http://example.com/smurfi/spots/1")
+ assert "SE 15" in html
+
+
+def test_build_email_body_contains_spot_detail_link():
+ """Email body contains the view-forecast link."""
+ sr = _make_spot_request()
+ sf = _make_spot_forecast(sr)
+ _, html = build_spot_forecast_email(sf, spot_detail_url="http://example.com/smurfi/spots/99")
+ assert "http://example.com/smurfi/spots/99" in html
+
+
+@pytest.mark.anyio
+async def test_send_spot_forecast_emails_calls_ches():
+ """send_spot_forecast_emails fetches a token and POSTs to CHES /email/merge."""
+ mock_token_response = MagicMock()
+ mock_token_response.json.return_value = {"access_token": "test-token"}
+ mock_token_response.raise_for_status = MagicMock()
+
+ mock_merge_response = MagicMock()
+ mock_merge_response.raise_for_status = MagicMock()
+
+ with patch("app.smurfi.email.httpx.AsyncClient") as mock_client_cls:
+ mock_client = AsyncMock()
+ mock_client_cls.return_value.__aenter__.return_value = mock_client
+ mock_client.post.side_effect = [mock_token_response, mock_merge_response]
+
+ await send_spot_forecast_emails(
+ subscriber_emails=["a@example.com", "b@example.com"],
+ subject="Test Subject",
+ html_body="Test
",
+ )
+
+ assert mock_client.post.call_count == 2
+ merge_call = mock_client.post.call_args_list[1]
+ merge_body = merge_call.kwargs.get("json") or merge_call.args[1]
+ assert len(merge_body["contexts"]) == 2
+ assert merge_body["contexts"][0]["to"] == ["a@example.com"]
+ assert merge_body["contexts"][1]["to"] == ["b@example.com"]
diff --git a/backend/packages/wps-api/src/app/tests/smurfi/test_subscribe_router.py b/backend/packages/wps-api/src/app/tests/smurfi/test_subscribe_router.py
new file mode 100644
index 0000000000..43c77c8963
--- /dev/null
+++ b/backend/packages/wps-api/src/app/tests/smurfi/test_subscribe_router.py
@@ -0,0 +1,405 @@
+"""Unit tests for smurfi subscribe endpoints."""
+
+from datetime import datetime, timezone
+from unittest.mock import ANY, AsyncMock, patch
+
+import app.main
+import pytest
+from app.routers.smurfi import (
+ _get_spot_request_subscriber_emails,
+ _spot_request_instance_has_changed,
+)
+from app.smurfi.nats_config import smurfi_spot_update_subject, stream_name, subjects
+from fastapi.testclient import TestClient
+from wps_shared.schemas.smurfi import SpotRequestInput, SpotRequestInstanceInput, SpotSubscriberData
+
+DB_READ = "app.routers.smurfi.get_async_read_session_scope"
+DB_WRITE = "app.routers.smurfi.get_async_write_session_scope"
+SUBSCRIBE = "app.routers.smurfi.subscribe_to_spot_request"
+UNSUBSCRIBE = "app.routers.smurfi.unsubscribe_from_spot_request"
+GET_IDS = "app.routers.smurfi.get_subscribed_spot_request_ids"
+GET_FORECASTS = "app.routers.smurfi.get_spot_forecasts_for_request"
+GET_OR_CREATE_INSTANCE = "app.routers.smurfi.get_or_create_spot_request_instance"
+GET_REQUEST = "app.routers.smurfi.get_spot_request_by_id"
+CREATE_INSTANCE = "app.routers.smurfi.create_spot_request_instance"
+UPDATE_REQUEST = "app.routers.smurfi.update_spot_request_details"
+UPDATE_INSTANCE = "app.routers.smurfi.update_spot_request_instance_details"
+SYNC_SUBSCRIBERS = "app.routers.smurfi.sync_spot_subscribers"
+SYNC_GROUPS = "app.routers.smurfi.sync_spot_request_distribution_groups"
+
+
+def _make_subscriber(status: str):
+ return type("SpotSubscriber", (), {"subscriber_status": status})()
+
+
+def _make_spot_request_instance(instance_id: int = 3):
+ return type(
+ "SpotRequestInstance",
+ (),
+ {
+ "id": instance_id,
+ "geographic_description": "Clearwater Valley",
+ "aspect": "North",
+ "elevation": 1000,
+ "valley": None,
+ "latitude": 48.5,
+ "longitude": -123.5,
+ "created_at": datetime(2026, 5, 21, tzinfo=timezone.utc),
+ "updated_at": datetime(2026, 5, 21, tzinfo=timezone.utc),
+ },
+ )()
+
+
+def _make_spot_request():
+ return type(
+ "SpotRequestBase",
+ (),
+ {
+ "id": 42,
+ "request_reference": "WPS-test",
+ "fire_number": ["V12345"],
+ "fire_centre": 1,
+ "status": "Requested",
+ "requestor_name": "Original Owner",
+ "requestor_idir": "owner_idir",
+ "requestor_email": "owner@example.com",
+ "request_frequency": ["Monday"],
+ "request_type": "Full",
+ "additional_information": "Original notes",
+ "requested_at": datetime(2026, 5, 21, tzinfo=timezone.utc),
+ "start_at": datetime(2026, 5, 22, tzinfo=timezone.utc),
+ "end_at": datetime(2026, 5, 24, tzinfo=timezone.utc),
+ "spot_request_instances": [_make_spot_request_instance()],
+ "spot_forecasts": [],
+ "spot_subscribers": [],
+ "distribution_groups": [],
+ },
+ )()
+
+
+def _make_spot_request_input(subscribers: list[SpotSubscriberData]):
+ return SpotRequestInput(
+ request_reference="WPS-test",
+ fire_number=["V12345"],
+ fire_centre=1,
+ initial_instance=SpotRequestInstanceInput(
+ geographic_description="Clearwater Valley",
+ latitude=48.5,
+ longitude=-123.5,
+ ),
+ requested_at=datetime(2026, 5, 21, tzinfo=timezone.utc),
+ start_at=datetime(2026, 5, 22, tzinfo=timezone.utc),
+ end_at=datetime(2026, 5, 24, tzinfo=timezone.utc),
+ subscribers=subscribers,
+ )
+
+
+def test_spot_request_subscriber_emails_include_requestor_once():
+ assert _get_spot_request_subscriber_emails(
+ _make_spot_request_input([SpotSubscriberData(email="owner@example.com")]),
+ ["test@email.com"],
+ ) == [
+ "owner@example.com",
+ "test@email.com",
+ ]
+
+ spot_request_input = _make_spot_request_input(
+ [
+ SpotSubscriberData(email="owner@example.com"),
+ SpotSubscriberData(email="TEST@EMAIL.COM"),
+ ]
+ )
+
+ assert _get_spot_request_subscriber_emails(spot_request_input, ["test@email.com"]) == [
+ "owner@example.com",
+ "TEST@EMAIL.COM",
+ ]
+
+
+def test_spot_request_instance_has_changed_detects_geographic_updates():
+ existing = _make_spot_request_instance()
+
+ assert not _spot_request_instance_has_changed(
+ existing,
+ SpotRequestInstanceInput(
+ geographic_description="Clearwater Valley",
+ aspect="North",
+ elevation=1000,
+ valley=None,
+ latitude=48.5,
+ longitude=-123.5,
+ ),
+ )
+
+ assert not _spot_request_instance_has_changed(
+ existing,
+ SpotRequestInstanceInput(
+ geographic_description="Clearwater Valley",
+ aspect="North",
+ elevation=1000,
+ valley=None,
+ latitude=48.5001,
+ longitude=-123.5001,
+ ),
+ )
+
+ assert _spot_request_instance_has_changed(
+ existing,
+ SpotRequestInstanceInput(
+ geographic_description="Clearwater Valley",
+ aspect="North",
+ elevation=1000,
+ valley=None,
+ latitude=48.5003,
+ longitude=-123.5,
+ ),
+ )
+
+ assert _spot_request_instance_has_changed(
+ existing,
+ SpotRequestInstanceInput(
+ geographic_description="New location description",
+ aspect="North",
+ elevation=1000,
+ valley=None,
+ latitude=48.5,
+ longitude=-123.5,
+ ),
+ )
+
+
+@pytest.mark.usefixtures("mock_jwt_decode")
+def test_update_spot_request_updates_existing_request_instance():
+ """PATCH spot request updates the request location instead of creating a new instance."""
+ client = TestClient(app.main.app)
+ spot_request = _make_spot_request()
+ payload = {
+ "fire_number": ["V12345"],
+ "fire_centre": 1,
+ "request_frequency": ["Monday"],
+ "request_type": "Full",
+ "additional_information": "Updated notes",
+ "request_instance": {
+ "geographic_description": "Updated location",
+ "aspect": "North",
+ "elevation": 1000,
+ "valley": None,
+ "latitude": 48.5003,
+ "longitude": -123.5,
+ },
+ "start_at": "2026-05-22T00:00:00Z",
+ "end_at": "2026-05-24T23:59:00Z",
+ "subscribers": [],
+ "distribution_group_ids": [],
+ }
+
+ with (
+ patch(DB_WRITE),
+ patch(GET_REQUEST, new_callable=AsyncMock, side_effect=[spot_request, spot_request]),
+ patch(UPDATE_REQUEST, new_callable=AsyncMock, return_value=spot_request),
+ patch(UPDATE_INSTANCE, new_callable=AsyncMock) as mock_update_instance,
+ patch(CREATE_INSTANCE, new_callable=AsyncMock) as mock_create_instance,
+ patch(SYNC_SUBSCRIBERS, new_callable=AsyncMock),
+ patch(SYNC_GROUPS, new_callable=AsyncMock),
+ ):
+ response = client.patch("/api/smurfi/spot_requests/42", json=payload)
+
+ assert response.status_code == 200
+ mock_update_instance.assert_awaited_once()
+ mock_create_instance.assert_not_awaited()
+
+
+@pytest.mark.usefixtures("mock_jwt_decode")
+def test_subscribe_returns_active_status():
+ """POST subscribe returns active status."""
+ client = TestClient(app.main.app)
+ with patch(DB_WRITE):
+ with patch(SUBSCRIBE, new_callable=AsyncMock, return_value=_make_subscriber("active")):
+ response = client.post("/api/smurfi/spots/42/subscribe")
+ assert response.status_code == 200
+ assert response.json()["subscriber_status"] == "active"
+
+
+def test_subscribe_requires_auth():
+ """POST subscribe returns 401 when not authenticated."""
+ client = TestClient(app.main.app)
+ response = client.post("/api/smurfi/spots/42/subscribe")
+ assert response.status_code == 401
+
+
+@pytest.mark.usefixtures("mock_jwt_decode")
+def test_unsubscribe_returns_204():
+ """DELETE subscribe returns 204 when subscription exists."""
+ client = TestClient(app.main.app)
+ with patch(DB_WRITE):
+ with patch(UNSUBSCRIBE, new_callable=AsyncMock, return_value=_make_subscriber("inactive")):
+ response = client.delete("/api/smurfi/spots/42/subscribe")
+ assert response.status_code == 204
+
+
+@pytest.mark.usefixtures("mock_jwt_decode")
+def test_unsubscribe_returns_404_when_not_subscribed():
+ """DELETE subscribe returns 404 when no subscription exists."""
+ client = TestClient(app.main.app)
+ with patch(DB_WRITE):
+ with patch(UNSUBSCRIBE, new_callable=AsyncMock, return_value=None):
+ response = client.delete("/api/smurfi/spots/42/subscribe")
+ assert response.status_code == 404
+
+
+def test_unsubscribe_requires_auth():
+ """DELETE subscribe returns 401 when not authenticated."""
+ client = TestClient(app.main.app)
+ response = client.delete("/api/smurfi/spots/42/subscribe")
+ assert response.status_code == 401
+
+
+@pytest.mark.usefixtures("mock_jwt_decode")
+def test_get_subscriptions_returns_ids():
+ """GET subscriptions returns the list of subscribed spot_request_ids."""
+ client = TestClient(app.main.app)
+ with patch(DB_READ):
+ with patch(GET_IDS, new_callable=AsyncMock, return_value=[1, 5, 10]):
+ response = client.get("/api/smurfi/subscriptions")
+ assert response.status_code == 200
+ assert response.json() == {"spot_request_ids": [1, 5, 10]}
+
+
+def test_get_subscriptions_requires_auth():
+ """GET subscriptions returns 401 when not authenticated."""
+ client = TestClient(app.main.app)
+ response = client.get("/api/smurfi/subscriptions")
+ assert response.status_code == 401
+
+
+@pytest.mark.usefixtures("mock_jwt_decode")
+def test_get_spot_forecasts_returns_saved_forecasts():
+ """GET spot forecasts returns forecasts and weather for a spot request."""
+ client = TestClient(app.main.app)
+ forecast_time = datetime(2026, 5, 21, 16, tzinfo=timezone.utc)
+ descriptive_weather = [
+ type(
+ "SpotDescriptiveWeather",
+ (),
+ {
+ "id": 7,
+ "period": "Today",
+ "temperature": 22,
+ "relative_humidity": 35,
+ "conditions": "Clear",
+ },
+ )()
+ ]
+ tabular_weather = [
+ type(
+ "SpotTabularWeather",
+ (),
+ {
+ "id": 8,
+ "forecast_time": forecast_time,
+ "temperature": 20,
+ "relative_humidity": 40,
+ "wind": "SE 10-20 G 30-35",
+ "probability_of_precipitation": 10,
+ "precipitation_amount": 0,
+ },
+ )()
+ ]
+ forecast = type(
+ "SpotForecast",
+ (),
+ {
+ "id": 99,
+ "spot_request_base_id": 42,
+ "spot_request_instance_id": 3,
+ "spot_request_instance": _make_spot_request_instance(),
+ "forecaster_name": "Test Forecaster",
+ "forecaster_email": "forecaster@example.com",
+ "forecaster_phone": "250-555-0100",
+ "synopsis": "High pressure.",
+ "inversion_and_venting": "Good venting.",
+ "outlook": "Dry.",
+ "confidence": "High.",
+ "forecast_type": "Mini",
+ "fire_size": [12.5],
+ "representative_station_codes": [1, 2],
+ "created_at": forecast_time,
+ "issued_at": forecast_time,
+ "expires_at": None,
+ "descriptive_weather": descriptive_weather,
+ "tabular_weather": tabular_weather,
+ },
+ )()
+
+ with (
+ patch(DB_READ),
+ patch(GET_FORECASTS, new_callable=AsyncMock, return_value=[forecast]),
+ ):
+ response = client.get("/api/smurfi/spot_requests/42/spot_forecasts")
+
+ assert response.status_code == 200
+ assert response.json()["spot_forecasts"][0]["id"] == 99
+ assert response.json()["spot_forecasts"][0]["forecast_type"] == "Mini"
+ assert response.json()["spot_forecasts"][0]["tabular_weather"][0]["wind"] == "SE 10-20 G 30-35"
+
+
+PUBLISH = "app.routers.smurfi.publish"
+CREATE_FORECAST = "app.routers.smurfi.create_spot_forecast"
+CREATE_DW = "app.routers.smurfi._create_descriptive_weather"
+CREATE_TW = "app.routers.smurfi._create_tabular_weather"
+START_REQUEST = "app.routers.smurfi.start_requested_spot_request"
+
+FORECAST_PAYLOAD = {
+ "spot_request_base_id": 1,
+ "spot_request_instance": {
+ "geographic_description": "Clearwater Valley",
+ "aspect": "North",
+ "elevation": 1000,
+ "valley": None,
+ "latitude": 48.5,
+ "longitude": -123.5,
+ },
+ "issued_at": "2026-05-21T16:00:00Z",
+ "expires_at": None,
+ "forecast_type": "Full",
+ "forecaster_phone": "250-555-0100",
+ "descriptive_weather": [],
+ "tabular_weather": [],
+}
+
+
+@pytest.mark.usefixtures("mock_jwt_decode")
+def test_create_spot_forecast_publishes_nats_message():
+ """Creating a spot forecast publishes a smurfi.spot.update NATS message."""
+ client = TestClient(app.main.app)
+ mock_result = type("SpotForecast", (), {"id": 99})()
+ with (
+ patch(DB_WRITE),
+ patch(
+ CREATE_FORECAST, new_callable=AsyncMock, return_value=mock_result
+ ) as mock_create_forecast,
+ patch(
+ GET_OR_CREATE_INSTANCE,
+ new_callable=AsyncMock,
+ return_value=_make_spot_request_instance(),
+ ),
+ patch(CREATE_DW, new_callable=AsyncMock, return_value=[]),
+ patch(CREATE_TW, new_callable=AsyncMock, return_value=[]),
+ patch(START_REQUEST, new_callable=AsyncMock) as mock_start_request,
+ patch(PUBLISH, new_callable=AsyncMock) as mock_publish,
+ ):
+ response = client.post("/api/smurfi/spot_forecast", json=FORECAST_PAYLOAD)
+ assert response.status_code == 200
+ saved_forecast = mock_create_forecast.call_args.args[1]
+ assert saved_forecast.forecaster_name == "test_username"
+ assert saved_forecast.forecaster_email == "test@email.com"
+ assert saved_forecast.forecaster_phone == "250-555-0100"
+ assert saved_forecast.forecast_type == "Full"
+ assert response.json()["spot_forecast"]["forecaster_phone"] == "250-555-0100"
+ mock_start_request.assert_awaited_once_with(ANY, FORECAST_PAYLOAD["spot_request_base_id"])
+ mock_publish.assert_called_once_with(
+ stream=stream_name,
+ subject=smurfi_spot_update_subject,
+ payload=ANY,
+ subjects=subjects,
+ )
diff --git a/backend/packages/wps-api/src/app/tests/test_auth.py b/backend/packages/wps-api/src/app/tests/test_auth.py
index aecb061fb7..f4761d582b 100644
--- a/backend/packages/wps-api/src/app/tests/test_auth.py
+++ b/backend/packages/wps-api/src/app/tests/test_auth.py
@@ -1,10 +1,16 @@
-from fastapi.testclient import TestClient
+from contextlib import asynccontextmanager
from datetime import datetime, timezone
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import app.main
import pytest
+import wps_shared.auth as auth
+from app.tests import load_json_file
+from fastapi import HTTPException, status
from fastapi.routing import APIRoute
+from fastapi.testclient import TestClient
from wps_shared.auth import authentication_required
-import app.main
-from app.tests import load_json_file
@pytest.mark.parametrize(
@@ -123,3 +129,72 @@ def test_non_fba_routes_blocked_for_test_guid():
else:
response = client.get(path, headers=headers)
assert response.status_code == 401
+
+
+@pytest.mark.anyio
+async def test_forecaster_or_spot_owner_auth_allows_forecaster_without_db_lookup(monkeypatch):
+ token = {"client_roles": ["morecast2_write_forecast"], "idir_username": "someone"}
+
+ def fail_if_queried():
+ raise AssertionError("forecaster role should not need a spot request lookup")
+
+ monkeypatch.setattr("wps_shared.db.database.get_async_read_session_scope", fail_if_queried)
+
+ result = await auth.auth_with_forecaster_role_or_spot_owner_required(42, token)
+
+ assert result == token
+
+
+@pytest.mark.anyio
+async def test_forecaster_or_spot_owner_auth_allows_spot_owner(monkeypatch):
+ session = SimpleNamespace()
+ token = {"client_roles": [], "idir_username": "owner_idir"}
+ get_spot_request = AsyncMock(return_value=SimpleNamespace(requestor_idir="OWNER_IDIR"))
+
+ @asynccontextmanager
+ async def session_scope():
+ yield session
+
+ monkeypatch.setattr("wps_shared.db.database.get_async_read_session_scope", session_scope)
+ monkeypatch.setattr("wps_shared.db.crud.smurfi.get_spot_request_by_id", get_spot_request)
+
+ result = await auth.auth_with_forecaster_role_or_spot_owner_required(42, token)
+
+ assert result == token
+ get_spot_request.assert_awaited_once_with(session, 42)
+
+
+@pytest.mark.anyio
+async def test_forecaster_or_spot_owner_auth_rejects_non_owner(monkeypatch):
+ token = {"client_roles": [], "idir_username": "other_idir"}
+ get_spot_request = AsyncMock(return_value=SimpleNamespace(requestor_idir="owner_idir"))
+
+ @asynccontextmanager
+ async def session_scope():
+ yield SimpleNamespace()
+
+ monkeypatch.setattr("wps_shared.db.database.get_async_read_session_scope", session_scope)
+ monkeypatch.setattr("wps_shared.db.crud.smurfi.get_spot_request_by_id", get_spot_request)
+
+ with pytest.raises(HTTPException) as exc:
+ await auth.auth_with_forecaster_role_or_spot_owner_required(42, token)
+
+ assert exc.value.status_code == status.HTTP_403_FORBIDDEN
+
+
+@pytest.mark.anyio
+async def test_forecaster_or_spot_owner_auth_returns_404_for_missing_spot_request(monkeypatch):
+ token = {"client_roles": [], "idir_username": "owner_idir"}
+ get_spot_request = AsyncMock(return_value=None)
+
+ @asynccontextmanager
+ async def session_scope():
+ yield SimpleNamespace()
+
+ monkeypatch.setattr("wps_shared.db.database.get_async_read_session_scope", session_scope)
+ monkeypatch.setattr("wps_shared.db.crud.smurfi.get_spot_request_by_id", get_spot_request)
+
+ with pytest.raises(HTTPException) as exc:
+ await auth.auth_with_forecaster_role_or_spot_owner_required(42, token)
+
+ assert exc.value.status_code == status.HTTP_404_NOT_FOUND
diff --git a/backend/packages/wps-shared/src/wps_shared/auth.py b/backend/packages/wps-shared/src/wps_shared/auth.py
index f7280952df..5fe850dedf 100644
--- a/backend/packages/wps-shared/src/wps_shared/auth.py
+++ b/backend/packages/wps-shared/src/wps_shared/auth.py
@@ -129,6 +129,38 @@ async def check_token_for_role(role: str, token):
return token
+async def auth_with_forecaster_role_or_spot_owner_required(
+ spot_request_id: int, token=Depends(authentication_required)
+):
+ """Return token if the user is a forecaster or owns the requested spot."""
+ if "morecast2_write_forecast" in (token.get("client_roles", []) or []):
+ return token
+
+ from wps_shared.db.crud.smurfi import get_spot_request_by_id
+ from wps_shared.db.database import get_async_read_session_scope
+
+ async with get_async_read_session_scope() as session:
+ spot_request = await get_spot_request_by_id(session, spot_request_id)
+
+ if spot_request is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"SpotRequestBase {spot_request_id} not found",
+ )
+
+ idir = token.get("idir_username", None)
+ is_owner = bool(idir and spot_request.requestor_idir) and (
+ idir.lower() == spot_request.requestor_idir.lower()
+ )
+ if not is_owner:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Not authorized to update this spot request status",
+ )
+
+ return token
+
+
def create_role_auth_dependency(role: str):
"""Factory function to create role-based authentication dependencies."""
diff --git a/backend/packages/wps-shared/src/wps_shared/db/crud/smurfi.py b/backend/packages/wps-shared/src/wps_shared/db/crud/smurfi.py
new file mode 100644
index 0000000000..14d0821ec7
--- /dev/null
+++ b/backend/packages/wps-shared/src/wps_shared/db/crud/smurfi.py
@@ -0,0 +1,426 @@
+from datetime import datetime, timezone
+
+from sqlalchemy import delete, func, insert, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from wps_shared.db.models.smurfi import (
+ SmurfiDistributionGroup,
+ SpotDescriptiveWeather,
+ SpotForecast,
+ SpotRequestBase,
+ SpotRequestInstance,
+ SpotRequestStatusEnum,
+ SpotSubscriber,
+ SpotSubscriberStatusEnum,
+ SpotTabularWeather,
+ spot_request_distribution_groups,
+)
+
+# roughly 20m in degrees, which is sufficient for matching spot request instances to forecasts without requiring exact coordinate matches
+COORDINATE_MATCH_TOLERANCE = 0.0002
+
+
+async def create_spot_request(session: AsyncSession, spot_request_base: SpotRequestBase):
+ session.add(spot_request_base)
+ await session.flush()
+ return spot_request_base
+
+
+async def create_spot_request_instance(
+ session: AsyncSession, spot_request_instance: SpotRequestInstance
+) -> SpotRequestInstance:
+ session.add(spot_request_instance)
+ await session.flush()
+ return spot_request_instance
+
+
+async def get_matching_spot_request_instance(
+ session: AsyncSession, spot_request_instance: SpotRequestInstance
+) -> SpotRequestInstance | None:
+ result = await session.execute(
+ select(SpotRequestInstance).where(
+ SpotRequestInstance.spot_request_base_id == spot_request_instance.spot_request_base_id,
+ func.abs(SpotRequestInstance.latitude - spot_request_instance.latitude)
+ <= COORDINATE_MATCH_TOLERANCE,
+ func.abs(SpotRequestInstance.longitude - spot_request_instance.longitude)
+ <= COORDINATE_MATCH_TOLERANCE,
+ SpotRequestInstance.geographic_description
+ == spot_request_instance.geographic_description,
+ SpotRequestInstance.aspect == spot_request_instance.aspect,
+ SpotRequestInstance.elevation == spot_request_instance.elevation,
+ SpotRequestInstance.valley == spot_request_instance.valley,
+ )
+ )
+ return result.scalar_one_or_none()
+
+
+async def get_or_create_spot_request_instance(
+ session: AsyncSession, spot_request_instance: SpotRequestInstance
+) -> SpotRequestInstance:
+ existing = await get_matching_spot_request_instance(session, spot_request_instance)
+ if existing is not None:
+ return existing
+ return await create_spot_request_instance(session, spot_request_instance)
+
+
+async def get_spot_request_by_id(
+ session: AsyncSession, spot_request_base_id: int
+) -> SpotRequestBase | None:
+ result = await session.execute(
+ select(SpotRequestBase)
+ .where(SpotRequestBase.id == spot_request_base_id)
+ .options(
+ selectinload(SpotRequestBase.spot_subscribers),
+ selectinload(SpotRequestBase.spot_request_instances),
+ selectinload(SpotRequestBase.spot_forecasts).selectinload(SpotForecast.tabular_weather),
+ selectinload(SpotRequestBase.spot_forecasts).selectinload(
+ SpotForecast.spot_request_instance
+ ),
+ selectinload(SpotRequestBase.distribution_groups),
+ )
+ )
+ return result.scalar_one_or_none()
+
+
+async def create_spot_forecast(session: AsyncSession, spot_forecast: SpotForecast):
+ session.add(spot_forecast)
+ await session.flush()
+ return spot_forecast
+
+
+async def sync_spot_subscribers(
+ session: AsyncSession, spot_request_base_id: int, emails: list[str]
+) -> list[SpotSubscriber]:
+ result = await session.execute(
+ select(SpotSubscriber).where(SpotSubscriber.spot_request_base_id == spot_request_base_id)
+ )
+ existing = result.scalars().all()
+
+ active_emails = set(emails)
+ existing_by_email = {s.email: s for s in existing}
+ active_subscribers = []
+
+ for subscriber in existing:
+ target_status = (
+ SpotSubscriberStatusEnum.ACTIVE.value
+ if subscriber.email in active_emails
+ else SpotSubscriberStatusEnum.INACTIVE.value
+ )
+ if subscriber.subscriber_status != target_status:
+ subscriber.subscriber_status = target_status
+ if target_status == SpotSubscriberStatusEnum.ACTIVE.value:
+ active_subscribers.append(subscriber)
+
+ for email in active_emails:
+ if email not in existing_by_email:
+ new_subscriber = SpotSubscriber(spot_request_base_id=spot_request_base_id, email=email)
+ session.add(new_subscriber)
+ active_subscribers.append(new_subscriber)
+
+ await session.flush()
+ return active_subscribers
+
+
+async def create_spot_tabular_weather(
+ session: AsyncSession, spot_tabular_weather: SpotTabularWeather
+):
+ session.add(spot_tabular_weather)
+ await session.flush()
+ return spot_tabular_weather
+
+
+async def create_spot_descriptive_weather(
+ session: AsyncSession, spot_descriptive_weather: SpotDescriptiveWeather
+):
+ session.add(spot_descriptive_weather)
+ await session.flush()
+ return spot_descriptive_weather
+
+
+async def start_requested_spot_request(session: AsyncSession, spot_request_base_id: int):
+ await session.execute(
+ update(SpotRequestBase)
+ .where(
+ SpotRequestBase.id == spot_request_base_id,
+ SpotRequestBase.status == SpotRequestStatusEnum.REQUESTED.value,
+ )
+ .values(status=SpotRequestStatusEnum.STARTED.value)
+ )
+ await session.flush()
+
+
+async def update_spot_request_status(
+ session: AsyncSession, spot_request: SpotRequestBase, status: SpotRequestStatusEnum
+) -> SpotRequestBase:
+ spot_request.status = status.value
+ await session.flush()
+ return spot_request
+
+
+async def update_spot_request_details(session: AsyncSession, updated: SpotRequestBase):
+ result = await session.execute(select(SpotRequestBase).where(SpotRequestBase.id == updated.id))
+ existing = result.scalar_one_or_none()
+ if existing is not None:
+ existing.fire_number = updated.fire_number
+ existing.fire_centre = updated.fire_centre
+ existing.request_frequency = updated.request_frequency
+ existing.request_type = updated.request_type
+ existing.additional_information = updated.additional_information
+ existing.start_at = updated.start_at
+ existing.end_at = updated.end_at
+ await session.flush()
+ return existing
+
+
+async def update_spot_request_instance_details(
+ session: AsyncSession, existing: SpotRequestInstance, updated: SpotRequestInstance
+) -> SpotRequestInstance:
+ existing.latitude = updated.latitude
+ existing.longitude = updated.longitude
+ existing.geom = updated.geom
+ existing.geographic_description = updated.geographic_description
+ existing.aspect = updated.aspect
+ existing.elevation = updated.elevation
+ existing.valley = updated.valley
+ await session.flush()
+ return existing
+
+
+async def get_spot_requests_for_year(session: AsyncSession, year: int):
+ year_start = datetime(year, 1, 1, tzinfo=timezone.utc)
+ year_end = datetime(year + 1, 1, 1, tzinfo=timezone.utc)
+ result = await session.execute(
+ select(SpotRequestBase)
+ .where(SpotRequestBase.start_at >= year_start, SpotRequestBase.start_at < year_end)
+ .options(
+ selectinload(SpotRequestBase.spot_subscribers),
+ selectinload(SpotRequestBase.spot_request_instances),
+ selectinload(SpotRequestBase.spot_forecasts).selectinload(SpotForecast.tabular_weather),
+ selectinload(SpotRequestBase.spot_forecasts).selectinload(
+ SpotForecast.spot_request_instance
+ ),
+ selectinload(SpotRequestBase.distribution_groups),
+ )
+ )
+ return result.scalars().all()
+
+
+async def update_spot_subscriber_status(
+ session: AsyncSession, subscriber_id: int, status: SpotSubscriberStatusEnum
+):
+ result = await session.execute(select(SpotSubscriber).where(SpotSubscriber.id == subscriber_id))
+ subscriber = result.scalar_one_or_none()
+ if subscriber is not None:
+ subscriber.subscriber_status = status.value
+ await session.flush()
+ return subscriber
+
+
+async def subscribe_to_spot_request(
+ session: AsyncSession, spot_request_base_id: int, email: str
+) -> SpotSubscriber:
+ result = await session.execute(
+ select(SpotSubscriber).where(
+ SpotSubscriber.spot_request_base_id == spot_request_base_id,
+ SpotSubscriber.email == email,
+ )
+ )
+ subscriber = result.scalar_one_or_none()
+ if subscriber is None:
+ subscriber = SpotSubscriber(
+ spot_request_base_id=spot_request_base_id,
+ email=email,
+ subscriber_status=SpotSubscriberStatusEnum.ACTIVE.value,
+ )
+ session.add(subscriber)
+ else:
+ subscriber.subscriber_status = SpotSubscriberStatusEnum.ACTIVE.value
+ await session.flush()
+ return subscriber
+
+
+async def unsubscribe_from_spot_request(
+ session: AsyncSession, spot_request_base_id: int, email: str
+) -> SpotSubscriber | None:
+ result = await session.execute(
+ select(SpotSubscriber).where(
+ SpotSubscriber.spot_request_base_id == spot_request_base_id,
+ SpotSubscriber.email == email,
+ )
+ )
+ subscriber = result.scalar_one_or_none()
+ if subscriber is not None:
+ subscriber.subscriber_status = SpotSubscriberStatusEnum.INACTIVE.value
+ await session.flush()
+ return subscriber
+
+
+async def get_subscribed_spot_request_ids(session: AsyncSession, email: str) -> list[int]:
+ result = await session.execute(
+ select(SpotSubscriber.spot_request_base_id).where(
+ SpotSubscriber.email == email,
+ SpotSubscriber.subscriber_status == SpotSubscriberStatusEnum.ACTIVE.value,
+ )
+ )
+ return list(result.scalars().all())
+
+
+async def get_active_subscribers_for_spot(
+ session: AsyncSession, spot_request_base_id: int
+) -> list[SpotSubscriber]:
+ result = await session.execute(
+ select(SpotSubscriber).where(
+ SpotSubscriber.spot_request_base_id == spot_request_base_id,
+ SpotSubscriber.subscriber_status == SpotSubscriberStatusEnum.ACTIVE.value,
+ )
+ )
+ return list(result.scalars().all())
+
+
+async def get_distribution_groups(
+ session: AsyncSession, owner_idir: str
+) -> list[SmurfiDistributionGroup]:
+ result = await session.execute(
+ select(SmurfiDistributionGroup)
+ .where(SmurfiDistributionGroup.owner_idir == owner_idir)
+ .order_by(SmurfiDistributionGroup.name)
+ )
+ return list(result.scalars().all())
+
+
+async def get_distribution_group(
+ session: AsyncSession, group_id: int
+) -> SmurfiDistributionGroup | None:
+ result = await session.execute(
+ select(SmurfiDistributionGroup).where(SmurfiDistributionGroup.id == group_id)
+ )
+ return result.scalar_one_or_none()
+
+
+async def create_distribution_group(
+ session: AsyncSession, group: SmurfiDistributionGroup
+) -> SmurfiDistributionGroup:
+ session.add(group)
+ await session.flush()
+ return group
+
+
+async def update_distribution_group(
+ session: AsyncSession, group_id: int, name: str, emails: list[str], owner_idir: str
+) -> SmurfiDistributionGroup | None:
+ result = await session.execute(
+ select(SmurfiDistributionGroup).where(
+ SmurfiDistributionGroup.id == group_id,
+ SmurfiDistributionGroup.owner_idir == owner_idir,
+ )
+ )
+ group = result.scalar_one_or_none()
+ if group is not None:
+ group.name = name
+ group.emails = emails
+ await session.flush()
+ return group
+
+
+async def delete_distribution_group(session: AsyncSession, group_id: int, owner_idir: str) -> bool:
+ result = await session.execute(
+ select(SmurfiDistributionGroup).where(
+ SmurfiDistributionGroup.id == group_id,
+ SmurfiDistributionGroup.owner_idir == owner_idir,
+ )
+ )
+ group = result.scalar_one_or_none()
+ if group is None:
+ return False
+ await session.delete(group)
+ await session.flush()
+ return True
+
+
+async def sync_spot_request_distribution_groups(
+ session: AsyncSession, spot_request_id: int, group_ids: list[int]
+) -> None:
+ await session.execute(
+ delete(spot_request_distribution_groups).where(
+ spot_request_distribution_groups.c.spot_request_id == spot_request_id
+ )
+ )
+ for group_id in group_ids:
+ await session.execute(
+ insert(spot_request_distribution_groups).values(
+ spot_request_id=spot_request_id, distribution_group_id=group_id
+ )
+ )
+ await session.flush()
+
+
+async def get_all_notification_emails_for_spot(
+ session: AsyncSession, spot_request_id: int
+) -> list[str]:
+ sub_result = await session.execute(
+ select(SpotSubscriber.email).where(
+ SpotSubscriber.spot_request_base_id == spot_request_id,
+ SpotSubscriber.subscriber_status == SpotSubscriberStatusEnum.ACTIVE.value,
+ )
+ )
+ emails: set[str] = set(sub_result.scalars().all())
+
+ group_result = await session.execute(
+ select(SmurfiDistributionGroup.emails)
+ .join(
+ spot_request_distribution_groups,
+ SmurfiDistributionGroup.id == spot_request_distribution_groups.c.distribution_group_id,
+ )
+ .where(spot_request_distribution_groups.c.spot_request_id == spot_request_id)
+ )
+ for group_emails in group_result.scalars().all():
+ emails.update(group_emails or [])
+
+ return list(emails)
+
+
+async def get_distribution_groups_for_spot(
+ session: AsyncSession, spot_request_id: int
+) -> list[SmurfiDistributionGroup]:
+ result = await session.execute(
+ select(SmurfiDistributionGroup)
+ .join(
+ spot_request_distribution_groups,
+ SmurfiDistributionGroup.id == spot_request_distribution_groups.c.distribution_group_id,
+ )
+ .where(spot_request_distribution_groups.c.spot_request_id == spot_request_id)
+ )
+ return list(result.scalars().all())
+
+
+async def get_spot_forecast_with_weather(
+ session: AsyncSession, spot_forecast_id: int
+) -> SpotForecast | None:
+ result = await session.execute(
+ select(SpotForecast)
+ .where(SpotForecast.id == spot_forecast_id)
+ .options(
+ selectinload(SpotForecast.descriptive_weather),
+ selectinload(SpotForecast.tabular_weather),
+ selectinload(SpotForecast.spot_request_base),
+ selectinload(SpotForecast.spot_request_instance),
+ )
+ )
+ return result.scalar_one_or_none()
+
+
+async def get_spot_forecasts_for_request(
+ session: AsyncSession, spot_request_base_id: int
+) -> list[SpotForecast]:
+ result = await session.execute(
+ select(SpotForecast)
+ .where(SpotForecast.spot_request_base_id == spot_request_base_id)
+ .options(
+ selectinload(SpotForecast.descriptive_weather),
+ selectinload(SpotForecast.tabular_weather),
+ selectinload(SpotForecast.spot_request_instance),
+ )
+ .order_by(SpotForecast.created_at.desc())
+ )
+ return list(result.scalars().all())
diff --git a/backend/packages/wps-shared/src/wps_shared/db/models/__init__.py b/backend/packages/wps-shared/src/wps_shared/db/models/__init__.py
index 8ab2a4d70c..4b85489ffd 100644
--- a/backend/packages/wps-shared/src/wps_shared/db/models/__init__.py
+++ b/backend/packages/wps-shared/src/wps_shared/db/models/__init__.py
@@ -42,3 +42,11 @@
from wps_shared.db.models.fire_watch import FireWatch, FireWatchWeather, PrescriptionStatus
from wps_shared.db.models.sfms_run import SFMSRunLog
from wps_shared.db.models.fcm import DeviceToken
+from wps_shared.db.models.smurfi import (
+ SpotRequestBase,
+ SpotRequestInstance,
+ SpotSubscriber,
+ SpotForecast,
+ SpotDescriptiveWeather,
+ SpotTabularWeather,
+)
diff --git a/backend/packages/wps-shared/src/wps_shared/db/models/smurfi.py b/backend/packages/wps-shared/src/wps_shared/db/models/smurfi.py
new file mode 100644
index 0000000000..b1bf17e46b
--- /dev/null
+++ b/backend/packages/wps-shared/src/wps_shared/db/models/smurfi.py
@@ -0,0 +1,353 @@
+import enum
+
+from geoalchemy2 import Geometry
+from sqlalchemy import (
+ ARRAY,
+ CheckConstraint,
+ Column,
+ Enum,
+ Float,
+ ForeignKey,
+ Integer,
+ String,
+ Table,
+ Text,
+)
+from sqlalchemy.orm import relationship
+
+import wps_shared.utils.time as time_utils
+from wps_shared.db.models import Base
+from wps_shared.db.models.common import TZTimeStamp
+from wps_shared.db.models.psu import FireCentre
+from wps_shared.geospatial.geospatial import NAD83_BC_ALBERS
+
+
+class FrequencyDayEnum(enum.Enum):
+ Monday = "Monday"
+ Tuesday = "Tuesday"
+ Wednesday = "Wednesday"
+ Thursday = "Thursday"
+ Friday = "Friday"
+ Saturday = "Saturday"
+ Sunday = "Sunday"
+
+
+class RequestTypeEnum(enum.Enum):
+ FULL = "Full"
+ MINI = "Mini"
+
+
+request_type_values = (RequestTypeEnum.FULL.value, RequestTypeEnum.MINI.value)
+
+
+class SpotRequestStatusEnum(enum.Enum):
+ REQUESTED = "Requested"
+ STARTED = "Started"
+ SUSPENDED = "Suspended"
+ COMPLETE = "Complete"
+ ARCHIVED = "Archived"
+
+
+request_status_values = (
+ SpotRequestStatusEnum.REQUESTED.value,
+ SpotRequestStatusEnum.STARTED.value,
+ SpotRequestStatusEnum.SUSPENDED.value,
+ SpotRequestStatusEnum.COMPLETE.value,
+ SpotRequestStatusEnum.ARCHIVED.value,
+)
+
+
+class SpotForecastPeriodEnum(enum.Enum):
+ TODAY = "Today"
+ TONIGHT = "Tonight"
+ TOMORROW = "Tomorrow"
+
+
+forecast_period_values = (
+ SpotForecastPeriodEnum.TODAY.value,
+ SpotForecastPeriodEnum.TONIGHT.value,
+ SpotForecastPeriodEnum.TOMORROW.value,
+)
+
+
+class CardinalDirectionEnum(enum.Enum):
+ N = "North"
+ NW = "Northwest"
+ W = "West"
+ SW = "Southwest"
+ S = "South"
+ SE = "Southeast"
+ E = "East"
+ NE = "Northeast"
+
+
+cardinal_direction_values = (
+ CardinalDirectionEnum.N.value,
+ CardinalDirectionEnum.NW.value,
+ CardinalDirectionEnum.W.value,
+ CardinalDirectionEnum.SW.value,
+ CardinalDirectionEnum.S.value,
+ CardinalDirectionEnum.SE.value,
+ CardinalDirectionEnum.E.value,
+ CardinalDirectionEnum.NE.value,
+)
+
+
+spot_request_distribution_groups = Table(
+ "spot_request_distribution_group",
+ Base.metadata,
+ Column(
+ "spot_request_id",
+ Integer,
+ ForeignKey("spot_request_base.id", ondelete="CASCADE"),
+ primary_key=True,
+ ),
+ Column(
+ "distribution_group_id",
+ Integer,
+ ForeignKey("smurfi_distribution_group.id", ondelete="CASCADE"),
+ primary_key=True,
+ ),
+)
+
+
+class SmurfiDistributionGroup(Base):
+ """Named email distribution groups that can be attached to spot requests."""
+
+ __tablename__ = "smurfi_distribution_group"
+ __table_args__ = {"comment": "Named distribution groups for spot forecast email notifications."}
+
+ id = Column(Integer, primary_key=True)
+ name = Column(String, nullable=False, unique=True)
+ emails = Column(ARRAY(String), nullable=False, default=list)
+ owner_idir = Column(String, nullable=False)
+ created_at = Column(TZTimeStamp, nullable=False, default=time_utils.get_utc_now)
+ updated_at = Column(
+ TZTimeStamp, nullable=False, onupdate=time_utils.get_utc_now, default=time_utils.get_utc_now
+ )
+
+ spot_requests = relationship(
+ "SpotRequestBase",
+ secondary=spot_request_distribution_groups,
+ back_populates="distribution_groups",
+ )
+
+
+class SpotSubscriberStatusEnum(enum.Enum):
+ ACTIVE = "active"
+ INACTIVE = "inactive"
+
+
+subscriber_status_values = (
+ SpotSubscriberStatusEnum.ACTIVE.value,
+ SpotSubscriberStatusEnum.INACTIVE.value,
+)
+
+
+class SpotRequestBase(Base):
+ """A durable administrative request for spot forecasts."""
+
+ __tablename__ = "spot_request_base"
+
+ id = Column(Integer, primary_key=True)
+ request_reference = Column(String, nullable=False)
+ fire_number = Column(
+ ARRAY(String), nullable=True
+ ) # nullable to allow spot forecasts for prescribed burns
+ fire_centre = Column(Integer, ForeignKey(FireCentre.id), nullable=False)
+ status = Column(
+ String,
+ nullable=False,
+ default=SpotRequestStatusEnum.REQUESTED.value,
+ index=True,
+ )
+ requestor_name = Column(String, nullable=False)
+ requestor_idir = Column(String, nullable=False)
+ requestor_email = Column(String, nullable=False)
+ request_frequency = Column(ARRAY(Enum(FrequencyDayEnum)), nullable=True)
+ request_type = Column(String, nullable=False, default=RequestTypeEnum.FULL.value)
+ additional_information = Column(Text, nullable=True)
+ requested_at = Column(TZTimeStamp, nullable=False)
+ start_at = Column(TZTimeStamp, nullable=False, index=True)
+ end_at = Column(TZTimeStamp, nullable=False, index=True)
+ created_at = Column(TZTimeStamp, nullable=False, default=time_utils.get_utc_now)
+ updated_at = Column(
+ TZTimeStamp, nullable=False, onupdate=time_utils.get_utc_now, default=time_utils.get_utc_now
+ )
+
+ # Relationships
+ spot_forecasts = relationship("SpotForecast", back_populates="spot_request_base")
+ spot_request_instances = relationship(
+ "SpotRequestInstance",
+ back_populates="spot_request_base",
+ foreign_keys="SpotRequestInstance.spot_request_base_id",
+ )
+ spot_subscribers = relationship("SpotSubscriber", back_populates="spot_request_base")
+ distribution_groups = relationship(
+ "SmurfiDistributionGroup",
+ secondary=spot_request_distribution_groups,
+ back_populates="spot_requests",
+ )
+
+ __table_args__ = (
+ CheckConstraint(status.in_(request_status_values), name="chk_status_spot_request_base"),
+ CheckConstraint(
+ request_type.in_(request_type_values), name="chk_request_type_spot_request_base"
+ ),
+ {"comment": "Tracks administrative requests for spot weather forecasts."},
+ )
+
+
+class SpotRequestInstance(Base):
+ """An instance of a spot_request containing geographic and terrain context for a spot request or forecast."""
+
+ __tablename__ = "spot_request_instance"
+
+ id = Column(Integer, primary_key=True)
+ spot_request_base_id = Column(
+ Integer, ForeignKey("spot_request_base.id"), nullable=False, index=True
+ )
+ latitude = Column(Float, nullable=False)
+ longitude = Column(Float, nullable=False)
+ geom = Column(Geometry("POINT", spatial_index=False, srid=NAD83_BC_ALBERS), nullable=False)
+ geographic_description = Column(String, nullable=False)
+ aspect = Column(String, nullable=True)
+ elevation = Column(Integer, nullable=True)
+ valley = Column(String, nullable=True)
+ created_at = Column(TZTimeStamp, nullable=False, default=time_utils.get_utc_now)
+ updated_at = Column(
+ TZTimeStamp, nullable=False, onupdate=time_utils.get_utc_now, default=time_utils.get_utc_now
+ )
+
+ # Relationships
+ spot_request_base = relationship(
+ "SpotRequestBase",
+ back_populates="spot_request_instances",
+ foreign_keys=[spot_request_base_id],
+ )
+ spot_forecasts = relationship("SpotForecast", back_populates="spot_request_instance")
+
+ __table_args__ = (
+ {"comment": "Tracks geographic instances used by spot requests and forecasts."},
+ )
+
+
+class SpotSubscriber(Base):
+ """A class representing emails addresses that will receive spot forecasts for a spot request."""
+
+ __tablename__ = "spot_subscriber"
+
+ id = Column(Integer, primary_key=True)
+ spot_request_base_id = Column(
+ Integer, ForeignKey("spot_request_base.id"), nullable=False, index=True
+ )
+ email = Column(String, nullable=False, index=True)
+ subscriber_status = Column(
+ String, nullable=False, default=SpotSubscriberStatusEnum.ACTIVE.value
+ )
+ created_at = Column(TZTimeStamp, nullable=False, default=time_utils.get_utc_now)
+ updated_at = Column(
+ TZTimeStamp, nullable=False, onupdate=time_utils.get_utc_now, default=time_utils.get_utc_now
+ )
+
+ # Relationships
+ spot_request_base = relationship("SpotRequestBase", back_populates="spot_subscribers")
+
+ __table_args__ = (
+ CheckConstraint(
+ subscriber_status.in_(subscriber_status_values),
+ name="chk_subscriber_status_spot_subscriber",
+ ),
+ {"comment": "Tracks email addresses subscribed to spot forecasts for a spot requests."},
+ )
+
+
+class SpotForecast(Base):
+ """Represents a spot forecast for a spot request."""
+
+ __tablename__ = "spot_forecast"
+
+ id = Column(Integer, primary_key=True)
+ spot_request_base_id = Column(
+ Integer, ForeignKey("spot_request_base.id"), nullable=False, index=True
+ )
+ spot_request_instance_id = Column(
+ Integer, ForeignKey("spot_request_instance.id"), nullable=False, index=True
+ )
+
+ # forecaster info
+ forecaster_name = Column(String, nullable=False, index=True)
+ forecaster_email = Column(String, nullable=False)
+ forecaster_phone = Column(String, nullable=True)
+
+ synopsis = Column(Text, nullable=True)
+ inversion_and_venting = Column(Text, nullable=True)
+ outlook = Column(Text, nullable=True)
+ confidence = Column(Text, nullable=True)
+ forecast_type = Column(String, nullable=False, default=RequestTypeEnum.FULL.value)
+ fire_size = Column(ARRAY(Float), nullable=True)
+ representative_station_codes = Column(ARRAY(Integer), nullable=True)
+ created_at = Column(TZTimeStamp, nullable=False, default=time_utils.get_utc_now)
+ issued_at = Column(TZTimeStamp, nullable=False)
+ expires_at = Column(TZTimeStamp, nullable=True)
+
+ # Relationships
+ spot_request_base = relationship("SpotRequestBase", back_populates="spot_forecasts")
+ spot_request_instance = relationship("SpotRequestInstance", back_populates="spot_forecasts")
+ tabular_weather = relationship("SpotTabularWeather", back_populates="spot_forecast")
+ descriptive_weather = relationship("SpotDescriptiveWeather", back_populates="spot_forecast")
+
+ __table_args__ = (
+ CheckConstraint(
+ forecast_type.in_(request_type_values), name="chk_forecast_type_spot_forecast"
+ ),
+ {"comment": "Spot forecasts for spot requests."},
+ )
+
+
+class SpotTabularWeather(Base):
+ __tablename__ = "spot_tabular_weather"
+ __table_args__ = {
+ "comment": "Detailed numerical forecasts for weather variable in a spot forecast."
+ }
+
+ id = Column(Integer, primary_key=True)
+ spot_forecast_id = Column(Integer, ForeignKey("spot_forecast.id"), nullable=False, index=True)
+ forecast_time = Column(TZTimeStamp, nullable=False)
+ temperature = Column(Float, nullable=True)
+ relative_humidity = Column(Float, nullable=True)
+ wind = Column(String, nullable=True)
+ probability_of_precipitation = Column(Float, nullable=True)
+ precipitation_amount = Column(Float, nullable=True)
+
+ # Relationships
+ spot_forecast = relationship("SpotForecast", back_populates="tabular_weather")
+
+
+class SpotDescriptiveWeather(Base):
+ """A class representing the descriptive AFTERNOON/TONIGHT/TOMORROW forecasts in a full spot forecast.
+ eg. AFTERNOON: Mainly sunny in the morning then increasing afternoon cloud. MAX TEMP 11C, MIN RH 40%
+ TONIGHT: Mainly clear. MIN TEMP -2C. MAX RH 90%.
+ TOMORROW: Cloudy. TEMP 12C. MIN RH 40%.
+ """
+
+ __tablename__ = "spot_descriptive_weather"
+
+ id = Column(Integer, primary_key=True)
+ spot_forecast_id = Column(Integer, ForeignKey("spot_forecast.id"), nullable=False, index=True)
+ period = Column(String, nullable=False)
+ temperature = Column(Float, nullable=True)
+ relative_humidity = Column(Float, nullable=True)
+ conditions = Column(String, nullable=True)
+
+ # Relationships
+ spot_forecast = relationship("SpotForecast", back_populates="descriptive_weather")
+
+ __table_args__ = (
+ CheckConstraint(
+ period.in_(forecast_period_values), name="chk_period_spot_descriptive_weather"
+ ),
+ {
+ "comment": "Represents a general text based forecast which includes a description of conditions, temperature and humidity. "
+ },
+ )
diff --git a/backend/packages/wps-shared/src/wps_shared/schemas/smurfi.py b/backend/packages/wps-shared/src/wps_shared/schemas/smurfi.py
new file mode 100644
index 0000000000..e7eeffd412
--- /dev/null
+++ b/backend/packages/wps-shared/src/wps_shared/schemas/smurfi.py
@@ -0,0 +1,240 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from pydantic import BaseModel, Field
+
+from wps_shared.db.models.smurfi import SmurfiDistributionGroup
+
+
+class PullFromChefsResponse(BaseModel):
+ success: bool
+
+
+class DistributionGroupInput(BaseModel):
+ name: str
+ emails: list[str] = []
+
+
+class DistributionGroupOutput(DistributionGroupInput):
+ id: int
+
+ @classmethod
+ def to_schema(cls, group: SmurfiDistributionGroup) -> DistributionGroupOutput:
+ return cls(id=group.id, name=group.name, emails=group.emails)
+
+
+class SpotSubscriberData(BaseModel):
+ id: int | None = None
+ email: str
+ subscriber_status: str = "active"
+
+
+class UpdateSubscriberStatusData(BaseModel):
+ subscriber_id: int
+ status: str
+
+
+class SpotLatestForecastData(BaseModel):
+ id: int
+ created_at: datetime
+ issued_at: datetime
+ expires_at: datetime | None = None
+ forecast_end_at: datetime | None = None
+ forecaster_name: str | None = None
+
+
+class SpotRequestInstanceInput(BaseModel):
+ geographic_description: str
+ aspect: str | None = None
+ elevation: int | None = None
+ valley: str | None = None
+ latitude: float
+ longitude: float
+
+
+class SpotRequestInstanceData(SpotRequestInstanceInput):
+ id: int
+ created_at: datetime
+
+
+class SpotRequestInput(BaseModel):
+ id: int | None = None
+ request_reference: str
+ fire_number: list[str] | None = None
+ fire_centre: int
+ status: str = "Requested"
+ request_frequency: list[str] | None = None
+ request_type: str = "Full"
+ additional_information: str | None = None
+ initial_instance: SpotRequestInstanceInput
+ requested_at: datetime
+ start_at: datetime
+ end_at: datetime
+ subscribers: list[SpotSubscriberData] = Field(default_factory=list)
+ distribution_group_ids: list[int] = Field(default_factory=list)
+
+
+class SpotRequestEditInput(BaseModel):
+ # edit payloads intentionally omit create-only fields like requestor, requested_at, and status
+ fire_number: list[str] | None = None
+ fire_centre: int
+ request_frequency: list[str] | None = None
+ request_type: str = "Full"
+ additional_information: str | None = None
+ request_instance: SpotRequestInstanceInput
+ start_at: datetime
+ end_at: datetime
+ subscribers: list[SpotSubscriberData] = Field(default_factory=list)
+ distribution_group_ids: list[int] = Field(default_factory=list)
+
+
+class SpotRequestData(BaseModel):
+ id: int
+ request_reference: str
+ fire_number: list[str] | None = None
+ fire_centre: int
+ status: str
+ requestor_name: str
+ requestor_idir: str
+ requestor_email: str
+ request_frequency: list[str] | None = None
+ request_type: str
+ additional_information: str | None = None
+ request_instance: SpotRequestInstanceData
+ requested_at: datetime
+ start_at: datetime
+ end_at: datetime
+ subscribers: list[SpotSubscriberData] = Field(default_factory=list)
+ distribution_group_ids: list[int] = Field(default_factory=list)
+ latest_forecast: SpotLatestForecastData | None = None
+ distribution_groups: list[DistributionGroupOutput] = []
+
+
+class SpotRequestResponse(BaseModel):
+ spot_request: SpotRequestData
+
+
+class SpotRequestStatusUpdate(BaseModel):
+ status: str
+
+
+class SpotRequestListResponse(BaseModel):
+ spot_requests: list[SpotRequestData]
+
+
+class SpotDescriptiveWeatherInput(BaseModel):
+ period: str
+ temperature: float | None = None
+ relative_humidity: float | None = None
+ conditions: str | None = None
+
+
+class SpotDescriptiveWeatherData(SpotDescriptiveWeatherInput):
+ id: int | None = None
+
+
+class SpotTabularWeatherInput(BaseModel):
+ forecast_time: datetime
+ temperature: float | None = None
+ relative_humidity: float | None = None
+ wind: str | None = None
+ probability_of_precipitation: float | None = None
+ precipitation_amount: float | None = None
+
+
+class SpotTabularWeatherData(SpotTabularWeatherInput):
+ id: int | None = None
+
+
+class SpotForecastInput(BaseModel):
+ spot_request_base_id: int
+ spot_request_instance: SpotRequestInstanceInput
+ forecast_type: str = "Full"
+ issued_at: datetime
+ expires_at: datetime | None = None
+ synopsis: str | None = None
+ inversion_and_venting: str | None = None
+ outlook: str | None = None
+ confidence: str | None = None
+ forecaster_phone: str | None = None
+ fire_size: list[float | None] | None = None
+ representative_station_codes: list[int] | None = None
+ descriptive_weather: list[SpotDescriptiveWeatherInput] = Field(default_factory=list)
+ tabular_weather: list[SpotTabularWeatherInput] = Field(default_factory=list)
+
+
+class SpotForecastData(SpotForecastInput):
+ id: int | None = None
+ spot_request_instance_id: int
+ spot_request_instance: SpotRequestInstanceData
+ created_at: datetime | None = None
+ forecaster_name: str | None = None
+ forecaster_email: str | None = None
+ forecaster_phone: str | None = None
+ descriptive_weather: list[SpotDescriptiveWeatherData] = Field(default_factory=list)
+ tabular_weather: list[SpotTabularWeatherData] = Field(default_factory=list)
+
+
+class SpotForecastResponse(BaseModel):
+ spot_forecast: SpotForecastData
+
+
+class SpotForecastListResponse(BaseModel):
+ spot_forecasts: list[SpotForecastData]
+
+
+class SmurfiGeneralForecastData(BaseModel):
+ period: str
+ temperature: float | None = None
+ relative_humidity: float | None = None
+ conditions: str | None = None
+
+
+class SmurfiForecastData(BaseModel):
+ forecast_time: str
+ temperature: float | None = None
+ relative_humidity: float | None = None
+ wind: str | None = None
+ probability_of_precipitation: float | None = None
+ precipitation_amount: float | None = None
+
+
+class SpotUpdatePayload(BaseModel):
+ spot_request_id: int
+ spot_forecast_id: int
+
+
+class SubscribeResponse(BaseModel):
+ subscriber_status: str
+
+
+class SubscriptionsResponse(BaseModel):
+ spot_request_ids: list[int]
+
+
+class SmurfiSpotVersionData(BaseModel):
+ spot_id: int
+ fire_number: str
+ requested_by: str
+ forecaster: str
+ latitude: float
+ longitude: float
+ elevation: float | None = None
+ representative_weather_stations: list[str] | None = None
+ forecaster_email: str | None = None
+ forecaster_phone: str | None = None
+ additional_fire_numbers: list[str] | None = None
+ geographic_area_name: str | None = None
+ fire_centre: str | None = None
+ elevation: float | None = None
+ fire_size: list[float | None] | None = None
+ slope: float | None = None
+ aspect: str | None = None
+ valley: str | None = None
+ synopsis: str | None = None
+ inversion_and_venting: str | None = None
+ outlook: str | None = None
+ confidence: str | None = None
+ general_forecasts: list[SmurfiGeneralForecastData] | None = None
+ forecasts: list[SmurfiForecastData] | None = None
diff --git a/backend/packages/wps-shared/src/wps_shared/schemas/stations.py b/backend/packages/wps-shared/src/wps_shared/schemas/stations.py
index cc2e1cb5d6..8e0a5c78bd 100644
--- a/backend/packages/wps-shared/src/wps_shared/schemas/stations.py
+++ b/backend/packages/wps-shared/src/wps_shared/schemas/stations.py
@@ -1,5 +1,7 @@
"""This module contains pydandict schemas relating to weather stations for the API."""
+
from typing import List, Optional
+
from pydantic import BaseModel, ConfigDict, Field
@@ -32,6 +34,7 @@ class WeatherStationProperties(BaseModel):
name: str
ecodivision_name: Optional[str] = None
core_season: Optional[Season] = None
+ elevation: Optional[int] = None
class WeatherVariables(BaseModel):
@@ -87,18 +90,21 @@ class WeatherStation(BaseModel):
class WeatherStationsResponse(BaseModel):
"""List of fire weather stations in geojson format."""
+
type: str = "FeatureCollection"
features: List[GeoJsonWeatherStation]
class DetailedWeatherStationsResponse(BaseModel):
"""List of fire weather stations, with details, in geojson format."""
+
type: str = "FeatureCollection"
features: List[GeoJsonDetailedWeatherStation]
class StationCodeList(BaseModel):
"""List of station codes."""
+
stations: List[int]
@@ -115,6 +121,7 @@ class WeatherStationGroupMember(BaseModel):
class WeatherStationGroupMembersResponse(BaseModel):
"""Response to a request for the stations in a group"""
+
stations: List[WeatherStationGroupMember]
@@ -130,6 +137,7 @@ class WeatherStationGroup(BaseModel):
class WeatherStationGroupsResponse(BaseModel):
"""Response to a request for all WFWX groups"""
+
groups: List[WeatherStationGroup]
diff --git a/backend/packages/wps-wf1/src/wps_wf1/parsers.py b/backend/packages/wps-wf1/src/wps_wf1/parsers.py
index eebb3c07ae..f820076d67 100644
--- a/backend/packages/wps-wf1/src/wps_wf1/parsers.py
+++ b/backend/packages/wps-wf1/src/wps_wf1/parsers.py
@@ -195,6 +195,7 @@ async def station_list_mapper(raw_stations: Generator[dict, None, None]):
name=raw_station["displayLabel"],
lat=raw_station["latitude"],
long=raw_station["longitude"],
+ elevation=raw_station["elevation"],
)
)
return stations
diff --git a/backend/packages/wps-wf1/src/wps_wf1/wfwx_api.py b/backend/packages/wps-wf1/src/wps_wf1/wfwx_api.py
index 7810a527a3..806a99cc97 100644
--- a/backend/packages/wps-wf1/src/wps_wf1/wfwx_api.py
+++ b/backend/packages/wps-wf1/src/wps_wf1/wfwx_api.py
@@ -562,6 +562,7 @@ async def get_stations_as_geojson(self) -> List[GeoJsonWeatherStation]:
name=station.name,
ecodivision_name=station.ecodivision_name,
core_season=station.core_season,
+ elevation=station.elevation,
),
geometry=WeatherStationGeometry(coordinates=[station.long, station.lat]),
)
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 7d602ae278..873acc3aab 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -9,4 +9,5 @@ dev = [
"pytest-xdist>=3,<4",
"coverage>=7.6.4,<8",
"ruff>=0.11.5,<1",
+ "requests>=2.32.5",
]
diff --git a/backend/uv.lock b/backend/uv.lock
index 5fca26045d..5beff12335 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -25,6 +25,7 @@ dev = [
{ name = "pytest-cov", specifier = ">=7.0.0,<8" },
{ name = "pytest-mock", specifier = ">=3,<4" },
{ name = "pytest-xdist", specifier = ">=3,<4" },
+ { name = "requests", specifier = ">=2.32.5" },
{ name = "ruff", specifier = ">=0.11.5,<1" },
]
diff --git a/codecov.yml b/codecov.yml
index 122b1f5422..f122649a36 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -6,6 +6,7 @@ ignore:
- "web/apps/wps-web/src/features/cHaines"
- "web/apps/wps-web/src/features/fwiCalculator"
- "web/apps/wps-web/src/features/fireWatch"
+ - "web/apps/wps-web/src/features/smurfi"
- "web/packages/api/src/fbaAPI.ts" # Unstable while we work on ASA prototype
- "web/packages/utils/src/env.ts" # Just configuration
- "backend/packages/wps-shared/src/wps_shared/db/crud" # We don't write tests for the crud layer, the crud layer is mocked out in tests.
diff --git a/openshift/scripts/oc_deploy_to_production.sh b/openshift/scripts/oc_deploy_to_production.sh
index 2c2d7c867f..28ffbff15d 100755
--- a/openshift/scripts/oc_deploy_to_production.sh
+++ b/openshift/scripts/oc_deploy_to_production.sh
@@ -38,7 +38,7 @@ echo Provision database
PROJ_TARGET=${PROJ_TARGET} BUCKET=lwzrin CPU_REQUEST=2 MEMORY_REQUEST=2Gi MEMORY_LIMIT=16Gi DATA_SIZE=65Gi WAL_SIZE=15Gi REPLICAS=3 bash $(dirname ${0})/oc_provision_crunchy.sh prod ${RUN_TYPE}
echo Provision NATS
-PROJ_TARGET=${PROJ_TARGET} bash $(dirname ${0})/oc_provision_nats.sh prod ${RUN_TYPE}
+PROJ_TARGET=${PROJ_TARGET} VANITY_DOMAIN=psu.nrs.gov.bc.ca bash $(dirname ${0})/oc_provision_nats.sh prod ${RUN_TYPE}
echo Deploy API
MODULE_NAME=api GUNICORN_WORKERS=8 CPU_REQUEST=100m MEMORY_REQUEST=6Gi MEMORY_LIMIT=8Gi REPLICAS=3 PROJ_TARGET=${PROJ_TARGET} VANITY_DOMAIN=psu.nrs.gov.bc.ca SECOND_LEVEL_DOMAIN=apps.silver.devops.gov.bc.ca ENVIRONMENT="production" bash $(dirname ${0})/oc_deploy.sh prod ${RUN_TYPE}
echo Deploy ASA Go API
diff --git a/openshift/scripts/oc_provision_nats.sh b/openshift/scripts/oc_provision_nats.sh
index 7933b8ce4e..526fda90c6 100755
--- a/openshift/scripts/oc_provision_nats.sh
+++ b/openshift/scripts/oc_provision_nats.sh
@@ -33,7 +33,8 @@ OC_PROCESS="oc -n ${PROJ_TARGET} process -f ${PATH_NATS} \
${MEMORY_LIMIT:+ "-p MEMORY_LIMIT=${MEMORY_LIMIT}"} \
${CPU_REQUEST:+ "-p CPU_REQUEST=${CPU_REQUEST}"} \
-p CRUNCHYDB_USER=${CRUNCHY_NAME}-${SUFFIX}-pguser-${CRUNCHY_NAME}-${SUFFIX} \
- -p APP_NAME=${APP_NAME}"
+ -p APP_NAME=${APP_NAME} \
+ -p VANITY_DOMAIN=${VANITY_DOMAIN}"
# Apply a template (apply or use --dry-run=client)
#
diff --git a/openshift/templates/nats.yaml b/openshift/templates/nats.yaml
index a2a2c78327..81e04f9d39 100644
--- a/openshift/templates/nats.yaml
+++ b/openshift/templates/nats.yaml
@@ -45,6 +45,9 @@ parameters:
- name: CPU_REQUEST
required: true
value: "1.5"
+ - name: VANITY_DOMAIN
+ description: Vanity domain for the web application (e.g. psu.nrs.gov.bc.ca)
+ required: true
objects:
- apiVersion: v1
@@ -449,3 +452,119 @@ objects:
name: monitor
- containerPort: 7777
name: metrics
+ - apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+ name: ${APP_NAME}-${SUFFIX}-smurfi-consumer
+ labels:
+ app: ${APP_NAME}-${SUFFIX}
+ annotations:
+ image.openshift.io/triggers: |-
+ [
+ {
+ "from": {
+ "kind": "ImageStreamTag",
+ "name": "${IMAGE_NAME}:${IMAGE_TAG}",
+ "namespace": "${PROJ_TOOLS}"
+ },
+ "fieldPath": "spec.template.spec.containers[0].image"
+ }
+ ]
+ spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: ${APP_NAME}-${SUFFIX}-smurfi-consumer
+ template:
+ metadata:
+ labels:
+ app: ${APP_NAME}-${SUFFIX}-smurfi-consumer
+ spec:
+ containers:
+ - name: ${APP_NAME}-${SUFFIX}-smurfi-consumer
+ image: ${IMAGE_REGISTRY}/${PROJ_TOOLS}/${IMAGE_NAME}:${IMAGE_TAG}
+ imagePullPolicy: "Always"
+ resources:
+ requests:
+ cpu: "50m"
+ memory: "128Mi"
+ limits:
+ memory: "256Mi"
+ command:
+ [
+ "uv",
+ "run",
+ "--no-sync",
+ "python",
+ "-m",
+ "app.smurfi.nats_consumer",
+ ]
+ env:
+ - name: UV_NO_CACHE
+ value: "1"
+ - name: NATS_STREAM_PREFIX
+ value: ${APP_NAME}-${SUFFIX}
+ - name: NATS_SERVER
+ valueFrom:
+ configMapKeyRef:
+ name: ${APP_NAME}-${SUFFIX}-nats-server
+ key: nats.server
+ - name: POSTGRES_READ_USER
+ valueFrom:
+ secretKeyRef:
+ name: ${CRUNCHYDB_USER}
+ key: user
+ - name: POSTGRES_WRITE_USER
+ valueFrom:
+ secretKeyRef:
+ name: ${CRUNCHYDB_USER}
+ key: user
+ - name: POSTGRES_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: ${CRUNCHYDB_USER}
+ key: password
+ - name: POSTGRES_WRITE_HOST
+ valueFrom:
+ secretKeyRef:
+ name: ${CRUNCHYDB_USER}
+ key: pgbouncer-host
+ - name: POSTGRES_READ_HOST
+ valueFrom:
+ secretKeyRef:
+ name: ${CRUNCHYDB_USER}
+ key: pgbouncer-host
+ - name: POSTGRES_PORT
+ valueFrom:
+ secretKeyRef:
+ name: ${CRUNCHYDB_USER}
+ key: pgbouncer-port
+ - name: POSTGRES_DATABASE
+ value: ${POSTGRES_DATABASE}
+ - name: CHES_TOKEN_URL
+ valueFrom:
+ secretKeyRef:
+ name: ${GLOBAL_NAME}
+ key: ches-token-url
+ - name: CHES_CLIENT_ID
+ valueFrom:
+ secretKeyRef:
+ name: ${GLOBAL_NAME}
+ key: ches-client-id
+ - name: CHES_CLIENT_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: ${GLOBAL_NAME}
+ key: ches-client-secret
+ - name: CHES_SENDER_EMAIL
+ valueFrom:
+ secretKeyRef:
+ name: ${GLOBAL_NAME}
+ key: ches-sender-email
+ - name: CHES_MERGE_URL
+ valueFrom:
+ secretKeyRef:
+ name: ${GLOBAL_NAME}
+ key: ches-merge-url
+ - name: WEB_BASE_URL
+ value: https://${VANITY_DOMAIN}
diff --git a/web/apps/wps-web/package.json b/web/apps/wps-web/package.json
index e0810eb717..579662b296 100644
--- a/web/apps/wps-web/package.json
+++ b/web/apps/wps-web/package.json
@@ -16,11 +16,13 @@
"dependencies": {
"@emotion/react": "^11.8.2",
"@emotion/styled": "^11.8.1",
+ "@hookform/resolvers": "^5.2.2",
"@mui/icons-material": "^9.0.0",
"@mui/material": "^9.0.0",
"@mui/system": "^9.0.0",
"@mui/x-data-grid-pro": "^9.0.0",
"@mui/x-date-pickers": "^9.0.0",
+ "@mui/x-date-pickers-pro": "^9.0.0",
"@psu/cffdrs_ts": "git+https://github.com/cffdrs/cffdrs_ts#b9afdabc89dd4bdf04ccf1e406a4a5d8d552ff51",
"@reduxjs/toolkit": "^2.2.7",
"@sentry/react": "^10.0.0",
@@ -61,11 +63,13 @@
"prettier": "^3.3.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-hook-form": "^7.71.1",
"react-is": "18.3.1",
"react-redux": "^9.1.2",
"react-router-dom": "^7.6.2",
"recharts": "^3.0.0",
- "whatwg-fetch": "^3.6.20"
+ "whatwg-fetch": "^3.6.20",
+ "zod": "3"
},
"scripts": {
"start": "vite",
diff --git a/web/apps/wps-web/public/images/smurfi/smurfi_submit.webp b/web/apps/wps-web/public/images/smurfi/smurfi_submit.webp
new file mode 100644
index 0000000000..a5093ed206
Binary files /dev/null and b/web/apps/wps-web/public/images/smurfi/smurfi_submit.webp differ
diff --git a/web/apps/wps-web/src/app/Routes.tsx b/web/apps/wps-web/src/app/Routes.tsx
index 74b95a57fb..fded9823ce 100644
--- a/web/apps/wps-web/src/app/Routes.tsx
+++ b/web/apps/wps-web/src/app/Routes.tsx
@@ -1,35 +1,38 @@
import React, { Suspense, lazy } from 'react'
-import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom'
+import { Navigate, Route, BrowserRouter as Router, Routes } from 'react-router-dom'
-import { HIDE_DISCLAIMER } from '@wps/utils/env'
-import AuthWrapper from 'features/auth/components/AuthWrapper'
-const PercentileCalculatorPageWithDisclaimer = lazy(
- () => import('features/percentileCalculator/pages/PercentileCalculatorPageWithDisclaimer')
-)
-const HfiCalculatorPage = lazy(() => import('features/hfiCalculator/pages/HfiCalculatorPage'))
-const CHainesPage = lazy(() => import('features/cHaines/pages/CHainesPage'))
+import AuthWrapper from '@/features/auth/components/AuthWrapper'
+import FireWatchPage from '@/features/fireWatch/pages/FireWatchPage'
+import { SFMSInsightsPage } from '@/features/sfmsInsights/pages/SFMSInsightsPage'
+import WeatherToolkitPage from '@/features/weatherToolkit/pages/WeatherToolkitPage'
import {
- PERCENTILE_CALC_ROUTE,
- HFI_CALC_ROUTE,
- MORECAST_ROUTE,
C_HAINES_ROUTE,
FIRE_BEHAVIOR_CALC_ROUTE,
FIRE_BEHAVIOUR_ADVISORY_ROUTE,
+ FIRE_WATCH_ROUTE,
+ HFI_CALC_ROUTE,
LANDING_PAGE_ROUTE,
+ MORECAST_ROUTE,
MORE_CAST_2_ROUTE,
+ PERCENTILE_CALC_ROUTE,
SFMS_INSIGHTS_ROUTE,
- FIRE_WATCH_ROUTE,
+ SMURFI_ROUTE,
+ SMURFI_DASHBOARD_ROUTE,
WEATHER_TOOLKIT_ROUTE
} from '@wps/utils/constants'
+import { HIDE_DISCLAIMER } from '@wps/utils/env'
import { NoMatchPage } from 'features/NoMatchPage'
+import LoadingBackdrop from 'features/hfiCalculator/components/LoadingBackdrop'
+const PercentileCalculatorPageWithDisclaimer = lazy(
+ () => import('features/percentileCalculator/pages/PercentileCalculatorPageWithDisclaimer')
+)
+const HfiCalculatorPage = lazy(() => import('features/hfiCalculator/pages/HfiCalculatorPage'))
+const CHainesPage = lazy(() => import('features/cHaines/pages/CHainesPage'))
const FireBehaviourCalculator = lazy(() => import('features/fbaCalculator/pages/FireBehaviourCalculatorPage'))
const FireBehaviourAdvisoryPage = lazy(() => import('features/fba/pages/FireBehaviourAdvisoryPage'))
const LandingPage = lazy(() => import('features/landingPage/pages/LandingPage'))
const MoreCast2Page = lazy(() => import('features/moreCast2/pages/MoreCast2Page'))
-import LoadingBackdrop from 'features/hfiCalculator/components/LoadingBackdrop'
-import { SFMSInsightsPage } from '@/features/sfmsInsights/pages/SFMSInsightsPage'
-import FireWatchPage from '@/features/fireWatch/pages/FireWatchPage'
-import WeatherToolkitPage from '@/features/weatherToolkit/pages/WeatherToolkitPage'
+const SMURFIPage = lazy(() => import('features/smurfi/pages/SMURFIPage'))
const shouldShowDisclaimer = HIDE_DISCLAIMER === 'false' || HIDE_DISCLAIMER === undefined
@@ -112,6 +115,15 @@ const WPSRoutes: React.FunctionComponent = () => {
}
/>
} />
+
+
+
+ }
+ />
+ } />
} />
diff --git a/web/apps/wps-web/src/app/rootReducer.ts b/web/apps/wps-web/src/app/rootReducer.ts
index 1340b31523..458aae4f20 100644
--- a/web/apps/wps-web/src/app/rootReducer.ts
+++ b/web/apps/wps-web/src/app/rootReducer.ts
@@ -25,6 +25,8 @@ import fireWatchSlice from 'features/fireWatch/slices/fireWatchSlice'
import fireWatchFireCentresSlice from '@/features/fireWatch/slices/fireWatchFireCentresSlice'
import burnForecastsSlice from '@/features/fireWatch/slices/burnForecastSlice'
import { filterHFIFuelStatsByArea } from '@/features/fba/hfiStatsUtils'
+import smurfiSlice from '@/features/smurfi/slices/smurfiSlice'
+import subscriptionsReducer from '@/features/smurfi/slices/subscriptionsSlice'
const rootReducer = combineReducers({
percentileStations: stationReducer,
@@ -51,7 +53,9 @@ const rootReducer = combineReducers({
morecastInputValid: morecastInputValidSlice,
fireWatch: fireWatchSlice,
fireWatchFireCentres: fireWatchFireCentresSlice,
- burnForecasts: burnForecastsSlice
+ burnForecasts: burnForecastsSlice,
+ smurfi: smurfiSlice,
+ subscriptions: subscriptionsReducer
})
// Infer whatever gets returned from rootReducer and use it as the type of the root state
diff --git a/web/apps/wps-web/src/features/auth/slices/authenticationSlice.test.ts b/web/apps/wps-web/src/features/auth/slices/authenticationSlice.test.ts
index d3388b2bbe..259822ede9 100644
--- a/web/apps/wps-web/src/features/auth/slices/authenticationSlice.test.ts
+++ b/web/apps/wps-web/src/features/auth/slices/authenticationSlice.test.ts
@@ -23,14 +23,19 @@ describe('authenticationSlice', () => {
})
const testToken = 'testToken'
const idir_username = 'test@idir'
+ const name = 'Test User'
const email = 'test@example.com'
const decodedAllRoles = {
idir_username,
+ given_name: 'Test',
+ family_name: 'User',
email,
client_roles: Object.values(ROLES.HFI)
}
const decodedNoRoles = {
idir_username,
+ given_name: 'Test',
+ family_name: 'User',
email,
client_roles: []
}
@@ -47,7 +52,7 @@ describe('authenticationSlice', () => {
it('should return idir username from token', () => {
sandbox.stub(jwt, 'jwtDecode').returns(decodedNoRoles)
const userDetails = decodeUserDetails(testToken)
- expect(userDetails).toEqual({ idir: idir_username, email })
+ expect(userDetails).toEqual({ idir: idir_username, name, email })
})
describe('reducer', () => {
it('should be initialized with correct state', () => {
@@ -72,6 +77,7 @@ describe('authenticationSlice', () => {
authenticating: false,
isAuthenticated: true,
idir: 'test@idir',
+ name,
email,
token: testToken,
idToken: testToken,
@@ -87,6 +93,7 @@ describe('authenticationSlice', () => {
authenticating: false,
isAuthenticated: true,
idir: 'test@idir',
+ name,
email,
token: testToken,
idToken: testToken,
@@ -114,6 +121,7 @@ describe('authenticationSlice', () => {
token: testToken,
idToken: testToken,
idir: 'test@idir',
+ name,
email,
roles: Object.values(ROLES.HFI)
})
@@ -129,6 +137,7 @@ describe('authenticationSlice', () => {
token: testToken,
idToken: testToken,
idir: 'test@idir',
+ name,
email,
roles: []
})
@@ -143,6 +152,7 @@ describe('authenticationSlice', () => {
token: testToken,
idToken: testToken,
idir: 'test@idir',
+ name,
email,
roles: Object.values(ROLES.HFI),
error: null
@@ -166,6 +176,7 @@ describe('authenticationSlice', () => {
token: testToken,
idToken: testToken,
idir: 'test@idir',
+ name,
email,
roles: Object.values(ROLES.HFI),
error: null
diff --git a/web/apps/wps-web/src/features/auth/slices/authenticationSlice.ts b/web/apps/wps-web/src/features/auth/slices/authenticationSlice.ts
index 400f376290..c10162f2f4 100644
--- a/web/apps/wps-web/src/features/auth/slices/authenticationSlice.ts
+++ b/web/apps/wps-web/src/features/auth/slices/authenticationSlice.ts
@@ -15,6 +15,7 @@ export interface AuthState {
token: string | undefined
idToken: string | undefined
idir: string | undefined
+ name: string | undefined
email: string | undefined
roles: string[]
error: string | null
@@ -27,6 +28,7 @@ export const initialState: AuthState = {
token: undefined,
idToken: undefined,
idir: undefined,
+ name: undefined,
email: undefined,
roles: [],
error: null
@@ -54,6 +56,7 @@ const authSlice = createSlice({
state.roles = decodeRoles(action.payload.token)
const userDetails = decodeUserDetails(action.payload.token)
state.idir = userDetails?.idir
+ state.name = userDetails?.name
state.email = userDetails?.email
},
authenticateError(state: AuthState, action: PayloadAction) {
@@ -76,6 +79,7 @@ const authSlice = createSlice({
state.roles = decodeRoles(action.payload.token)
const userDetails = decodeUserDetails(action.payload.token)
state.idir = userDetails?.idir
+ state.name = userDetails?.name
state.email = userDetails?.email
},
signoutFinished(state: AuthState) {
@@ -132,12 +136,14 @@ export const decodeUserDetails = (token: string | undefined) => {
return undefined
}
if (TEST_AUTH || window.Playwright) {
- return { idir: 'test@idir', email: 'test@example.com' }
+ return { idir: 'test@idir', name: 'test@idir', email: 'test@example.com' }
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const decodedToken: any = jwtDecode(token)
try {
- return { idir: decodedToken.idir_username, email: decodedToken.email }
+ const name =
+ [decodedToken.given_name, decodedToken.family_name].filter(Boolean).join(' ') || decodedToken.idir_username
+ return { idir: decodedToken.idir_username, name, email: decodedToken.email }
} catch (e) {
// No idir username
return undefined
diff --git a/web/apps/wps-web/src/features/currentFires/map/CurrentFiresClickInteraction.ts b/web/apps/wps-web/src/features/currentFires/map/CurrentFiresClickInteraction.ts
new file mode 100644
index 0000000000..429e0e03a0
--- /dev/null
+++ b/web/apps/wps-web/src/features/currentFires/map/CurrentFiresClickInteraction.ts
@@ -0,0 +1,86 @@
+import { Map, MapBrowserEvent } from 'ol'
+import { Coordinate } from 'ol/coordinate'
+import { EventsKey } from 'ol/events'
+import { unByKey } from 'ol/Observable'
+import { Interaction } from 'ol/interaction'
+import VectorLayer from 'ol/layer/Vector'
+import VectorSource from 'ol/source/Vector'
+import { CurrentFireAttributes } from '@/features/currentFires/map/currentFireLayers'
+import { getCurrentFireFeatureAtPixel } from '@/features/currentFires/map/currentFireFeaturePicking'
+
+export interface CurrentFireClickData {
+ attributes: CurrentFireAttributes
+ coordinate: Coordinate
+ pixel: number[]
+}
+
+export interface CurrentFiresClickInteractionOptions {
+ currentFirePointsLayer: VectorLayer
+ currentFirePolygonsLayer: VectorLayer
+ onFireClick?: (data: CurrentFireClickData) => void
+ onMapMiss?: (event: MapBrowserEvent) => void
+ shouldIgnoreClick?: (event: MapBrowserEvent) => boolean
+}
+
+export class CurrentFiresClickInteraction extends Interaction {
+ private readonly currentFirePointsLayer: VectorLayer
+ private readonly currentFirePolygonsLayer: VectorLayer
+ private readonly onFireClick?: (data: CurrentFireClickData) => void
+ private readonly onMapMiss?: (event: MapBrowserEvent) => void
+ private readonly shouldIgnoreClick?: (event: MapBrowserEvent) => boolean
+ private listenerKey?: EventsKey
+
+ constructor(options: CurrentFiresClickInteractionOptions) {
+ super()
+ this.currentFirePointsLayer = options.currentFirePointsLayer
+ this.currentFirePolygonsLayer = options.currentFirePolygonsLayer
+ this.onFireClick = options.onFireClick
+ this.onMapMiss = options.onMapMiss
+ this.shouldIgnoreClick = options.shouldIgnoreClick
+ }
+
+ setMap(map: Map | null) {
+ this.removeClickListener()
+ super.setMap(map)
+
+ if (map) {
+ this.listenerKey = map.on('click', this.handleClick.bind(this))
+ }
+ }
+
+ dispose() {
+ this.removeClickListener()
+ super.dispose()
+ }
+
+ private handleClick(event: MapBrowserEvent) {
+ if (!this.getActive() || this.shouldIgnoreClick?.(event)) {
+ return
+ }
+
+ const fireFeature = getCurrentFireFeatureAtPixel(
+ event.map,
+ event.pixel,
+ this.currentFirePointsLayer,
+ this.currentFirePolygonsLayer
+ )
+
+ if (!fireFeature) {
+ this.onMapMiss?.(event)
+ return
+ }
+
+ this.onFireClick?.({
+ attributes: fireFeature.attributes,
+ coordinate: event.coordinate,
+ pixel: event.pixel
+ })
+ }
+
+ private removeClickListener() {
+ if (this.listenerKey) {
+ unByKey(this.listenerKey)
+ this.listenerKey = undefined
+ }
+ }
+}
diff --git a/web/apps/wps-web/src/features/currentFires/map/currentFireFeaturePicking.ts b/web/apps/wps-web/src/features/currentFires/map/currentFireFeaturePicking.ts
new file mode 100644
index 0000000000..0ed2a1426d
--- /dev/null
+++ b/web/apps/wps-web/src/features/currentFires/map/currentFireFeaturePicking.ts
@@ -0,0 +1,31 @@
+import Map from 'ol/Map'
+import VectorLayer from 'ol/layer/Vector'
+import VectorSource from 'ol/source/Vector'
+import {
+ CurrentFireAttributes,
+ getCurrentFirePointAttributes,
+ getCurrentFirePolygonAttributes
+} from '@/features/currentFires/map/currentFireLayers'
+
+export interface CurrentFireFeaturePick {
+ attributes: CurrentFireAttributes
+}
+
+export const getCurrentFireFeatureAtPixel = (
+ map: Map,
+ pixel: number[],
+ currentFirePointsLayer: VectorLayer,
+ currentFirePolygonsLayer: VectorLayer
+): CurrentFireFeaturePick | null => {
+ const pointFeature = map.forEachFeatureAtPixel(pixel, (feature, layer) =>
+ layer === currentFirePointsLayer ? feature : undefined
+ )
+ if (pointFeature) {
+ return { attributes: getCurrentFirePointAttributes(pointFeature) }
+ }
+
+ const polygonFeature = map.forEachFeatureAtPixel(pixel, (feature, layer) =>
+ layer === currentFirePolygonsLayer ? feature : undefined
+ )
+ return polygonFeature ? { attributes: getCurrentFirePolygonAttributes(polygonFeature) } : null
+}
diff --git a/web/apps/wps-web/src/features/currentFires/map/currentFireLayerController.ts b/web/apps/wps-web/src/features/currentFires/map/currentFireLayerController.ts
new file mode 100644
index 0000000000..e125c83506
--- /dev/null
+++ b/web/apps/wps-web/src/features/currentFires/map/currentFireLayerController.ts
@@ -0,0 +1,29 @@
+import {
+ createCurrentFirePointStyle,
+ createCurrentFirePointsLayer,
+ createCurrentFirePolygonStyle,
+ createCurrentFirePolygonsLayer
+} from '@/features/currentFires/map/currentFireLayers'
+
+export interface CurrentFireLayers {
+ pointsLayer: ReturnType
+ polygonsLayer: ReturnType
+}
+
+export class CurrentFireLayerController {
+ private readonly layers: CurrentFireLayers
+
+ constructor(layers: CurrentFireLayers) {
+ this.layers = layers
+ }
+
+ setVisible(visible: boolean) {
+ this.layers.pointsLayer.setVisible(visible)
+ this.layers.polygonsLayer.setVisible(visible)
+ }
+
+ setStatuses(statuses: readonly string[]) {
+ this.layers.pointsLayer.setStyle(createCurrentFirePointStyle(statuses))
+ this.layers.polygonsLayer.setStyle(createCurrentFirePolygonStyle(statuses))
+ }
+}
diff --git a/web/apps/wps-web/src/features/currentFires/map/currentFireLayers.ts b/web/apps/wps-web/src/features/currentFires/map/currentFireLayers.ts
new file mode 100644
index 0000000000..d76decdade
--- /dev/null
+++ b/web/apps/wps-web/src/features/currentFires/map/currentFireLayers.ts
@@ -0,0 +1,189 @@
+import GeoJSON from 'ol/format/GeoJSON'
+import VectorLayer from 'ol/layer/Vector'
+import VectorSource from 'ol/source/Vector'
+import { Circle as CircleStyle, Fill, Stroke, Style, Text } from 'ol/style'
+import { CURRENT_FIRE_STATUS_OPTIONS, CurrentFireStatus } from '@/features/currentFires/map/layerVisibility'
+
+export interface CurrentFireAttributes {
+ fireNumber: string | null
+ fireSizeHectares: number | string | null
+ fireStatus: string | null
+ fireYear: number | string | null
+}
+
+const CURRENT_FIRE_POLYS_WFS_URL =
+ 'https://openmaps.gov.bc.ca/geo/pub/WHSE_LAND_AND_NATURAL_RESOURCE.PROT_CURRENT_FIRE_POLYS_SP/ows'
+const CURRENT_FIRE_POLYS_TYPE_NAME = 'pub:WHSE_LAND_AND_NATURAL_RESOURCE.PROT_CURRENT_FIRE_POLYS_SP'
+
+const CURRENT_FIRE_POINTS_WFS_URL =
+ 'https://openmaps.gov.bc.ca/geo/pub/WHSE_LAND_AND_NATURAL_RESOURCE.PROT_CURRENT_FIRE_PNTS_SP/ows'
+const CURRENT_FIRE_POINTS_TYPE_NAME = 'pub:WHSE_LAND_AND_NATURAL_RESOURCE.PROT_CURRENT_FIRE_PNTS_SP'
+
+const ACTIVE_FIRE_FILTER = "FIRE_STATUS <> 'Out'"
+const FIRE_LABEL_MAX_RESOLUTION = 1500
+
+export const CURRENT_FIRE_STATUS_COLORS: Record = {
+ 'Out of Control': '#D32F2F',
+ 'Being Held': '#F9A825',
+ 'Under Control': '#2E7D32'
+}
+
+const currentFirePolygonsUrl = new URL(CURRENT_FIRE_POLYS_WFS_URL)
+currentFirePolygonsUrl.search = new URLSearchParams({
+ service: 'WFS',
+ version: '2.0.0',
+ request: 'GetFeature',
+ typeNames: CURRENT_FIRE_POLYS_TYPE_NAME,
+ outputFormat: 'application/json',
+ srsName: 'EPSG:4326',
+ CQL_FILTER: ACTIVE_FIRE_FILTER
+}).toString()
+
+const currentFirePointsUrl = new URL(CURRENT_FIRE_POINTS_WFS_URL)
+currentFirePointsUrl.search = new URLSearchParams({
+ service: 'WFS',
+ version: '2.0.0',
+ request: 'GetFeature',
+ typeNames: CURRENT_FIRE_POINTS_TYPE_NAME,
+ outputFormat: 'application/json',
+ srsName: 'EPSG:4326',
+ CQL_FILTER: ACTIVE_FIRE_FILTER
+}).toString()
+
+const buildCurrentFirePolygonsUrl = (cqlFilter: string) => {
+ const url = new URL(CURRENT_FIRE_POLYS_WFS_URL)
+ url.search = new URLSearchParams({
+ service: 'WFS',
+ version: '2.0.0',
+ request: 'GetFeature',
+ typeNames: CURRENT_FIRE_POLYS_TYPE_NAME,
+ outputFormat: 'application/json',
+ srsName: 'EPSG:4326',
+ CQL_FILTER: cqlFilter
+ }).toString()
+ return url.toString()
+}
+
+export const fetchCurrentFireSizeByFireNumber = async (fireNumber: string): Promise => {
+ const escapedFireNumber = fireNumber.replaceAll("'", "''")
+ const response = await fetch(
+ buildCurrentFirePolygonsUrl(`${ACTIVE_FIRE_FILTER} AND FIRE_NUMBER = '${escapedFireNumber}'`)
+ )
+ const data = await response.json()
+ const fireSize = data?.features?.[0]?.properties?.FIRE_SIZE_HECTARES
+ const numericFireSize = Number(fireSize)
+ return Number.isFinite(numericFireSize) ? numericFireSize : null
+}
+
+export const fetchCurrentFireSizesByFireNumbers = async (fireNumbers: string[]): Promise<(number | null)[]> =>
+ Promise.all(fireNumbers.map(fetchCurrentFireSizeByFireNumber))
+
+const createFireLabel = (feature: { get: (property: string) => string | undefined }, resolution: number) => {
+ if (resolution > FIRE_LABEL_MAX_RESOLUTION) {
+ return undefined
+ }
+
+ return new Text({
+ text: feature.get('FIRE_NUMBER') ?? '',
+ font: '600 12px sans-serif',
+ overflow: true,
+ fill: new Fill({
+ color: '#2F1B16'
+ }),
+ stroke: new Stroke({
+ color: '#FFFFFF',
+ width: 3
+ })
+ })
+}
+
+const createPointFireLabel = (feature: { get: (property: string) => string | undefined }, resolution: number) => {
+ const label = createFireLabel(feature, resolution)
+ label?.setOffsetY(-12)
+ return label
+}
+
+const currentFirePolygonStyle = (feature: { get: (property: string) => string | undefined }, resolution: number) =>
+ new Style({
+ stroke: new Stroke({
+ color: '#B3261E',
+ width: 2
+ }),
+ fill: new Fill({
+ color: 'rgba(179, 38, 30, 0.16)'
+ })
+ })
+
+const isCurrentFireStatus = (status: string | undefined): status is CurrentFireStatus =>
+ CURRENT_FIRE_STATUS_OPTIONS.includes(status as CurrentFireStatus)
+
+const getCurrentFireStatusColor = (status: string | undefined) =>
+ isCurrentFireStatus(status) ? CURRENT_FIRE_STATUS_COLORS[status] : '#757575'
+
+const isFireStatusVisible = (
+ feature: { get: (property: string) => string | undefined },
+ visibleStatuses: readonly string[]
+) => visibleStatuses.includes(feature.get('FIRE_STATUS') ?? '')
+
+const currentFirePointStyle = (feature: { get: (property: string) => string | undefined }, resolution: number) =>
+ new Style({
+ image: new CircleStyle({
+ radius: 5,
+ fill: new Fill({
+ color: getCurrentFireStatusColor(feature.get('FIRE_STATUS'))
+ }),
+ stroke: new Stroke({
+ color: '#FFFFFF',
+ width: 1.5
+ })
+ }),
+ text: createPointFireLabel(feature, resolution)
+ })
+
+export const createCurrentFirePolygonStyle =
+ (visibleStatuses: readonly string[]) =>
+ (feature: { get: (property: string) => string | undefined }, resolution: number) =>
+ isFireStatusVisible(feature, visibleStatuses) ? currentFirePolygonStyle(feature, resolution) : undefined
+
+export const createCurrentFirePointStyle =
+ (visibleStatuses: readonly string[]) =>
+ (feature: { get: (property: string) => string | undefined }, resolution: number) =>
+ isFireStatusVisible(feature, visibleStatuses) ? currentFirePointStyle(feature, resolution) : undefined
+
+export const getCurrentFirePolygonAttributes = (feature: {
+ get: (property: string) => string | number | null | undefined
+}): CurrentFireAttributes => ({
+ fireNumber: feature.get('FIRE_NUMBER')?.toString() ?? null,
+ fireSizeHectares: feature.get('FIRE_SIZE_HECTARES') ?? null,
+ fireStatus: feature.get('FIRE_STATUS')?.toString() ?? null,
+ fireYear: feature.get('FIRE_YEAR') ?? null
+})
+
+export const getCurrentFirePointAttributes = (feature: {
+ get: (property: string) => string | number | null | undefined
+}): CurrentFireAttributes => ({
+ fireNumber: feature.get('FIRE_NUMBER')?.toString() ?? null,
+ fireSizeHectares: feature.get('CURRENT_SIZE') ?? null,
+ fireStatus: feature.get('FIRE_STATUS')?.toString() ?? null,
+ fireYear: feature.get('FIRE_YEAR') ?? null
+})
+
+export const createCurrentFirePolygonsLayer = (visibleStatuses: readonly string[]) =>
+ new VectorLayer({
+ source: new VectorSource({
+ format: new GeoJSON(),
+ url: currentFirePolygonsUrl.toString()
+ }),
+ style: createCurrentFirePolygonStyle(visibleStatuses),
+ zIndex: 20
+ })
+
+export const createCurrentFirePointsLayer = (visibleStatuses: readonly string[]) =>
+ new VectorLayer({
+ source: new VectorSource({
+ format: new GeoJSON(),
+ url: currentFirePointsUrl.toString()
+ }),
+ style: createCurrentFirePointStyle(visibleStatuses),
+ zIndex: 30
+ })
diff --git a/web/apps/wps-web/src/features/currentFires/map/layerVisibility.ts b/web/apps/wps-web/src/features/currentFires/map/layerVisibility.ts
new file mode 100644
index 0000000000..b629ee4abf
--- /dev/null
+++ b/web/apps/wps-web/src/features/currentFires/map/layerVisibility.ts
@@ -0,0 +1,11 @@
+export const CURRENT_FIRE_STATUS_OPTIONS = ['Out of Control', 'Being Held', 'Under Control'] as const
+export type CurrentFireStatus = (typeof CURRENT_FIRE_STATUS_OPTIONS)[number]
+
+export const DEFAULT_CURRENT_FIRE_STATUS_VISIBILITY: Record = {
+ 'Out of Control': true,
+ 'Being Held': true,
+ 'Under Control': false
+}
+
+export const getVisibleCurrentFireStatusDefaults = (): CurrentFireStatus[] =>
+ CURRENT_FIRE_STATUS_OPTIONS.filter(status => DEFAULT_CURRENT_FIRE_STATUS_VISIBILITY[status])
diff --git a/web/apps/wps-web/src/features/fba/components/ASADatePicker.tsx b/web/apps/wps-web/src/features/fba/components/ASADatePicker.tsx
index cba5e94dd4..b0e1b02eeb 100644
--- a/web/apps/wps-web/src/features/fba/components/ASADatePicker.tsx
+++ b/web/apps/wps-web/src/features/fba/components/ASADatePicker.tsx
@@ -87,7 +87,11 @@ function CustomDateTextField(props: Readonly) {
readOnly: true,
startAdornment: renderStartAdornments(),
endAdornment: renderEndAdornments(),
- sx: { cursor: disabled ? 'default' : 'pointer', '& *': { cursor: 'inherit' }, ...(externalSlotProps?.input as { sx?: object })?.sx }
+ sx: {
+ cursor: disabled ? 'default' : 'pointer',
+ '& *': { cursor: 'inherit' },
+ ...(externalSlotProps?.input as { sx?: object })?.sx
+ }
}
}}
/>
@@ -142,6 +146,7 @@ const ASADatePicker = ({
slotProps: other.slotProps
} as any
}}
+ sx={{ ...other.sx }}
value={date}
/>
diff --git a/web/apps/wps-web/src/features/landingPage/toolInfo.tsx b/web/apps/wps-web/src/features/landingPage/toolInfo.tsx
index d40f5cd63d..6fd64737f8 100644
--- a/web/apps/wps-web/src/features/landingPage/toolInfo.tsx
+++ b/web/apps/wps-web/src/features/landingPage/toolInfo.tsx
@@ -30,6 +30,8 @@ import {
MORECAST_ROUTE,
PERCENTILE_CALC_NAME,
PERCENTILE_CALC_ROUTE,
+ SMURFI_NAME,
+ SMURFI_ROUTE,
SFMS_INSIGHTS_NAME,
SFMS_INSIGHTS_ROUTE,
WEATHER_TOOLKIT_NAME,
@@ -207,6 +209,18 @@ export const weatherToolkitInfo: ToolInfo = {
isBeta: true
}
+export const smurfiInfo: ToolInfo = {
+ name: SMURFI_NAME,
+ route: SMURFI_ROUTE,
+ description: (
+
+ Spot Forecast Management Interface - An application for managing and forecasting spots related to wildfires in BC
+
+ ),
+ icon: ,
+ isBeta: true
+}
+
// The order of items in this array determines the order of items as they appear in the landing page
// side bar and order of tiles.
export const toolInfos = [
@@ -217,6 +231,7 @@ export const toolInfos = [
fireBehaviourCalcInfo,
fireWatchInfo,
sfmsInsightsInfo,
+ smurfiInfo,
percentileCalcInfo,
cHainesInfo,
weatherToolkitInfo
diff --git a/web/apps/wps-web/src/features/map/mapPopupUtils.ts b/web/apps/wps-web/src/features/map/mapPopupUtils.ts
new file mode 100644
index 0000000000..ccc1a9d771
--- /dev/null
+++ b/web/apps/wps-web/src/features/map/mapPopupUtils.ts
@@ -0,0 +1,46 @@
+import Map from 'ol/Map'
+
+export const panMapToFitElement = (map: Map, element: HTMLElement | null, margin = 16) => {
+ requestAnimationFrame(() => {
+ if (!element) {
+ return
+ }
+
+ const mapElement = map.getTargetElement()
+ const mapRect = mapElement.getBoundingClientRect()
+ const elementRect = element.getBoundingClientRect()
+
+ if (elementRect.width === 0 || elementRect.height === 0) {
+ return
+ }
+
+ let deltaX = 0
+ let deltaY = 0
+
+ if (elementRect.left < mapRect.left + margin) {
+ deltaX = elementRect.left - (mapRect.left + margin)
+ } else if (elementRect.right > mapRect.right - margin) {
+ deltaX = elementRect.right - (mapRect.right - margin)
+ }
+
+ if (elementRect.top < mapRect.top + margin) {
+ deltaY = elementRect.top - (mapRect.top + margin)
+ } else if (elementRect.bottom > mapRect.bottom - margin) {
+ deltaY = elementRect.bottom - (mapRect.bottom - margin)
+ }
+
+ if (deltaX === 0 && deltaY === 0) {
+ return
+ }
+
+ const view = map.getView()
+ const center = view.getCenter()
+ if (!center) {
+ return
+ }
+
+ const centerPixel = map.getPixelFromCoordinate(center)
+ const newCenter = map.getCoordinateFromPixel([centerPixel[0] + deltaX, centerPixel[1] + deltaY])
+ view.animate({ center: newCenter, duration: 150 })
+ })
+}
diff --git a/web/apps/wps-web/src/features/smurfi/components/ForecasterInitialsChip.tsx b/web/apps/wps-web/src/features/smurfi/components/ForecasterInitialsChip.tsx
new file mode 100644
index 0000000000..02b32d158e
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/ForecasterInitialsChip.tsx
@@ -0,0 +1,38 @@
+import { Chip, Tooltip } from '@mui/material'
+
+interface ForecasterInitialsChipProps {
+ forecasterName?: string | null
+}
+
+const getForecasterInitials = (forecasterName: string) => {
+ const nameParts = forecasterName.trim().split(/\s+/).filter(Boolean)
+
+ if (nameParts.length === 0) {
+ return ''
+ }
+
+ if (nameParts.length === 1) {
+ return nameParts[0].slice(0, 2).toUpperCase()
+ }
+
+ return `${nameParts[0][0]}${nameParts.at(-1)?.[0] ?? ''}`.toUpperCase()
+}
+
+const ForecasterInitialsChip = ({ forecasterName }: ForecasterInitialsChipProps) => {
+ if (!forecasterName) {
+ return null
+ }
+
+ const initials = getForecasterInitials(forecasterName)
+ if (!initials) {
+ return null
+ }
+
+ return (
+
+
+
+ )
+}
+
+export default ForecasterInitialsChip
diff --git a/web/apps/wps-web/src/features/smurfi/components/SpotForecasterFilter.tsx b/web/apps/wps-web/src/features/smurfi/components/SpotForecasterFilter.tsx
new file mode 100644
index 0000000000..95ea85a8cc
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/SpotForecasterFilter.tsx
@@ -0,0 +1,44 @@
+import { Autocomplete, SxProps, TextField, Theme } from '@mui/material'
+import { SpotRequestOutput } from '@wps/api/SMURFIAPI'
+import { useMemo } from 'react'
+
+interface SpotForecasterFilterProps {
+ spotRequests: SpotRequestOutput[]
+ value: string | null
+ onChange: (forecasterName: string | null) => void
+ label?: string
+ sx?: SxProps
+}
+
+const SpotForecasterFilter = ({
+ spotRequests,
+ value,
+ onChange,
+ label = 'Search by Forecaster',
+ sx
+}: SpotForecasterFilterProps) => {
+ const forecasterOptions = useMemo(
+ () =>
+ [
+ ...new Set(
+ spotRequests
+ .map(spot => spot.latest_forecast?.forecaster_name)
+ .filter((name): name is string => Boolean(name))
+ )
+ ].sort((a, b) => a.localeCompare(b)),
+ [spotRequests]
+ )
+
+ return (
+ onChange(newValue)}
+ renderInput={params => }
+ />
+ )
+}
+
+export default SpotForecasterFilter
diff --git a/web/apps/wps-web/src/features/smurfi/components/SpotRequestStatsButton.tsx b/web/apps/wps-web/src/features/smurfi/components/SpotRequestStatsButton.tsx
new file mode 100644
index 0000000000..ef5bd32776
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/SpotRequestStatsButton.tsx
@@ -0,0 +1,120 @@
+import AssessmentOutlinedIcon from '@mui/icons-material/AssessmentOutlined'
+import { Box, Button, Chip, Divider, Popover, Stack, Typography } from '@mui/material'
+import { SpotRequestStatusColorMap } from '@/features/smurfi/interfaces'
+import { SpotRequestOutput, SpotRequestStatus } from '@wps/api/SMURFIAPI'
+import { useMemo, useState } from 'react'
+
+interface SpotRequestStatsButtonProps {
+ spotRequests: SpotRequestOutput[]
+}
+
+interface ForecasterStats {
+ name: string
+ active: number
+ total: number
+}
+
+const STATUS_ORDER = [
+ SpotRequestStatus.REQUESTED,
+ SpotRequestStatus.STARTED,
+ SpotRequestStatus.SUSPENDED,
+ SpotRequestStatus.COMPLETE,
+ SpotRequestStatus.ARCHIVED
+]
+
+const NO_FORECAST_LABEL = 'No forecast yet'
+
+const SpotRequestStatsButton = ({ spotRequests }: SpotRequestStatsButtonProps) => {
+ const [anchorEl, setAnchorEl] = useState(null)
+ const open = Boolean(anchorEl)
+
+ const statusCounts = useMemo(
+ () =>
+ STATUS_ORDER.map(status => ({
+ status,
+ count: spotRequests.filter(spotRequest => spotRequest.status === status).length
+ })),
+ [spotRequests]
+ )
+
+ const forecasterStats = useMemo(() => {
+ const statsByName = spotRequests.reduce>((stats, spotRequest) => {
+ const name = spotRequest.latest_forecast?.forecaster_name ?? NO_FORECAST_LABEL
+ const existing = stats[name] ?? { name, active: 0, total: 0 }
+
+ stats[name] = {
+ ...existing,
+ active: existing.active + (spotRequest.status === SpotRequestStatus.STARTED ? 1 : 0),
+ total: existing.total + 1
+ }
+ return stats
+ }, {})
+
+ return Object.values(statsByName).sort(
+ (a, b) => b.active - a.active || b.total - a.total || a.name.localeCompare(b.name)
+ )
+ }, [spotRequests])
+
+ return (
+ <>
+ }
+ onClick={event => setAnchorEl(event.currentTarget)}
+ sx={{ height: 40, whiteSpace: 'nowrap' }}
+ >
+ Stats
+
+ setAnchorEl(null)}
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
+ transformOrigin={{ vertical: 'top', horizontal: 'right' }}
+ >
+
+
+ Status
+
+
+ {statusCounts.map(({ status, count }) => {
+ const colors = SpotRequestStatusColorMap[status]
+ return (
+
+ )
+ })}
+
+
+
+ Forecasters
+
+
+ {forecasterStats.map(({ name, active, total }) => (
+
+
+ {name}
+
+
+ {name === NO_FORECAST_LABEL ? `${total} total` : `${active} active / ${total} total`}
+
+
+ ))}
+
+
+
+ >
+ )
+}
+
+export default SpotRequestStatsButton
diff --git a/web/apps/wps-web/src/features/smurfi/components/SpotStatusControl.tsx b/web/apps/wps-web/src/features/smurfi/components/SpotStatusControl.tsx
new file mode 100644
index 0000000000..1a99ab4420
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/SpotStatusControl.tsx
@@ -0,0 +1,119 @@
+import { AppDispatch } from '@/app/store'
+import { statusToPath } from '@/features/smurfi/components/map/SpotStatusMarkers'
+import useSpotPermissions from '@/features/smurfi/hooks/useSpotPermissions'
+import { SpotRequestStatusColorMap } from '@/features/smurfi/interfaces'
+import { selectSmurfi, updateSpotRequestStatus } from '@/features/smurfi/slices/smurfiSlice'
+import { canChangeSpotStatus, getAllowedSpotStatusOptions } from '@/features/smurfi/utils/spotStatusUtils'
+import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'
+import { Box, Button, Menu, MenuItem, Typography } from '@mui/material'
+import { SpotRequestOutput, SpotRequestStatus } from '@wps/api/SMURFIAPI'
+import { MouseEvent, useState } from 'react'
+import { useDispatch, useSelector } from 'react-redux'
+
+interface SpotStatusControlProps {
+ spotRequest: SpotRequestOutput
+ fullWidth?: boolean
+ onStatusChanged?: (spotRequest: SpotRequestOutput) => void
+}
+
+const StatusContent = ({ status }: { status: SpotRequestStatus }) => (
+ <>
+
+
+ {status}
+
+ >
+)
+
+const getStatusSx = (status: SpotRequestStatus, fullWidth: boolean) => {
+ const colors = SpotRequestStatusColorMap[status]
+ return {
+ backgroundColor: colors.bgColor,
+ border: `1px solid ${colors.borderColor}`,
+ borderRadius: 1,
+ color: colors.color,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 0.75,
+ minHeight: 30,
+ minWidth: fullWidth ? '100%' : 116,
+ width: fullWidth ? '100%' : 'auto',
+ px: 1,
+ textTransform: 'none',
+ '&:hover': {
+ backgroundColor: colors.bgColor,
+ borderColor: colors.borderColor
+ },
+ '&.Mui-disabled': {
+ color: colors.color,
+ opacity: 0.8
+ }
+ }
+}
+
+const SpotStatusControl = ({ spotRequest, fullWidth = false, onStatusChanged }: SpotStatusControlProps) => {
+ const dispatch = useDispatch()
+ const { spotRequestStatusUpdatingById } = useSelector(selectSmurfi)
+ const { isOwner, isForecaster } = useSpotPermissions(spotRequest)
+ const [anchorEl, setAnchorEl] = useState(null)
+ const allowedStatuses = getAllowedSpotStatusOptions({ spotRequest, isOwner, isForecaster })
+ const isEditable = canChangeSpotStatus({ spotRequest, isOwner, isForecaster })
+ const isUpdating = Boolean(spotRequestStatusUpdatingById[spotRequest.id])
+ const open = Boolean(anchorEl)
+
+ const handleOpen = (event: MouseEvent) => {
+ event.stopPropagation()
+ setAnchorEl(event.currentTarget)
+ }
+
+ const handleClose = () => {
+ setAnchorEl(null)
+ }
+
+ const handleStatusChange = async (status: SpotRequestStatus) => {
+ handleClose()
+ if (status === spotRequest.status) {
+ return
+ }
+
+ const result = await dispatch(updateSpotRequestStatus({ spotRequestId: spotRequest.id, status }))
+
+ if (!result.spotRequest) {
+ return
+ }
+
+ onStatusChanged?.(result.spotRequest)
+ }
+
+ if (!isEditable) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+ <>
+ }
+ disabled={isUpdating}
+ onClick={handleOpen}
+ sx={getStatusSx(spotRequest.status, fullWidth)}
+ >
+
+
+
+ >
+ )
+}
+
+export default SpotStatusControl
diff --git a/web/apps/wps-web/src/features/smurfi/components/SpotSubscriptionButton.tsx b/web/apps/wps-web/src/features/smurfi/components/SpotSubscriptionButton.tsx
new file mode 100644
index 0000000000..9aea725d85
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/SpotSubscriptionButton.tsx
@@ -0,0 +1,67 @@
+import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive'
+import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone'
+import { Button, ButtonProps, Tooltip } from '@mui/material'
+import { AppDispatch } from '@/app/store'
+import useSpotPermissions from '@/features/smurfi/hooks/useSpotPermissions'
+import {
+ selectSubscribedIds,
+ selectSubscriptionsLoading,
+ toggleSpotSubscription
+} from '@/features/smurfi/slices/subscriptionsSlice'
+import { SpotRequestOutput } from '@wps/api/SMURFIAPI'
+import { useDispatch, useSelector } from 'react-redux'
+
+interface SpotSubscriptionButtonProps {
+ spotRequest: SpotRequestOutput
+ size?: ButtonProps['size']
+ variant?: ButtonProps['variant']
+ color?: ButtonProps['color']
+ fullWidth?: boolean
+}
+
+const OWNER_TOOLTIP = 'The owner of a spot request cannot unsubscribe from forecast notifications.'
+
+const SpotSubscriptionButton = ({
+ spotRequest,
+ size = 'small',
+ variant = 'outlined',
+ color = 'primary',
+ fullWidth = false
+}: SpotSubscriptionButtonProps) => {
+ const dispatch = useDispatch()
+ const subscribedIds = useSelector(selectSubscribedIds)
+ const isLoading = useSelector(selectSubscriptionsLoading)
+ const { isOwner } = useSpotPermissions(spotRequest)
+ const isSubscribed = subscribedIds.includes(spotRequest.id)
+ const isOwnerSubscribed = isOwner && isSubscribed
+ const label = isOwnerSubscribed ? 'Subscribed' : isSubscribed ? 'Unsubscribe' : 'Subscribe'
+ const startIcon = isSubscribed ? :
+
+ const button = (
+
+ )
+
+ if (!isOwnerSubscribed) {
+ return button
+ }
+
+ return (
+
+
+ {button}
+
+
+ )
+}
+
+export default SpotSubscriptionButton
diff --git a/web/apps/wps-web/src/features/smurfi/components/StationSelector.tsx b/web/apps/wps-web/src/features/smurfi/components/StationSelector.tsx
new file mode 100644
index 0000000000..2781c45ddc
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/StationSelector.tsx
@@ -0,0 +1,39 @@
+import React, { useEffect, useState } from 'react'
+import { useSelector } from 'react-redux'
+import { Autocomplete, TextField } from '@mui/material'
+import { selectFireWeatherStations } from '@/app/rootReducer'
+import { Option as StationOption } from '@wps/utils/dropdown'
+import { GeoJsonStation } from '@wps/types/stationTypes'
+
+interface StationSelectorProps {
+ value: number[]
+ onChange: (value: number[]) => void
+}
+
+const StationSelector: React.FC = ({ value, onChange }) => {
+ const [stationOptions, setStationOptions] = useState([])
+ const { stations } = useSelector(selectFireWeatherStations)
+
+ useEffect(() => {
+ const allStationOptions: StationOption[] = (stations as GeoJsonStation[]).map(station => ({
+ name: `${station.properties.name} (${station.properties.elevation}m)`,
+ code: station.properties.code
+ }))
+ setStationOptions(allStationOptions)
+ }, [stations])
+
+ return (
+ option.name}
+ value={stationOptions.filter(option => value.includes(option.code))}
+ onChange={(_, newValue) => {
+ onChange(newValue.map(v => v.code))
+ }}
+ renderInput={params => }
+ />
+ )
+}
+
+export default StationSelector
diff --git a/web/apps/wps-web/src/features/smurfi/components/admin/DistributionGroupsAdmin.tsx b/web/apps/wps-web/src/features/smurfi/components/admin/DistributionGroupsAdmin.tsx
new file mode 100644
index 0000000000..84c11be670
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/admin/DistributionGroupsAdmin.tsx
@@ -0,0 +1,277 @@
+import {
+ Box,
+ Button,
+ Chip,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ IconButton,
+ Paper,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ TextField,
+ Typography
+} from '@mui/material'
+import AddIcon from '@mui/icons-material/Add'
+import DeleteIcon from '@mui/icons-material/Delete'
+import EditIcon from '@mui/icons-material/Edit'
+import UploadFileIcon from '@mui/icons-material/UploadFile'
+import {
+ DistributionGroup,
+ DistributionGroupInput,
+ deleteDistributionGroup,
+ postDistributionGroup,
+ putDistributionGroup
+} from '@wps/api/SMURFIAPI'
+import { useEffect, useState } from 'react'
+import { useDispatch, useSelector } from 'react-redux'
+import { AppDispatch } from '@/app/store'
+import { fetchDistributionGroups, fetchSpotRequests, selectSmurfi } from '@/features/smurfi/slices/smurfiSlice'
+
+const EMPTY_FORM: DistributionGroupInput = { name: '', emails: [] }
+
+const DistributionGroupsAdmin = () => {
+ const dispatch: AppDispatch = useDispatch()
+ const { distributionGroups: groups } = useSelector(selectSmurfi)
+ const [dialogOpen, setDialogOpen] = useState(false)
+ const [editingGroup, setEditingGroup] = useState(null)
+ const [formValues, setFormValues] = useState(EMPTY_FORM)
+ const [emailInput, setEmailInput] = useState('')
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState(null)
+ const [confirmDeleteGroup, setConfirmDeleteGroup] = useState(null)
+
+ useEffect(() => {
+ dispatch(fetchDistributionGroups())
+ }, [dispatch])
+
+ const openCreate = () => {
+ setEditingGroup(null)
+ setFormValues(EMPTY_FORM)
+ setEmailInput('')
+ setError(null)
+ setDialogOpen(true)
+ }
+
+ const openEdit = (group: DistributionGroup) => {
+ setEditingGroup(group)
+ setFormValues({ name: group.name, emails: [...group.emails] })
+ setEmailInput('')
+ setError(null)
+ setDialogOpen(true)
+ }
+
+ const handleClose = () => {
+ setDialogOpen(false)
+ }
+
+ const commitEmailInput = () => {
+ const trimmed = emailInput.trim()
+ if (trimmed && !formValues.emails.includes(trimmed)) {
+ setFormValues(v => ({ ...v, emails: [...v.emails, trimmed] }))
+ }
+ setEmailInput('')
+ }
+
+ const removeEmail = (email: string) => {
+ setFormValues(v => ({ ...v, emails: v.emails.filter(e => e !== email) }))
+ }
+
+ const handleCsvUpload = (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0]
+ if (!file) return
+ const reader = new FileReader()
+ reader.onload = e => {
+ const text = e.target?.result as string
+ const emailRegex = /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g
+ const found = text.match(emailRegex) ?? []
+ setFormValues(v => {
+ const existing = new Set(v.emails)
+ const added = found.filter(email => !existing.has(email.toLowerCase())).map(e => e.toLowerCase())
+ return { ...v, emails: [...v.emails, ...added] }
+ })
+ }
+ reader.readAsText(file)
+ event.target.value = ''
+ }
+
+ const handleSave = async () => {
+ commitEmailInput()
+ const payload = { ...formValues }
+ if (emailInput.trim() && !payload.emails.includes(emailInput.trim())) {
+ payload.emails = [...payload.emails, emailInput.trim()]
+ }
+ if (!payload.name.trim()) {
+ setError('Name is required')
+ return
+ }
+ setSaving(true)
+ setError(null)
+ try {
+ if (editingGroup) {
+ await putDistributionGroup(editingGroup.id, payload)
+ } else {
+ await postDistributionGroup(payload)
+ }
+ dispatch(fetchDistributionGroups())
+ setDialogOpen(false)
+ } catch {
+ setError('Failed to save distribution group')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleDeleteConfirmed = async () => {
+ if (!confirmDeleteGroup) return
+ try {
+ await deleteDistributionGroup(confirmDeleteGroup.id)
+ dispatch(fetchDistributionGroups())
+ dispatch(fetchSpotRequests())
+ } catch {
+ // ignore
+ } finally {
+ setConfirmDeleteGroup(null)
+ }
+ }
+
+ return (
+
+
+ Distribution Groups
+ } onClick={openCreate}>
+ New Group
+
+
+
+
+
+
+
+ Name
+ Members
+
+
+
+
+ {groups.length === 0 && (
+
+
+
+ No distribution groups yet.
+
+
+
+ )}
+ {groups.map(group => (
+
+ {group.name}
+ {group.emails.length}
+
+ openEdit(group)}>
+
+
+ setConfirmDeleteGroup(group)}>
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ )
+}
+
+export default DistributionGroupsAdmin
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecastForm/ControlledForecastDateTimePicker.tsx b/web/apps/wps-web/src/features/smurfi/components/forecastForm/ControlledForecastDateTimePicker.tsx
new file mode 100644
index 0000000000..eb1c61990e
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecastForm/ControlledForecastDateTimePicker.tsx
@@ -0,0 +1,40 @@
+import React from 'react'
+import { DateTimePicker } from '@mui/x-date-pickers-pro'
+import { Control, Controller } from 'react-hook-form'
+import { SpotFormData } from '@wps/api/schema/spotForecastSchema'
+
+interface ControlledForecastDateTimePickerProps {
+ control: Control
+ name: 'issuedDate' | 'expiryDate'
+ label: string
+ errorMessage?: string
+}
+
+const ControlledForecastDateTimePicker: React.FC = ({
+ control,
+ name,
+ label,
+ errorMessage
+}) => (
+ (
+
+ )}
+ />
+)
+
+export default ControlledForecastDateTimePicker
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecastForm/ControlledForecastTextField.tsx b/web/apps/wps-web/src/features/smurfi/components/forecastForm/ControlledForecastTextField.tsx
new file mode 100644
index 0000000000..0cdaf8acb7
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecastForm/ControlledForecastTextField.tsx
@@ -0,0 +1,46 @@
+import React from 'react'
+import { TextField, TextFieldProps } from '@mui/material'
+import { Control, Controller, FieldPath } from 'react-hook-form'
+import { SpotFormData } from '@wps/api/schema/spotForecastSchema'
+
+interface ControlledForecastTextFieldProps extends Omit<
+ TextFieldProps,
+ 'error' | 'helperText' | 'name' | 'onChange' | 'value'
+> {
+ control: Control
+ name: FieldPath
+ errorMessage?: string
+ endAdornment?: React.ReactNode
+ parseValue?: (value: string) => unknown
+}
+
+const ControlledForecastTextField: React.FC = ({
+ control,
+ name,
+ errorMessage,
+ endAdornment,
+ parseValue,
+ ...textFieldProps
+}) => (
+ (
+ field.onChange(parseValue ? parseValue(event.target.value) : event.target.value)}
+ slotProps={{
+ input: {
+ endAdornment
+ }
+ }}
+ />
+ )}
+ />
+)
+
+export default ControlledForecastTextField
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecastForm.tsx b/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecastForm.tsx
new file mode 100644
index 0000000000..fe9f602d29
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecastForm.tsx
@@ -0,0 +1,310 @@
+import React, { useEffect, useState, useMemo } from 'react'
+import { useForm, useFieldArray } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useDispatch, useSelector } from 'react-redux'
+import { Alert, Grid, Button, Box, FormControlLabel, FormControl, FormLabel, RadioGroup, Radio } from '@mui/material'
+import { AppDispatch } from '@/app/store'
+import { selectAuthentication } from '@/app/rootReducer'
+import { getDefaultValues, defaultWeatherRows } from '@/features/smurfi/constants/spotForecastDefaults'
+import SpotForecasterInfo from '@/features/smurfi/components/forecastForm/SpotForecasterInfo'
+import SpotForecastHeader from '@/features/smurfi/components/forecastForm/SpotForecastHeader'
+import SpotForecastSynopsis from '@/features/smurfi/components/forecastForm/SpotForecastSynopsis'
+import WeatherDataTable from '@/features/smurfi/components/forecastForm/WeatherDataTable'
+import SpotForecastSummaries from '@/features/smurfi/components/forecastForm/SpotForecastSummaries'
+import SpotForecastSections from '@/features/smurfi/components/forecastForm/SpotForecastSections'
+import { SpotForecastOutput, SpotForecastType, SpotRequestOutput } from '@wps/api/SMURFIAPI'
+import { createSchema, SpotFormData } from '@wps/api/schema/spotForecastSchema'
+import { clearSpotForecastSubmitState, submitSpotForecast, selectSmurfi } from '@/features/smurfi/slices/smurfiSlice'
+import { fetchCurrentFireSizesByFireNumbers } from '@/features/currentFires/map/currentFireLayers'
+import { fetchWxStations } from '@/features/stations/slices/stationsSlice'
+import { getStations, StationSource } from '@wps/api/stationAPI'
+import {
+ formatFireNumbers,
+ getEmptyFireSizes,
+ toForecastDateTimeString
+} from '@/features/smurfi/utils/spotForecastUtils'
+
+const toFormString = (value: number | string | null | undefined) =>
+ value === null || value === undefined ? '' : String(value)
+
+const getDescriptiveWeather = (forecast: SpotForecastOutput | undefined, period: string) =>
+ forecast?.descriptive_weather.find(weather => weather.period === period)
+
+const getInitialForecastType = (
+ requestType: string,
+ sourceForecastType: string | null | undefined
+): SpotForecastType => {
+ if (sourceForecastType === 'Mini' || sourceForecastType === 'Full') {
+ return sourceForecastType
+ }
+
+ return requestType === 'Mini' ? 'Mini' : 'Full'
+}
+
+const getForecastLocationInstance = (
+ spotRequest: SpotRequestOutput,
+ sourceForecast: SpotForecastOutput | undefined,
+ useSourceForecastLocation: boolean
+) => {
+ if (useSourceForecastLocation && sourceForecast) {
+ return sourceForecast.spot_request_instance
+ }
+
+ return spotRequest.request_instance
+}
+
+const normalizeForecasterText = (value: string | null | undefined) => value?.trim().toLowerCase() ?? ''
+
+const isSameForecaster = (
+ forecast: SpotForecastOutput | undefined,
+ forecasterName: string | undefined,
+ forecasterEmail: string | undefined
+) =>
+ !!forecast &&
+ normalizeForecasterText(forecast.forecaster_name) === normalizeForecasterText(forecasterName) &&
+ normalizeForecasterText(forecast.forecaster_email) === normalizeForecasterText(forecasterEmail)
+
+const getPrefilledForecasterPhone = (
+ sourceForecast: SpotForecastOutput | undefined,
+ forecasterName: string | undefined,
+ forecasterEmail: string | undefined,
+ prefillFullForecast: boolean
+) => {
+ if (!sourceForecast?.forecaster_phone) {
+ return ''
+ }
+
+ // carry phone forward for create-from-previous, otherwise only for the same forecaster
+ return prefillFullForecast || isSameForecaster(sourceForecast, forecasterName, forecasterEmail)
+ ? sourceForecast.forecaster_phone
+ : ''
+}
+
+interface SpotForecastFormProps {
+ spotRequest: SpotRequestOutput
+ /** optional prior forecast used to carry forward stable fields like stations and forecast type. */
+ sourceForecast?: SpotForecastOutput
+ /** when true, sourceForecast also fills location and terrain fields without prefilling forecast text/weather. */
+ prefillForecastLocation?: boolean
+ /** when true, sourceForecast also fills editable forecast content for the create-from-previous flow. */
+ prefillFullForecast?: boolean
+ onSubmitSuccess?: () => void
+}
+
+const SpotForecastForm: React.FC = ({
+ spotRequest,
+ sourceForecast,
+ prefillForecastLocation = false,
+ prefillFullForecast = false,
+ onSubmitSuccess
+}) => {
+ const dispatch: AppDispatch = useDispatch()
+ const { spotForecastSubmitting, spotForecastSubmitError } = useSelector(selectSmurfi)
+ const { name: forecasterName, email: forecasterEmail } = useSelector(selectAuthentication)
+ const initialForecastType = useMemo(
+ () => getInitialForecastType(spotRequest.request_type, sourceForecast?.forecast_type),
+ [sourceForecast?.forecast_type, spotRequest.request_type]
+ )
+ const [forecastType, setForecastType] = useState(initialForecastType)
+ const isMini = forecastType === 'Mini'
+ const schema = useMemo(() => createSchema(isMini), [isMini])
+ const resolver = useMemo(() => zodResolver(schema), [schema])
+
+ const defaultValues = useMemo>(() => {
+ const baseDefaults = getDefaultValues()
+ const fullPrefillForecast = prefillFullForecast ? sourceForecast : undefined
+ const requestInstance = getForecastLocationInstance(
+ spotRequest,
+ sourceForecast,
+ prefillFullForecast || prefillForecastLocation
+ )
+ const afternoonWeather = getDescriptiveWeather(fullPrefillForecast, 'Today')
+ const tonightWeather = getDescriptiveWeather(fullPrefillForecast, 'Tonight')
+ const tomorrowWeather = getDescriptiveWeather(fullPrefillForecast, 'Tomorrow')
+
+ return {
+ ...baseDefaults,
+ forecasterPhone: getPrefilledForecasterPhone(
+ sourceForecast,
+ forecasterName,
+ forecasterEmail,
+ prefillFullForecast
+ ),
+ fireProj: formatFireNumbers(spotRequest.fire_number),
+ requestBy: spotRequest.requestor_name,
+ stns: sourceForecast?.representative_station_codes ?? baseDefaults.stns,
+ latitude: toFormString(requestInstance.latitude.toFixed(4)),
+ longitude: toFormString(requestInstance.longitude.toFixed(4)),
+ geographicDescription: requestInstance.geographic_description,
+ slopeAspect: requestInstance.aspect ?? baseDefaults.slopeAspect,
+ valley: requestInstance.valley ?? baseDefaults.valley,
+ elevation: toFormString(requestInstance.elevation),
+ fireSizes: fullPrefillForecast?.fire_size?.map(toFormString) ?? getEmptyFireSizes(spotRequest.fire_number),
+ synopsis: fullPrefillForecast?.synopsis ?? baseDefaults.synopsis,
+ afternoonForecast: {
+ description: afternoonWeather?.conditions ?? '',
+ maxTemp: afternoonWeather?.temperature ?? undefined,
+ minRh: afternoonWeather?.relative_humidity ?? undefined
+ },
+ tonightForecast: {
+ description: tonightWeather?.conditions ?? '',
+ minTemp: tonightWeather?.temperature ?? undefined,
+ maxRh: tonightWeather?.relative_humidity ?? undefined
+ },
+ tomorrowForecast: {
+ description: tomorrowWeather?.conditions ?? '',
+ maxTemp: tomorrowWeather?.temperature ?? undefined,
+ minRh: tomorrowWeather?.relative_humidity ?? undefined
+ },
+ weatherData:
+ fullPrefillForecast?.tabular_weather.map(row => ({
+ dateTime: toForecastDateTimeString(row.forecast_time),
+ temp: toFormString(row.temperature),
+ rh: toFormString(row.relative_humidity),
+ wind: row.wind ?? '',
+ rain: toFormString(row.precipitation_amount),
+ chanceRain: toFormString(row.probability_of_precipitation)
+ })) ?? defaultWeatherRows,
+ inversionVenting: fullPrefillForecast?.inversion_and_venting ?? baseDefaults.inversionVenting,
+ outlook: fullPrefillForecast?.outlook ?? baseDefaults.outlook,
+ confidenceDiscussion: fullPrefillForecast?.confidence ?? baseDefaults.confidenceDiscussion
+ }
+ }, [forecasterEmail, forecasterName, prefillForecastLocation, prefillFullForecast, sourceForecast, spotRequest])
+
+ const {
+ control,
+ getValues,
+ handleSubmit,
+ reset,
+ setValue,
+ formState: { errors, submitCount }
+ } = useForm({
+ resolver,
+ defaultValues,
+ mode: 'onBlur',
+ reValidateMode: 'onChange'
+ })
+
+ const { fields, append, remove } = useFieldArray({
+ control,
+ name: 'weatherData'
+ })
+ const hasValidationErrors = Object.keys(errors).length > 0
+
+ const onSubmit = async (data: SpotFormData) => {
+ const submittedForecast = await dispatch(
+ submitSpotForecast({
+ formData: data,
+ forecastType,
+ spotRequestId: spotRequest.id
+ })
+ )
+
+ if (submittedForecast) {
+ onSubmitSuccess?.()
+ }
+ }
+
+ useEffect(() => {
+ setForecastType(initialForecastType)
+ }, [initialForecastType])
+
+ useEffect(() => {
+ reset(defaultValues)
+ }, [defaultValues, reset])
+
+ useEffect(() => {
+ const fireNumbers = spotRequest.fire_number ?? []
+ if (fireNumbers.length === 0) {
+ return
+ }
+
+ let cancelled = false
+ fetchCurrentFireSizesByFireNumbers(fireNumbers)
+ .then(fireSizes => {
+ const currentFireSizes = getValues('fireSizes') ?? []
+ const hasExistingFireSizes = currentFireSizes.some(fireSize => fireSize?.trim())
+ if (!cancelled && !hasExistingFireSizes) {
+ setValue('fireSizes', fireSizes.map(toFormString), { shouldValidate: true })
+ }
+ })
+ .catch(() => {
+ // keep fire sizes editable when the public fire layer cannot provide values
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [getValues, setValue, spotRequest.fire_number])
+
+ useEffect(() => {
+ dispatch(fetchWxStations(getStations, StationSource.wildfire_one))
+ }, [dispatch])
+
+ useEffect(() => {
+ return () => {
+ dispatch(clearSpotForecastSubmitState())
+ }
+ }, [dispatch])
+
+ return (
+
+
+
+ Forecast Type
+ setForecastType(event.target.value as SpotForecastType)}
+ >
+ } label="Mini Spot" />
+ } label="Full Spot" />
+
+
+
+
+
+
+ )
+}
+
+export default SpotForecastForm
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecastFormPage.tsx b/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecastFormPage.tsx
new file mode 100644
index 0000000000..57eb0221b9
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecastFormPage.tsx
@@ -0,0 +1,87 @@
+import { AppDispatch } from '@/app/store'
+import { fetchSpotForecasts, selectSmurfi } from '@/features/smurfi/slices/smurfiSlice'
+import SpotForecastForm from '@/features/smurfi/components/forecastForm/SpotForecastForm'
+import useSpotPermissions from '@/features/smurfi/hooks/useSpotPermissions'
+import { getMostRecentForecast } from '@/features/smurfi/utils/spotForecastUtils'
+import { Alert, Box, Button, CircularProgress, Typography } from '@mui/material'
+import { getSmurfiForecastsRoute } from '@wps/utils/constants'
+import { useEffect, useMemo } from 'react'
+import { useDispatch, useSelector } from 'react-redux'
+import { useLocation, useNavigate, useParams } from 'react-router-dom'
+
+interface ForecastFormLocationState {
+ sourceForecastId?: number
+}
+
+const SpotForecastFormPage = () => {
+ const dispatch = useDispatch()
+ const navigate = useNavigate()
+ const location = useLocation()
+ const { id } = useParams()
+ const spotRequestId = Number(id)
+ const sourceForecastId = (location.state as ForecastFormLocationState | null)?.sourceForecastId
+ const { spotRequests, spotRequestsLoading, spotRequestsError, spotForecastsByRequestId, spotForecastsError } =
+ useSelector(selectSmurfi)
+ const spotRequest = spotRequests.find(request => request.id === spotRequestId)
+ const { isForecaster } = useSpotPermissions(spotRequest)
+ const forecastsRoute = getSmurfiForecastsRoute(spotRequestId)
+ const spotForecasts = spotForecastsByRequestId[spotRequestId]
+ const selectedSourceForecast = useMemo(
+ () => spotForecasts?.find(forecast => forecast.id === sourceForecastId),
+ [sourceForecastId, spotForecasts]
+ )
+ const carryForwardForecast = useMemo(
+ () => selectedSourceForecast ?? getMostRecentForecast(spotForecasts ?? []),
+ [selectedSourceForecast, spotForecasts]
+ )
+
+ useEffect(() => {
+ if (Number.isFinite(spotRequestId) && spotForecastsByRequestId[spotRequestId] === undefined) {
+ dispatch(fetchSpotForecasts(spotRequestId))
+ }
+ }, [dispatch, spotForecastsByRequestId, spotRequestId])
+
+ if (spotRequestsLoading) {
+ return
+ }
+
+ if (spotRequestsError) {
+ return Unable to load spot request.
+ }
+
+ if (!Number.isFinite(spotRequestId) || !spotRequest) {
+ return Spot request not found.
+ }
+
+ if (spotForecasts === undefined && !spotForecastsError) {
+ return
+ }
+
+ if (!isForecaster) {
+ return You do not have permission to submit spot forecasts.
+ }
+
+ return (
+
+
+
+ Submit Spot Forecast
+
+ Spot ID: {spotRequest.id}
+
+
+
+
+ navigate(forecastsRoute, { state: { showForecastSubmitSuccess: true } })}
+ />
+
+ )
+}
+
+export default SpotForecastFormPage
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecastHeader.tsx b/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecastHeader.tsx
new file mode 100644
index 0000000000..7acbd340f3
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecastHeader.tsx
@@ -0,0 +1,179 @@
+import React from 'react'
+import { Controller, Control, FieldErrors, UseFormSetValue, useWatch } from 'react-hook-form'
+import { Grid, Card, CardContent, InputAdornment, Typography } from '@mui/material'
+import StationSelector from '@/features/smurfi/components/StationSelector'
+import { SpotFormData } from '@wps/api/schema/spotForecastSchema'
+import ControlledForecastTextField from '@/features/smurfi/components/forecastForm/ControlledForecastTextField'
+import ControlledForecastDateTimePicker from '@/features/smurfi/components/forecastForm/ControlledForecastDateTimePicker'
+import SpotRequestLocationMap from '@/features/smurfi/components/requestForm/SpotRequestLocationMap'
+import { SpotRequestOutput } from '@wps/api/SMURFIAPI'
+
+interface SpotForecastHeaderProps {
+ control: Control
+ errors: FieldErrors
+ fireNumbers: string[] | null | undefined
+ spotRequest: SpotRequestOutput
+ setValue: UseFormSetValue
+}
+
+const toNumericLocation = (latitude: string | undefined, longitude: string | undefined) => {
+ const lat = Number(latitude)
+ const lon = Number(longitude)
+ return Number.isFinite(lat) && Number.isFinite(lon) ? { latitude: lat, longitude: lon } : null
+}
+
+const getFireSizeErrorMessage = (errors: FieldErrors, index: number) => {
+ const fireSizesError = errors.fireSizes
+ return Array.isArray(fireSizesError) ? fireSizesError[index]?.message : undefined
+}
+
+const SpotForecastHeader: React.FC = ({
+ control,
+ errors,
+ fireNumbers,
+ spotRequest,
+ setValue
+}) => {
+ const latitude = useWatch({ control, name: 'latitude' })
+ const longitude = useWatch({ control, name: 'longitude' })
+ const selectedLocation = toNumericLocation(latitude, longitude)
+ const fireSizeLabels = fireNumbers?.length ? fireNumbers : ['Fire Size']
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+ {
+ if (!location) {
+ return
+ }
+ setValue('latitude', location.latitude.toFixed(6), { shouldValidate: true, shouldDirty: true })
+ setValue('longitude', location.longitude.toFixed(6), { shouldValidate: true, shouldDirty: true })
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ m}
+ />
+
+
+
+ Fire Size(s)
+
+
+ {fireSizeLabels.map((fireNumber, index) => (
+
+ ha}
+ />
+
+ ))}
+
+
+
+
+
+
+ )
+}
+
+export default SpotForecastHeader
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecastSections.tsx b/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecastSections.tsx
new file mode 100644
index 0000000000..43dd4d316a
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecastSections.tsx
@@ -0,0 +1,78 @@
+import React from 'react'
+import { Control, FieldErrors } from 'react-hook-form'
+import { Grid, Card, CardContent, Typography } from '@mui/material'
+import { SpotFormData } from '@wps/api/schema/spotForecastSchema'
+import ControlledForecastTextField from '@/features/smurfi/components/forecastForm/ControlledForecastTextField'
+
+interface SpotForecastSectionsProps {
+ control: Control
+ errors: FieldErrors
+ isMini: boolean
+}
+
+const SpotForecastSections: React.FC = ({ control, errors, isMini }) => {
+ return (
+ <>
+ {/* ─── Inversion & Venting ─────────────────────────── */}
+
+
+
+
+ Inversion & Venting
+
+
+
+
+
+
+ {/* ─── Outlook ─────────────────────────────────────── */}
+ {!isMini && (
+
+
+
+
+ Outlook (3-5 Day)
+
+
+
+
+
+ )}
+
+ {/* ─── Confidence/Discussion ───────────────────────── */}
+
+
+
+
+ Confidence / Discussion
+
+
+
+
+
+ >
+ )
+}
+
+export default SpotForecastSections
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecastSummaries.tsx b/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecastSummaries.tsx
new file mode 100644
index 0000000000..adf728c6d7
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecastSummaries.tsx
@@ -0,0 +1,162 @@
+import React from 'react'
+import { Control, FieldErrors } from 'react-hook-form'
+import { Grid, Card, CardContent, Typography } from '@mui/material'
+import { SpotFormData } from '@wps/api/schema/spotForecastSchema'
+import ControlledForecastTextField from '@/features/smurfi/components/forecastForm/ControlledForecastTextField'
+
+interface SpotForecastSummariesProps {
+ control: Control
+ errors: FieldErrors
+}
+
+const parseOptionalNumber = (value: string) => (value === '' ? undefined : Number(value))
+
+const SpotForecastSummaries: React.FC = ({ control, errors }) => {
+ return (
+
+
+
+
+ Forecast Summaries
+
+
+ {/* Afternoon */}
+
+
+ Afternoon
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Tonight */}
+
+
+ Tonight
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Tomorrow */}
+
+
+ Tomorrow
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default SpotForecastSummaries
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecastSynopsis.tsx b/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecastSynopsis.tsx
new file mode 100644
index 0000000000..9f614cadff
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecastSynopsis.tsx
@@ -0,0 +1,34 @@
+import { Card, CardContent, Grid, Typography } from '@mui/material'
+import { SpotFormData } from '@wps/api/schema/spotForecastSchema'
+import React from 'react'
+import { Control, FieldErrors } from 'react-hook-form'
+import ControlledForecastTextField from '@/features/smurfi/components/forecastForm/ControlledForecastTextField'
+
+interface SpotForecastSynopsisProps {
+ control: Control
+ errors: FieldErrors
+}
+
+const SpotForecastSynopsis: React.FC = ({ control, errors }) => {
+ return (
+
+
+
+
+ Synopsis
+
+
+
+
+
+ )
+}
+
+export default SpotForecastSynopsis
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecasterInfo.tsx b/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecasterInfo.tsx
new file mode 100644
index 0000000000..bd4d607005
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecastForm/SpotForecasterInfo.tsx
@@ -0,0 +1,45 @@
+import React from 'react'
+import { Control, FieldErrors } from 'react-hook-form'
+import { Card, CardContent, Grid, TextField } from '@mui/material'
+import { SpotFormData } from '@wps/api/schema/spotForecastSchema'
+import ControlledForecastTextField from '@/features/smurfi/components/forecastForm/ControlledForecastTextField'
+
+interface SpotForecasterInfoProps {
+ control: Control
+ errors: FieldErrors
+ forecasterName?: string
+ forecasterEmail?: string
+}
+
+const SpotForecasterInfo: React.FC = ({
+ control,
+ errors,
+ forecasterName,
+ forecasterEmail
+}) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+)
+
+export default SpotForecasterInfo
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecastForm/WeatherDataCell.tsx b/web/apps/wps-web/src/features/smurfi/components/forecastForm/WeatherDataCell.tsx
new file mode 100644
index 0000000000..d7f42c85fc
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecastForm/WeatherDataCell.tsx
@@ -0,0 +1,28 @@
+import React from 'react'
+import { SxProps, TableCell, Theme } from '@mui/material'
+import { Control, FieldPath } from 'react-hook-form'
+import { SpotFormData } from '@wps/api/schema/spotForecastSchema'
+import ControlledForecastTextField from '@/features/smurfi/components/forecastForm/ControlledForecastTextField'
+
+interface WeatherDataCellProps {
+ control: Control
+ name: FieldPath
+ errorMessage?: string
+ type?: 'text' | 'number'
+ sx?: SxProps
+}
+
+const WeatherDataCell: React.FC = ({ control, name, errorMessage, type = 'text', sx }) => (
+
+
+
+)
+
+export default WeatherDataCell
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecastForm/WeatherDataTable.tsx b/web/apps/wps-web/src/features/smurfi/components/forecastForm/WeatherDataTable.tsx
new file mode 100644
index 0000000000..b739d7a1da
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecastForm/WeatherDataTable.tsx
@@ -0,0 +1,140 @@
+import React from 'react'
+import { Control, UseFieldArrayReturn, FieldErrors, FieldPath } from 'react-hook-form'
+import {
+ Grid,
+ Card,
+ CardContent,
+ Typography,
+ Button,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Paper,
+ IconButton,
+ Box
+} from '@mui/material'
+import { DateTime } from 'luxon'
+import AddIcon from '@mui/icons-material/Add'
+import DeleteIcon from '@mui/icons-material/Delete'
+import { SpotFormData } from '@wps/api/schema/spotForecastSchema'
+import WeatherDataCell from '@/features/smurfi/components/forecastForm/WeatherDataCell'
+
+interface WeatherDataTableProps {
+ control: Control
+ errors: FieldErrors
+ fields: UseFieldArrayReturn['fields']
+ append: UseFieldArrayReturn['append']
+ remove: UseFieldArrayReturn['remove']
+}
+
+const dateCellSx = { width: 175, minWidth: 175 }
+const compactWeatherCellSx = { width: 90, minWidth: 90 }
+const windCellSx = { width: 190, minWidth: 190 }
+
+const WeatherDataTable: React.FC = ({ control, errors, fields, append, remove }) => {
+ const weatherDataArrayError = Array.isArray(errors.weatherData) ? undefined : errors.weatherData?.message
+ const weatherDataError = errors.weatherData?.root?.message ?? weatherDataArrayError
+
+ return (
+
+
+
+
+ Weather Data
+ }
+ onClick={() =>
+ append({
+ dateTime: DateTime.now().toFormat('yyyy-MM-dd HH:mm'),
+ temp: '',
+ rh: '',
+ wind: '',
+ rain: '-',
+ chanceRain: '-'
+ })
+ }
+ >
+ Add Row
+
+
+
+
+
+
+
+ Date/Time (PDT)
+ Temp (C)
+ RH (%)
+ Wind
+ Rain (mm)
+ Chance Rain (%)
+
+
+
+
+ {fields.map((field, index) => (
+
+ }
+ control={control}
+ errorMessage={errors.weatherData?.[index]?.dateTime?.message}
+ sx={dateCellSx}
+ />
+ }
+ control={control}
+ type="number"
+ errorMessage={errors.weatherData?.[index]?.temp?.message}
+ sx={compactWeatherCellSx}
+ />
+ }
+ control={control}
+ type="number"
+ errorMessage={errors.weatherData?.[index]?.rh?.message}
+ sx={compactWeatherCellSx}
+ />
+ }
+ control={control}
+ sx={windCellSx}
+ />
+ }
+ control={control}
+ type="number"
+ sx={compactWeatherCellSx}
+ />
+ }
+ control={control}
+ type="number"
+ sx={compactWeatherCellSx}
+ />
+
+ remove(index)}>
+
+
+
+
+ ))}
+
+
+
+
+ {weatherDataError && (
+
+ {weatherDataError}
+
+ )}
+
+
+
+ )
+}
+
+export default WeatherDataTable
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecasts/ForecastLocationMap.tsx b/web/apps/wps-web/src/features/smurfi/components/forecasts/ForecastLocationMap.tsx
new file mode 100644
index 0000000000..24d9163bdd
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecasts/ForecastLocationMap.tsx
@@ -0,0 +1,83 @@
+import { Box } from '@mui/material'
+import { source as baseMapSource } from '@/features/fireWeather/components/maps/constants'
+import {
+ createCurrentFirePointsLayer,
+ createCurrentFirePolygonsLayer
+} from '@/features/currentFires/map/currentFireLayers'
+import { CURRENT_FIRE_STATUS_OPTIONS } from '@/features/currentFires/map/layerVisibility'
+import { createSpotStatusIcon } from '@/features/smurfi/components/map/SpotStatusMarkers'
+import { SpotRequestStatus } from '@wps/api/SMURFIAPI'
+import { Feature, Map, View } from 'ol'
+import { Point } from 'ol/geom'
+import TileLayer from 'ol/layer/Tile'
+import VectorLayer from 'ol/layer/Vector'
+import { fromLonLat } from 'ol/proj'
+import VectorSource from 'ol/source/Vector'
+import { Style } from 'ol/style'
+import { useEffect, useRef } from 'react'
+import 'ol/ol.css'
+
+interface ForecastLocation {
+ latitude: number
+ longitude: number
+}
+
+interface ForecastLocationMapProps {
+ location: ForecastLocation
+ spotStatus: SpotRequestStatus
+ height?: number | string
+}
+
+const getForecastMarkerStyle = (status: SpotRequestStatus) =>
+ new Style({
+ image: createSpotStatusIcon(status)
+ })
+
+const ForecastLocationMap = ({ location, spotStatus, height = 280 }: ForecastLocationMapProps) => {
+ const mapRef = useRef(null)
+
+ useEffect(() => {
+ if (!mapRef.current) {
+ return
+ }
+
+ const forecastCoordinate = fromLonLat([location.longitude, location.latitude])
+ const forecastMarker = new Feature({
+ geometry: new Point(forecastCoordinate)
+ })
+ forecastMarker.setStyle(getForecastMarkerStyle(spotStatus))
+
+ const mapObject = new Map({
+ target: mapRef.current,
+ layers: [
+ new TileLayer({
+ source: baseMapSource
+ }),
+ createCurrentFirePolygonsLayer(CURRENT_FIRE_STATUS_OPTIONS),
+ createCurrentFirePointsLayer(CURRENT_FIRE_STATUS_OPTIONS),
+ new VectorLayer({
+ source: new VectorSource({ features: [forecastMarker] }),
+ zIndex: 50
+ })
+ ],
+ view: new View({
+ center: forecastCoordinate,
+ zoom: 12
+ })
+ })
+
+ requestAnimationFrame(() => mapObject.updateSize())
+
+ return () => {
+ mapObject.setTarget('')
+ }
+ }, [location.latitude, location.longitude, spotStatus])
+
+ return (
+
+
+
+ )
+}
+
+export default ForecastLocationMap
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecasts/FullSpotForecast.tsx b/web/apps/wps-web/src/features/smurfi/components/forecasts/FullSpotForecast.tsx
new file mode 100644
index 0000000000..8f629f94bd
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecasts/FullSpotForecast.tsx
@@ -0,0 +1,127 @@
+import React from 'react'
+import { Box, Typography } from '@mui/material'
+import { SpotForecastOutput, SpotRequestOutput } from '@wps/api/SMURFIAPI'
+import { RepresentativeStation } from '@/features/smurfi/interfaces'
+import {
+ Field,
+ Section,
+ TextSection,
+ WeatherDataSection
+} from '@/features/smurfi/components/forecasts/SpotForecastComponents'
+import { formatDateTime, formatStationsStr } from '@/features/smurfi/utils/spotForecastUtils'
+
+const formatFireSizes = (fireSizes: (number | null)[] | null | undefined) =>
+ fireSizes?.some(fireSize => fireSize !== null)
+ ? fireSizes.map(fireSize => (fireSize === null ? '—' : `${fireSize} ha`)).join(', ')
+ : null
+
+export interface FullSpotForecastProps {
+ forecast: SpotForecastOutput
+ spotRequest: SpotRequestOutput
+ representativeStations: RepresentativeStation[]
+ locationMap?: React.ReactNode
+}
+
+const FullSpotForecast: React.FC = ({
+ forecast,
+ spotRequest,
+ representativeStations,
+ locationMap
+}) => {
+ const afternoonForecast = forecast.descriptive_weather.find(dw => dw.period === 'Today')
+ const tonightForecast = forecast.descriptive_weather.find(dw => dw.period === 'Tonight')
+ const tomorrowForecast = forecast.descriptive_weather.find(dw => dw.period === 'Tomorrow')
+ const stationsStr = formatStationsStr(representativeStations)
+
+ const forecastInstance = forecast.spot_request_instance
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {forecast.fire_size != null && }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {locationMap}
+
+
+
+ {forecast.synopsis && {forecast.synopsis}}
+
+ {(afternoonForecast ?? tonightForecast ?? tomorrowForecast) && (
+
+ {afternoonForecast && (
+
+
+ Afternoon
+
+
+ {`${afternoonForecast.conditions} Max Temp: ${afternoonForecast.temperature ?? '—'}°C, Min RH: ${afternoonForecast.relative_humidity ?? '—'}%`}
+
+
+ )}
+ {tonightForecast && (
+
+
+ Tonight
+
+
+ {`${tonightForecast.conditions} Min Temp: ${tonightForecast.temperature ?? '—'}°C, Max RH: ${tonightForecast.relative_humidity ?? '—'}%`}
+
+
+ )}
+ {tomorrowForecast && (
+
+
+ Tomorrow
+
+
+ {`${tomorrowForecast.conditions} Temp: ${tomorrowForecast.temperature ?? '—'}°C, Min RH: ${tomorrowForecast.relative_humidity ?? '—'}%`}
+
+
+ )}
+
+ )}
+
+
+
+
+ {forecast.inversion_and_venting && (
+ {forecast.inversion_and_venting}
+ )}
+ {forecast.outlook && {forecast.outlook}}
+
+
+ {forecast.confidence && {forecast.confidence}}
+
+ )
+}
+
+export default FullSpotForecast
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecasts/MiniSpotForecast.tsx b/web/apps/wps-web/src/features/smurfi/components/forecasts/MiniSpotForecast.tsx
new file mode 100644
index 0000000000..277259c921
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecasts/MiniSpotForecast.tsx
@@ -0,0 +1,77 @@
+import React from 'react'
+import { Box } from '@mui/material'
+import { SpotForecastOutput, SpotRequestOutput } from '@wps/api/SMURFIAPI'
+import { RepresentativeStation } from '@/features/smurfi/interfaces'
+import {
+ Field,
+ Section,
+ TextSection,
+ WeatherDataSection
+} from '@/features/smurfi/components/forecasts/SpotForecastComponents'
+import { formatDateTime, formatStationsStr } from '@/features/smurfi/utils/spotForecastUtils'
+
+export interface MiniSpotForecastProps {
+ forecast: SpotForecastOutput
+ spotRequest: SpotRequestOutput
+ representativeStations: RepresentativeStation[]
+ locationMap?: React.ReactNode
+}
+
+const MiniSpotForecast: React.FC = ({
+ forecast,
+ spotRequest,
+ representativeStations,
+ locationMap
+}) => {
+ const stationsStr = formatStationsStr(representativeStations)
+ const forecastInstance = forecast.spot_request_instance
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {locationMap}
+
+
+
+
+
+ {forecast.synopsis && {forecast.synopsis}}
+
+ {forecast.inversion_and_venting && (
+ {forecast.inversion_and_venting}
+ )}
+
+ {forecast.confidence && (
+ {forecast.confidence}
+ )}
+
+ )
+}
+
+export default MiniSpotForecast
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecasts/PrintableFullSpotForecast.tsx b/web/apps/wps-web/src/features/smurfi/components/forecasts/PrintableFullSpotForecast.tsx
new file mode 100644
index 0000000000..e7c57a9ba3
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecasts/PrintableFullSpotForecast.tsx
@@ -0,0 +1,71 @@
+import React from 'react'
+import { Box, Typography } from '@mui/material'
+import { SpotForecastOutput, SpotRequestOutput } from '@wps/api/SMURFIAPI'
+import SpotForecastHeaderTable from '@/features/smurfi/components/forecasts/SpotForecastHeaderTable'
+import WeatherDataTable from '@/features/smurfi/components/forecasts/WeatherDataTable'
+import SpotForecastSummarySection from '@/features/smurfi/components/forecasts/SpotForecastSummarySection'
+import { RepresentativeStation } from '@/features/smurfi/interfaces'
+
+interface PrintableFullSpotForecastProps {
+ forecast: SpotForecastOutput
+ spotRequest: SpotRequestOutput
+ representativeStations: RepresentativeStation[]
+}
+
+const PrintableFullSpotForecast: React.FC = ({
+ forecast,
+ spotRequest,
+ representativeStations
+}) => {
+ return (
+
+
+ BC Wild Fire Service Spot Forecast
+
+
+
+
+
+ SYNOPSIS:
+
+ {' '}
+ {forecast.synopsis}
+
+
+
+
+
+
+
+
+ INVERSION & VENTING:
+
+ {' '}
+ {forecast.inversion_and_venting}
+
+
+ {forecast.outlook && (
+
+
+ OUTLOOK (3-5 Day Outlook)
+ {' '}
+ {forecast.outlook}
+
+ )}
+
+
+
+ CONFIDENCE/DISCUSSION:
+
+ {' '}
+ {forecast.confidence}
+
+
+ )
+}
+
+export default PrintableFullSpotForecast
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecasts/PrintableMiniSpotForecast.tsx b/web/apps/wps-web/src/features/smurfi/components/forecasts/PrintableMiniSpotForecast.tsx
new file mode 100644
index 0000000000..4242dbbb3f
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecasts/PrintableMiniSpotForecast.tsx
@@ -0,0 +1,96 @@
+import React from 'react'
+import { Box, Typography } from '@mui/material'
+import { DateTime } from 'luxon'
+import { SpotForecastOutput, SpotRequestOutput } from '@wps/api/SMURFIAPI'
+import { RepresentativeStation } from '@/features/smurfi/interfaces'
+import WeatherDataTable from '@/features/smurfi/components/forecasts/WeatherDataTable'
+
+const TIMEZONE = 'America/Vancouver'
+const FONT_SIZE = '12px'
+
+const ordinal = (n: number): string => {
+ const s = ['th', 'st', 'nd', 'rd']
+ const v = n % 100
+ return n + (s[(v - 20) % 10] ?? s[v] ?? s[0])
+}
+
+export interface PrintableMiniSpotForecastProps {
+ forecast: SpotForecastOutput
+ spotRequest: SpotRequestOutput
+ representativeStations: RepresentativeStation[]
+}
+
+const PrintableMiniSpotForecast: React.FC = ({
+ forecast,
+ spotRequest,
+ representativeStations
+}) => {
+ const issuedDt = DateTime.fromISO(forecast.issued_at).setZone(TIMEZONE)
+ const fireNumberStr = spotRequest.fire_number?.join(', ') ?? ''
+ const title = [fireNumberStr, 'Mini Spot Forecast'].filter(Boolean).join(' ')
+ const issuedDateStr = `${issuedDt.toFormat('EEEE, MMMM ')}${ordinal(issuedDt.day)}${issuedDt.toFormat(', yyyy')}`
+ const stationsStr = representativeStations
+ .map(s => s.name + (s.elevation == null ? '' : ` (${s.elevation} m)`))
+ .join(', ')
+ const forecastInstance = forecast.spot_request_instance
+
+ return (
+
+
+ {title}
+
+ {issuedDateStr}
+
+
+ {forecastInstance.geographic_description}
+
+
+ Lat/Long: {forecastInstance.latitude.toFixed(4)},{' '}
+ {forecastInstance.longitude.toFixed(4)}
+
+
+ {stationsStr && (
+
+ Rep. Wx Station: {stationsStr}
+
+ )}
+
+
+
+ {forecast.synopsis && (
+
+
+ Notes/Discussion:
+
+ {forecast.synopsis}
+
+ )}
+
+ {forecast.inversion_and_venting && (
+
+
+ Venting/Inversions:
+
+ {forecast.inversion_and_venting}
+
+ )}
+
+ {forecast.confidence && (
+
+
+ Long range model guidance and discussion:
+
+ {forecast.confidence}
+
+ )}
+
+
+ {forecast.forecaster_name}
+ {forecast.forecaster_phone && {forecast.forecaster_phone}}
+ {forecast.forecaster_email}
+
+
+ )
+}
+
+export default PrintableMiniSpotForecast
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecasts/SpotForecast.tsx b/web/apps/wps-web/src/features/smurfi/components/forecasts/SpotForecast.tsx
new file mode 100644
index 0000000000..a76c23cea6
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecasts/SpotForecast.tsx
@@ -0,0 +1,59 @@
+import { Box, Button, CircularProgress, Typography } from '@mui/material'
+import { useNavigate, useParams } from 'react-router-dom'
+import MiniSpotForecast from '@/features/smurfi/components/forecasts/MiniSpotForecast'
+import FullSpotForecast from '@/features/smurfi/components/forecasts/FullSpotForecast'
+import { getSmurfiForecastPrintRoute } from '@wps/utils/constants'
+import useSpotForecastData from '@/features/smurfi/hooks/useSpotForecastData'
+import ForecastLocationMap from '@/features/smurfi/components/forecasts/ForecastLocationMap'
+
+const SpotForecast = () => {
+ const { id, forecastId } = useParams<{ id: string; forecastId: string }>()
+ const navigate = useNavigate()
+ const { loading, spotRequest, spotForecast, representativeStations } = useSpotForecastData()
+
+ const spotRequestId = Number(id)
+ const spotForecastId = Number(forecastId)
+
+ if (loading) {
+ return
+ }
+
+ if (!spotRequest || !spotForecast) {
+ return Forecast not found
+ }
+
+ const printUrl = getSmurfiForecastPrintRoute(spotRequestId, spotForecastId)
+ const isMini = spotForecast.forecast_type === 'Mini'
+ const forecastLocation = {
+ latitude: spotForecast.spot_request_instance.latitude,
+ longitude: spotForecast.spot_request_instance.longitude
+ }
+ const locationMap =
+
+ return (
+
+
+
+
+ {isMini ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+export default SpotForecast
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecasts/SpotForecastComponents.tsx b/web/apps/wps-web/src/features/smurfi/components/forecasts/SpotForecastComponents.tsx
new file mode 100644
index 0000000000..fdadd8a851
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecasts/SpotForecastComponents.tsx
@@ -0,0 +1,58 @@
+import React from 'react'
+import { Box, Divider, Paper, Typography } from '@mui/material'
+import WeatherDataTable from '@/features/smurfi/components/forecasts/WeatherDataTable'
+import { SpotForecastOutput } from '@wps/api/SMURFIAPI'
+
+export const Field = ({ label, value }: { label: string; value: React.ReactNode }) => (
+
+
+ {label}
+
+ {value ?? '—'}
+
+)
+
+export const Section = ({
+ title,
+ children,
+ contentSx
+}: {
+ title: string
+ children: React.ReactNode
+ contentSx?: object
+}) => (
+
+
+ {title}
+
+
+ {children}
+
+)
+
+export const TextSection = ({ title, children }: { title: string; children: React.ReactNode }) => (
+
+
+ {title}
+
+
+ {children}
+
+)
+
+export const WeatherDataSection = ({ forecast }: { forecast: SpotForecastOutput }) => {
+ if (forecast.tabular_weather.length === 0) return null
+ return (
+
+
+ Weather Data
+
+
+
+
+ )
+}
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecasts/SpotForecastHeaderTable.tsx b/web/apps/wps-web/src/features/smurfi/components/forecasts/SpotForecastHeaderTable.tsx
new file mode 100644
index 0000000000..81b2f72106
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecasts/SpotForecastHeaderTable.tsx
@@ -0,0 +1,121 @@
+import React from 'react'
+import { Box, Typography } from '@mui/material'
+import { DateTime } from 'luxon'
+import { SpotForecastOutput, SpotRequestOutput } from '@wps/api/SMURFIAPI'
+import { RepresentativeStation } from '@/features/smurfi/interfaces'
+
+const TIMEZONE = 'America/Vancouver'
+
+const toDDM = (decimal: number): string => {
+ const abs = Math.abs(decimal)
+ const degrees = Math.floor(abs)
+ const minutes = ((abs - degrees) * 60).toFixed(3)
+ return `${decimal < 0 ? '-' : ''}${degrees} ${minutes}`
+}
+
+const formatFireSizes = (fireSizes: (number | null)[] | null | undefined) =>
+ fireSizes?.some(fireSize => fireSize !== null)
+ ? fireSizes.map(fireSize => (fireSize === null ? '—' : `${fireSize} ha`)).join(', ')
+ : null
+
+const COLUMNS = '145px 210px 1fr 190px'
+
+const grid: React.CSSProperties = {
+ display: 'grid',
+ gridTemplateColumns: COLUMNS,
+ borderTop: '1px solid black',
+ borderLeft: '1px solid black',
+ width: '100%'
+}
+
+const cell: React.CSSProperties = {
+ borderRight: '1px solid black',
+ borderBottom: '1px solid black',
+ padding: '3px 8px',
+ fontSize: '12px'
+}
+
+const span2: React.CSSProperties = {
+ ...cell,
+ gridColumn: 'span 2'
+}
+
+interface SpotForecastHeaderTableProps {
+ forecast: SpotForecastOutput
+ spotRequest: SpotRequestOutput
+ representativeStations: RepresentativeStation[]
+}
+
+const SpotForecastHeaderTable: React.FC = ({
+ forecast,
+ spotRequest,
+ representativeStations
+}) => {
+ const issuedDt = DateTime.fromISO(forecast.issued_at).setZone(TIMEZONE)
+ const expiryDt = forecast.expires_at ? DateTime.fromISO(forecast.expires_at).setZone(TIMEZONE) : null
+
+ const issuedStr = `${issuedDt.toFormat('HHmm')} ${issuedDt.offsetNameShort} ${issuedDt.toFormat('EEEE, MMMM d, yyyy')}`
+ const expiryStr = expiryDt ? expiryDt.toFormat('EEEE MMMM d') : '—'
+
+ const stationsStr =
+ representativeStations.length > 0
+ ? representativeStations.map(s => s.name + (s.elevation == null ? '' : ` (${s.elevation}m)`)).join(', ')
+ : '—'
+ const fireNumberStr = spotRequest.fire_number?.join(', ') ?? '—'
+ const forecastInstance = forecast.spot_request_instance
+ const fireSizeStr = formatFireSizes(forecast.fire_size)
+
+ return (
+
+
+
+ Date/time Issued:
+ {' '}
+ {issuedStr}
+
+
+ Default Expiry: {expiryStr}
+
+
+
+
+
Fire/Proj #
+
{fireNumberStr}
+
+ Request by:{' '}
+ {spotRequest.requestor_name}
+
+
+
Forecast by
+
{forecast.forecaster_name}
+
Email: {forecast.forecaster_email}
+
Phone: {forecast.forecaster_phone ?? '—'}
+
+
Geographic
+
{forecastInstance.geographic_description}
+
+ Representative Stations: {stationsStr}
+
+
+
+ Coordinates (approx)
+
+
+ {toDDM(forecastInstance.latitude)},{toDDM(forecastInstance.longitude)}
+
+
+ Slope/aspect:{' '}
+ {forecastInstance.aspect ?? '—'}
+
+
+
+
Elevation
+
{forecastInstance.elevation ? `${forecastInstance.elevation} m` : '—'}
+
{fireSizeStr ? `Size: ${fireSizeStr}` : ''}
+
+
+
+ )
+}
+
+export default SpotForecastHeaderTable
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecasts/SpotForecastSummarySection.tsx b/web/apps/wps-web/src/features/smurfi/components/forecasts/SpotForecastSummarySection.tsx
new file mode 100644
index 0000000000..fb4cf0852f
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecasts/SpotForecastSummarySection.tsx
@@ -0,0 +1,58 @@
+import React from 'react'
+import { Box, Typography } from '@mui/material'
+import { SpotDescriptiveWeatherOutput } from '@wps/api/SMURFIAPI'
+
+const ensurePeriod = (text: string | null): string => {
+ if (!text) {
+ return ''
+ }
+ return text.endsWith('.') ? text : `${text}.`
+}
+
+interface SpotForecastSummarySectionProps {
+ descriptiveWeather: SpotDescriptiveWeatherOutput[]
+}
+
+const SpotForecastSummarySection: React.FC = ({ descriptiveWeather }) => {
+ if (!descriptiveWeather || descriptiveWeather.length === 0) return null
+
+ const afternoonForecast = descriptiveWeather.find(dw => dw.period === 'Today')
+ const tonightForecast = descriptiveWeather.find(dw => dw.period === 'Tonight')
+ const tomorrowForecast = descriptiveWeather.find(dw => dw.period === 'Tomorrow')
+
+ return (
+
+
+
+ FORECAST:
+
+
+ {afternoonForecast && (
+
+ AFTERNOON:
+ {' '}
+ {ensurePeriod(afternoonForecast.conditions)} MAX TEMP {afternoonForecast.temperature}C, MIN RH{' '}
+ {afternoonForecast.relative_humidity}%
+
+ )}
+ {tonightForecast && (
+
+ TONIGHT:
+ {' '}
+ {ensurePeriod(tonightForecast.conditions)} MIN TEMP {tonightForecast.temperature}C. MAX RH{' '}
+ {tonightForecast.relative_humidity}%.
+
+ )}
+ {tomorrowForecast && (
+
+ TOMORROW:
+ {' '}
+ {ensurePeriod(tomorrowForecast.conditions)} TEMP {tomorrowForecast.temperature}C. MIN RH{' '}
+ {tomorrowForecast.relative_humidity}%.
+
+ )}
+
+ )
+}
+
+export default SpotForecastSummarySection
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecasts/SpotForecasts.tsx b/web/apps/wps-web/src/features/smurfi/components/forecasts/SpotForecasts.tsx
new file mode 100644
index 0000000000..06b6720d0d
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecasts/SpotForecasts.tsx
@@ -0,0 +1,217 @@
+import { AppDispatch } from '@/app/store'
+import { selectFireCentres } from '@/app/rootReducer'
+import { fetchSpotForecasts, selectSmurfi } from '@/features/smurfi/slices/smurfiSlice'
+import { formatDateTime, TIMEZONE } from '@/features/smurfi/utils/spotForecastUtils'
+import { DateTime } from 'luxon'
+import useSpotPermissions from '@/features/smurfi/hooks/useSpotPermissions'
+import { SpotRequestStatusColorMap } from '@/features/smurfi/interfaces'
+import { Field } from '@/features/smurfi/components/forecasts/SpotForecastComponents'
+import { formatRequestFrequency, formatSpotRequestDate } from '@/features/smurfi/utils/spotRequestFormatters'
+import { Box, Button, CircularProgress, Divider, Paper, Snackbar, Typography } from '@mui/material'
+import { DataGridPro, GridColDef } from '@mui/x-data-grid-pro'
+import { SpotRequestStatus } from '@wps/api/SMURFIAPI'
+import { getSmurfiEditForecastRoute, getSmurfiForecastRoute, getSmurfiNewForecastRoute } from '@wps/utils/constants'
+import { useEffect, useState } from 'react'
+import { useDispatch, useSelector } from 'react-redux'
+import { useLocation, useNavigate, useParams } from 'react-router-dom'
+
+interface SpotForecastsLocationState {
+ showForecastSubmitSuccess?: boolean
+}
+
+const SpotForecasts = () => {
+ const { id } = useParams<{ id: string }>()
+ const dispatch = useDispatch()
+ const navigate = useNavigate()
+ const location = useLocation()
+ const { spotRequests, spotForecastsByRequestId, spotForecastsLoading } = useSelector(selectSmurfi)
+ const { fireCentres } = useSelector(selectFireCentres)
+ const shouldShowSubmitSuccess = Boolean(
+ (location.state as SpotForecastsLocationState | null)?.showForecastSubmitSuccess
+ )
+ const [submitSuccessOpen, setSubmitSuccessOpen] = useState(shouldShowSubmitSuccess)
+
+ const spotRequestId = Number(id)
+
+ useEffect(() => {
+ if (!spotForecastsByRequestId[spotRequestId]) {
+ dispatch(fetchSpotForecasts(spotRequestId))
+ }
+ }, [dispatch, spotRequestId, spotForecastsByRequestId])
+
+ useEffect(() => {
+ if (!shouldShowSubmitSuccess) {
+ return
+ }
+
+ setSubmitSuccessOpen(true)
+ navigate(`${location.pathname}${location.search}`, { replace: true, state: null })
+ }, [location.pathname, location.search, navigate, shouldShowSubmitSuccess])
+
+ const spotRequest = spotRequests.find(sr => sr.id === spotRequestId)
+ const { isForecaster } = useSpotPermissions(spotRequest)
+
+ if (spotForecastsLoading) {
+ return
+ }
+
+ const fireNumber = spotRequest?.fire_number?.join(', ') ?? '—'
+ const fireCentreName = spotRequest
+ ? (fireCentres.find(fc => fc.id === spotRequest.fire_centre)?.name ?? String(spotRequest.fire_centre)).replace(
+ / Fire Centre$/,
+ ''
+ )
+ : '—'
+ const status = spotRequest?.status
+ const statusBadge = status ? (
+
+
+ {status}
+
+
+ ) : undefined
+
+ const rows = spotForecastsByRequestId[spotRequestId] ?? []
+
+ const columns: GridColDef<(typeof rows)[number]>[] = [
+ { field: 'id', headerName: 'ID', width: 80 },
+ { field: 'forecast_type', headerName: 'Type', width: 110 },
+ {
+ field: 'issued_at',
+ headerName: 'Issued At',
+ width: 230,
+ renderCell: params => formatDateTime(params.value)
+ },
+ {
+ field: 'expires_at',
+ headerName: 'Expires At',
+ width: 230,
+ renderCell: params => {
+ if (!params.value) return '—'
+ const dt = DateTime.fromISO(params.value).setZone(TIMEZONE)
+ return dt.isValid ? dt.toFormat('EEE, MMM d, yyyy') : params.value
+ }
+ },
+ { field: 'forecaster_name', headerName: 'Forecaster', width: 180 },
+ {
+ field: 'actions',
+ headerName: 'Actions',
+ width: 160,
+ sortable: false,
+ renderCell: params => (
+
+
+ {params.api.getSortedRowIds()[0] === params.id && (
+
+ )}
+
+ )
+ }
+ ]
+
+ return (
+
+
+
+
+ Request Info
+
+ {statusBadge}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isForecaster && (
+
+
+
+ )}
+ {
+ if (!params.row.expires_at) return ''
+ const expiry = DateTime.fromISO(params.row.expires_at).setZone(TIMEZONE)
+ return expiry.isValid && expiry < DateTime.now() ? 'expired-cell' : ''
+ }}
+ sx={{ '& .expired-cell': { opacity: 0.4 } }}
+ />
+ {
+ if (reason !== 'clickaway') {
+ setSubmitSuccessOpen(false)
+ }
+ }}
+ sx={{
+ '& .MuiSnackbarContent-root': {
+ p: 0,
+ bgcolor: 'transparent',
+ boxShadow: 'none',
+ minWidth: 0
+ }
+ }}
+ >
+
+
+
+ )
+}
+
+export default SpotForecasts
diff --git a/web/apps/wps-web/src/features/smurfi/components/forecasts/WeatherDataTable.tsx b/web/apps/wps-web/src/features/smurfi/components/forecasts/WeatherDataTable.tsx
new file mode 100644
index 0000000000..124632ca26
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/forecasts/WeatherDataTable.tsx
@@ -0,0 +1,92 @@
+import React from 'react'
+import { DateTime } from 'luxon'
+import { SpotForecastOutput } from '@wps/api/SMURFIAPI'
+
+const TIMEZONE = 'America/Vancouver'
+
+const formatDateLabel = (dateTimeStr: string, issuedDateISO: string): string => {
+ const dt = DateTime.fromISO(dateTimeStr).setZone(TIMEZONE)
+ const issuedDay = DateTime.fromISO(issuedDateISO).setZone(TIMEZONE).startOf('day')
+ const dayDiff = Math.round(dt.startOf('day').diff(issuedDay, 'days').days)
+ const time = dt.toFormat('HHmm')
+
+ if (dayDiff === 0) return `Today ${time}`
+ if (dayDiff === 1) return dt.hour === 0 ? 'Tonight' : `Tomorrow ${time}`
+ if (dayDiff === 2) return dt.hour === 0 ? 'Tomorrow night' : `Next Day ${time}`
+ return dateTimeStr
+}
+
+const COLUMNS = '2fr 1fr 1fr 2fr 1fr 1fr'
+
+const grid: React.CSSProperties = {
+ display: 'grid',
+ gridTemplateColumns: COLUMNS,
+ borderTop: '1px solid black',
+ borderLeft: '1px solid black',
+ marginTop: 12,
+ width: '100%'
+}
+
+const cell: React.CSSProperties = {
+ borderRight: '1px solid black',
+ borderBottom: '1px solid black',
+ padding: '3px 8px',
+ verticalAlign: 'top',
+ fontSize: '0.875rem'
+}
+
+const hdrCell: React.CSSProperties = {
+ ...cell,
+ fontWeight: 'bold',
+ textAlign: 'center'
+}
+
+const numCell: React.CSSProperties = {
+ ...cell,
+ fontWeight: 'bold',
+ textAlign: 'center'
+}
+
+const dashCell: React.CSSProperties = {
+ ...cell,
+ textAlign: 'center'
+}
+
+const HEADERS = ['Date/Time (PDT)', 'Temp (C)', 'RH', 'Wind (km/h)', 'Rain (mm)', 'Chance Rain']
+
+interface WeatherDataTableProps {
+ rows: SpotForecastOutput['tabular_weather']
+ issuedDate: string
+ fontSize?: string
+}
+
+const WeatherDataTable: React.FC = ({ rows, issuedDate, fontSize = '0.875rem' }) => {
+ const sorted = [...rows].sort((a, b) => a.forecast_time.localeCompare(b.forecast_time))
+
+ const cellWithSize: React.CSSProperties = { ...cell, fontSize }
+ const numCellWithSize: React.CSSProperties = { ...numCell, fontSize }
+ const dashCellWithSize: React.CSSProperties = { ...dashCell, fontSize }
+ const hdrCellWithSize: React.CSSProperties = { ...hdrCell, fontSize }
+
+ return (
+
+ {HEADERS.map((header, i) => (
+
+ {header}
+
+ ))}
+ {sorted.map(row => (
+
+ {formatDateLabel(row.forecast_time, issuedDate)}
+ {row.temperature ?? '-'}
+ {row.relative_humidity ?? '-'}
+ {row.wind ?? '-'}
+ {row.precipitation_amount ?? '-'}
+ {row.probability_of_precipitation ?? '-'}
+
+ ))}
+
+ )
+}
+
+export default WeatherDataTable
diff --git a/web/apps/wps-web/src/features/smurfi/components/map/CurrentFirePolygonPopup.tsx b/web/apps/wps-web/src/features/smurfi/components/map/CurrentFirePolygonPopup.tsx
new file mode 100644
index 0000000000..6625291240
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/map/CurrentFirePolygonPopup.tsx
@@ -0,0 +1,52 @@
+import { CurrentFireAttributes } from '@/features/currentFires/map/currentFireLayers'
+import CloseIcon from '@mui/icons-material/Close'
+import { Box, IconButton, Typography } from '@mui/material'
+
+interface CurrentFirePolygonPopupProps {
+ attributes: CurrentFireAttributes
+ onClose: () => void
+}
+
+const formatValue = (value: string | number | null | undefined) => {
+ if (value === null || value === undefined || value === '') {
+ return '-'
+ }
+ return String(value)
+}
+
+const Field = ({ label, value }: { label: string; value: string | number | null | undefined }) => (
+
+
+ {label}
+
+ {formatValue(value)}
+
+)
+
+const CurrentFirePolygonPopup = ({ attributes, onClose }: CurrentFirePolygonPopupProps) => (
+
+
+
+ Current Fire
+
+
+
+
+
+
+
+
+
+
+)
+
+export default CurrentFirePolygonPopup
diff --git a/web/apps/wps-web/src/features/smurfi/components/map/ForecastPopup.tsx b/web/apps/wps-web/src/features/smurfi/components/map/ForecastPopup.tsx
new file mode 100644
index 0000000000..8f95cdf738
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/map/ForecastPopup.tsx
@@ -0,0 +1,99 @@
+import React from 'react'
+import { Box, Button, Typography } from '@mui/material'
+import { ForecastPopupData } from '@/features/smurfi/interfaces'
+import { formatDateTime } from '@/features/smurfi/utils/spotForecastUtils'
+
+interface ForecastPopupProps {
+ popupData: ForecastPopupData
+ canSubmitForecast: boolean
+ onOpenRequest: (spotId: number) => void
+ onOpenForecast: (spotId: number, forecastId: number) => void
+ onSubmitForecast: (spotId: number, sourceForecastId: number) => void
+}
+
+const ForecastPopup: React.FC = ({
+ popupData,
+ canSubmitForecast,
+ onOpenRequest,
+ onOpenForecast,
+ onSubmitForecast
+}) => {
+ const { lat, lng, fireNumber, spotId, forecastCount, latestForecast } = popupData
+ const hasMultipleForecasts = forecastCount > 1
+
+ const handleRequestClick = (event: React.MouseEvent) => {
+ event.stopPropagation()
+ event.preventDefault()
+ onOpenRequest(spotId)
+ }
+
+ const handleForecastClick = (event: React.MouseEvent) => {
+ event.stopPropagation()
+ event.preventDefault()
+ onOpenForecast(spotId, latestForecast.id)
+ }
+
+ const handleSubmitForecastClick = (event: React.MouseEvent) => {
+ event.stopPropagation()
+ event.preventDefault()
+ onSubmitForecast(spotId, latestForecast.id)
+ }
+
+ return (
+
+
+ {fireNumber}
+
+ Forecast location
+
+
+ Lat: {lat.toFixed(6)}, Lng: {lng.toFixed(6)}
+
+
+
+
+ {hasMultipleForecasts && {forecastCount} forecasts at this location}
+
+ {hasMultipleForecasts ? 'Latest issued' : 'Issued'}: {formatDateTime(latestForecast.issued_at)}
+
+ Type: {latestForecast.forecast_type}
+ {latestForecast.forecaster_name && (
+ Forecaster: {latestForecast.forecaster_name}
+ )}
+
+
+
+
+
+
+ {canSubmitForecast && (
+
+ )}
+
+ )
+}
+
+export default ForecastPopup
diff --git a/web/apps/wps-web/src/features/smurfi/components/map/NewRequestClickInteraction.ts b/web/apps/wps-web/src/features/smurfi/components/map/NewRequestClickInteraction.ts
new file mode 100644
index 0000000000..22e1bdbbd5
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/map/NewRequestClickInteraction.ts
@@ -0,0 +1,94 @@
+import { Interaction } from 'ol/interaction'
+import type { MapBrowserEvent } from 'ol'
+import type Map from 'ol/Map'
+import type Overlay from 'ol/Overlay'
+import Feature from 'ol/Feature'
+import { Point } from 'ol/geom'
+import VectorLayer from 'ol/layer/Vector'
+import VectorSource from 'ol/source/Vector'
+import { Circle as CircleStyle, Fill, Stroke, Style } from 'ol/style'
+import { toLonLat } from 'ol/proj'
+
+export interface NewRequestClickData {
+ lat: number
+ lon: number
+ coordinate: number[]
+}
+
+const PENDING_MARKER_STYLE = new Style({
+ image: new CircleStyle({
+ radius: 8,
+ fill: new Fill({ color: '#fe6900' }),
+ stroke: new Stroke({ color: '#ffffff', width: 2 })
+ })
+})
+
+export class NewRequestClickInteraction extends Interaction {
+ private readonly overlay: Overlay
+ private readonly onEmptyClick: (data: NewRequestClickData) => void
+ private readonly onDismiss: () => void
+ private popupOpen = false
+
+ private readonly pendingMarkerSource = new VectorSource>()
+ private readonly pendingMarkerLayer = new VectorLayer({
+ source: this.pendingMarkerSource,
+ style: PENDING_MARKER_STYLE,
+ zIndex: 60
+ })
+
+ private readonly shouldIgnoreClick?: (event: MapBrowserEvent) => boolean
+
+ constructor(options: {
+ overlay: Overlay
+ onEmptyClick: (data: NewRequestClickData) => void
+ onDismiss: () => void
+ shouldIgnoreClick?: (event: MapBrowserEvent) => boolean
+ }) {
+ super()
+ this.overlay = options.overlay
+ this.onEmptyClick = options.onEmptyClick
+ this.onDismiss = options.onDismiss
+ this.shouldIgnoreClick = options.shouldIgnoreClick
+ }
+
+ override setMap(map: Map | null) {
+ this.getMap()?.removeLayer(this.pendingMarkerLayer)
+ super.setMap(map)
+ map?.addLayer(this.pendingMarkerLayer)
+ }
+
+ override handleEvent(event: MapBrowserEvent): boolean {
+ if (event.type !== 'click') return true
+
+ if (this.popupOpen) {
+ this.clearPopup()
+ this.onDismiss()
+ // let the event through if a feature was clicked so other handlers can open its popup
+ return !!event.map.forEachFeatureAtPixel(event.pixel, () => true)
+ }
+
+ if (this.shouldIgnoreClick?.(event)) return true
+
+ const [lon, lat] = toLonLat(event.coordinate)
+ this.overlay.setPosition(event.coordinate)
+ this.pendingMarkerSource.addFeature(new Feature({ geometry: new Point(event.coordinate) }))
+ this.popupOpen = true
+ this.onEmptyClick({ lat, lon, coordinate: event.coordinate })
+ return false
+ }
+
+ close() {
+ this.clearPopup()
+ }
+
+ private clearPopup() {
+ this.overlay.setPosition(undefined)
+ this.pendingMarkerSource.clear()
+ this.popupOpen = false
+ }
+
+ override dispose() {
+ this.getMap()?.removeLayer(this.pendingMarkerLayer)
+ super.dispose()
+ }
+}
diff --git a/web/apps/wps-web/src/features/smurfi/components/map/NewRequestPopup.tsx b/web/apps/wps-web/src/features/smurfi/components/map/NewRequestPopup.tsx
new file mode 100644
index 0000000000..a1f248b790
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/map/NewRequestPopup.tsx
@@ -0,0 +1,37 @@
+import { Box, Button, Divider, Stack, Typography } from '@mui/material'
+
+interface NewRequestPopupProps {
+ onConfirm: () => void
+ onCancel: () => void
+}
+
+const NewRequestPopup = ({ onConfirm, onCancel }: NewRequestPopupProps) => (
+
+
+ New Spot Request
+
+
+
+ Create new spot request at this location?
+
+
+
+
+
+
+)
+
+export default NewRequestPopup
diff --git a/web/apps/wps-web/src/features/smurfi/components/map/SMURFIMap.tsx b/web/apps/wps-web/src/features/smurfi/components/map/SMURFIMap.tsx
new file mode 100644
index 0000000000..9d8b5b6aae
--- /dev/null
+++ b/web/apps/wps-web/src/features/smurfi/components/map/SMURFIMap.tsx
@@ -0,0 +1,624 @@
+import { Box } from '@mui/material'
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { Feature, Map, View } from 'ol'
+import { fromLonLat } from 'ol/proj'
+import Overlay from 'ol/Overlay'
+import 'ol/ol.css'
+import { BASEMAP_LAYER_NAME } from '@/features/sfmsInsights/components/map/layerDefinitions'
+import { boundingExtent } from 'ol/extent'
+import VectorLayer from 'ol/layer/Vector'
+import { LineString, Point } from 'ol/geom'
+import VectorSource from 'ol/source/Vector'
+import NewRequestPopup from './NewRequestPopup'
+import SpotPopup from './SpotPopup'
+import ForecastPopup from './ForecastPopup'
+import {
+ BC_EXTENT,
+ CENTER_OF_BC,
+ SMURFI_NEW_REQUEST_ROUTE,
+ getSmurfiForecastRoute,
+ getSmurfiForecastsRoute,
+ getSmurfiNewForecastRoute,
+ getSmurfiRequestRoute
+} from '@wps/utils/constants'
+import { createVectorTileLayer, getStyleJson } from '@wps/utils/vectorLayerUtils'
+import { BASEMAP_STYLE_URL, BASEMAP_TILE_URL } from '@wps/utils/env'
+import { SpotForecastOutput, SpotRequestOutput, SpotRequestStatus } from '@wps/api/SMURFIAPI'
+import { useDispatch, useSelector } from 'react-redux'
+import { AppDispatch } from '@/app/store'
+import { fetchSpotForecasts, fetchSpotRequests, selectSmurfi } from '@/features/smurfi/slices/smurfiSlice'
+import { useNavigate } from 'react-router-dom'
+import useSpotPermissions from '@/features/smurfi/hooks/useSpotPermissions'
+import {
+ createCurrentFirePointsLayer,
+ createCurrentFirePolygonsLayer
+} from '@/features/currentFires/map/currentFireLayers'
+import { CurrentFiresClickInteraction } from '@/features/currentFires/map/CurrentFiresClickInteraction'
+import { CurrentFireLayerController } from '@/features/currentFires/map/currentFireLayerController'
+import { NewRequestClickInteraction } from '@/features/smurfi/components/map/NewRequestClickInteraction'
+import CurrentFirePolygonPopup from '@/features/smurfi/components/map/CurrentFirePolygonPopup'
+import SpotMapLayerSwitcher from '@/features/smurfi/components/map/SpotMapLayerSwitcher'
+import { panMapToFitElement } from '@/features/map/mapPopupUtils'
+import { CurrentFireStatus, getVisibleCurrentFireStatusDefaults } from '@/features/currentFires/map/layerVisibility'
+import {
+ SPOT_REQUEST_STATUS_OPTIONS,
+ getVisibleSpotRequestStatusDefaults
+} from '@/features/smurfi/components/map/mapLayerVisibility'
+import {
+ FirePopupData,
+ ForecastPopupData,
+ MapClickPopupData,
+ SelectedCoordinates,
+ SpotPopupData
+} from '@/features/smurfi/interfaces'
+import { buildSpotFeature, getForecastFeaturesForRequest } from '@/features/smurfi/components/map/spotMapFeatureUtils'
+import {
+ createForecastMarkerStyle,
+ createSpotMarkerStyle,
+ forecastLineStyle
+} from '@/features/smurfi/components/map/mapFeatureStyles'
+import { MapContext } from '@/features/smurfi/components/map/mapContext'
+
+const bcExtent = boundingExtent(BC_EXTENT.map(coord => fromLonLat(coord)))
+
+interface SMURFIMapProps {
+ selectedCoordinates?: SelectedCoordinates | null
+ spotRequests?: SpotRequestOutput[]
+}
+
+const SMURFIMap = ({ selectedCoordinates, spotRequests: propSpotRequests }: SMURFIMapProps) => {
+ // hooks
+ const navigate = useNavigate()
+ const dispatch = useDispatch()
+ const { spotForecastsByRequestId, spotRequests } = useSelector(selectSmurfi)
+ const { isForecaster } = useSpotPermissions(undefined)
+
+ // state
+ const [selectedStatuses, setSelectedStatuses] = useState(getVisibleSpotRequestStatusDefaults)
+ const [selectedFireNumbers, setSelectedFireNumbers] = useState([])
+ const [currentFiresVisible, setCurrentFiresVisible] = useState(true)
+ const [selectedCurrentFireStatuses, setSelectedCurrentFireStatuses] = useState(
+ getVisibleCurrentFireStatusDefaults
+ )
+ const [map, setMap] = useState