Skip to content
Open
Show file tree
Hide file tree
Changes from 63 commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
7f05edf
sending hooked up
conbrad Mar 23, 2026
916a864
cleanup
conbrad Mar 23, 2026
344e413
Merge branch 'main' into task/send-fcm-notifications
conbrad Mar 24, 2026
0d6bb73
remove TODO
conbrad Mar 24, 2026
01326fd
FCM creds
conbrad Mar 24, 2026
abbf802
hook up to process stats
conbrad Mar 24, 2026
7f53e74
flush on upsert, hook up app
conbrad Mar 25, 2026
cbf12d9
fixes and tests
conbrad Mar 25, 2026
6f94abb
fixes
conbrad Mar 25, 2026
4f70114
fix
conbrad Mar 25, 2026
b9c5be7
tag groups
conbrad Mar 25, 2026
926dc60
cleanup
conbrad Mar 25, 2026
4549426
add tests
conbrad Mar 25, 2026
7fd7ed2
tests for process_stats
conbrad Mar 25, 2026
5beda5c
cleanup tests
conbrad Mar 25, 2026
d2b7194
add test
conbrad Mar 25, 2026
20dd931
set threshold
conbrad Mar 25, 2026
2a3bcb4
bug fix, cleanup
conbrad Mar 25, 2026
4693591
bug fix, cleanup
conbrad Mar 25, 2026
7092310
Simplify cleanup
conbrad Mar 25, 2026
1b527e4
comment
conbrad Mar 25, 2026
7196112
manage blocking call
conbrad Mar 25, 2026
28c6b7a
forecasts for today send notifications
conbrad Mar 25, 2026
f9f1e7a
Set lifespan for notifications to 2 days
conbrad Mar 26, 2026
962ed4e
Batch messaging
conbrad Mar 26, 2026
f724bcb
remove await
conbrad Mar 26, 2026
de1fd00
capture previous subs to replace if server update fails
conbrad Mar 26, 2026
99b98d7
Hook up to map context menu
conbrad Mar 26, 2026
055b43c
fix tests
conbrad Mar 26, 2026
7e88251
deviceID error handling
conbrad Mar 26, 2026
65a3e97
state machine for notifications
conbrad Mar 26, 2026
66e71b4
state machine cleanup and retry logic
conbrad Mar 30, 2026
b40dc98
reuse code
conbrad Mar 30, 2026
f62f4ef
initsubsonce
conbrad Mar 31, 2026
6b3e975
device and register error handling
conbrad Mar 31, 2026
4793133
type fixes
conbrad Mar 31, 2026
5abf8ee
simplify init subs
conbrad Mar 31, 2026
848a848
update diagram
conbrad Mar 31, 2026
f751485
Fix notification tests
conbrad Mar 31, 2026
0ce34db
reuse error message
conbrad Mar 31, 2026
2a9d3e9
fix tests
conbrad Mar 31, 2026
76745d0
Merge branch 'main' into task/send-fcm-notifications
conbrad Mar 31, 2026
5b404e1
fix tests and imports
conbrad Mar 31, 2026
d7be929
add error tests
conbrad Mar 31, 2026
40df06d
remove acts
conbrad Mar 31, 2026
fe2eb8c
fix import
conbrad Mar 31, 2026
3d3f4b3
error if no placename
conbrad Mar 31, 2026
639c48d
update diagram
conbrad Mar 31, 2026
f94fa80
Fix notification title
conbrad Mar 31, 2026
6cab844
settingsSlice tests
conbrad Mar 31, 2026
10917ef
registrationFailed test
conbrad Mar 31, 2026
d682f2f
Trigger Build
conbrad Mar 31, 2026
52c457e
Merge branch 'main' into task/send-fcm-notifications
conbrad Apr 1, 2026
e410bc2
more logging
conbrad Apr 1, 2026
f903c48
undo batching
conbrad Apr 1, 2026
ffed8cd
Revert "undo batching"
conbrad Apr 1, 2026
d591d03
add more logging
conbrad Apr 1, 2026
8028e5f
pydantic model
conbrad Apr 7, 2026
ad23ba2
not toggle error, snackbar at top with close button
conbrad Apr 7, 2026
2dd5621
remove disabled reason from context menu
conbrad Apr 7, 2026
4bbc3c5
Add retry registration logic to context menu open
conbrad Apr 7, 2026
dae425c
fix error message
conbrad Apr 7, 2026
020a42c
Merge branch 'main' into task/send-fcm-notifications
conbrad Apr 7, 2026
d096cb4
250ms base delay
conbrad Apr 7, 2026
8c6e2ba
Merge branch 'main' into task/send-fcm-notifications
conbrad Apr 7, 2026
c364e54
notification error snackbar
conbrad Apr 7, 2026
c2855cb
Merge branch 'main' into task/send-fcm-notifications
conbrad Apr 7, 2026
7cabde8
refactor: move retry registration logic into dedicated useRetryRegist…
conbrad Apr 9, 2026
0a41807
move registration into hook
conbrad Apr 9, 2026
7c01f34
add styling to notification error
conbrad Apr 9, 2026
813126f
Merge branch 'main' into task/send-fcm-notifications
conbrad Apr 9, 2026
40c5336
Merge branch 'main' into task/send-fcm-notifications
conbrad Apr 9, 2026
efca4c4
max limit of retries
conbrad Apr 9, 2026
a2d40c9
type annotations
conbrad Apr 9, 2026
ceda0b3
sub utils
conbrad Apr 9, 2026
9b8ef97
getToken retries
conbrad Apr 9, 2026
b401ed8
retryRegistration resets attempts
conbrad Apr 9, 2026
64a4871
adjust tests
conbrad Apr 9, 2026
a7f087a
Merge branch 'main' into task/send-fcm-notifications
conbrad Apr 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
meta {
name: Get Notification Settings
type: http
seq: 1
}

get {
url: {{API_BASE_URL}}/device/notification-settings?device_id=9242842270074025
body: none
auth: bearer
}

params:query {
device_id: 9242842270074025
}

auth:bearer {
token: {{AUTH_TOKEN}}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
meta {
name: Update Notification Settings
type: http
seq: 2
}

post {
url: {{API_BASE_URL}}/device/notification-settings
body: json
auth: bearer
}

auth:bearer {
token: {{AUTH_TOKEN}}
}

body:json {
{
"device_id": "9242842270074025",
"fire_zone_source_ids": ["24"]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
vars {
API_BASE_URL: https://wps-pr-5246-e1e498-dev.apps.silver.devops.gov.bc.ca/api
}
vars:secret [
AUTH_TOKEN
]
1 change: 1 addition & 0 deletions backend/packages/wps-api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ dependencies = [
"gdal==3.12.3",
"wps-wf1",
"earthaccess>=0.15.1",
"firebase-admin>=7.3.0",
]

[project.optional-dependencies]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,28 @@

import asyncio
import json
from datetime import datetime
import logging
from datetime import datetime
from typing import List
from starlette.background import BackgroundTasks

import nats
from nats.js.api import StreamConfig, RetentionPolicy
from nats.aio.msg import Msg
from nats.js.api import RetentionPolicy, StreamConfig
from starlette.background import BackgroundTasks
from wps_shared import config
from wps_shared.utils.time import get_utc_datetime
from wps_shared.wps_logging import configure_logging

from app.auto_spatial_advisory.nats_config import (
hfi_classify_durable_group,
server,
stream_name,
sfms_file_subject,
stream_name,
subjects,
hfi_classify_durable_group,
)
from app.auto_spatial_advisory.process_hfi import RunType
from app.auto_spatial_advisory.process_stats import process_sfms_hfi_stats
from app.nats_publish import publish
from wps_shared.wps_logging import configure_logging
from wps_shared.utils.time import get_utc_datetime

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -102,6 +105,13 @@ async def closed_cb():

if __name__ == "__main__":
configure_logging()
creds_json = config.get("FCM_CREDS")
if creds_json:
from firebase_admin import credentials, initialize_app

initialize_app(credentials.Certificate(json.loads(creds_json)))
else:
raise ValueError("FCM_CREDS is not set — Firebase cannot be initialized.")
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
asyncio.run(run())
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import logging
from datetime import date, datetime

from wps_shared.db.crud.auto_spatial_advisory import mark_run_parameter_complete
from wps_shared.db.database import get_async_write_session_scope
from wps_shared.db.models.auto_spatial_advisory import RunTypeEnum

from app.auto_spatial_advisory.critical_hours import calculate_critical_hours
from app.auto_spatial_advisory.hfi_minimum_wind_speed import process_hfi_min_wind_speed
from app.auto_spatial_advisory.hfi_percent_conifer import process_hfi_percent_conifer
Expand All @@ -8,8 +13,9 @@
from app.auto_spatial_advisory.process_hfi import RunType, process_hfi
from app.auto_spatial_advisory.process_high_hfi_area import process_high_hfi_area
from app.auto_spatial_advisory.process_zone_status import process_zone_statuses
from wps_shared.db.crud.auto_spatial_advisory import mark_run_parameter_complete
from wps_shared.db.database import get_async_write_session_scope
from app.fcm.notifications import trigger_notifications

logger = logging.getLogger(__name__)


async def process_sfms_hfi_stats(run_type: RunType, run_datetime: datetime, for_date: date):
Expand All @@ -24,3 +30,16 @@ async def process_sfms_hfi_stats(run_type: RunType, run_datetime: datetime, for_

async with get_async_write_session_scope() as session:
await mark_run_parameter_complete(session, run_type, run_datetime, for_date)

try:
async with get_async_write_session_scope() as session:
await trigger_notifications(
session, RunTypeEnum(run_type.value), run_datetime, for_date
)
except Exception:
logger.exception(
"Failed to send FCM notifications for run_type=%s run_datetime=%s for_date=%s.",
run_type,
run_datetime,
for_date,
)
Empty file.
134 changes: 134 additions & 0 deletions backend/packages/wps-api/src/app/fcm/notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import asyncio
import logging
from datetime import date, datetime, timedelta, timezone

from firebase_admin import exceptions as firebase_exceptions
from firebase_admin import messaging
from sqlalchemy.ext.asyncio import AsyncSession
from wps_shared.db.crud.auto_spatial_advisory import ZoneAdvisoryStatus, get_zones_with_advisories
from wps_shared.db.crud.fcm import get_device_tokens_for_zone, update_device_tokens_are_active
from wps_shared.db.models.auto_spatial_advisory import RunTypeEnum
from wps_shared.utils.time import get_vancouver_now

logger = logging.getLogger(__name__)

FCM_BATCH_SIZE = 500


def build_notification_content(zone_with_advisory: ZoneAdvisoryStatus, for_date: date):
return f"{for_date.strftime('%a, %B %-d')} - An advisory has been issued for {zone_with_advisory.placename_label}"


def build_notification_title(zone_with_advisory: ZoneAdvisoryStatus):
zone = zone_with_advisory.placename_label.split("-")[0]
return f"Fire Behaviour Advisory, {zone}"


def build_fcm_message(
for_date: date, zone_with_advisory: ZoneAdvisoryStatus, device_tokens: list[str]
):
title = build_notification_title(zone_with_advisory)
content = build_notification_content(zone_with_advisory, for_date)
tag = f"advisory-{zone_with_advisory.source_identifier}"
ttl = timedelta(days=2)
apns_expiration = str(int((datetime.now(timezone.utc) + ttl).timestamp()))
message = messaging.MulticastMessage(
notification=messaging.Notification(title=title, body=content),
android=messaging.AndroidConfig(
ttl=ttl, notification=messaging.AndroidNotification(tag=tag)
),
apns=messaging.APNSConfig(
headers={"apns-expiration": apns_expiration},
payload=messaging.APNSPayload(aps=messaging.Aps(thread_id=tag)),
),
tokens=device_tokens,
)

return message


async def trigger_notifications(
session: AsyncSession, run_type: RunTypeEnum, run_datetime: datetime, for_date: date
) -> None:
if run_type == RunTypeEnum.actual:
return

if for_date != get_vancouver_now().date():
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we only want to send notifications in the morning (the 08:00 forecast SFMS run). I think this logic will also result in notifications being sent during the 3:45pm forecast SFMS run.

Copy link
Copy Markdown
Collaborator Author

@conbrad conbrad Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So what we could do is after the for_date check, convert run_datetime (UTC) to Vancouver time and returns early if hour >= 12. This filters out the 15:45 forecast run but leaves buffer room for the morning forecast run. However this will become very difficult to test e2e, so I'll save adding this until the end..

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this comment still needs addressing

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was left unchanged in case y'all wanted to test notifications locally again, I can address it now if satisified.

logger.info("Skipping FCM notifications: for_date=%s is not today", for_date)
return

logger.info("Checking for warnings/advisories to send FCM notifications for")
zones_with_advisories = await get_zones_with_advisories(
session, run_type, run_datetime, for_date
)
logger.info(
f"{len(zones_with_advisories)} have warnings/advisories, checking for devices to notify"
)
for zone_with_advisory in zones_with_advisories:
if not zone_with_advisory.placename_label:
logger.error(
"Skipping FCM notification: missing placename_label for zone source_identifier=%s",
zone_with_advisory.source_identifier,
)
continue
device_tokens = await get_device_tokens_for_zone(
session, zone_with_advisory.source_identifier
)
if len(device_tokens) == 0:
logger.info(f"No devices subscribed to {zone_with_advisory.placename_label}")
continue
logger.info(
f"{len(device_tokens)} are subscribed to {zone_with_advisory.placename_label} about to notify"
)
for i in range(0, len(device_tokens), FCM_BATCH_SIZE):
batch = device_tokens[i : i + FCM_BATCH_SIZE]
message = build_fcm_message(for_date, zone_with_advisory, batch)
try:
logger.info(f"Notifiying {len(batch)} devices")
# messaging.send_each_for_multicast is a synchronous blocking call
response = await asyncio.to_thread(messaging.send_each_for_multicast, message)
except firebase_exceptions.FirebaseError:
logger.exception(
"FCM send failed for zone=%s date=%s token_count=%d",
zone_with_advisory.placename_label,
for_date,
len(batch),
)
continue
await handle_fcm_response(
session, for_date, zone_with_advisory.placename_label, batch, response
)


async def handle_fcm_response(
session,
for_date,
placename_label: str,
device_tokens: list[str],
response: messaging.BatchResponse,
):
logger.info(
f"Received FCM response with successful notifications sent: {response.success_count}"
)
# Only deactivate permanently invalid tokens (UnregisteredError — token is no longer registered).
# Transient failures (quota, server errors) are not deactivated; the token remains valid.
# Deactivated tokens are re-activated when the app re-registers on open.
permanently_failed = [
device_tokens[idx]
for idx, resp in enumerate(response.responses)
if not resp.success and isinstance(resp.exception, messaging.UnregisteredError)
]
transient_failed_count = response.failure_count - len(permanently_failed)

if response.failure_count > 0:
logger.warning(
"FCM send for zone=%s date=%s: %d permanent failures, %d transient failures out of %d tokens",
placename_label,
for_date,
len(permanently_failed),
transient_failed_count,
len(device_tokens),
)

if permanently_failed:
await update_device_tokens_are_active(session, permanently_failed, False)
1 change: 0 additions & 1 deletion backend/packages/wps-api/src/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@
# We recommend adjusting this value in production.
profiles_sample_rate=0.5,
)

# This is the api app.
api = FastAPI(title="Predictive Services API", description=API_INFO, version="0.0.0")

Expand Down
1 change: 1 addition & 0 deletions backend/packages/wps-api/src/app/routers/fcm.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ async def update_notification_settings(
if not found:
logger.error("Notification settings update for unknown device_id: %s", request.device_id)
raise HTTPException(status_code=404, detail=f"Device not found: {request.device_id}")
await session.flush()
fire_zone_source_ids = await get_notification_settings_for_device(
session, request.device_id
)
Expand Down
Loading
Loading