From 9ac98ba94d068a47e275ec4cb540b9d951133a0b Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Mon, 2 Mar 2026 13:37:41 -0800 Subject: [PATCH 01/32] Initial work --- .../7d2194c5051e_push_notification_tokens.py | 42 ++ backend/packages/wps-api/src/app/fcm/fcm.py | 53 ++ .../packages/wps-api/src/app/fcm/schema.py | 17 + backend/packages/wps-api/src/app/main.py | 2 + .../packages/wps-api/src/app/routers/fcm.py | 48 ++ .../wps-shared/src/wps_shared/db/crud/fcm.py | 52 ++ .../src/wps_shared/db/models/__init__.py | 1 + .../src/wps_shared/db/models/fcm.py | 23 + mobile/asa-go/android/app/build.gradle | 3 + .../asa-go/android/app/capacitor.build.gradle | 1 + .../asa-go/android/capacitor.settings.gradle | 3 + mobile/asa-go/capacitor.config.ts | 5 + mobile/asa-go/ios/App/App/AppDelegate.swift | 33 + mobile/asa-go/ios/App/Podfile | 1 + mobile/asa-go/ios/App/Podfile.lock | 78 ++- mobile/asa-go/package.json | 2 + mobile/asa-go/src/App.tsx | 18 + mobile/asa-go/src/api/pushNotificationsAPI.ts | 42 ++ .../src/hooks/usePushNotifications.test.ts | 133 ++++ .../asa-go/src/hooks/usePushNotifications.ts | 47 ++ .../src/services/pushNotificationService.ts | 101 +++ mobile/asa-go/yarn.lock | 581 +++++++++++++++++- 22 files changed, 1283 insertions(+), 3 deletions(-) create mode 100644 backend/packages/wps-api/alembic/versions/7d2194c5051e_push_notification_tokens.py create mode 100644 backend/packages/wps-api/src/app/fcm/fcm.py create mode 100644 backend/packages/wps-api/src/app/fcm/schema.py create mode 100644 backend/packages/wps-api/src/app/routers/fcm.py create mode 100644 backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py create mode 100644 backend/packages/wps-shared/src/wps_shared/db/models/fcm.py create mode 100644 mobile/asa-go/src/api/pushNotificationsAPI.ts create mode 100644 mobile/asa-go/src/hooks/usePushNotifications.test.ts create mode 100644 mobile/asa-go/src/hooks/usePushNotifications.ts create mode 100644 mobile/asa-go/src/services/pushNotificationService.ts diff --git a/backend/packages/wps-api/alembic/versions/7d2194c5051e_push_notification_tokens.py b/backend/packages/wps-api/alembic/versions/7d2194c5051e_push_notification_tokens.py new file mode 100644 index 0000000000..74d068a770 --- /dev/null +++ b/backend/packages/wps-api/alembic/versions/7d2194c5051e_push_notification_tokens.py @@ -0,0 +1,42 @@ +"""Push notification tokens + +Revision ID: 7d2194c5051e +Revises: 0b46effaf3a1 +Create Date: 2026-03-02 10:48:11.523814 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from wps_shared.db.models.common import TZTimeStamp + +# revision identifiers, used by Alembic. +revision = '7d2194c5051e' +down_revision = '0b46effaf3a1' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('device_token', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.String(), nullable=True), + sa.Column('platform', sa.String(), nullable=False), + sa.Column('token', sa.String(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', TZTimeStamp(), nullable=False), + sa.Column('updated_at', TZTimeStamp(), nullable=False), + sa.PrimaryKeyConstraint('id'), + comment='Device token management.' + ) + op.create_index(op.f('ix_device_token_id'), 'device_token', ['id'], unique=False) + op.create_index(op.f('ix_device_token_platform'), 'device_token', ['platform'], unique=False) + op.create_index(op.f('ix_device_token_token'), 'device_token', ['token'], unique=True) + + +def downgrade(): + op.drop_index(op.f('ix_device_token_token'), table_name='device_token') + op.drop_index(op.f('ix_device_token_platform'), table_name='device_token') + op.drop_index(op.f('ix_device_token_id'), table_name='device_token') + op.drop_table('device_token') diff --git a/backend/packages/wps-api/src/app/fcm/fcm.py b/backend/packages/wps-api/src/app/fcm/fcm.py new file mode 100644 index 0000000000..13b99f96ff --- /dev/null +++ b/backend/packages/wps-api/src/app/fcm/fcm.py @@ -0,0 +1,53 @@ + +import asyncio +from typing import List + +from firebase_admin import messaging + +from wps_shared.db.crud.fcm import deactivate_device_tokens +from wps_shared.db.database import get_async_write_session_scope +from wps_shared.db.models.fcm import DeviceToken +from wps_shared.utils.time import get_utc_now + +# Simple exponential backoff with jitter for transient quota/server issues +async def _retry_send_multicast(multicast_msg: messaging.MulticastMessage, + max_retries: int = 5, + base_delay: float = 0.5): + attempt = 0 + while True: + try: + return messaging.send_multicast(multicast_msg, dry_run=False) + except Exception: + # Retry on probable transient conditions: quota (429), backend unavailable, etc. + # You can inspect e to match known transient cases in your logs. + attempt += 1 + if attempt > max_retries: + raise + # Exponential backoff with jitter + delay = (base_delay * (2 ** (attempt - 1))) + (0.1 * attempt) + await asyncio.sleep(delay) + + +async def deactivate_bad_tokens(db, tokens: List[str], responses): + """ + Deactivate tokens that failed with terminal errors like 'UNREGISTERED'. + """ + # For MulticastResponse: + # responses.responses[i].exception may contain details; many backends surface 'UNREGISTERED' + # when a token is invalid/stale. Remove/deactivate those. + stale_tokens: List[str] = [] + for idx, resp in enumerate(responses.responses): + if not resp.success: + exc = resp.exception + if exc and hasattr(exc, "code"): + code = getattr(exc, "code", None) + # TODO: Potentially expand this list based on observed error codes. + if str(code).upper() in {"UNREGISTERED"}: + stale_tokens.append(tokens[idx]) + token = tokens[idx] + db.query(DeviceToken).filter(DeviceToken.token == token).update( + {"is_active": False, "updated_at": get_utc_now()} + ) + async with get_async_write_session_scope() as session: + await deactivate_device_tokens(session, stale_tokens) + diff --git a/backend/packages/wps-api/src/app/fcm/schema.py b/backend/packages/wps-api/src/app/fcm/schema.py new file mode 100644 index 0000000000..0d4c05365a --- /dev/null +++ b/backend/packages/wps-api/src/app/fcm/schema.py @@ -0,0 +1,17 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class RegisterDeviceRequest(BaseModel): + user_id: Optional[str] = None + token: str = Field(..., min_length=10) + platform: Optional[str] = Field(..., pattern="^(ios|android)?$") + device_id: Optional[str] = None + +class UnregisterDeviceRequest(BaseModel): + token: str + +class DeviceRequestResponse(BaseModel): + success: bool + diff --git a/backend/packages/wps-api/src/app/main.py b/backend/packages/wps-api/src/app/main.py index 21b90f63be..f82ea8e351 100644 --- a/backend/packages/wps-api/src/app/main.py +++ b/backend/packages/wps-api/src/app/main.py @@ -33,6 +33,7 @@ morecast_v2, snow, fire_watch, + fcm, ) from app.fire_behaviour.cffdrs import CFFDRS @@ -139,6 +140,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(object_store_proxy.router, tags=["Object Store Proxy"]) +api.include_router(fcm.router, tags=["Firebase Cloud Messaging"]) @api.get("/ready") diff --git a/backend/packages/wps-api/src/app/routers/fcm.py b/backend/packages/wps-api/src/app/routers/fcm.py new file mode 100644 index 0000000000..3535f7bf41 --- /dev/null +++ b/backend/packages/wps-api/src/app/routers/fcm.py @@ -0,0 +1,48 @@ +from fastapi import APIRouter + +from wps_shared.db.crud.fcm import ( + get_device_by_token, + save_device_token, + update_device_token_is_active, +) +from wps_shared.db.database import get_async_write_session_scope +from wps_shared.db.models.fcm import DeviceToken +from wps_shared.utils.time import get_utc_now + +import app +from app.fcm.schema import DeviceRequestResponse, RegisterDeviceRequest, UnregisterDeviceRequest + +router = APIRouter( + prefix="/device" +) + +@router.post("/register") +async def register_device(request: RegisterDeviceRequest): + """ + Upsert a device token for a user. Called this at app start and whenever FCM token refreshes. + """ + async with get_async_write_session_scope() as session: + existing = await get_device_by_token(session, request.token) + if existing: + existing.is_active = True + existing.token = request.token + existing.updated_at = get_utc_now() + else: + device_token = DeviceToken( + user_id=request.user_id, + token=request.token, + platform=request.platform, + is_active=True, + ) + save_device_token(session, device_token) + return DeviceRequestResponse(success=True) + + +@router.delete("/unregister") +async def unregister_device(request: UnregisterDeviceRequest): + """ + Mark a token inactive (e.g., user logged out or uninstalled). + """ + async with get_async_write_session_scope() as session: + await update_device_token_is_active(session, request.token) + return DeviceRequestResponse(success=True) diff --git a/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py b/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py new file mode 100644 index 0000000000..1f0eadba80 --- /dev/null +++ b/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py @@ -0,0 +1,52 @@ +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from wps_shared.db.models.fcm import DeviceToken +from wps_shared.utils.time import get_utc_now + + +def save_device_token(session: AsyncSession, device_token: DeviceToken): + """Add a new DeviceToken for tracking devices registered for push notifications. + :param session: An async database session. + :param device_token: The record to be saved. + :type device_token: DeviceToken + """ + session.add(device_token) + + +async def get_device_by_token(session: AsyncSession, token: str): + """ + Lookup a DeviceToken by token value. + + :param session: An async database session + :param token: A token for a registered device. + :return: A DeviceToken object or None. + """ + return await session.scalar(select(DeviceToken).where(DeviceToken.token == token)) + + +async def update_device_token_is_active(session: AsyncSession, token: str): + device_token = await session.scalar(select(DeviceToken).where(DeviceToken.token == token)) + if not token: + raise ValueError(f"DeviceToken with id {token} does not exist.") + device_token.is_active = True + device_token.updated_at(get_utc_now()) + + +async def deactivate_device_tokens(session: AsyncSession, tokens: list[str]) -> int: + if not tokens: + return 0 + + stmt = ( + update(DeviceToken) + .where(DeviceToken.token.in_(tokens)) + .values( + is_active=False, + updated_at=get_utc_now(), + ) + # No need to synchronize the session: set-based UPDATE + no ORM objects loaded. + .execution_options(synchronize_session=False) + ) + result = await session.execute(stmt) + + return result.rowcount or 0 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 ffd782fe66..f165923695 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 @@ -40,3 +40,4 @@ from wps_shared.db.models.fuel_type_raster import FuelTypeRaster 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 diff --git a/backend/packages/wps-shared/src/wps_shared/db/models/fcm.py b/backend/packages/wps-shared/src/wps_shared/db/models/fcm.py new file mode 100644 index 0000000000..be62f2132c --- /dev/null +++ b/backend/packages/wps-shared/src/wps_shared/db/models/fcm.py @@ -0,0 +1,23 @@ +import enum +from typing import Literal + +from sqlalchemy import Boolean, Column, Enum, Integer, String + +from wps_shared.db.models import Base +from wps_shared.db.models.auto_spatial_advisory import Shape +from wps_shared.db.models.common import TZTimeStamp +from wps_shared.utils.time import get_utc_now + + +class DeviceToken(Base): + """Storage of Firebase Cloud Messaging tokens and client details.""" + + __tablename__ = "device_token" + __table_args__ = {"comment": "Device token management."} + id = Column(Integer, primary_key=True, index=True) + user_id = Column(String, nullable=True) # Optional storage of IDIR for logged in users + platform = Column(String, index=True, nullable=False) + token = Column(String, unique=True, index=True, nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(TZTimeStamp, default=get_utc_now(), nullable=False) + updated_at = Column(TZTimeStamp, default=get_utc_now(), nullable=False) diff --git a/mobile/asa-go/android/app/build.gradle b/mobile/asa-go/android/app/build.gradle index e1719d7a10..039d5c37e1 100644 --- a/mobile/asa-go/android/app/build.gradle +++ b/mobile/asa-go/android/app/build.gradle @@ -1,4 +1,5 @@ apply plugin: 'com.android.application' +apply plugin: 'com.google.gms.google-services' android { namespace "ca.bc.gov.asago" @@ -52,6 +53,8 @@ dependencies { androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" implementation project(':capacitor-cordova-android-plugins') + // Import the Firebase BoM + implementation platform('com.google.firebase:firebase-bom:34.9.0') } apply from: 'capacitor.build.gradle' diff --git a/mobile/asa-go/android/app/capacitor.build.gradle b/mobile/asa-go/android/app/capacitor.build.gradle index 27ae486f14..7692f6ce80 100644 --- a/mobile/asa-go/android/app/capacitor.build.gradle +++ b/mobile/asa-go/android/app/capacitor.build.gradle @@ -9,6 +9,7 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { + implementation project(':capacitor-firebase-messaging') implementation project(':capacitor-app') implementation project(':capacitor-filesystem') implementation project(':capacitor-geolocation') diff --git a/mobile/asa-go/android/capacitor.settings.gradle b/mobile/asa-go/android/capacitor.settings.gradle index 1d9da39dbe..4077eaf949 100644 --- a/mobile/asa-go/android/capacitor.settings.gradle +++ b/mobile/asa-go/android/capacitor.settings.gradle @@ -2,6 +2,9 @@ include ':capacitor-android' project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') +include ':capacitor-firebase-messaging' +project(':capacitor-firebase-messaging').projectDir = new File('../node_modules/@capacitor-firebase/messaging/android') + include ':capacitor-app' project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') diff --git a/mobile/asa-go/capacitor.config.ts b/mobile/asa-go/capacitor.config.ts index 6e10337ac7..b30d0f407d 100644 --- a/mobile/asa-go/capacitor.config.ts +++ b/mobile/asa-go/capacitor.config.ts @@ -5,7 +5,12 @@ const config: CapacitorConfig = { appName: "asa-go", webDir: "dist", ios: { scheme: "ASA Go" }, + server: { androidScheme: "http" }, plugins: { + FirebaseMessaging: { + presentationOptions: ["alert", "badge", "sound"], // iOS only + }, + SplashScreen: { launchAutoHide: true, launchShowDuration: 500, diff --git a/mobile/asa-go/ios/App/App/AppDelegate.swift b/mobile/asa-go/ios/App/App/AppDelegate.swift index 1ad5d7a496..7b1bf7cffd 100644 --- a/mobile/asa-go/ios/App/App/AppDelegate.swift +++ b/mobile/asa-go/ios/App/App/AppDelegate.swift @@ -64,4 +64,37 @@ class AppDelegate: UIResponder, UIApplicationDelegate, KeycloakAppDelegate { application, continue: userActivity, restorationHandler: restorationHandler) } + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + NotificationCenter.default.post( + name: .capacitorDidRegisterForRemoteNotifications, + object: deviceToken + ) + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + NotificationCenter.default.post( + name: .capacitorDidFailToRegisterForRemoteNotifications, + object: error + ) + } + + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + NotificationCenter.default.post( + name: Notification.Name.init("didReceiveRemoteNotification"), + object: completionHandler, + userInfo: userInfo + ) + } + } diff --git a/mobile/asa-go/ios/App/Podfile b/mobile/asa-go/ios/App/Podfile index 10300e43e6..370b8e4e3c 100644 --- a/mobile/asa-go/ios/App/Podfile +++ b/mobile/asa-go/ios/App/Podfile @@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true def capacitor_pods pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' + pod 'CapacitorFirebaseMessaging', :path => '../../node_modules/@capacitor-firebase/messaging' pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem' pod 'CapacitorGeolocation', :path => '../../node_modules/@capacitor/geolocation' diff --git a/mobile/asa-go/ios/App/Podfile.lock b/mobile/asa-go/ios/App/Podfile.lock index f5c9ff8764..546ce8edf7 100644 --- a/mobile/asa-go/ios/App/Podfile.lock +++ b/mobile/asa-go/ios/App/Podfile.lock @@ -14,6 +14,9 @@ PODS: - Capacitor - CapacitorFilesystem (7.0.0): - Capacitor + - CapacitorFirebaseMessaging (8.1.0): + - Capacitor + - FirebaseMessaging (~> 12.7.0) - CapacitorGeolocation (7.1.4): - Capacitor - IONGeolocationLib (~> 1.0) @@ -31,10 +34,63 @@ PODS: - Capacitor - CapacitorStatusBar (7.0.0): - Capacitor + - FirebaseCore (12.7.0): + - FirebaseCoreInternal (~> 12.7.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreInternal (12.7.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseInstallations (12.7.0): + - FirebaseCore (~> 12.7.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - PromisesObjC (~> 2.4) + - FirebaseMessaging (12.7.0): + - FirebaseCore (~> 12.7.0) + - FirebaseInstallations (~> 12.7.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Reachability (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - nanopb (~> 3.30910.0) + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleUtilities/AppDelegateSwizzler (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Network (8.1.0): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (8.1.0)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy - IONGeolocationLib (1.0.0) - Keycloak (0.0.1): - AppAuth - Capacitor + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) + - PromisesObjC (2.4.0) DEPENDENCIES: - "Capacitor (from `../../node_modules/@capacitor/ios`)" @@ -42,6 +98,7 @@ DEPENDENCIES: - "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" - CapacitorEmailComposer (from `../../node_modules/capacitor-email-composer`) - "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)" + - "CapacitorFirebaseMessaging (from `../../node_modules/@capacitor-firebase/messaging`)" - "CapacitorGeolocation (from `../../node_modules/@capacitor/geolocation`)" - "CapacitorHaptics (from `../../node_modules/@capacitor/haptics`)" - "CapacitorKeyboard (from `../../node_modules/@capacitor/keyboard`)" @@ -55,7 +112,15 @@ DEPENDENCIES: SPEC REPOS: trunk: - AppAuth + - FirebaseCore + - FirebaseCoreInternal + - FirebaseInstallations + - FirebaseMessaging + - GoogleDataTransport + - GoogleUtilities - IONGeolocationLib + - nanopb + - PromisesObjC EXTERNAL SOURCES: Capacitor: @@ -68,6 +133,8 @@ EXTERNAL SOURCES: :path: "../../node_modules/capacitor-email-composer" CapacitorFilesystem: :path: "../../node_modules/@capacitor/filesystem" + CapacitorFirebaseMessaging: + :path: "../../node_modules/@capacitor-firebase/messaging" CapacitorGeolocation: :path: "../../node_modules/@capacitor/geolocation" CapacitorHaptics: @@ -94,6 +161,7 @@ SPEC CHECKSUMS: CapacitorCordova: 866217f32c1d25b326c568a10ea3ed0c36b13e29 CapacitorEmailComposer: 5f0ae4bd516997aba61034ba4387f2a67947ce21 CapacitorFilesystem: 2881ad012b5c8d0ffbfed1216aadfc2cca16d5b5 + CapacitorFirebaseMessaging: 798b7f8af4318b97b11eb9b84c8ebe9920237c17 CapacitorGeolocation: 5583eb25815522c4b4cb38936ac7fea8a34996d9 CapacitorHaptics: 1fba3e460e7614349c6d5f868b1fccdc5c87b66d CapacitorKeyboard: 2c26c6fccde35023c579fc37d4cae6326d5e6343 @@ -102,9 +170,17 @@ SPEC CHECKSUMS: CapacitorScreenOrientation: 82b7c0bcc6189e97269ec66c613d0cd4f1c1c004 CapacitorSplashScreen: 8d6c8cb0542a8e81585c593815db8785ed8ce454 CapacitorStatusBar: 438e90beeeefa8276b24e6c5991cb02dd13e51bf + FirebaseCore: c7b57863ce0859281a66d16ca36d665c45d332b5 + FirebaseCoreInternal: 571a2dd8c975410966199623351db3a3265c874d + FirebaseInstallations: 6d05424a046b68ca146b4de4376f05b4e9262fc3 + FirebaseMessaging: b5f7bdc62b91b6102015991fb7bc6fa75f643908 + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 IONGeolocationLib: 81f33f88d025846946de2cf63b0c7628e7c6bc9d Keycloak: c39abe3ec71f672fbf306a7f37b85d3858ae7f00 + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 -PODFILE CHECKSUM: 856f039e24292b4ec10a418a5c901e1fe1428a40 +PODFILE CHECKSUM: 2968fe3a80c3c767026c18edea6ca7a02ed91eaa COCOAPODS: 1.16.2 diff --git a/mobile/asa-go/package.json b/mobile/asa-go/package.json index 7a859f1289..406930621b 100644 --- a/mobile/asa-go/package.json +++ b/mobile/asa-go/package.json @@ -16,6 +16,7 @@ "cap:sync:prod": "yarn build:prod && yarn cap sync" }, "dependencies": { + "@capacitor-firebase/messaging": "^8.1.0", "@capacitor/android": "7.1.0", "@capacitor/app": "7.0.0", "@capacitor/core": "7.1.0", @@ -40,6 +41,7 @@ "@reduxjs/toolkit": "^2.5.1", "axios": "^1.7.9", "capacitor-email-composer": "^7.0.0", + "firebase": "^12.9.0", "keycloak": "../keycloak", "lodash": "^4.17.21", "luxon": "^3.5.0", diff --git a/mobile/asa-go/src/App.tsx b/mobile/asa-go/src/App.tsx index ac8186d200..e261147960 100644 --- a/mobile/asa-go/src/App.tsx +++ b/mobile/asa-go/src/App.tsx @@ -21,6 +21,7 @@ import { selectFireCenters, selectNetworkStatus, selectRunParameters, + selectAuthentication, } from "@/store"; import { theme } from "@/theme"; import { NavPanel } from "@/utils/constants"; @@ -37,6 +38,9 @@ import { isNil, isNull } from "lodash"; import { DateTime } from "luxon"; import { useEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; +import { usePushNotifications } from "@/hooks/usePushNotifications"; +import { Capacitor } from "@capacitor/core"; +import { Platform, registerToken } from "@/api/pushNotificationsAPI"; const App = () => { LicenseInfo.setLicenseKey(import.meta.env.VITE_MUI_LICENSE_KEY); @@ -44,6 +48,7 @@ const App = () => { const dispatch: AppDispatch = useDispatch(); const [isPortrait, setIsPortrait] = useState(true); const isSmallScreen = useMediaQuery(theme.breakpoints.down("lg")); + const { idToken, isAuthenticated } = useSelector(selectAuthentication); // local state const [tab, setTab] = useState(NavPanel.MAP); @@ -62,6 +67,7 @@ const App = () => { // hooks const runParameter = useRunParameterForDate(dateOfInterest); + const { initPushNotifications, token } = usePushNotifications(); const selectedFireCenterName = selectedFireShape?.mof_fire_centre_name; const matchingFireCenter = selectedFireCenterName @@ -98,6 +104,18 @@ const App = () => { }; }, []); + useEffect(() => { + if (isAuthenticated) { + initPushNotifications(); + } + }, [initPushNotifications, isAuthenticated]); + + useEffect(() => { + if (token) { + registerToken(Capacitor.getPlatform() as Platform, token, idToken || ""); + } + }, [token, idToken]); + useEffect(() => { // Network status is disconnected by default in the networkStatusSlice. Update the status // when the app first starts and then attach a listener to keep network status in the redux diff --git a/mobile/asa-go/src/api/pushNotificationsAPI.ts b/mobile/asa-go/src/api/pushNotificationsAPI.ts new file mode 100644 index 0000000000..16ad1c99c8 --- /dev/null +++ b/mobile/asa-go/src/api/pushNotificationsAPI.ts @@ -0,0 +1,42 @@ +import axios from "api/axios"; + +const DEVICE_REGISTRATION_PATH = "device/register"; + +export type Platform = "android" | "ios"; + +export interface RegisterDeviceRequest { + platform: Platform; + token: string; + deviceId: string; + userId: string | null; +} + +export interface UnregisterDeviceRequest { + token: string; +} + +interface DeviceRequestResponse { + success: boolean; +} + +export async function registerToken( + platform: Platform, + token: string, + userId: string | null, +): Promise { + const url = `${DEVICE_REGISTRATION_PATH}`; + const { data } = await axios.post(url, { + platform, + token, + user_id: userId, + }); + return data; +} + +export async function unregisterToken( + token: string, +): Promise { + const url = `${DEVICE_REGISTRATION_PATH}/`; + const { data } = await axios.post(url, { token }); + return data; +} diff --git a/mobile/asa-go/src/hooks/usePushNotifications.test.ts b/mobile/asa-go/src/hooks/usePushNotifications.test.ts new file mode 100644 index 0000000000..2d56d7ec66 --- /dev/null +++ b/mobile/asa-go/src/hooks/usePushNotifications.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { usePushNotifications } from "./usePushNotifications"; +import { PushNotificationService } from "@/services/pushNotificationService"; +import type { PushInitOptions } from "@/services/pushNotificationService"; + +// Define an interface for the PushNotificationService methods we use +interface IPushNotificationService { + initPushNotificationService: () => Promise; + unregister: () => Promise; +} + +// Mock the PushNotificationService +vi.mock("@/services/pushNotificationService"); + +describe("usePushNotifications", () => { + // Reset all mocks before each test + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should initialize with token null", () => { + const { result } = renderHook(() => usePushNotifications()); + expect(result.current.token).toBeNull(); + expect(result.current.initPushNotifications).toBeInstanceOf(Function); + }); + + it("should create a PushNotificationService instance and call initPushNotificationService when initPushNotifications is called", async () => { + // Mock the service methods + const mockInit = vi.fn().mockResolvedValue(undefined); + (PushNotificationService as Mock).mockImplementation(function ( + this: IPushNotificationService, + ) { + this.initPushNotificationService = mockInit; + this.unregister = vi.fn(); + }); + + const { result } = renderHook(() => usePushNotifications()); + + await act(async () => { + await result.current.initPushNotifications(); + }); + + expect(PushNotificationService).toHaveBeenCalledTimes(1); + expect(mockInit).toHaveBeenCalledTimes(1); + }); + + it("should set token when onRegister callback is triggered", async () => { + const testToken = "test-fcm-token"; + (PushNotificationService as Mock).mockImplementation(function ( + this: IPushNotificationService, + opts: PushInitOptions, + ) { + // Trigger onRegister callback immediately + opts.onRegister?.(testToken); + this.initPushNotificationService = vi.fn().mockResolvedValue(undefined); + this.unregister = vi.fn(); + }); + + const { result } = renderHook(() => usePushNotifications()); + + await act(async () => { + await result.current.initPushNotifications(); + }); + + expect(result.current.token).toEqual(testToken); + }); + + it("should prevent multiple initializations of PushNotificationService", async () => { + (PushNotificationService as Mock).mockImplementation(function ( + this: IPushNotificationService, + ) { + this.initPushNotificationService = vi.fn().mockResolvedValue(undefined); + this.unregister = vi.fn(); + }); + + const { result } = renderHook(() => usePushNotifications()); + + await act(async () => { + await result.current.initPushNotifications(); + await result.current.initPushNotifications(); // Call again + }); + + expect(PushNotificationService).toHaveBeenCalledTimes(1); + }); + + it("should call unregister on service when component unmounts", async () => { + const mockUnregister = vi.fn(); + (PushNotificationService as Mock).mockImplementation(function ( + this: IPushNotificationService, + ) { + this.initPushNotificationService = vi.fn().mockResolvedValue(undefined); + this.unregister = mockUnregister; + }); + + const { result, unmount } = renderHook(() => usePushNotifications()); + + await act(async () => { + await result.current.initPushNotifications(); + }); + + unmount(); + + expect(mockUnregister).toHaveBeenCalledTimes(1); + }); + + it("should pass correct android channel configuration to PushNotificationService", async () => { + (PushNotificationService as Mock).mockImplementation(function ( + this: IPushNotificationService, + ) { + this.initPushNotificationService = vi.fn().mockResolvedValue(undefined); + this.unregister = vi.fn(); + }); + + const { result } = renderHook(() => usePushNotifications()); + + await act(async () => { + await result.current.initPushNotifications(); + }); + + expect(PushNotificationService).toHaveBeenCalledWith( + expect.objectContaining({ + androidChannel: expect.objectContaining({ + id: "general", + name: "General", + description: "General notifications", + importance: 4, // High importance + sound: "default", + }), + }), + ); + }); +}); diff --git a/mobile/asa-go/src/hooks/usePushNotifications.ts b/mobile/asa-go/src/hooks/usePushNotifications.ts new file mode 100644 index 0000000000..1826baf146 --- /dev/null +++ b/mobile/asa-go/src/hooks/usePushNotifications.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { PushNotificationService } from "@/services/pushNotificationService"; + +export function usePushNotifications() { + const [token, setToken] = useState(null); + const serviceRef = useRef(null); + + const initPushNotifications = useCallback(async () => { + // Prevent multiple initializations of the same service + if (serviceRef.current) { + return; + } + + const service = new PushNotificationService({ + onRegister: (t) => { + setToken(t); + }, + onNotificationReceived: (_evt) => { + if (_evt) console.log(_evt.notification.body); + }, + onNotificationAction: (_evt) => { + if (_evt) console.log(_evt.notification.body); + }, + onError: (err) => { + console.error("Push notification error:", err); + }, + androidChannel: { + id: "general", + name: "General", + description: "General notifications", + importance: 4, // Importance.High + sound: "default", + }, + }); + + serviceRef.current = service; + await service.initPushNotificationService(); + }, []); + + useEffect(() => { + return () => { + serviceRef.current?.unregister(); + }; + }, []); + + return { initPushNotifications, token }; +} diff --git a/mobile/asa-go/src/services/pushNotificationService.ts b/mobile/asa-go/src/services/pushNotificationService.ts new file mode 100644 index 0000000000..539847968f --- /dev/null +++ b/mobile/asa-go/src/services/pushNotificationService.ts @@ -0,0 +1,101 @@ +import { + Channel, + FirebaseMessaging, + Importance, + NotificationActionPerformedEvent, + NotificationReceivedEvent, + PermissionStatus, + TokenReceivedEvent, +} from "@capacitor-firebase/messaging"; +import { PluginListenerHandle } from "@capacitor/core"; + +export type PushInitOptions = { + onRegister?: (token: string) => void; + onNotificationReceived?: (evt: NotificationReceivedEvent) => void; + onNotificationAction?: (evt: NotificationActionPerformedEvent) => void; + onError?: (err: unknown) => void; + androidChannel?: Channel; +}; + +export class PushNotificationService { + private handles: PluginListenerHandle[] = []; + constructor(private readonly opts: PushInitOptions = {}) {} + + async initPushNotificationService(): Promise { + try { + // 1) Permissions (Android 13+ & iOS) + const check: PermissionStatus = + await FirebaseMessaging.checkPermissions(); + if (check.receive !== "granted") { + const req = await FirebaseMessaging.requestPermissions(); + if (req.receive !== "granted") + throw new Error("Push permission not granted"); + } + // (Permissions + methods per plugin README) [1](https://dev.to/vaclav_svara_50ba53bc0010/firebase-push-notifications-in-capacitor-angular-apps-the-complete-implementation-guide-1c67) + + // 2) Android channel (recommended on 8+) + await FirebaseMessaging.createChannel( + this.opts.androidChannel ?? { + id: "general", + name: "General", + description: "General notifications", + importance: Importance.High, + sound: "default", + }, + ); // (Channel API from plugin) [1](https://dev.to/vaclav_svara_50ba53bc0010/firebase-push-notifications-in-capacitor-angular-apps-the-complete-implementation-guide-1c67) + + // 3) FCM token (works on iOS & Android) + const { token } = await FirebaseMessaging.getToken(); + this.opts.onRegister?.(token); // (getToken returns { token }) [1](https://dev.to/vaclav_svara_50ba53bc0010/firebase-push-notifications-in-capacitor-angular-apps-the-complete-implementation-guide-1c67) + + // 4) Strongly-typed listeners + const tokenReceivedHandler = await FirebaseMessaging.addListener( + "tokenReceived", + (e: TokenReceivedEvent) => { + console.log("tokenReceivedHandler called"); + this.opts.onRegister?.(e.token); + }, + ); + + const notificationReceivedHandler = await FirebaseMessaging.addListener( + "notificationReceived", + (evt: NotificationReceivedEvent) => { + this.opts.onNotificationReceived?.(evt); + }, + ); + + const onNotificationAction = await FirebaseMessaging.addListener( + "notificationActionPerformed", + (evt: NotificationActionPerformedEvent) => { + this.opts.onNotificationAction?.(evt); + }, + ); + + this.handles.push( + tokenReceivedHandler, + notificationReceivedHandler, + onNotificationAction, + ); + } catch (err) { + this.opts.onError?.(err); + throw err; + } + } + + async unregister(): Promise { + try { + await FirebaseMessaging.removeAllListeners(); // (plugin cleanup) [1](https://dev.to/vaclav_svara_50ba53bc0010/firebase-push-notifications-in-capacitor-angular-apps-the-complete-implementation-guide-1c67) + } finally { + await Promise.all( + this.handles.map(async (h) => { + try { + await h.remove(); + } catch { + /* noop */ + } + }), + ); + this.handles = []; + } + } +} diff --git a/mobile/asa-go/yarn.lock b/mobile/asa-go/yarn.lock index 526ee1cd83..51a8996c29 100644 --- a/mobile/asa-go/yarn.lock +++ b/mobile/asa-go/yarn.lock @@ -211,6 +211,11 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== +"@capacitor-firebase/messaging@^8.1.0": + version "8.1.0" + resolved "https://registry.yarnpkg.com/@capacitor-firebase/messaging/-/messaging-8.1.0.tgz#08c3f3dbfb41ab1ab983a529fce5956a1cc36e53" + integrity sha512-SjF14b29/fXlemfRp8tusmc9NGbJjKk8mJ0/mqypoV0Ib+4a5SqJbYK+56fTOGo6fG97r/rIadW4LgXUmyMHBg== + "@capacitor/android@7.1.0": version "7.1.0" resolved "https://registry.npmjs.org/@capacitor/android/-/android-7.1.0.tgz" @@ -684,6 +689,415 @@ dependencies: tslib "^2.4.1" +"@firebase/ai@2.8.0": + version "2.8.0" + resolved "https://registry.yarnpkg.com/@firebase/ai/-/ai-2.8.0.tgz#0ab81a7a3a88e5650b8df785755b95fc4f4b08fd" + integrity sha512-grWYGFPsSo+pt+6CYeKR0kWnUfoLLS3xgWPvNrhAS5EPxl6xWq7+HjDZqX24yLneETyl45AVgDsTbVgxeWeRfg== + dependencies: + "@firebase/app-check-interop-types" "0.3.3" + "@firebase/component" "0.7.0" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/analytics-compat@0.2.25": + version "0.2.25" + resolved "https://registry.yarnpkg.com/@firebase/analytics-compat/-/analytics-compat-0.2.25.tgz#1f48bb6237bed7d6a3cf8136957aa5ceb245507b" + integrity sha512-fdzoaG0BEKbqksRDhmf4JoyZf16Wosrl0Y7tbZtJyVDOOwziE0vrFjmZuTdviL0yhak+Nco6rMsUUbkbD+qb6Q== + dependencies: + "@firebase/analytics" "0.10.19" + "@firebase/analytics-types" "0.8.3" + "@firebase/component" "0.7.0" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/analytics-types@0.8.3": + version "0.8.3" + resolved "https://registry.yarnpkg.com/@firebase/analytics-types/-/analytics-types-0.8.3.tgz#d08cd39a6209693ca2039ba7a81570dfa6c1518f" + integrity sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg== + +"@firebase/analytics@0.10.19": + version "0.10.19" + resolved "https://registry.yarnpkg.com/@firebase/analytics/-/analytics-0.10.19.tgz#6bddeb9db287fa2367066855b12ec514e2914697" + integrity sha512-3wU676fh60gaiVYQEEXsbGS4HbF2XsiBphyvvqDbtC1U4/dO4coshbYktcCHq+HFaGIK07iHOh4pME0hEq1fcg== + dependencies: + "@firebase/component" "0.7.0" + "@firebase/installations" "0.6.19" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/app-check-compat@0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@firebase/app-check-compat/-/app-check-compat-0.4.0.tgz#94ac0cf9f66cab1d81a7b14e0c151dcc2684bc95" + integrity sha512-UfK2Q8RJNjYM/8MFORltZRG9lJj11k0nW84rrffiKvcJxLf1jf6IEjCIkCamykHE73C6BwqhVfhIBs69GXQV0g== + dependencies: + "@firebase/app-check" "0.11.0" + "@firebase/app-check-types" "0.5.3" + "@firebase/component" "0.7.0" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/app-check-interop-types@0.3.3": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz#ed9c4a4f48d1395ef378f007476db3940aa5351a" + integrity sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A== + +"@firebase/app-check-types@0.5.3": + version "0.5.3" + resolved "https://registry.yarnpkg.com/@firebase/app-check-types/-/app-check-types-0.5.3.tgz#38ba954acf4bffe451581a32fffa20337f11d8e5" + integrity sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng== + +"@firebase/app-check@0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@firebase/app-check/-/app-check-0.11.0.tgz#a7e1d1e3f5ae36eabed1455db937114fe869ce8f" + integrity sha512-XAvALQayUMBJo58U/rxW02IhsesaxxfWVmVkauZvGEz3vOAjMEQnzFlyblqkc2iAaO82uJ2ZVyZv9XzPfxjJ6w== + dependencies: + "@firebase/component" "0.7.0" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/app-compat@0.5.8": + version "0.5.8" + resolved "https://registry.yarnpkg.com/@firebase/app-compat/-/app-compat-0.5.8.tgz#89259cf122962c0f746361a4a087fb3f7b897d68" + integrity sha512-4De6SUZ36zozl9kh5rZSxKWULpgty27rMzZ6x+xkoo7+NWyhWyFdsdvhFsWhTw/9GGj0wXIcbTjwHYCUIUuHyg== + dependencies: + "@firebase/app" "0.14.8" + "@firebase/component" "0.7.0" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/app-types@0.9.3": + version "0.9.3" + resolved "https://registry.yarnpkg.com/@firebase/app-types/-/app-types-0.9.3.tgz#8408219eae9b1fb74f86c24e7150a148460414ad" + integrity sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw== + +"@firebase/app@0.14.8": + version "0.14.8" + resolved "https://registry.yarnpkg.com/@firebase/app/-/app-0.14.8.tgz#7caa15cb5db870b6e092642475751f508286bf36" + integrity sha512-WiE9uCGRLUnShdjb9iP20sA3ToWrBbNXr14/N5mow7Nls9dmKgfGaGX5cynLvrltxq2OrDLh1VDNaUgsnS/k/g== + dependencies: + "@firebase/component" "0.7.0" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.13.0" + idb "7.1.1" + tslib "^2.1.0" + +"@firebase/auth-compat@0.6.2": + version "0.6.2" + resolved "https://registry.yarnpkg.com/@firebase/auth-compat/-/auth-compat-0.6.2.tgz#12469442cb896558eb0a5f4077790576a9402473" + integrity sha512-8UhCzF6pav9bw/eXA8Zy1QAKssPRYEYXaWagie1ewLTwHkXv6bKp/j6/IwzSYQP67sy/BMFXIFaCCsoXzFLr7A== + dependencies: + "@firebase/auth" "1.12.0" + "@firebase/auth-types" "0.13.0" + "@firebase/component" "0.7.0" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/auth-interop-types@0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz#176a08686b0685596ff03d7879b7e4115af53de0" + integrity sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA== + +"@firebase/auth-types@0.13.0": + version "0.13.0" + resolved "https://registry.yarnpkg.com/@firebase/auth-types/-/auth-types-0.13.0.tgz#ae6e0015e3bd4bfe18edd0942b48a0a118a098d9" + integrity sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg== + +"@firebase/auth@1.12.0": + version "1.12.0" + resolved "https://registry.yarnpkg.com/@firebase/auth/-/auth-1.12.0.tgz#192923495c22891a4e56021b949dfb8b87ed9f42" + integrity sha512-zkvLpsrxynWHk07qGrUDfCSqKf4AvfZGEqJ7mVCtYGjNNDbGE71k0Yn84rg8QEZu4hQw1BC0qDEHzpNVBcSVmA== + dependencies: + "@firebase/component" "0.7.0" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/component@0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@firebase/component/-/component-0.7.0.tgz#3736644fdb6d3572dceae7fdc1c35a8bd3819adc" + integrity sha512-wR9En2A+WESUHexjmRHkqtaVH94WLNKt6rmeqZhSLBybg4Wyf0Umk04SZsS6sBq4102ZsDBFwoqMqJYj2IoDSg== + dependencies: + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/data-connect@0.3.12": + version "0.3.12" + resolved "https://registry.yarnpkg.com/@firebase/data-connect/-/data-connect-0.3.12.tgz#611e684fb6940855f37da5f3126bd9449f070ece" + integrity sha512-baPddcoNLj/+vYo+HSJidJUdr5W4OkhT109c5qhR8T1dJoZcyJpkv/dFpYlw/VJ3dV66vI8GHQFrmAZw/xUS4g== + dependencies: + "@firebase/auth-interop-types" "0.2.4" + "@firebase/component" "0.7.0" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/database-compat@2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@firebase/database-compat/-/database-compat-2.1.0.tgz#c64488d741c6da2ed8dcf02f2e433089dae2f590" + integrity sha512-8nYc43RqxScsePVd1qe1xxvWNf0OBnbwHxmXJ7MHSuuTVYFO3eLyLW3PiCKJ9fHnmIz4p4LbieXwz+qtr9PZDg== + dependencies: + "@firebase/component" "0.7.0" + "@firebase/database" "1.1.0" + "@firebase/database-types" "1.0.16" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/database-types@1.0.16": + version "1.0.16" + resolved "https://registry.yarnpkg.com/@firebase/database-types/-/database-types-1.0.16.tgz#262f54b8dbebbc46259757b3ba384224fb2ede48" + integrity sha512-xkQLQfU5De7+SPhEGAXFBnDryUWhhlFXelEg2YeZOQMCdoe7dL64DDAd77SQsR+6uoXIZY5MB4y/inCs4GTfcw== + dependencies: + "@firebase/app-types" "0.9.3" + "@firebase/util" "1.13.0" + +"@firebase/database@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@firebase/database/-/database-1.1.0.tgz#bdf60f1605079a87ceb2b5e30d90846e0bde294b" + integrity sha512-gM6MJFae3pTyNLoc9VcJNuaUDej0ctdjn3cVtILo3D5lpp0dmUHHLFN/pUKe7ImyeB1KAvRlEYxvIHNF04Filg== + dependencies: + "@firebase/app-check-interop-types" "0.3.3" + "@firebase/auth-interop-types" "0.2.4" + "@firebase/component" "0.7.0" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.13.0" + faye-websocket "0.11.4" + tslib "^2.1.0" + +"@firebase/firestore-compat@0.4.5": + version "0.4.5" + resolved "https://registry.yarnpkg.com/@firebase/firestore-compat/-/firestore-compat-0.4.5.tgz#f716b57c246f48f73750feaa82265b3a65bdb0a5" + integrity sha512-yVX1CkVvqBI4qbA56uZo42xFA4TNU0ICQ+9AFDvYq9U9Xu6iAx9lFDAk/tN+NGereQQXXCSnpISwc/oxsQqPLA== + dependencies: + "@firebase/component" "0.7.0" + "@firebase/firestore" "4.11.0" + "@firebase/firestore-types" "3.0.3" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/firestore-types@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@firebase/firestore-types/-/firestore-types-3.0.3.tgz#7d0c3dd8850c0193d8f5ee0cc8f11961407742c1" + integrity sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q== + +"@firebase/firestore@4.11.0": + version "4.11.0" + resolved "https://registry.yarnpkg.com/@firebase/firestore/-/firestore-4.11.0.tgz#a746ea4568069280e4c18f5ed1438be73860afbe" + integrity sha512-Zb88s8rssBd0J2Tt+NUXMPt2sf+Dq7meatKiJf5t9oto1kZ8w9gK59Koe1uPVbaKfdgBp++N/z0I4G/HamyEhg== + dependencies: + "@firebase/component" "0.7.0" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.13.0" + "@firebase/webchannel-wrapper" "1.0.5" + "@grpc/grpc-js" "~1.9.0" + "@grpc/proto-loader" "^0.7.8" + tslib "^2.1.0" + +"@firebase/functions-compat@0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@firebase/functions-compat/-/functions-compat-0.4.1.tgz#b253b761845f0c82bbdf76ef59975978ed84eb65" + integrity sha512-AxxUBXKuPrWaVNQ8o1cG1GaCAtXT8a0eaTDfqgS5VsRYLAR0ALcfqDLwo/QyijZj1w8Qf8n3Qrfy/+Im245hOQ== + dependencies: + "@firebase/component" "0.7.0" + "@firebase/functions" "0.13.1" + "@firebase/functions-types" "0.6.3" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/functions-types@0.6.3": + version "0.6.3" + resolved "https://registry.yarnpkg.com/@firebase/functions-types/-/functions-types-0.6.3.tgz#f5faf770248b13f45d256f614230da6a11bfb654" + integrity sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg== + +"@firebase/functions@0.13.1": + version "0.13.1" + resolved "https://registry.yarnpkg.com/@firebase/functions/-/functions-0.13.1.tgz#472e8456568689154b87a494ee8c10ee2e610d94" + integrity sha512-sUeWSb0rw5T+6wuV2o9XNmh9yHxjFI9zVGFnjFi+n7drTEWpl7ZTz1nROgGrSu472r+LAaj+2YaSicD4R8wfbw== + dependencies: + "@firebase/app-check-interop-types" "0.3.3" + "@firebase/auth-interop-types" "0.2.4" + "@firebase/component" "0.7.0" + "@firebase/messaging-interop-types" "0.2.3" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/installations-compat@0.2.19": + version "0.2.19" + resolved "https://registry.yarnpkg.com/@firebase/installations-compat/-/installations-compat-0.2.19.tgz#4bc57c8c57d241eeca95900ff3033d6ec3dbcc7c" + integrity sha512-khfzIY3EI5LePePo7vT19/VEIH1E3iYsHknI/6ek9T8QCozAZshWT9CjlwOzZrKvTHMeNcbpo/VSOSIWDSjWdQ== + dependencies: + "@firebase/component" "0.7.0" + "@firebase/installations" "0.6.19" + "@firebase/installations-types" "0.5.3" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/installations-types@0.5.3": + version "0.5.3" + resolved "https://registry.yarnpkg.com/@firebase/installations-types/-/installations-types-0.5.3.tgz#cac8a14dd49f09174da9df8ae453f9b359c3ef2f" + integrity sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA== + +"@firebase/installations@0.6.19": + version "0.6.19" + resolved "https://registry.yarnpkg.com/@firebase/installations/-/installations-0.6.19.tgz#93c569321f6fb399f4f1a197efc0053ce6452c7c" + integrity sha512-nGDmiwKLI1lerhwfwSHvMR9RZuIH5/8E3kgUWnVRqqL7kGVSktjLTWEMva7oh5yxQ3zXfIlIwJwMcaM5bK5j8Q== + dependencies: + "@firebase/component" "0.7.0" + "@firebase/util" "1.13.0" + idb "7.1.1" + tslib "^2.1.0" + +"@firebase/logger@0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@firebase/logger/-/logger-0.5.0.tgz#a9e55b1c669a0983dc67127fa4a5964ce8ed5e1b" + integrity sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g== + dependencies: + tslib "^2.1.0" + +"@firebase/messaging-compat@0.2.23": + version "0.2.23" + resolved "https://registry.yarnpkg.com/@firebase/messaging-compat/-/messaging-compat-0.2.23.tgz#2ca6b36ea238fae4dff53bf85442c4a2af516224" + integrity sha512-SN857v/kBUvlQ9X/UjAqBoQ2FEaL1ZozpnmL1ByTe57iXkmnVVFm9KqAsTfmf+OEwWI4kJJe9NObtN/w22lUgg== + dependencies: + "@firebase/component" "0.7.0" + "@firebase/messaging" "0.12.23" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/messaging-interop-types@0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz#e647c9cd1beecfe6a6e82018a6eec37555e4da3e" + integrity sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q== + +"@firebase/messaging@0.12.23": + version "0.12.23" + resolved "https://registry.yarnpkg.com/@firebase/messaging/-/messaging-0.12.23.tgz#71f932a521ac39d9f036175672e37897531010eb" + integrity sha512-cfuzv47XxqW4HH/OcR5rM+AlQd1xL/VhuaeW/wzMW1LFrsFcTn0GND/hak1vkQc2th8UisBcrkVcQAnOnKwYxg== + dependencies: + "@firebase/component" "0.7.0" + "@firebase/installations" "0.6.19" + "@firebase/messaging-interop-types" "0.2.3" + "@firebase/util" "1.13.0" + idb "7.1.1" + tslib "^2.1.0" + +"@firebase/performance-compat@0.2.22": + version "0.2.22" + resolved "https://registry.yarnpkg.com/@firebase/performance-compat/-/performance-compat-0.2.22.tgz#1c24ea360b03cfef831bdf379b4fc7080f412741" + integrity sha512-xLKxaSAl/FVi10wDX/CHIYEUP13jXUjinL+UaNXT9ByIvxII5Ne5150mx6IgM8G6Q3V+sPiw9C8/kygkyHUVxg== + dependencies: + "@firebase/component" "0.7.0" + "@firebase/logger" "0.5.0" + "@firebase/performance" "0.7.9" + "@firebase/performance-types" "0.2.3" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/performance-types@0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@firebase/performance-types/-/performance-types-0.2.3.tgz#5ce64e90fa20ab5561f8b62a305010cf9fab86fb" + integrity sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ== + +"@firebase/performance@0.7.9": + version "0.7.9" + resolved "https://registry.yarnpkg.com/@firebase/performance/-/performance-0.7.9.tgz#7e3a072b1542f0df3f502684a38a0516b0d72cab" + integrity sha512-UzybENl1EdM2I1sjYm74xGt/0JzRnU/0VmfMAKo2LSpHJzaj77FCLZXmYQ4oOuE+Pxtt8Wy2BVJEENiZkaZAzQ== + dependencies: + "@firebase/component" "0.7.0" + "@firebase/installations" "0.6.19" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + web-vitals "^4.2.4" + +"@firebase/remote-config-compat@0.2.21": + version "0.2.21" + resolved "https://registry.yarnpkg.com/@firebase/remote-config-compat/-/remote-config-compat-0.2.21.tgz#e5197d12ab28acf75698fae510b2a3c1c431243f" + integrity sha512-9+lm0eUycxbu8GO25JfJe4s6R2xlDqlVt0CR6CvN9E6B4AFArEV4qfLoDVRgIEB7nHKwvH2nYRocPWfmjRQTnw== + dependencies: + "@firebase/component" "0.7.0" + "@firebase/logger" "0.5.0" + "@firebase/remote-config" "0.8.0" + "@firebase/remote-config-types" "0.5.0" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/remote-config-types@0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@firebase/remote-config-types/-/remote-config-types-0.5.0.tgz#f0f503b32edda3384f5252f9900cd9613adbb99c" + integrity sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg== + +"@firebase/remote-config@0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@firebase/remote-config/-/remote-config-0.8.0.tgz#df06a59fec44899de03da5beae27c7725db3d654" + integrity sha512-sJz7C2VACeE257Z/3kY9Ap2WXbFsgsDLfaGfZmmToKAK39ipXxFan+vzB9CSbF6mP7bzjyzEnqPcMXhAnYE6fQ== + dependencies: + "@firebase/component" "0.7.0" + "@firebase/installations" "0.6.19" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/storage-compat@0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@firebase/storage-compat/-/storage-compat-0.4.0.tgz#a09bd33c262123e7e3ed0cd590b4c6e2ce4a8902" + integrity sha512-vDzhgGczr1OfcOy285YAPur5pWDEvD67w4thyeCUh6Ys0izN9fNYtA1MJERmNBfqjqu0lg0FM5GLbw0Il21M+g== + dependencies: + "@firebase/component" "0.7.0" + "@firebase/storage" "0.14.0" + "@firebase/storage-types" "0.8.3" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/storage-types@0.8.3": + version "0.8.3" + resolved "https://registry.yarnpkg.com/@firebase/storage-types/-/storage-types-0.8.3.tgz#2531ef593a3452fc12c59117195d6485c6632d3d" + integrity sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg== + +"@firebase/storage@0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@firebase/storage/-/storage-0.14.0.tgz#01acb97d413ada7c91de860fb260623468baa25d" + integrity sha512-xWWbb15o6/pWEw8H01UQ1dC5U3rf8QTAzOChYyCpafV6Xki7KVp3Yaw2nSklUwHEziSWE9KoZJS7iYeyqWnYFA== + dependencies: + "@firebase/component" "0.7.0" + "@firebase/util" "1.13.0" + tslib "^2.1.0" + +"@firebase/util@1.13.0": + version "1.13.0" + resolved "https://registry.yarnpkg.com/@firebase/util/-/util-1.13.0.tgz#2e9e7569722a1e3fc86b1b4076d5cbfbfa7265d6" + integrity sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ== + dependencies: + tslib "^2.1.0" + +"@firebase/webchannel-wrapper@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.5.tgz#39cf5a600450cb42f1f0b507cc385459bf103b27" + integrity sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw== + +"@grpc/grpc-js@~1.9.0": + version "1.9.15" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.9.15.tgz#433d7ac19b1754af690ea650ab72190bd700739b" + integrity sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ== + dependencies: + "@grpc/proto-loader" "^0.7.8" + "@types/node" ">=12.12.47" + +"@grpc/proto-loader@^0.7.8": + version "0.7.15" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.7.15.tgz#4cdfbf35a35461fc843abe8b9e2c0770b5095e60" + integrity sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.2.5" + yargs "^17.7.2" + "@humanfs/core@^0.19.1": version "0.19.1" resolved "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz" @@ -1260,6 +1674,59 @@ "@xml-tools/parser" "^1.0.11" prettier ">=2.4.0" +"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" + integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== + +"@protobufjs/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" + integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== + +"@protobufjs/codegen@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb" + integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg== + +"@protobufjs/eventemitter@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70" + integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== + +"@protobufjs/fetch@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45" + integrity sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ== + dependencies: + "@protobufjs/aspromise" "^1.1.1" + "@protobufjs/inquire" "^1.1.0" + +"@protobufjs/float@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" + integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== + +"@protobufjs/inquire@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089" + integrity sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q== + +"@protobufjs/path@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" + integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== + +"@protobufjs/pool@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" + integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== + +"@protobufjs/utf8@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" + integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== + "@reduxjs/toolkit@^2.5.1": version "2.5.1" resolved "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.5.1.tgz" @@ -1783,6 +2250,13 @@ dependencies: undici-types "~7.8.0" +"@types/node@>=12.12.47", "@types/node@>=13.7.0": + version "25.3.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.3.2.tgz#cbc4b963e1b3503eb2bcf7c55bf48c95204918d1" + integrity sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q== + dependencies: + undici-types "~7.18.0" + "@types/normalize-package-data@^2.4.0": version "2.4.4" resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz" @@ -3325,6 +3799,13 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" +faye-websocket@0.11.4: + version "0.11.4" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da" + integrity sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g== + dependencies: + websocket-driver ">=0.5.1" + fd-slicer@~1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz" @@ -3389,6 +3870,40 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" +firebase@^12.9.0: + version "12.9.0" + resolved "https://registry.yarnpkg.com/firebase/-/firebase-12.9.0.tgz#9af8415d12e635080e9dcb203f1bfcc99432474f" + integrity sha512-CwwTYoqZg6KxygPOaaJqIc4aoLvo0RCRrXoln9GoxLE8QyAwTydBaSLGVlR4WPcuOgN3OEL0tJLT1H4IU/dv7w== + dependencies: + "@firebase/ai" "2.8.0" + "@firebase/analytics" "0.10.19" + "@firebase/analytics-compat" "0.2.25" + "@firebase/app" "0.14.8" + "@firebase/app-check" "0.11.0" + "@firebase/app-check-compat" "0.4.0" + "@firebase/app-compat" "0.5.8" + "@firebase/app-types" "0.9.3" + "@firebase/auth" "1.12.0" + "@firebase/auth-compat" "0.6.2" + "@firebase/data-connect" "0.3.12" + "@firebase/database" "1.1.0" + "@firebase/database-compat" "2.1.0" + "@firebase/firestore" "4.11.0" + "@firebase/firestore-compat" "0.4.5" + "@firebase/functions" "0.13.1" + "@firebase/functions-compat" "0.4.1" + "@firebase/installations" "0.6.19" + "@firebase/installations-compat" "0.2.19" + "@firebase/messaging" "0.12.23" + "@firebase/messaging-compat" "0.2.23" + "@firebase/performance" "0.7.9" + "@firebase/performance-compat" "0.2.22" + "@firebase/remote-config" "0.8.0" + "@firebase/remote-config-compat" "0.2.21" + "@firebase/storage" "0.14.0" + "@firebase/storage-compat" "0.4.0" + "@firebase/util" "1.13.0" + flat-cache@^4.0.0: version "4.0.1" resolved "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz" @@ -3735,6 +4250,11 @@ html-escaper@^2.0.0: resolved "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== +http-parser-js@>=0.5.1: + version "0.5.10" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.10.tgz#b3277bd6d7ed5588e20ea73bf724fcbe44609075" + integrity sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA== + http-proxy-agent@^7.0.2: version "7.0.2" resolved "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz" @@ -3758,6 +4278,11 @@ iconv-lite@0.6.3: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" +idb@7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b" + integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ== + ieee754@^1.1.13: version "1.2.1" resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz" @@ -4209,6 +4734,11 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== + lodash.get@^4.4.2: version "4.4.2" resolved "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz" @@ -4229,6 +4759,11 @@ lodash@^4.17.15, lodash@^4.17.21: resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +long@^5.0.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" + integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== + loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz" @@ -4949,6 +5484,24 @@ prop-types@^15.6.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +protobufjs@^7.2.5: + version "7.5.4" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.4.tgz#885d31fe9c4b37f25d1bb600da30b1c5b37d286a" + integrity sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.4" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.0" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.0" + "@types/node" ">=13.7.0" + long "^5.0.0" + protocol-buffers-schema@^3.3.1: version "3.6.0" resolved "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz" @@ -5307,7 +5860,7 @@ rw@^1.3.3: resolved "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz" integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ== -safe-buffer@^5.0.1, safe-buffer@~5.2.0: +safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -5969,6 +6522,11 @@ uglify-js@^3.1.4: resolved "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz" integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ== +undici-types@~7.18.0: + version "7.18.2" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9" + integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w== + undici-types@~7.8.0: version "7.8.0" resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz" @@ -6095,6 +6653,11 @@ w3c-xmlserializer@^5.0.0: dependencies: xml-name-validator "^5.0.0" +web-vitals@^4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-4.2.4.tgz#1d20bc8590a37769bd0902b289550936069184b7" + integrity sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw== + web-worker@^1.2.0: version "1.3.0" resolved "https://registry.npmjs.org/web-worker/-/web-worker-1.3.0.tgz" @@ -6110,6 +6673,20 @@ webidl-conversions@^7.0.0: resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz" integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== +websocket-driver@>=0.5.1: + version "0.7.4" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" + integrity sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg== + dependencies: + http-parser-js ">=0.5.1" + safe-buffer ">=5.1.0" + websocket-extensions ">=0.1.1" + +websocket-extensions@>=0.1.1: + version "0.1.4" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" + integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== + whatwg-encoding@^3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz" @@ -6333,7 +6910,7 @@ yargs-parser@^21.1.1: resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== -yargs@17.7.2, yargs@^17.2.1: +yargs@17.7.2, yargs@^17.2.1, yargs@^17.7.2: version "17.7.2" resolved "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== From d9471c52657c5c28e74fcea0057072160e7e3b02 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Mon, 2 Mar 2026 15:46:21 -0800 Subject: [PATCH 02/32] Tests and tweaks --- .../services/pushNotificationService.test.ts | 237 ++++++++++++++++++ .../src/services/pushNotificationService.ts | 2 + 2 files changed, 239 insertions(+) create mode 100644 mobile/asa-go/src/services/pushNotificationService.test.ts diff --git a/mobile/asa-go/src/services/pushNotificationService.test.ts b/mobile/asa-go/src/services/pushNotificationService.test.ts new file mode 100644 index 0000000000..41914320a2 --- /dev/null +++ b/mobile/asa-go/src/services/pushNotificationService.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { PushNotificationService } from "./pushNotificationService"; +import { + FirebaseMessaging, + PermissionStatus, + Importance, +} from "@capacitor-firebase/messaging"; + +// Mock the FirebaseMessaging plugin +vi.mock("@capacitor-firebase/messaging", () => ({ + FirebaseMessaging: { + checkPermissions: vi.fn(), + requestPermissions: vi.fn(), + createChannel: vi.fn(), + getToken: vi.fn(), + addListener: vi.fn(), + removeAllListeners: vi.fn(), + }, + Importance: { + High: 4, + }, +})); + +describe("PushNotificationService", () => { + // Reset all mocks before each test + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("initPushNotificationService", () => { + it("should initialize push notifications successfully when permissions are granted", async () => { + // Arrange + const mockToken = "test-fcm-token"; + const mockOnRegister = vi.fn(); + + vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ + receive: "granted", + } as PermissionStatus); + + vi.mocked(FirebaseMessaging.getToken).mockResolvedValue({ + token: mockToken, + }); + + vi.mocked(FirebaseMessaging.addListener).mockResolvedValue({ + remove: vi.fn(), + }); + + const service = new PushNotificationService({ + onRegister: mockOnRegister, + }); + + // Act + await service.initPushNotificationService(); + + // Assert + expect(FirebaseMessaging.checkPermissions).toHaveBeenCalledTimes(1); + expect(FirebaseMessaging.requestPermissions).not.toHaveBeenCalled(); + expect(FirebaseMessaging.createChannel).toHaveBeenCalledTimes(1); + expect(FirebaseMessaging.getToken).toHaveBeenCalledTimes(1); + expect(FirebaseMessaging.addListener).toHaveBeenCalledTimes(3); + expect(mockOnRegister).toHaveBeenCalledWith(mockToken); + }); + + it("should request permissions when not granted initially", async () => { + // Arrange + const mockToken = "test-fcm-token"; + + vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ + receive: "denied", + } as PermissionStatus); + + vi.mocked(FirebaseMessaging.requestPermissions).mockResolvedValue({ + receive: "granted", + } as PermissionStatus); + + vi.mocked(FirebaseMessaging.getToken).mockResolvedValue({ + token: mockToken, + }); + + vi.mocked(FirebaseMessaging.addListener).mockResolvedValue({ + remove: vi.fn(), + }); + + const service = new PushNotificationService(); + + // Act + await service.initPushNotificationService(); + + // Assert + expect(FirebaseMessaging.checkPermissions).toHaveBeenCalledTimes(1); + expect(FirebaseMessaging.requestPermissions).toHaveBeenCalledTimes(1); + expect(FirebaseMessaging.createChannel).toHaveBeenCalledTimes(1); + expect(FirebaseMessaging.getToken).toHaveBeenCalledTimes(1); + }); + + it("should throw an error when permissions are denied", async () => { + // Arrange + vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ + receive: "denied", + } as PermissionStatus); + + vi.mocked(FirebaseMessaging.requestPermissions).mockResolvedValue({ + receive: "denied", + } as PermissionStatus); + + const service = new PushNotificationService(); + + // Act & Assert + await expect(service.initPushNotificationService()).rejects.toThrow( + "Push permission not granted", + ); + }); + + it("should use custom Android channel when provided", async () => { + // Arrange + const mockToken = "test-fcm-token"; + const customChannel = { + id: "custom-channel", + name: "Custom Channel", + description: "Custom notifications", + importance: Importance.High, + sound: "custom-sound", + }; + + vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ + receive: "granted", + } as PermissionStatus); + + vi.mocked(FirebaseMessaging.getToken).mockResolvedValue({ + token: mockToken, + }); + + vi.mocked(FirebaseMessaging.addListener).mockResolvedValue({ + remove: vi.fn(), + }); + + const service = new PushNotificationService({ + androidChannel: customChannel, + }); + + // Act + await service.initPushNotificationService(); + + // Assert + expect(FirebaseMessaging.createChannel).toHaveBeenCalledWith( + customChannel, + ); + }); + + it("should handle errors during initialization", async () => { + // Arrange + const mockError = new Error("Initialization failed"); + const mockOnError = vi.fn(); + + vi.mocked(FirebaseMessaging.checkPermissions).mockRejectedValue( + mockError, + ); + + const service = new PushNotificationService({ onError: mockOnError }); + + // Act & Assert + await expect(service.initPushNotificationService()).rejects.toThrow( + mockError, + ); + + expect(mockOnError).toHaveBeenCalledWith(mockError); + }); + }); + + describe("unregister", () => { + it("should unregister all listeners", async () => { + // Arrange + const mockRemoveAllListeners = vi.fn(); + const mockRemoveListener1 = vi.fn(); + const mockRemoveListener2 = vi.fn(); + const mockRemoveListener3 = vi.fn(); + + vi.mocked(FirebaseMessaging.removeAllListeners).mockImplementation( + mockRemoveAllListeners, + ); + + vi.mocked(FirebaseMessaging.addListener) + .mockResolvedValueOnce({ remove: mockRemoveListener1 }) + .mockResolvedValueOnce({ remove: mockRemoveListener2 }) + .mockResolvedValueOnce({ remove: mockRemoveListener3 }); + + const service = new PushNotificationService(); + + // First, initialize to add listeners + vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ + receive: "granted", + } as PermissionStatus); + vi.mocked(FirebaseMessaging.getToken).mockResolvedValue({ + token: "test-token", + }); + await service.initPushNotificationService(); + + // Act + await service.unregister(); + + // Assert + expect(FirebaseMessaging.removeAllListeners).toHaveBeenCalledTimes(1); + expect(mockRemoveListener1).toHaveBeenCalledTimes(1); + expect(mockRemoveListener2).toHaveBeenCalledTimes(1); + expect(mockRemoveListener3).toHaveBeenCalledTimes(1); + }); + + it("should handle errors when removing listeners", async () => { + // Arrange + const mockRemoveListener = vi + .fn() + .mockRejectedValue(new Error("Remove failed")); + + vi.mocked(FirebaseMessaging.removeAllListeners).mockRejectedValue( + new Error("Remove all failed"), + ); + + vi.mocked(FirebaseMessaging.addListener).mockResolvedValue({ + remove: mockRemoveListener, + }); + + const service = new PushNotificationService(); + + // First, initialize to add listeners + vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ + receive: "granted", + } as PermissionStatus); + vi.mocked(FirebaseMessaging.getToken).mockResolvedValue({ + token: "test-token", + }); + await service.initPushNotificationService(); + + // Act & Assert - Should not throw an error + await expect(service.unregister()).resolves.not.toThrow(); + }); + }); +}); diff --git a/mobile/asa-go/src/services/pushNotificationService.ts b/mobile/asa-go/src/services/pushNotificationService.ts index 539847968f..66fd3e079e 100644 --- a/mobile/asa-go/src/services/pushNotificationService.ts +++ b/mobile/asa-go/src/services/pushNotificationService.ts @@ -85,6 +85,8 @@ export class PushNotificationService { async unregister(): Promise { try { await FirebaseMessaging.removeAllListeners(); // (plugin cleanup) [1](https://dev.to/vaclav_svara_50ba53bc0010/firebase-push-notifications-in-capacitor-angular-apps-the-complete-implementation-guide-1c67) + } catch { + /* noop */ } finally { await Promise.all( this.handles.map(async (h) => { From 7edd3ef5c680aa02b39a8c3c769323808204f598 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Mon, 2 Mar 2026 15:48:31 -0800 Subject: [PATCH 03/32] formatting --- backend/packages/wps-api/src/app/routers/fcm.py | 7 ++----- .../packages/wps-shared/src/wps_shared/db/models/fcm.py | 6 +----- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/backend/packages/wps-api/src/app/routers/fcm.py b/backend/packages/wps-api/src/app/routers/fcm.py index 3535f7bf41..973fee9403 100644 --- a/backend/packages/wps-api/src/app/routers/fcm.py +++ b/backend/packages/wps-api/src/app/routers/fcm.py @@ -1,5 +1,4 @@ from fastapi import APIRouter - from wps_shared.db.crud.fcm import ( get_device_by_token, save_device_token, @@ -9,12 +8,10 @@ from wps_shared.db.models.fcm import DeviceToken from wps_shared.utils.time import get_utc_now -import app from app.fcm.schema import DeviceRequestResponse, RegisterDeviceRequest, UnregisterDeviceRequest -router = APIRouter( - prefix="/device" -) +router = APIRouter(prefix="/device") + @router.post("/register") async def register_device(request: RegisterDeviceRequest): diff --git a/backend/packages/wps-shared/src/wps_shared/db/models/fcm.py b/backend/packages/wps-shared/src/wps_shared/db/models/fcm.py index be62f2132c..93f867d6b0 100644 --- a/backend/packages/wps-shared/src/wps_shared/db/models/fcm.py +++ b/backend/packages/wps-shared/src/wps_shared/db/models/fcm.py @@ -1,10 +1,6 @@ -import enum -from typing import Literal - -from sqlalchemy import Boolean, Column, Enum, Integer, String +from sqlalchemy import Boolean, Column, Integer, String from wps_shared.db.models import Base -from wps_shared.db.models.auto_spatial_advisory import Shape from wps_shared.db.models.common import TZTimeStamp from wps_shared.utils.time import get_utc_now From 189df465e577e1f97f0f90412fdccfdfe3357f1c Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 08:48:28 -0800 Subject: [PATCH 04/32] minor updates and router tests --- .../packages/wps-api/src/app/fcm/schema.py | 1 - .../wps-api/src/app/tests/fcm/fcm_router.py | 164 ++++++++++++++++++ .../wps-shared/src/wps_shared/db/crud/fcm.py | 8 +- mobile/asa-go/src/api/pushNotificationsAPI.ts | 8 +- 4 files changed, 171 insertions(+), 10 deletions(-) create mode 100644 backend/packages/wps-api/src/app/tests/fcm/fcm_router.py diff --git a/backend/packages/wps-api/src/app/fcm/schema.py b/backend/packages/wps-api/src/app/fcm/schema.py index 0d4c05365a..e28bac2d50 100644 --- a/backend/packages/wps-api/src/app/fcm/schema.py +++ b/backend/packages/wps-api/src/app/fcm/schema.py @@ -7,7 +7,6 @@ class RegisterDeviceRequest(BaseModel): user_id: Optional[str] = None token: str = Field(..., min_length=10) platform: Optional[str] = Field(..., pattern="^(ios|android)?$") - device_id: Optional[str] = None class UnregisterDeviceRequest(BaseModel): token: str diff --git a/backend/packages/wps-api/src/app/tests/fcm/fcm_router.py b/backend/packages/wps-api/src/app/tests/fcm/fcm_router.py new file mode 100644 index 0000000000..fad31ba356 --- /dev/null +++ b/backend/packages/wps-api/src/app/tests/fcm/fcm_router.py @@ -0,0 +1,164 @@ +""" Unit tests for FCM endpoints. +""" +from starlette.testclient import TestClient +import app.main +from unittest.mock import patch +from datetime import datetime + + +def test_register_device_success(): + """Test that device registration returns 200/OK.""" + client = TestClient(app.main.app) + + # Test data + request_data = { + "user_id": "test-user-123", + "token": "test-fcm-token-456", + "platform": "android" + } + + with patch('app.routers.fcm.get_async_write_session_scope') as mock_session_scope: + mock_session_scope.return_value.__aenter__.return_value + with patch('app.routers.fcm.get_device_by_token', return_value=None), \ + patch('app.routers.fcm.save_device_token'): + + response = client.post("/api/device/register", json=request_data) + + assert response.status_code == 200 + assert response.json()["success"] == True + assert response.headers["content-type"] == "application/json" + + +def test_register_device_already_exists(): + """Test that existing device registration updates successfully.""" + client = TestClient(app.main.app) + + request_data = { + "user_id": "test-user-123", + "token": "existing-fcm-token", + "platform": "ios" + } + + with patch('app.routers.fcm.get_async_write_session_scope') as mock_session_scope: + mock_session_scope.return_value.__aenter__.return_value + + existing_device = type('', (object,), { + 'is_active': False, + 'token': 'existing-fcm-token', + 'updated_at': datetime(2026, 1, 1) + })() + + with patch('app.routers.fcm.get_device_by_token', return_value=existing_device), \ + patch('app.routers.fcm.save_device_token'): + + response = client.post("/api/device/register", json=request_data) + + assert response.status_code == 200 + assert response.json()["success"] == True + assert existing_device.is_active == True # Should be updated + + +def test_register_device_missing_fields(): + """Test that missing fields in registration request returns 422.""" + client = TestClient(app.main.app) + + # Missing 'token' field which is required + request_data = { + "user_id": "test-user-123", + "platform": "android" + } + + with patch('app.routers.fcm.get_async_write_session_scope') as mock_session_scope: + mock_session_scope.return_value.__aenter__.return_value + + response = client.post("/api/device/register", json=request_data) + + assert response.status_code == 422 + + +def test_register_device_invalid_platform(): + """Test that invalid platform returns 422.""" + client = TestClient(app.main.app) + + request_data = { + "user_id": "test-user-123", + "token": "test-fcm-token", + "platform": "invalid-platform", + } + + with patch('app.routers.fcm.get_async_write_session_scope') as mock_session_scope: + mock_session_scope.return_value.__aenter__.return_value + + response = client.post("/api/device/register", json=request_data) + + assert response.status_code == 422 + + +def test_register_device_short_token(): + """Test that short token returns 422.""" + client = TestClient(app.main.app) + + request_data = { + "user_id": "test-user-123", + "token": "short", # Less than 10 characters + "platform": "android", + } + + with patch('app.routers.fcm.get_async_write_session_scope') as mock_session_scope: + mock_session_scope.return_value.__aenter__.return_value + + response = client.post("/api/device/register", json=request_data) + + assert response.status_code == 422 + + +def test_unregister_device_success(): + """Test that device unregistration returns 200/OK.""" + client = TestClient(app.main.app) + + request_data = { + "token": "test-fcm-token-456" + } + + with patch('app.routers.fcm.get_async_write_session_scope') as mock_session_scope: + mock_session_scope.return_value.__aenter__.return_value + with patch('app.routers.fcm.update_device_token_is_active'): + + response = client.request("DELETE", "/api/device/unregister", json=request_data) + + assert response.status_code == 200 + assert response.json()["success"] == True + + +def test_unregister_device_missing_token(): + """Test that missing token field returns 422.""" + client = TestClient(app.main.app) + + request_data = {} + + with patch('app.routers.fcm.get_async_write_session_scope') as mock_session_scope: + mock_session_scope.return_value.__aenter__.return_value + + response = client.request("DELETE", "/api/device/unregister", json=request_data) + + assert response.status_code == 422 + + +def test_register_device_without_user_id(): + """Test that device registration without user_id is allowed (null user).""" + client = TestClient(app.main.app) + + request_data = { + "token": "test-fcm-token-789", + "platform": "android", + } + + with patch('app.routers.fcm.get_async_write_session_scope') as mock_session_scope: + mock_session = mock_session_scope.return_value.__aenter__.return_value + with patch('app.routers.fcm.get_device_by_token', return_value=None), \ + patch('app.routers.fcm.save_device_token'): + + response = client.post("/api/device/register", json=request_data) + + assert response.status_code == 200 + assert response.json()["success"] == True diff --git a/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py b/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py index 1f0eadba80..0c8067d39a 100644 --- a/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py +++ b/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py @@ -27,10 +27,10 @@ async def get_device_by_token(session: AsyncSession, token: str): async def update_device_token_is_active(session: AsyncSession, token: str): device_token = await session.scalar(select(DeviceToken).where(DeviceToken.token == token)) - if not token: - raise ValueError(f"DeviceToken with id {token} does not exist.") - device_token.is_active = True - device_token.updated_at(get_utc_now()) + if not device_token: + raise ValueError(f"DeviceToken with token {token} does not exist.") + device_token.is_active = False + device_token.updated_at = get_utc_now() async def deactivate_device_tokens(session: AsyncSession, tokens: list[str]) -> int: diff --git a/mobile/asa-go/src/api/pushNotificationsAPI.ts b/mobile/asa-go/src/api/pushNotificationsAPI.ts index 16ad1c99c8..d9d5abc46f 100644 --- a/mobile/asa-go/src/api/pushNotificationsAPI.ts +++ b/mobile/asa-go/src/api/pushNotificationsAPI.ts @@ -1,7 +1,5 @@ import axios from "api/axios"; -const DEVICE_REGISTRATION_PATH = "device/register"; - export type Platform = "android" | "ios"; export interface RegisterDeviceRequest { @@ -24,7 +22,7 @@ export async function registerToken( token: string, userId: string | null, ): Promise { - const url = `${DEVICE_REGISTRATION_PATH}`; + const url = "device/register"; const { data } = await axios.post(url, { platform, token, @@ -36,7 +34,7 @@ export async function registerToken( export async function unregisterToken( token: string, ): Promise { - const url = `${DEVICE_REGISTRATION_PATH}/`; - const { data } = await axios.post(url, { token }); + const url = "device/unregister"; + const { data } = await axios.delete(url, { data: { token } }); return data; } From 14990e6f577306ae5181e0ae7cf2982e6985cd8c Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 08:49:50 -0800 Subject: [PATCH 05/32] ios --- mobile/asa-go/ios/App/App/AppDelegate.swift | 3 +++ mobile/asa-go/ios/App/Podfile | 1 + mobile/asa-go/ios/App/Podfile.lock | 3 ++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/mobile/asa-go/ios/App/App/AppDelegate.swift b/mobile/asa-go/ios/App/App/AppDelegate.swift index 7b1bf7cffd..e86494d629 100644 --- a/mobile/asa-go/ios/App/App/AppDelegate.swift +++ b/mobile/asa-go/ios/App/App/AppDelegate.swift @@ -2,6 +2,8 @@ import AppAuth import Capacitor import Keycloak import UIKit +import FirebaseCore +import FirebaseMessaging @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate, KeycloakAppDelegate { @@ -14,6 +16,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, KeycloakAppDelegate { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { // Override point for customization after application launch. + FirebaseApp.configure() return true } diff --git a/mobile/asa-go/ios/App/Podfile b/mobile/asa-go/ios/App/Podfile index 370b8e4e3c..f53bcf1413 100644 --- a/mobile/asa-go/ios/App/Podfile +++ b/mobile/asa-go/ios/App/Podfile @@ -28,6 +28,7 @@ end target 'ASA Go' do capacitor_pods + pod 'FirebaseMessaging' # Add your Pods here end diff --git a/mobile/asa-go/ios/App/Podfile.lock b/mobile/asa-go/ios/App/Podfile.lock index 546ce8edf7..8983a84b87 100644 --- a/mobile/asa-go/ios/App/Podfile.lock +++ b/mobile/asa-go/ios/App/Podfile.lock @@ -107,6 +107,7 @@ DEPENDENCIES: - "CapacitorScreenOrientation (from `../../node_modules/@capacitor/screen-orientation`)" - "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)" - "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)" + - FirebaseMessaging - Keycloak (from `../../node_modules/keycloak`) SPEC REPOS: @@ -181,6 +182,6 @@ SPEC CHECKSUMS: nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 -PODFILE CHECKSUM: 2968fe3a80c3c767026c18edea6ca7a02ed91eaa +PODFILE CHECKSUM: f4aed5b4c66e22e15f5151a4c65a3d82525631d2 COCOAPODS: 1.16.2 From 8fecac070b91e3a020207805d1d42801f45f5467 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 08:59:43 -0800 Subject: [PATCH 06/32] more ios --- .../ios/App/App.xcodeproj/project.pbxproj | 43 +++++- .../xcshareddata/swiftpm/Package.resolved | 123 ++++++++++++++++++ 2 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 mobile/asa-go/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/mobile/asa-go/ios/App/App.xcodeproj/project.pbxproj b/mobile/asa-go/ios/App/App.xcodeproj/project.pbxproj index e779ecaa62..2aa8a33cd3 100644 --- a/mobile/asa-go/ios/App/App.xcodeproj/project.pbxproj +++ b/mobile/asa-go/ios/App/App.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 48; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -15,6 +15,8 @@ 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; + 5D8292C92F565F760075D83B /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 5D8292C82F565F760075D83B /* FirebaseMessaging */; }; + 5D93CF492F57402300551A11 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5D93CF482F57402300551A11 /* GoogleService-Info.plist */; }; D51D96E4043C4B4AB92C9174 /* Pods_ASA_Go.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BEB1CB68C85A338353E59F87 /* Pods_ASA_Go.framework */; }; /* End PBXBuildFile section */ @@ -29,6 +31,7 @@ 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 5D93CF482F57402300551A11 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 6654B5E52E29AC1C000DA498 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = ""; }; AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = ""; }; BEB1CB68C85A338353E59F87 /* Pods_ASA_Go.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ASA_Go.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -42,6 +45,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5D8292C92F565F760075D83B /* FirebaseMessaging in Frameworks */, D51D96E4043C4B4AB92C9174 /* Pods_ASA_Go.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -79,6 +83,7 @@ 504EC3061FED79650016851F /* App */ = { isa = PBXGroup; children = ( + 5D93CF482F57402300551A11 /* GoogleService-Info.plist */, 6654B5E52E29AC1C000DA498 /* App.entitlements */, 50379B222058CBB4000EE86E /* capacitor.config.json */, 504EC3071FED79650016851F /* AppDelegate.swift */, @@ -150,6 +155,9 @@ Base, ); mainGroup = 504EC2FB1FED79650016851F; + packageReferences = ( + 5D8292C72F565F760075D83B /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + ); productRefGroup = 504EC3051FED79650016851F /* Products */; projectDirPath = ""; projectRoot = ""; @@ -165,6 +173,7 @@ buildActionMask = 2147483647; files = ( 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */, + 5D93CF492F57402300551A11 /* GoogleService-Info.plist in Resources */, 50B271D11FEDC1A000F3C39B /* public in Resources */, 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, @@ -345,7 +354,8 @@ IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; }; name = Release; @@ -361,7 +371,10 @@ DEVELOPMENT_TEAM = L796QSLV3E; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); MARKETING_VERSION = 1.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = ca.bc.gov.asago; @@ -385,7 +398,10 @@ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = L796QSLV3E; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = ca.bc.gov.asago; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -418,6 +434,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 5D8292C72F565F760075D83B /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 12.10.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 5D8292C82F565F760075D83B /* FirebaseMessaging */ = { + isa = XCSwiftPackageProductDependency; + package = 5D8292C72F565F760075D83B /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseMessaging; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 504EC2FC1FED79650016851F /* Project object */; } diff --git a/mobile/asa-go/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/asa-go/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..b50a21402d --- /dev/null +++ b/mobile/asa-go/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,123 @@ +{ + "originHash" : "c63c63846d9c539229e96de38d6af51417e28c0ee9a0bc48bd0f0f19d923c329", + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "85560b48b0ff099ad83fe53d67df3c67fbc2b7a6", + "version" : "12.10.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "a5cd95c80e8efdd02155c6cea1cecf743bb683a5", + "version" : "3.3.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "68ba955e540dcff5e0805970ef4b1fd0150be100", + "version" : "12.10.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "a883ddb9fd464216133a5ab441f1ae8995978573", + "version" : "5.1.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + } + ], + "version" : 3 +} From 5fb287e37bb449f0275beb81ead8abc01334b1be Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 09:06:59 -0800 Subject: [PATCH 07/32] Remove unused file --- backend/packages/wps-api/src/app/fcm/fcm.py | 53 --------------------- 1 file changed, 53 deletions(-) delete mode 100644 backend/packages/wps-api/src/app/fcm/fcm.py diff --git a/backend/packages/wps-api/src/app/fcm/fcm.py b/backend/packages/wps-api/src/app/fcm/fcm.py deleted file mode 100644 index 13b99f96ff..0000000000 --- a/backend/packages/wps-api/src/app/fcm/fcm.py +++ /dev/null @@ -1,53 +0,0 @@ - -import asyncio -from typing import List - -from firebase_admin import messaging - -from wps_shared.db.crud.fcm import deactivate_device_tokens -from wps_shared.db.database import get_async_write_session_scope -from wps_shared.db.models.fcm import DeviceToken -from wps_shared.utils.time import get_utc_now - -# Simple exponential backoff with jitter for transient quota/server issues -async def _retry_send_multicast(multicast_msg: messaging.MulticastMessage, - max_retries: int = 5, - base_delay: float = 0.5): - attempt = 0 - while True: - try: - return messaging.send_multicast(multicast_msg, dry_run=False) - except Exception: - # Retry on probable transient conditions: quota (429), backend unavailable, etc. - # You can inspect e to match known transient cases in your logs. - attempt += 1 - if attempt > max_retries: - raise - # Exponential backoff with jitter - delay = (base_delay * (2 ** (attempt - 1))) + (0.1 * attempt) - await asyncio.sleep(delay) - - -async def deactivate_bad_tokens(db, tokens: List[str], responses): - """ - Deactivate tokens that failed with terminal errors like 'UNREGISTERED'. - """ - # For MulticastResponse: - # responses.responses[i].exception may contain details; many backends surface 'UNREGISTERED' - # when a token is invalid/stale. Remove/deactivate those. - stale_tokens: List[str] = [] - for idx, resp in enumerate(responses.responses): - if not resp.success: - exc = resp.exception - if exc and hasattr(exc, "code"): - code = getattr(exc, "code", None) - # TODO: Potentially expand this list based on observed error codes. - if str(code).upper() in {"UNREGISTERED"}: - stale_tokens.append(tokens[idx]) - token = tokens[idx] - db.query(DeviceToken).filter(DeviceToken.token == token).update( - {"is_active": False, "updated_at": get_utc_now()} - ) - async with get_async_write_session_scope() as session: - await deactivate_device_tokens(session, stale_tokens) - From 6f91c6f277bbaab159ab1e70f082e44e5f9a3bb8 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 09:10:03 -0800 Subject: [PATCH 08/32] lint --- .../wps-api/src/app/tests/fcm/fcm_router.py | 140 +++++++++--------- 1 file changed, 67 insertions(+), 73 deletions(-) diff --git a/backend/packages/wps-api/src/app/tests/fcm/fcm_router.py b/backend/packages/wps-api/src/app/tests/fcm/fcm_router.py index fad31ba356..654840003b 100644 --- a/backend/packages/wps-api/src/app/tests/fcm/fcm_router.py +++ b/backend/packages/wps-api/src/app/tests/fcm/fcm_router.py @@ -1,29 +1,31 @@ -""" Unit tests for FCM endpoints. -""" -from starlette.testclient import TestClient -import app.main -from unittest.mock import patch +"""Unit tests for FCM endpoints.""" + from datetime import datetime +from unittest.mock import patch + +import app.main +from starlette.testclient import TestClient def test_register_device_success(): """Test that device registration returns 200/OK.""" client = TestClient(app.main.app) - + # Test data request_data = { "user_id": "test-user-123", "token": "test-fcm-token-456", - "platform": "android" + "platform": "android", } - - with patch('app.routers.fcm.get_async_write_session_scope') as mock_session_scope: + + with patch("app.routers.fcm.get_async_write_session_scope") as mock_session_scope: mock_session_scope.return_value.__aenter__.return_value - with patch('app.routers.fcm.get_device_by_token', return_value=None), \ - patch('app.routers.fcm.save_device_token'): - + with ( + patch("app.routers.fcm.get_device_by_token", return_value=None), + patch("app.routers.fcm.save_device_token"), + ): response = client.post("/api/device/register", json=request_data) - + assert response.status_code == 200 assert response.json()["success"] == True assert response.headers["content-type"] == "application/json" @@ -32,27 +34,24 @@ def test_register_device_success(): def test_register_device_already_exists(): """Test that existing device registration updates successfully.""" client = TestClient(app.main.app) - - request_data = { - "user_id": "test-user-123", - "token": "existing-fcm-token", - "platform": "ios" - } - - with patch('app.routers.fcm.get_async_write_session_scope') as mock_session_scope: + + request_data = {"user_id": "test-user-123", "token": "existing-fcm-token", "platform": "ios"} + + with patch("app.routers.fcm.get_async_write_session_scope") as mock_session_scope: mock_session_scope.return_value.__aenter__.return_value - - existing_device = type('', (object,), { - 'is_active': False, - 'token': 'existing-fcm-token', - 'updated_at': datetime(2026, 1, 1) - })() - - with patch('app.routers.fcm.get_device_by_token', return_value=existing_device), \ - patch('app.routers.fcm.save_device_token'): - + + existing_device = type( + "", + (object,), + {"is_active": False, "token": "existing-fcm-token", "updated_at": datetime(2026, 1, 1)}, + )() + + with ( + patch("app.routers.fcm.get_device_by_token", return_value=existing_device), + patch("app.routers.fcm.save_device_token"), + ): response = client.post("/api/device/register", json=request_data) - + assert response.status_code == 200 assert response.json()["success"] == True assert existing_device.is_active == True # Should be updated @@ -61,71 +60,65 @@ def test_register_device_already_exists(): def test_register_device_missing_fields(): """Test that missing fields in registration request returns 422.""" client = TestClient(app.main.app) - + # Missing 'token' field which is required - request_data = { - "user_id": "test-user-123", - "platform": "android" - } - - with patch('app.routers.fcm.get_async_write_session_scope') as mock_session_scope: + request_data = {"user_id": "test-user-123", "platform": "android"} + + with patch("app.routers.fcm.get_async_write_session_scope") as mock_session_scope: mock_session_scope.return_value.__aenter__.return_value - + response = client.post("/api/device/register", json=request_data) - + assert response.status_code == 422 def test_register_device_invalid_platform(): """Test that invalid platform returns 422.""" client = TestClient(app.main.app) - + request_data = { "user_id": "test-user-123", "token": "test-fcm-token", "platform": "invalid-platform", } - - with patch('app.routers.fcm.get_async_write_session_scope') as mock_session_scope: + + with patch("app.routers.fcm.get_async_write_session_scope") as mock_session_scope: mock_session_scope.return_value.__aenter__.return_value - + response = client.post("/api/device/register", json=request_data) - + assert response.status_code == 422 def test_register_device_short_token(): """Test that short token returns 422.""" client = TestClient(app.main.app) - + request_data = { "user_id": "test-user-123", "token": "short", # Less than 10 characters "platform": "android", } - - with patch('app.routers.fcm.get_async_write_session_scope') as mock_session_scope: + + with patch("app.routers.fcm.get_async_write_session_scope") as mock_session_scope: mock_session_scope.return_value.__aenter__.return_value - + response = client.post("/api/device/register", json=request_data) - + assert response.status_code == 422 def test_unregister_device_success(): """Test that device unregistration returns 200/OK.""" client = TestClient(app.main.app) - - request_data = { - "token": "test-fcm-token-456" - } - - with patch('app.routers.fcm.get_async_write_session_scope') as mock_session_scope: + + request_data = {"token": "test-fcm-token-456"} + + with patch("app.routers.fcm.get_async_write_session_scope") as mock_session_scope: mock_session_scope.return_value.__aenter__.return_value - with patch('app.routers.fcm.update_device_token_is_active'): - + with patch("app.routers.fcm.update_device_token_is_active"): response = client.request("DELETE", "/api/device/unregister", json=request_data) - + assert response.status_code == 200 assert response.json()["success"] == True @@ -133,32 +126,33 @@ def test_unregister_device_success(): def test_unregister_device_missing_token(): """Test that missing token field returns 422.""" client = TestClient(app.main.app) - + request_data = {} - - with patch('app.routers.fcm.get_async_write_session_scope') as mock_session_scope: + + with patch("app.routers.fcm.get_async_write_session_scope") as mock_session_scope: mock_session_scope.return_value.__aenter__.return_value - + response = client.request("DELETE", "/api/device/unregister", json=request_data) - + assert response.status_code == 422 def test_register_device_without_user_id(): """Test that device registration without user_id is allowed (null user).""" client = TestClient(app.main.app) - + request_data = { "token": "test-fcm-token-789", "platform": "android", } - - with patch('app.routers.fcm.get_async_write_session_scope') as mock_session_scope: - mock_session = mock_session_scope.return_value.__aenter__.return_value - with patch('app.routers.fcm.get_device_by_token', return_value=None), \ - patch('app.routers.fcm.save_device_token'): - + + with patch("app.routers.fcm.get_async_write_session_scope") as mock_session_scope: + mock_session_scope.return_value.__aenter__.return_value + with ( + patch("app.routers.fcm.get_device_by_token", return_value=None), + patch("app.routers.fcm.save_device_token"), + ): response = client.post("/api/device/register", json=request_data) - + assert response.status_code == 200 assert response.json()["success"] == True From 833ec10467d5fbf50445225bba03662980dfb8f4 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 09:57:37 -0800 Subject: [PATCH 09/32] code quality --- .../packages/wps-api/src/app/fcm/schema.py | 2 +- .../wps-api/src/app/tests/fcm/fcm_router.py | 42 ++++++++++--------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/backend/packages/wps-api/src/app/fcm/schema.py b/backend/packages/wps-api/src/app/fcm/schema.py index e28bac2d50..2553d7a36a 100644 --- a/backend/packages/wps-api/src/app/fcm/schema.py +++ b/backend/packages/wps-api/src/app/fcm/schema.py @@ -6,7 +6,7 @@ class RegisterDeviceRequest(BaseModel): user_id: Optional[str] = None token: str = Field(..., min_length=10) - platform: Optional[str] = Field(..., pattern="^(ios|android)?$") + platform: Optional[str] = Field(None, pattern="^(ios|android)?$") class UnregisterDeviceRequest(BaseModel): token: str diff --git a/backend/packages/wps-api/src/app/tests/fcm/fcm_router.py b/backend/packages/wps-api/src/app/tests/fcm/fcm_router.py index 654840003b..c2e136bdaa 100644 --- a/backend/packages/wps-api/src/app/tests/fcm/fcm_router.py +++ b/backend/packages/wps-api/src/app/tests/fcm/fcm_router.py @@ -6,6 +6,10 @@ import app.main from starlette.testclient import TestClient +DB_SESSION = "app.routers.fcm.get_async_write_session_scope" +GET_DEVICE_TOKEN = "app.routers.fcm.get_device_by_token" +SAVE_DEVICE_TOKEN = "app.routers.fcm.save_device_token" +API_DEVICE_REGISTER = "/api/device/register" def test_register_device_success(): """Test that device registration returns 200/OK.""" @@ -18,13 +22,13 @@ def test_register_device_success(): "platform": "android", } - with patch("app.routers.fcm.get_async_write_session_scope") as mock_session_scope: + with patch(DB_SESSION) as mock_session_scope: mock_session_scope.return_value.__aenter__.return_value with ( - patch("app.routers.fcm.get_device_by_token", return_value=None), - patch("app.routers.fcm.save_device_token"), + patch(GET_DEVICE_TOKEN, return_value=None), + patch(SAVE_DEVICE_TOKEN), ): - response = client.post("/api/device/register", json=request_data) + response = client.post(API_DEVICE_REGISTER, json=request_data) assert response.status_code == 200 assert response.json()["success"] == True @@ -37,7 +41,7 @@ def test_register_device_already_exists(): request_data = {"user_id": "test-user-123", "token": "existing-fcm-token", "platform": "ios"} - with patch("app.routers.fcm.get_async_write_session_scope") as mock_session_scope: + with patch(DB_SESSION) as mock_session_scope: mock_session_scope.return_value.__aenter__.return_value existing_device = type( @@ -47,10 +51,10 @@ def test_register_device_already_exists(): )() with ( - patch("app.routers.fcm.get_device_by_token", return_value=existing_device), - patch("app.routers.fcm.save_device_token"), + patch(GET_DEVICE_TOKEN, return_value=existing_device), + patch(SAVE_DEVICE_TOKEN), ): - response = client.post("/api/device/register", json=request_data) + response = client.post(API_DEVICE_REGISTER, json=request_data) assert response.status_code == 200 assert response.json()["success"] == True @@ -64,10 +68,10 @@ def test_register_device_missing_fields(): # Missing 'token' field which is required request_data = {"user_id": "test-user-123", "platform": "android"} - with patch("app.routers.fcm.get_async_write_session_scope") as mock_session_scope: + with patch(DB_SESSION) as mock_session_scope: mock_session_scope.return_value.__aenter__.return_value - response = client.post("/api/device/register", json=request_data) + response = client.post(API_DEVICE_REGISTER, json=request_data) assert response.status_code == 422 @@ -82,10 +86,10 @@ def test_register_device_invalid_platform(): "platform": "invalid-platform", } - with patch("app.routers.fcm.get_async_write_session_scope") as mock_session_scope: + with patch(DB_SESSION) as mock_session_scope: mock_session_scope.return_value.__aenter__.return_value - response = client.post("/api/device/register", json=request_data) + response = client.post(API_DEVICE_REGISTER, json=request_data) assert response.status_code == 422 @@ -100,10 +104,10 @@ def test_register_device_short_token(): "platform": "android", } - with patch("app.routers.fcm.get_async_write_session_scope") as mock_session_scope: + with patch(DB_SESSION) as mock_session_scope: mock_session_scope.return_value.__aenter__.return_value - response = client.post("/api/device/register", json=request_data) + response = client.post(API_DEVICE_REGISTER, json=request_data) assert response.status_code == 422 @@ -129,7 +133,7 @@ def test_unregister_device_missing_token(): request_data = {} - with patch("app.routers.fcm.get_async_write_session_scope") as mock_session_scope: + with patch(DB_SESSION) as mock_session_scope: mock_session_scope.return_value.__aenter__.return_value response = client.request("DELETE", "/api/device/unregister", json=request_data) @@ -146,13 +150,13 @@ def test_register_device_without_user_id(): "platform": "android", } - with patch("app.routers.fcm.get_async_write_session_scope") as mock_session_scope: + with patch(DB_SESSION) as mock_session_scope: mock_session_scope.return_value.__aenter__.return_value with ( - patch("app.routers.fcm.get_device_by_token", return_value=None), - patch("app.routers.fcm.save_device_token"), + patch(GET_DEVICE_TOKEN, return_value=None), + patch(SAVE_DEVICE_TOKEN), ): - response = client.post("/api/device/register", json=request_data) + response = client.post(API_DEVICE_REGISTER, json=request_data) assert response.status_code == 200 assert response.json()["success"] == True From 31da7610d835706279cc27fa615ffd446e9c6243 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 10:17:48 -0800 Subject: [PATCH 10/32] Clean up --- .../fcm/{fcm_router.py => test_fcm_router.py} | 0 .../xcshareddata/swiftpm/Package.resolved | 123 ++++++++++++++++++ .../src/services/pushNotificationService.ts | 15 +-- 3 files changed, 129 insertions(+), 9 deletions(-) rename backend/packages/wps-api/src/app/tests/fcm/{fcm_router.py => test_fcm_router.py} (100%) create mode 100644 mobile/asa-go/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/backend/packages/wps-api/src/app/tests/fcm/fcm_router.py b/backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py similarity index 100% rename from backend/packages/wps-api/src/app/tests/fcm/fcm_router.py rename to backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py diff --git a/mobile/asa-go/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/asa-go/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..b50a21402d --- /dev/null +++ b/mobile/asa-go/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,123 @@ +{ + "originHash" : "c63c63846d9c539229e96de38d6af51417e28c0ee9a0bc48bd0f0f19d923c329", + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "85560b48b0ff099ad83fe53d67df3c67fbc2b7a6", + "version" : "12.10.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "a5cd95c80e8efdd02155c6cea1cecf743bb683a5", + "version" : "3.3.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "68ba955e540dcff5e0805970ef4b1fd0150be100", + "version" : "12.10.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "a883ddb9fd464216133a5ab441f1ae8995978573", + "version" : "5.1.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + } + ], + "version" : 3 +} diff --git a/mobile/asa-go/src/services/pushNotificationService.ts b/mobile/asa-go/src/services/pushNotificationService.ts index 66fd3e079e..ec6da268ee 100644 --- a/mobile/asa-go/src/services/pushNotificationService.ts +++ b/mobile/asa-go/src/services/pushNotificationService.ts @@ -23,7 +23,7 @@ export class PushNotificationService { async initPushNotificationService(): Promise { try { - // 1) Permissions (Android 13+ & iOS) + // Permissions (Android 13+ & iOS) const check: PermissionStatus = await FirebaseMessaging.checkPermissions(); if (check.receive !== "granted") { @@ -31,9 +31,8 @@ export class PushNotificationService { if (req.receive !== "granted") throw new Error("Push permission not granted"); } - // (Permissions + methods per plugin README) [1](https://dev.to/vaclav_svara_50ba53bc0010/firebase-push-notifications-in-capacitor-angular-apps-the-complete-implementation-guide-1c67) - // 2) Android channel (recommended on 8+) + // Android channel (recommended on 8+) await FirebaseMessaging.createChannel( this.opts.androidChannel ?? { id: "general", @@ -42,17 +41,16 @@ export class PushNotificationService { importance: Importance.High, sound: "default", }, - ); // (Channel API from plugin) [1](https://dev.to/vaclav_svara_50ba53bc0010/firebase-push-notifications-in-capacitor-angular-apps-the-complete-implementation-guide-1c67) + ); - // 3) FCM token (works on iOS & Android) + // FCM token (works on iOS & Android) const { token } = await FirebaseMessaging.getToken(); - this.opts.onRegister?.(token); // (getToken returns { token }) [1](https://dev.to/vaclav_svara_50ba53bc0010/firebase-push-notifications-in-capacitor-angular-apps-the-complete-implementation-guide-1c67) + this.opts.onRegister?.(token); // 4) Strongly-typed listeners const tokenReceivedHandler = await FirebaseMessaging.addListener( "tokenReceived", (e: TokenReceivedEvent) => { - console.log("tokenReceivedHandler called"); this.opts.onRegister?.(e.token); }, ); @@ -78,13 +76,12 @@ export class PushNotificationService { ); } catch (err) { this.opts.onError?.(err); - throw err; } } async unregister(): Promise { try { - await FirebaseMessaging.removeAllListeners(); // (plugin cleanup) [1](https://dev.to/vaclav_svara_50ba53bc0010/firebase-push-notifications-in-capacitor-angular-apps-the-complete-implementation-guide-1c67) + await FirebaseMessaging.removeAllListeners(); } catch { /* noop */ } finally { From bcae5140618774d376c4a09fb6e1a45892764d16 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 10:32:12 -0800 Subject: [PATCH 11/32] Remove android http --- mobile/asa-go/capacitor.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/asa-go/capacitor.config.ts b/mobile/asa-go/capacitor.config.ts index b30d0f407d..dcf04a22b2 100644 --- a/mobile/asa-go/capacitor.config.ts +++ b/mobile/asa-go/capacitor.config.ts @@ -5,7 +5,6 @@ const config: CapacitorConfig = { appName: "asa-go", webDir: "dist", ios: { scheme: "ASA Go" }, - server: { androidScheme: "http" }, plugins: { FirebaseMessaging: { presentationOptions: ["alert", "badge", "sound"], // iOS only From 8997d371975f559f269d7869274a032d4be025db Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 10:43:43 -0800 Subject: [PATCH 12/32] Limit channel to android --- .../src/services/pushNotificationService.ts | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/mobile/asa-go/src/services/pushNotificationService.ts b/mobile/asa-go/src/services/pushNotificationService.ts index ec6da268ee..0e682fc83d 100644 --- a/mobile/asa-go/src/services/pushNotificationService.ts +++ b/mobile/asa-go/src/services/pushNotificationService.ts @@ -7,7 +7,7 @@ import { PermissionStatus, TokenReceivedEvent, } from "@capacitor-firebase/messaging"; -import { PluginListenerHandle } from "@capacitor/core"; +import { Capacitor, PluginListenerHandle } from "@capacitor/core"; export type PushInitOptions = { onRegister?: (token: string) => void; @@ -33,15 +33,17 @@ export class PushNotificationService { } // Android channel (recommended on 8+) - await FirebaseMessaging.createChannel( - this.opts.androidChannel ?? { - id: "general", - name: "General", - description: "General notifications", - importance: Importance.High, - sound: "default", - }, - ); + if (Capacitor.getPlatform() === "android") { + await FirebaseMessaging.createChannel( + this.opts.androidChannel ?? { + id: "general", + name: "General", + description: "General notifications", + importance: Importance.High, + sound: "default", + }, + ); + } // FCM token (works on iOS & Android) const { token } = await FirebaseMessaging.getToken(); From 24709f4e2409c88904fddcd66754b3cab61747da Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 11:25:13 -0800 Subject: [PATCH 13/32] use idir --- mobile/asa-go/ios/App/App/Info.plist | 34 ++++++++++--------- mobile/asa-go/package.json | 1 + mobile/asa-go/src/App.tsx | 6 ++-- .../asa-go/src/slices/authenticationSlice.ts | 31 ++++++++++++++--- mobile/asa-go/yarn.lock | 5 +++ 5 files changed, 54 insertions(+), 23 deletions(-) diff --git a/mobile/asa-go/ios/App/App/Info.plist b/mobile/asa-go/ios/App/App/Info.plist index 8bd49a8e72..1674e9465a 100644 --- a/mobile/asa-go/ios/App/App/Info.plist +++ b/mobile/asa-go/ios/App/App/Info.plist @@ -18,18 +18,33 @@ APPL CFBundleShortVersionString $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleURLName + ca.bc.gov.asago.auth + CFBundleURLSchemes + + ca.bc.gov.asago + + + CFBundleVersion $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + LSRequiresIPhoneOS NSDocumentsFolderUsageDescription Your app requires access to the Documents folder for file management. - NSLocationWhenInUseUsageDescription - This app needs location access to show your position and provide location-based fire advisory information. NSLocationAlwaysAndWhenInUseUsageDescription This app needs location access to show your position and provide location-based fire advisory information. + NSLocationWhenInUseUsageDescription + This app needs location access to show your position and provide location-based fire advisory information. UIBackgroundModes - + + remote-notification + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -53,18 +68,5 @@ UIViewControllerBasedStatusBarAppearance - ITSAppUsesNonExemptEncryption - - CFBundleURLTypes - - - CFBundleURLName - ca.bc.gov.asago.auth - CFBundleURLSchemes - - ca.bc.gov.asago - - - diff --git a/mobile/asa-go/package.json b/mobile/asa-go/package.json index 406930621b..646166ad7f 100644 --- a/mobile/asa-go/package.json +++ b/mobile/asa-go/package.json @@ -42,6 +42,7 @@ "axios": "^1.7.9", "capacitor-email-composer": "^7.0.0", "firebase": "^12.9.0", + "jwt-decode": "^4.0.0", "keycloak": "../keycloak", "lodash": "^4.17.21", "luxon": "^3.5.0", diff --git a/mobile/asa-go/src/App.tsx b/mobile/asa-go/src/App.tsx index e261147960..cc6c73bdc0 100644 --- a/mobile/asa-go/src/App.tsx +++ b/mobile/asa-go/src/App.tsx @@ -48,7 +48,7 @@ const App = () => { const dispatch: AppDispatch = useDispatch(); const [isPortrait, setIsPortrait] = useState(true); const isSmallScreen = useMediaQuery(theme.breakpoints.down("lg")); - const { idToken, isAuthenticated } = useSelector(selectAuthentication); + const { idir, isAuthenticated } = useSelector(selectAuthentication); // local state const [tab, setTab] = useState(NavPanel.MAP); @@ -112,9 +112,9 @@ const App = () => { useEffect(() => { if (token) { - registerToken(Capacitor.getPlatform() as Platform, token, idToken || ""); + registerToken(Capacitor.getPlatform() as Platform, token, idir || ""); } - }, [token, idToken]); + }, [token, idir]); useEffect(() => { // Network status is disconnected by default in the networkStatusSlice. Update the status diff --git a/mobile/asa-go/src/slices/authenticationSlice.ts b/mobile/asa-go/src/slices/authenticationSlice.ts index e7353cffdf..3c9eb39c0f 100644 --- a/mobile/asa-go/src/slices/authenticationSlice.ts +++ b/mobile/asa-go/src/slices/authenticationSlice.ts @@ -1,6 +1,8 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { AppThunk } from "@/store"; +import { jwtDecode } from "jwt-decode"; +import { isUndefined } from "lodash"; import { Keycloak } from "../../../keycloak/src"; export interface AuthState { @@ -9,6 +11,7 @@ export interface AuthState { tokenRefreshed: boolean; token: string | undefined; idToken: string | undefined; + idir: string | undefined; error: string | null; } @@ -18,6 +21,7 @@ export const initialState: AuthState = { tokenRefreshed: false, token: undefined, idToken: undefined, + idir: undefined, error: null, }; @@ -34,8 +38,10 @@ const authSlice = createSlice({ isAuthenticated: boolean; token: string | undefined; idToken: string | undefined; - }> + }>, ) { + const userDetails = decodeUserDetails(action.payload.token); + state.idir = userDetails?.idir; state.authenticating = false; state.isAuthenticated = action.payload.isAuthenticated; state.token = action.payload.token; @@ -52,8 +58,10 @@ const authSlice = createSlice({ tokenRefreshed: boolean; token: string | undefined; idToken: string | undefined; - }> + }>, ) { + const userDetails = decodeUserDetails(action.payload.token); + state.idir = userDetails?.idir; state.token = action.payload.token; state.idToken = action.payload.idToken; state.tokenRefreshed = action.payload.tokenRefreshed; @@ -101,7 +109,7 @@ export const authenticate = (): AppThunk => (dispatch) => { isAuthenticated: result.isAuthenticated, token: result.accessToken, idToken: result.idToken, - }) + }), ); } else { dispatch(authenticateError(JSON.stringify(result.error))); @@ -126,7 +134,7 @@ export const authenticate = (): AppThunk => (dispatch) => { tokenRefreshed: true, token: tokenResponse.accessToken, idToken: tokenResponse.idToken, - }) + }), ); } }; @@ -134,3 +142,18 @@ export const authenticate = (): AppThunk => (dispatch) => { // Set up event listener for token refresh events (works for both web and iOS) Keycloak.addListener("tokenRefresh", handleTokenRefresh); }; + +const decodeUserDetails = (token: string | undefined) => { + if (isUndefined(token)) { + return undefined; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const decodedToken: any = jwtDecode(token); + try { + return { idir: decodedToken.idir_username, email: decodedToken.email }; + } catch (e) { + // No idir username + console.error(e); + return undefined; + } +}; diff --git a/mobile/asa-go/yarn.lock b/mobile/asa-go/yarn.lock index 51a8996c29..bb52c79189 100644 --- a/mobile/asa-go/yarn.lock +++ b/mobile/asa-go/yarn.lock @@ -4659,6 +4659,11 @@ jsonparse@^1.2.0: resolved "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz" integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== +jwt-decode@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-4.0.0.tgz#2270352425fd413785b2faf11f6e755c5151bd4b" + integrity sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA== + keycloak@../keycloak: version "0.0.1" From 3a3033bce27a0bcfc89371bfbdde0c07e54fda49 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 13:07:03 -0800 Subject: [PATCH 14/32] test fixes --- .../services/pushNotificationService.test.ts | 48 +++++-------------- .../src/slices/authenticationSlice.test.ts | 40 +++++++++------- .../asa-go/src/slices/authenticationSlice.ts | 8 ++-- 3 files changed, 37 insertions(+), 59 deletions(-) diff --git a/mobile/asa-go/src/services/pushNotificationService.test.ts b/mobile/asa-go/src/services/pushNotificationService.test.ts index 41914320a2..46bead82f2 100644 --- a/mobile/asa-go/src/services/pushNotificationService.test.ts +++ b/mobile/asa-go/src/services/pushNotificationService.test.ts @@ -21,6 +21,17 @@ vi.mock("@capacitor-firebase/messaging", () => ({ }, })); +vi.mock(import("@capacitor/core"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Capacitor: { + ...actual.Capacitor, + getPlatform: vi.fn().mockReturnValue("android"), + }, + }; +}); + describe("PushNotificationService", () => { // Reset all mocks before each test beforeEach(() => { @@ -93,24 +104,6 @@ describe("PushNotificationService", () => { expect(FirebaseMessaging.getToken).toHaveBeenCalledTimes(1); }); - it("should throw an error when permissions are denied", async () => { - // Arrange - vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ - receive: "denied", - } as PermissionStatus); - - vi.mocked(FirebaseMessaging.requestPermissions).mockResolvedValue({ - receive: "denied", - } as PermissionStatus); - - const service = new PushNotificationService(); - - // Act & Assert - await expect(service.initPushNotificationService()).rejects.toThrow( - "Push permission not granted", - ); - }); - it("should use custom Android channel when provided", async () => { // Arrange const mockToken = "test-fcm-token"; @@ -146,25 +139,6 @@ describe("PushNotificationService", () => { customChannel, ); }); - - it("should handle errors during initialization", async () => { - // Arrange - const mockError = new Error("Initialization failed"); - const mockOnError = vi.fn(); - - vi.mocked(FirebaseMessaging.checkPermissions).mockRejectedValue( - mockError, - ); - - const service = new PushNotificationService({ onError: mockOnError }); - - // Act & Assert - await expect(service.initPushNotificationService()).rejects.toThrow( - mockError, - ); - - expect(mockOnError).toHaveBeenCalledWith(mockError); - }); }); describe("unregister", () => { diff --git a/mobile/asa-go/src/slices/authenticationSlice.test.ts b/mobile/asa-go/src/slices/authenticationSlice.test.ts index 664a757371..2c5b012cb9 100644 --- a/mobile/asa-go/src/slices/authenticationSlice.test.ts +++ b/mobile/asa-go/src/slices/authenticationSlice.test.ts @@ -19,6 +19,10 @@ interface TokenResponse { scope?: string; } +// Mock valid JWT token with idir_username and email claims +const mockValidToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZGlyX3VzZXJuYW1lIjoiSm9obiBEb2UiLCJlbWFpbCI6ImpvaG4uZG9lQGNvbnRhY3QuY29tIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + // Mock the Keycloak module vi.mock("../../../keycloak/src", () => ({ Keycloak: { @@ -36,7 +40,7 @@ describe("authenticationSlice", () => { const createSuccessfulAuthResult = (overrides = {}) => ({ isAuthenticated: true, - accessToken: "test-token", + accessToken: mockValidToken, idToken: "test-id-token", ...overrides, }); @@ -47,9 +51,9 @@ describe("authenticationSlice", () => { }); const createTokenResponse = ( - overrides: Partial = {} + overrides: Partial = {}, ): TokenResponse => ({ - accessToken: "new-access-token", + accessToken: mockValidToken, refreshToken: "new-refresh-token", tokenType: "Bearer", expiresIn: 3600, @@ -65,7 +69,7 @@ describe("authenticationSlice", () => { }; const setupTokenRefreshListener = ( - store: ReturnType + store: ReturnType, ) => { let tokenRefreshCallback: (tokenResponse: TokenResponse) => void = () => {}; @@ -91,7 +95,7 @@ describe("authenticationSlice", () => { describe("reducers", () => { it("should return the initial state", () => { expect(authenticationSlice(undefined, { type: "unknown" })).toEqual( - initialState + initialState, ); }); @@ -110,19 +114,19 @@ describe("authenticationSlice", () => { const previousState = createAuthState({ authenticating: true }); const payload = { isAuthenticated: true, - token: "access-token-123", + token: mockValidToken, idToken: "id-token-456", }; const nextState = authenticationSlice( previousState, - authenticateFinished(payload) + authenticateFinished(payload), ); expectAuthState(nextState, { authenticating: false, isAuthenticated: true, - token: "access-token-123", + token: mockValidToken, idToken: "id-token-456", }); }); @@ -137,7 +141,7 @@ describe("authenticationSlice", () => { const nextState = authenticationSlice( previousState, - authenticateFinished(payload) + authenticateFinished(payload), ); expectAuthState(nextState, { @@ -157,7 +161,7 @@ describe("authenticationSlice", () => { const nextState = authenticationSlice( previousState, - authenticateError(errorMessage) + authenticateError(errorMessage), ); expectAuthState(nextState, { @@ -175,17 +179,17 @@ describe("authenticationSlice", () => { }); const payload = { tokenRefreshed: true, - token: "new-access-token", + token: mockValidToken, idToken: "new-id-token", }; const nextState = authenticationSlice( previousState, - refreshTokenFinished(payload) + refreshTokenFinished(payload), ); expectAuthState(nextState, { - token: "new-access-token", + token: mockValidToken, idToken: "new-id-token", tokenRefreshed: true, }); @@ -205,7 +209,7 @@ describe("authenticationSlice", () => { const nextState = authenticationSlice( previousState, - refreshTokenFinished(payload) + refreshTokenFinished(payload), ); expect(nextState.token).toBeUndefined(); @@ -232,7 +236,7 @@ describe("authenticationSlice", () => { it("should dispatch authenticateFinished on successful authentication", async () => { const mockResult = createSuccessfulAuthResult({ - accessToken: "test-access-token", + accessToken: mockValidToken, }); const store = setupStoreWithMockAuth(mockResult); @@ -240,7 +244,7 @@ describe("authenticationSlice", () => { expectAuthState(store.getState().authentication, { isAuthenticated: true, - token: "test-access-token", + token: mockValidToken, idToken: "test-id-token", authenticating: false, error: null, @@ -298,7 +302,7 @@ describe("authenticationSlice", () => { expect(Keycloak.addListener).toHaveBeenCalledWith( "tokenRefresh", - expect.any(Function) + expect.any(Function), ); }); @@ -314,7 +318,7 @@ describe("authenticationSlice", () => { expectAuthState(store.getState().authentication, { tokenRefreshed: true, - token: "new-access-token", + token: mockValidToken, }); expect(store.getState().authentication.idToken).toBeUndefined(); }); diff --git a/mobile/asa-go/src/slices/authenticationSlice.ts b/mobile/asa-go/src/slices/authenticationSlice.ts index 3c9eb39c0f..ab3345998c 100644 --- a/mobile/asa-go/src/slices/authenticationSlice.ts +++ b/mobile/asa-go/src/slices/authenticationSlice.ts @@ -143,16 +143,16 @@ export const authenticate = (): AppThunk => (dispatch) => { Keycloak.addListener("tokenRefresh", handleTokenRefresh); }; -const decodeUserDetails = (token: string | undefined) => { +export const decodeUserDetails = (token: string | undefined) => { if (isUndefined(token)) { return undefined; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const decodedToken: any = jwtDecode(token); try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const decodedToken: any = jwtDecode(token); return { idir: decodedToken.idir_username, email: decodedToken.email }; } catch (e) { - // No idir username + // Handle invalid token or missing claims console.error(e); return undefined; } From 1c67610ba33f508ad5902cc4e588de91046beaa0 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 16:45:42 -0800 Subject: [PATCH 15/32] PR feedback --- backend/packages/wps-api/src/app/fcm/schema.py | 4 ++-- backend/packages/wps-api/src/app/main.py | 2 +- mobile/asa-go/src/services/pushNotificationService.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/packages/wps-api/src/app/fcm/schema.py b/backend/packages/wps-api/src/app/fcm/schema.py index 2553d7a36a..5cf0e03b22 100644 --- a/backend/packages/wps-api/src/app/fcm/schema.py +++ b/backend/packages/wps-api/src/app/fcm/schema.py @@ -6,10 +6,10 @@ class RegisterDeviceRequest(BaseModel): user_id: Optional[str] = None token: str = Field(..., min_length=10) - platform: Optional[str] = Field(None, pattern="^(ios|android)?$") + platform: str = Field(..., pattern="^(ios|android)$") class UnregisterDeviceRequest(BaseModel): - token: str + token: str = Field(..., min_length=10) class DeviceRequestResponse(BaseModel): success: bool diff --git a/backend/packages/wps-api/src/app/main.py b/backend/packages/wps-api/src/app/main.py index f82ea8e351..7c1055b277 100644 --- a/backend/packages/wps-api/src/app/main.py +++ b/backend/packages/wps-api/src/app/main.py @@ -123,7 +123,7 @@ async def catch_exception_middleware(request: Request, call_next): CORSMiddleware, allow_origins=ORIGINS, allow_credentials=True, - allow_methods=["GET", "HEAD", "POST", "PATCH"], + allow_methods=["GET", "HEAD", "POST", "PATCH", "DELETE"], allow_headers=["*"], ) api.middleware("http")(catch_exception_middleware) diff --git a/mobile/asa-go/src/services/pushNotificationService.ts b/mobile/asa-go/src/services/pushNotificationService.ts index 0e682fc83d..d2fab1e0a6 100644 --- a/mobile/asa-go/src/services/pushNotificationService.ts +++ b/mobile/asa-go/src/services/pushNotificationService.ts @@ -49,7 +49,7 @@ export class PushNotificationService { const { token } = await FirebaseMessaging.getToken(); this.opts.onRegister?.(token); - // 4) Strongly-typed listeners + // Strongly-typed listeners const tokenReceivedHandler = await FirebaseMessaging.addListener( "tokenReceived", (e: TokenReceivedEvent) => { From b0222b37af482e4b621e34886c48d5cc4d133ff6 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 17:00:54 -0800 Subject: [PATCH 16/32] Use enum, fix get_utc_now default --- .../7d2194c5051e_push_notification_tokens.py | 23 +++++++++++-------- .../src/wps_shared/db/models/fcm.py | 15 ++++++++---- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/backend/packages/wps-api/alembic/versions/7d2194c5051e_push_notification_tokens.py b/backend/packages/wps-api/alembic/versions/7d2194c5051e_push_notification_tokens.py index 74d068a770..b42a3ea85c 100644 --- a/backend/packages/wps-api/alembic/versions/7d2194c5051e_push_notification_tokens.py +++ b/backend/packages/wps-api/alembic/versions/7d2194c5051e_push_notification_tokens.py @@ -19,16 +19,19 @@ def upgrade(): - op.create_table('device_token', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.String(), nullable=True), - sa.Column('platform', sa.String(), nullable=False), - sa.Column('token', sa.String(), nullable=False), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('created_at', TZTimeStamp(), nullable=False), - sa.Column('updated_at', TZTimeStamp(), nullable=False), - sa.PrimaryKeyConstraint('id'), - comment='Device token management.' + op.create_table( + "device_token", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.String(), nullable=True), + sa.Column( + "platform", postgresql.ENUM("android", "ios", name="platformenum"), nullable=False + ), + sa.Column("token", sa.String(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("created_at", TZTimeStamp(), nullable=False), + sa.Column("updated_at", TZTimeStamp(), nullable=False), + sa.PrimaryKeyConstraint("id"), + comment="Device token management.", ) op.create_index(op.f('ix_device_token_id'), 'device_token', ['id'], unique=False) op.create_index(op.f('ix_device_token_platform'), 'device_token', ['platform'], unique=False) diff --git a/backend/packages/wps-shared/src/wps_shared/db/models/fcm.py b/backend/packages/wps-shared/src/wps_shared/db/models/fcm.py index 93f867d6b0..7ddf25e26f 100644 --- a/backend/packages/wps-shared/src/wps_shared/db/models/fcm.py +++ b/backend/packages/wps-shared/src/wps_shared/db/models/fcm.py @@ -1,10 +1,17 @@ -from sqlalchemy import Boolean, Column, Integer, String +import enum + +from sqlalchemy import Boolean, Column, Enum, Integer, String from wps_shared.db.models import Base from wps_shared.db.models.common import TZTimeStamp from wps_shared.utils.time import get_utc_now +class PlatformEnum(enum.Enum): + ANDROID = "android" + IOS = "ios" + + class DeviceToken(Base): """Storage of Firebase Cloud Messaging tokens and client details.""" @@ -12,8 +19,8 @@ class DeviceToken(Base): __table_args__ = {"comment": "Device token management."} id = Column(Integer, primary_key=True, index=True) user_id = Column(String, nullable=True) # Optional storage of IDIR for logged in users - platform = Column(String, index=True, nullable=False) + platform = Column(Enum(PlatformEnum), index=True, nullable=False) token = Column(String, unique=True, index=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False) - created_at = Column(TZTimeStamp, default=get_utc_now(), nullable=False) - updated_at = Column(TZTimeStamp, default=get_utc_now(), nullable=False) + created_at = Column(TZTimeStamp, default=get_utc_now, nullable=False) + updated_at = Column(TZTimeStamp, default=get_utc_now, nullable=False) From 245955128d6cdc865e4fbd99d2fd7ffab8fb2478 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 17:22:03 -0800 Subject: [PATCH 17/32] feedback --- backend/packages/wps-api/src/app/routers/fcm.py | 15 +++++++++++++-- .../wps-api/src/app/tests/fcm/test_fcm_router.py | 3 ++- .../wps-shared/src/wps_shared/db/crud/fcm.py | 5 +++-- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/backend/packages/wps-api/src/app/routers/fcm.py b/backend/packages/wps-api/src/app/routers/fcm.py index 973fee9403..28d0fef4fd 100644 --- a/backend/packages/wps-api/src/app/routers/fcm.py +++ b/backend/packages/wps-api/src/app/routers/fcm.py @@ -1,4 +1,6 @@ -from fastapi import APIRouter +import logging + +from fastapi import APIRouter, HTTPException from wps_shared.db.crud.fcm import ( get_device_by_token, save_device_token, @@ -10,6 +12,8 @@ from app.fcm.schema import DeviceRequestResponse, RegisterDeviceRequest, UnregisterDeviceRequest +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/device") @@ -18,12 +22,14 @@ async def register_device(request: RegisterDeviceRequest): """ Upsert a device token for a user. Called this at app start and whenever FCM token refreshes. """ + logger.info("/device/register") async with get_async_write_session_scope() as session: existing = await get_device_by_token(session, request.token) if existing: existing.is_active = True existing.token = request.token existing.updated_at = get_utc_now() + logger.info(f"Updated existing DeviceInfo record for token: {request.token}") else: device_token = DeviceToken( user_id=request.user_id, @@ -32,6 +38,7 @@ async def register_device(request: RegisterDeviceRequest): is_active=True, ) save_device_token(session, device_token) + logger.info("Successfully created new DeviceToken record.") return DeviceRequestResponse(success=True) @@ -40,6 +47,10 @@ async def unregister_device(request: UnregisterDeviceRequest): """ Mark a token inactive (e.g., user logged out or uninstalled). """ + logger.info("/device/unregister") async with get_async_write_session_scope() as session: - await update_device_token_is_active(session, request.token) + success = await update_device_token_is_active(session, request.token) + if not success: + logger.error(f"Could not find a record matching the provided token: {request.token}") + raise HTTPException(status_code=404, detail=f"Token not found: {request.token}") return DeviceRequestResponse(success=True) diff --git a/backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py b/backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py index c2e136bdaa..7b293e110a 100644 --- a/backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py +++ b/backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py @@ -52,13 +52,14 @@ def test_register_device_already_exists(): with ( patch(GET_DEVICE_TOKEN, return_value=existing_device), - patch(SAVE_DEVICE_TOKEN), + patch(SAVE_DEVICE_TOKEN) as mock_save, ): response = client.post(API_DEVICE_REGISTER, json=request_data) assert response.status_code == 200 assert response.json()["success"] == True assert existing_device.is_active == True # Should be updated + mock_save.assert_not_called() # Should not call save for existing device def test_register_device_missing_fields(): diff --git a/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py b/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py index 0c8067d39a..909c538f8b 100644 --- a/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py +++ b/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py @@ -25,12 +25,13 @@ async def get_device_by_token(session: AsyncSession, token: str): return await session.scalar(select(DeviceToken).where(DeviceToken.token == token)) -async def update_device_token_is_active(session: AsyncSession, token: str): +async def update_device_token_is_active(session: AsyncSession, token: str) -> bool: device_token = await session.scalar(select(DeviceToken).where(DeviceToken.token == token)) if not device_token: - raise ValueError(f"DeviceToken with token {token} does not exist.") + return False device_token.is_active = False device_token.updated_at = get_utc_now() + return True async def deactivate_device_tokens(session: AsyncSession, tokens: list[str]) -> int: From 086862e481742cda03aded2dacdbed04a498a602 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 21:31:41 -0800 Subject: [PATCH 18/32] fcm crud tests --- .../packages/wps-api/src/app/routers/fcm.py | 2 +- .../wps-shared/src/wps_shared/db/crud/fcm.py | 4 +- .../src/wps_shared/tests/db/crud/test_fcm.py | 130 ++++++++++++++++++ mobile/asa-go/capacitor.config.ts | 1 - .../src/services/pushNotificationService.ts | 11 +- 5 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 backend/packages/wps-shared/src/wps_shared/tests/db/crud/test_fcm.py diff --git a/backend/packages/wps-api/src/app/routers/fcm.py b/backend/packages/wps-api/src/app/routers/fcm.py index 28d0fef4fd..e96bd63684 100644 --- a/backend/packages/wps-api/src/app/routers/fcm.py +++ b/backend/packages/wps-api/src/app/routers/fcm.py @@ -49,7 +49,7 @@ async def unregister_device(request: UnregisterDeviceRequest): """ logger.info("/device/unregister") async with get_async_write_session_scope() as session: - success = await update_device_token_is_active(session, request.token) + success = await update_device_token_is_active(session, request.token, False) if not success: logger.error(f"Could not find a record matching the provided token: {request.token}") raise HTTPException(status_code=404, detail=f"Token not found: {request.token}") diff --git a/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py b/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py index 909c538f8b..37e4479ca9 100644 --- a/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py +++ b/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py @@ -25,11 +25,11 @@ async def get_device_by_token(session: AsyncSession, token: str): return await session.scalar(select(DeviceToken).where(DeviceToken.token == token)) -async def update_device_token_is_active(session: AsyncSession, token: str) -> bool: +async def update_device_token_is_active(session: AsyncSession, token: str, is_active: bool) -> bool: device_token = await session.scalar(select(DeviceToken).where(DeviceToken.token == token)) if not device_token: return False - device_token.is_active = False + device_token.is_active = is_active device_token.updated_at = get_utc_now() return True diff --git a/backend/packages/wps-shared/src/wps_shared/tests/db/crud/test_fcm.py b/backend/packages/wps-shared/src/wps_shared/tests/db/crud/test_fcm.py new file mode 100644 index 0000000000..f331e351d3 --- /dev/null +++ b/backend/packages/wps-shared/src/wps_shared/tests/db/crud/test_fcm.py @@ -0,0 +1,130 @@ +from datetime import date, datetime, timezone + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.future import select +from testcontainers.postgres import PostgresContainer +from wps_shared.db.crud.fcm import ( + deactivate_device_tokens, + get_device_by_token, + save_device_token, + update_device_token_is_active, +) +from wps_shared.db.models.fcm import DeviceToken, PlatformEnum +from wps_shared.utils.time import get_utc_now + +test_target_date = date(2025, 7, 15) +test_completed_at = datetime(2025, 7, 15, 20, 45, 0, tzinfo=timezone.utc) + +mock_fcm_token = "abcdefghijklmnop" +now = get_utc_now() + + +@pytest.fixture(scope="function") +def postgres_container(): + with PostgresContainer("postgres:16") as postgres: + yield postgres + + +@pytest.fixture(scope="function") +async def engine(postgres_container): + sync_url = postgres_container.get_connection_url() + db_url = sync_url.replace("postgresql+psycopg2://", "postgresql+asyncpg://") + + engine = create_async_engine(db_url, echo=False) + + async with engine.begin() as conn: + await conn.run_sync(DeviceToken.__table__.create) + # Insert a mock device_token record + await conn.execute( + text(f"""INSERT INTO device_token (user_id, platform, token, is_active, created_at, updated_at) + VALUES ('test_idir', 'ANDROID', '{mock_fcm_token}', True, '{now}', '{now}');""") + ) + + yield engine + + await engine.dispose() + + +@pytest.fixture(scope="function") +async def session_factory(engine): + return async_sessionmaker(engine, expire_on_commit=False) + + +@pytest.fixture(scope="function") +async def async_session(session_factory): + async with session_factory() as session: + yield session + + +@pytest.fixture(scope="function") +async def async_session_with_commit(session_factory): + async with session_factory() as session: + yield session + session.commit() + session.close + + +@pytest.mark.anyio +async def test_save_device_token(async_session: AsyncSession): + """Test inserting a new device_token record.""" + mock_fcm_token2 = "qwertyuiopasdfg" + device_token = DeviceToken( + user_id="test_idir2", + platform="IOS", + token=mock_fcm_token2, + is_active=True, + created_at=now, + updated_at=now, + ) + save_device_token(async_session, device_token) + await async_session.commit() + + result = await async_session.execute( + select(DeviceToken).where(DeviceToken.token == mock_fcm_token2) + ) + saved = result.scalar_one() + assert saved.user_id == "test_idir2" + assert saved.platform == "IOS" + assert saved.token == mock_fcm_token2 + assert saved.is_active is True + + +@pytest.mark.anyio +async def test_get_device_by_token(async_session: AsyncSession): + """Test retrieving an existing device_token record by token.""" + device_token = await get_device_by_token(async_session, mock_fcm_token) + + assert device_token.user_id == "test_idir" + assert device_token.platform == PlatformEnum.ANDROID + assert device_token.token == mock_fcm_token + assert device_token.is_active is True + + +@pytest.mark.anyio +async def test_update_device_token_is_active_valid_token(async_session: AsyncSession): + """Test updating the is_active field of an existing record.""" + result = await update_device_token_is_active(async_session, mock_fcm_token, False) + assert result is True + + +@pytest.mark.anyio +async def test_update_device_token_is_active_invalid_token(async_session: AsyncSession): + """Test updating the is_active field using invalid token.""" + result = await update_device_token_is_active(async_session, "invalid_token", True) + assert result is False + + +@pytest.mark.anyio +async def test_deactivate_device_tokens_empty_token_list(async_session: AsyncSession): + """Test unregistering with empty token list.""" + result = await deactivate_device_tokens(async_session, []) + assert result == 0 + + +@pytest.mark.anyio +async def test_deactivate_device_tokens_valid_token_list(async_session: AsyncSession): + """Test unregistering with empty token list.""" + result = await deactivate_device_tokens(async_session, [mock_fcm_token]) + assert result == 1 diff --git a/mobile/asa-go/capacitor.config.ts b/mobile/asa-go/capacitor.config.ts index dcf04a22b2..6c361b61e2 100644 --- a/mobile/asa-go/capacitor.config.ts +++ b/mobile/asa-go/capacitor.config.ts @@ -9,7 +9,6 @@ const config: CapacitorConfig = { FirebaseMessaging: { presentationOptions: ["alert", "badge", "sound"], // iOS only }, - SplashScreen: { launchAutoHide: true, launchShowDuration: 500, diff --git a/mobile/asa-go/src/services/pushNotificationService.ts b/mobile/asa-go/src/services/pushNotificationService.ts index d2fab1e0a6..d705bdf505 100644 --- a/mobile/asa-go/src/services/pushNotificationService.ts +++ b/mobile/asa-go/src/services/pushNotificationService.ts @@ -76,22 +76,25 @@ export class PushNotificationService { notificationReceivedHandler, onNotificationAction, ); - } catch (err) { - this.opts.onError?.(err); + } catch (e) { + console.error(e); + this.opts.onError?.(e); } } async unregister(): Promise { try { await FirebaseMessaging.removeAllListeners(); - } catch { + } catch (e) { + console.error(e); /* noop */ } finally { await Promise.all( this.handles.map(async (h) => { try { await h.remove(); - } catch { + } catch (e) { + console.error(e); /* noop */ } }), From 669b1c9c7bf74500428dab6e36727788cfcfd8c6 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 21:50:11 -0800 Subject: [PATCH 19/32] Guard service re-init --- mobile/asa-go/src/services/pushNotificationService.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mobile/asa-go/src/services/pushNotificationService.ts b/mobile/asa-go/src/services/pushNotificationService.ts index d705bdf505..5b1aeb5a23 100644 --- a/mobile/asa-go/src/services/pushNotificationService.ts +++ b/mobile/asa-go/src/services/pushNotificationService.ts @@ -19,9 +19,14 @@ export type PushInitOptions = { export class PushNotificationService { private handles: PluginListenerHandle[] = []; + private isInitialized = false; + constructor(private readonly opts: PushInitOptions = {}) {} async initPushNotificationService(): Promise { + if (this.isInitialized) { + return; + } try { // Permissions (Android 13+ & iOS) const check: PermissionStatus = @@ -79,6 +84,8 @@ export class PushNotificationService { } catch (e) { console.error(e); this.opts.onError?.(e); + } finally { + this.isInitialized = true; } } @@ -100,6 +107,7 @@ export class PushNotificationService { }), ); this.handles = []; + this.isInitialized = false; } } } From bcae983235f84a50c81d7a7eb66f6b28f03de89c Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 21:52:04 -0800 Subject: [PATCH 20/32] fix cap config --- mobile/asa-go/capacitor.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/asa-go/capacitor.config.ts b/mobile/asa-go/capacitor.config.ts index 6c361b61e2..07adc197fc 100644 --- a/mobile/asa-go/capacitor.config.ts +++ b/mobile/asa-go/capacitor.config.ts @@ -5,6 +5,7 @@ const config: CapacitorConfig = { appName: "asa-go", webDir: "dist", ios: { scheme: "ASA Go" }, + server: { androidScheme: "http" }, plugins: { FirebaseMessaging: { presentationOptions: ["alert", "badge", "sound"], // iOS only From 9508c8e252f944176fb296be91a658deea937bbe Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 21:52:27 -0800 Subject: [PATCH 21/32] fix cap config 2 --- mobile/asa-go/capacitor.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/asa-go/capacitor.config.ts b/mobile/asa-go/capacitor.config.ts index 07adc197fc..6c361b61e2 100644 --- a/mobile/asa-go/capacitor.config.ts +++ b/mobile/asa-go/capacitor.config.ts @@ -5,7 +5,6 @@ const config: CapacitorConfig = { appName: "asa-go", webDir: "dist", ios: { scheme: "ASA Go" }, - server: { androidScheme: "http" }, plugins: { FirebaseMessaging: { presentationOptions: ["alert", "badge", "sound"], // iOS only From 16d0f99d7613b8ca1a5e67fe18294efa2f9ce63f Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 21:57:04 -0800 Subject: [PATCH 22/32] Add auth to fcm routes --- backend/packages/wps-api/src/app/routers/fcm.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/packages/wps-api/src/app/routers/fcm.py b/backend/packages/wps-api/src/app/routers/fcm.py index e96bd63684..9bc729ef4d 100644 --- a/backend/packages/wps-api/src/app/routers/fcm.py +++ b/backend/packages/wps-api/src/app/routers/fcm.py @@ -1,6 +1,7 @@ import logging -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException +from wps_shared.auth import asa_authentication_required, audit_asa from wps_shared.db.crud.fcm import ( get_device_by_token, save_device_token, @@ -14,7 +15,10 @@ logger = logging.getLogger(__name__) -router = APIRouter(prefix="/device") +router = APIRouter( + prefix="/device", + dependencies=[Depends(asa_authentication_required), Depends(audit_asa)], +) @router.post("/register") From f484ac6135f75b7b737ca8f3e5a8bd207cb18da3 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Tue, 3 Mar 2026 22:00:41 -0800 Subject: [PATCH 23/32] Remove unused interfaces --- mobile/asa-go/src/api/pushNotificationsAPI.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/mobile/asa-go/src/api/pushNotificationsAPI.ts b/mobile/asa-go/src/api/pushNotificationsAPI.ts index d9d5abc46f..d8593b787f 100644 --- a/mobile/asa-go/src/api/pushNotificationsAPI.ts +++ b/mobile/asa-go/src/api/pushNotificationsAPI.ts @@ -2,17 +2,6 @@ import axios from "api/axios"; export type Platform = "android" | "ios"; -export interface RegisterDeviceRequest { - platform: Platform; - token: string; - deviceId: string; - userId: string | null; -} - -export interface UnregisterDeviceRequest { - token: string; -} - interface DeviceRequestResponse { success: boolean; } From f668fe073b881d768a61af49d9f23d9c2e4c2166 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Wed, 4 Mar 2026 08:15:06 -0800 Subject: [PATCH 24/32] Additional tests --- .../services/pushNotificationService.test.ts | 68 +++++++++++++++---- .../src/services/pushNotificationService.ts | 3 +- 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/mobile/asa-go/src/services/pushNotificationService.test.ts b/mobile/asa-go/src/services/pushNotificationService.test.ts index 46bead82f2..af83163af7 100644 --- a/mobile/asa-go/src/services/pushNotificationService.test.ts +++ b/mobile/asa-go/src/services/pushNotificationService.test.ts @@ -5,6 +5,7 @@ import { PermissionStatus, Importance, } from "@capacitor-firebase/messaging"; +import { Capacitor } from "@capacitor/core"; // Mock the FirebaseMessaging plugin vi.mock("@capacitor-firebase/messaging", () => ({ @@ -21,6 +22,7 @@ vi.mock("@capacitor-firebase/messaging", () => ({ }, })); +// Mock Capacitor.getPlatform() vi.mock(import("@capacitor/core"), async (importOriginal) => { const actual = await importOriginal(); return { @@ -39,11 +41,37 @@ describe("PushNotificationService", () => { }); describe("initPushNotificationService", () => { + it("should not call createChannel when platform is iOS", async () => { + const mockToken = "test-fcm-token"; + + // Override Capacitor.getPlatform to return 'ios' + vi.mocked(Capacitor.getPlatform).mockReturnValue("ios"); + + vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ + receive: "granted", + } as PermissionStatus); + + vi.mocked(FirebaseMessaging.getToken).mockResolvedValue({ + token: mockToken, + }); + + vi.mocked(FirebaseMessaging.addListener).mockResolvedValue({ + remove: vi.fn(), + }); + + const service = new PushNotificationService(); + await service.initPushNotificationService(); + + expect(FirebaseMessaging.createChannel).not.toHaveBeenCalled(); + }); + it("should initialize push notifications successfully when permissions are granted", async () => { - // Arrange const mockToken = "test-fcm-token"; const mockOnRegister = vi.fn(); + // Ensure platform is back to Android for this test + vi.mocked(Capacitor.getPlatform).mockReturnValue("android"); + vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ receive: "granted", } as PermissionStatus); @@ -73,7 +101,6 @@ describe("PushNotificationService", () => { }); it("should request permissions when not granted initially", async () => { - // Arrange const mockToken = "test-fcm-token"; vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ @@ -94,18 +121,39 @@ describe("PushNotificationService", () => { const service = new PushNotificationService(); - // Act await service.initPushNotificationService(); - // Assert expect(FirebaseMessaging.checkPermissions).toHaveBeenCalledTimes(1); expect(FirebaseMessaging.requestPermissions).toHaveBeenCalledTimes(1); expect(FirebaseMessaging.createChannel).toHaveBeenCalledTimes(1); expect(FirebaseMessaging.getToken).toHaveBeenCalledTimes(1); }); + it("should call onError handler when permission request is denied", async () => { + const mockOnError = vi.fn(); + + vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ + receive: "denied", + } as PermissionStatus); + + vi.mocked(FirebaseMessaging.requestPermissions).mockResolvedValue({ + receive: "denied", + } as PermissionStatus); + + vi.mocked(FirebaseMessaging.addListener).mockResolvedValue({ + remove: vi.fn(), + }); + + const service = new PushNotificationService({ + onError: mockOnError, + }); + + await service.initPushNotificationService(); + + expect(mockOnError).toHaveBeenCalledOnce(); + }); + it("should use custom Android channel when provided", async () => { - // Arrange const mockToken = "test-fcm-token"; const customChannel = { id: "custom-channel", @@ -131,10 +179,8 @@ describe("PushNotificationService", () => { androidChannel: customChannel, }); - // Act await service.initPushNotificationService(); - // Assert expect(FirebaseMessaging.createChannel).toHaveBeenCalledWith( customChannel, ); @@ -143,7 +189,6 @@ describe("PushNotificationService", () => { describe("unregister", () => { it("should unregister all listeners", async () => { - // Arrange const mockRemoveAllListeners = vi.fn(); const mockRemoveListener1 = vi.fn(); const mockRemoveListener2 = vi.fn(); @@ -164,15 +209,15 @@ describe("PushNotificationService", () => { vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ receive: "granted", } as PermissionStatus); + vi.mocked(FirebaseMessaging.getToken).mockResolvedValue({ token: "test-token", }); + await service.initPushNotificationService(); - // Act await service.unregister(); - // Assert expect(FirebaseMessaging.removeAllListeners).toHaveBeenCalledTimes(1); expect(mockRemoveListener1).toHaveBeenCalledTimes(1); expect(mockRemoveListener2).toHaveBeenCalledTimes(1); @@ -180,7 +225,6 @@ describe("PushNotificationService", () => { }); it("should handle errors when removing listeners", async () => { - // Arrange const mockRemoveListener = vi .fn() .mockRejectedValue(new Error("Remove failed")); @@ -195,7 +239,6 @@ describe("PushNotificationService", () => { const service = new PushNotificationService(); - // First, initialize to add listeners vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ receive: "granted", } as PermissionStatus); @@ -204,7 +247,6 @@ describe("PushNotificationService", () => { }); await service.initPushNotificationService(); - // Act & Assert - Should not throw an error await expect(service.unregister()).resolves.not.toThrow(); }); }); diff --git a/mobile/asa-go/src/services/pushNotificationService.ts b/mobile/asa-go/src/services/pushNotificationService.ts index 5b1aeb5a23..e589dc9a91 100644 --- a/mobile/asa-go/src/services/pushNotificationService.ts +++ b/mobile/asa-go/src/services/pushNotificationService.ts @@ -81,11 +81,10 @@ export class PushNotificationService { notificationReceivedHandler, onNotificationAction, ); + this.isInitialized = true; } catch (e) { console.error(e); this.opts.onError?.(e); - } finally { - this.isInitialized = true; } } From de523dd6a192cd35ff9c7c46bbde766ec3c5fddc Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Wed, 4 Mar 2026 10:26:29 -0800 Subject: [PATCH 25/32] Update gitignore --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c4a650674a..82640faded 100644 --- a/.gitignore +++ b/.gitignore @@ -166,4 +166,8 @@ Pods **/local.bru # hypothesis directories -.hypothesis/ \ No newline at end of file +.hypothesis/ + +# Push notification config +google-services.json +GoogleService-Info.plist \ No newline at end of file From 709be151285f2bb96b04aa7927e5b1ccda78bdd0 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Wed, 4 Mar 2026 10:30:52 -0800 Subject: [PATCH 26/32] post instead of delete, secure endpoint tests --- .../packages/wps-api/src/app/routers/fcm.py | 2 +- .../src/app/tests/fcm/test_fcm_router.py | 56 +++++++++++++++---- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/backend/packages/wps-api/src/app/routers/fcm.py b/backend/packages/wps-api/src/app/routers/fcm.py index 9bc729ef4d..455c7a085b 100644 --- a/backend/packages/wps-api/src/app/routers/fcm.py +++ b/backend/packages/wps-api/src/app/routers/fcm.py @@ -46,7 +46,7 @@ async def register_device(request: RegisterDeviceRequest): return DeviceRequestResponse(success=True) -@router.delete("/unregister") +@router.post("/unregister") async def unregister_device(request: UnregisterDeviceRequest): """ Mark a token inactive (e.g., user logged out or uninstalled). diff --git a/backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py b/backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py index 7b293e110a..867a09f743 100644 --- a/backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py +++ b/backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py @@ -4,13 +4,49 @@ from unittest.mock import patch import app.main -from starlette.testclient import TestClient +import pytest +from app.fcm.schema import RegisterDeviceRequest +from fastapi.testclient import TestClient +from wps_shared.db.models.fcm import PlatformEnum DB_SESSION = "app.routers.fcm.get_async_write_session_scope" GET_DEVICE_TOKEN = "app.routers.fcm.get_device_by_token" SAVE_DEVICE_TOKEN = "app.routers.fcm.save_device_token" API_DEVICE_REGISTER = "/api/device/register" +API_DEVICE_UNREGISTER = "/api/device/unregister" +MOCK_DEVICE_TOKEN = "abcdefghijklmonp" +TEST_REGISTER_DEVICE_REQUEST = { + "user_id": "test_idir", + "token": MOCK_DEVICE_TOKEN, + "platform": PlatformEnum.ANDROID.value, +} +TEST_UNREGISTER_DEVICE_REQUEST = {"token": MOCK_DEVICE_TOKEN} + +@pytest.fixture() +def client(): + from app.main import app as test_app + + with TestClient(test_app) as test_client: + yield test_client + + +@pytest.mark.usefixtures("mock_client_session") +@pytest.mark.parametrize( + "endpoint, payload", + [ + (API_DEVICE_REGISTER, TEST_REGISTER_DEVICE_REQUEST), + (API_DEVICE_UNREGISTER, TEST_UNREGISTER_DEVICE_REQUEST), + ], +) +def test_get_endpoints_unauthorized(endpoint: str, payload, client: TestClient): + """Forbidden to get fire zone areas when unauthorized""" + + response = client.post(endpoint, json=payload) + assert response.status_code == 401 + + +@pytest.mark.usefixtures("mock_jwt_decode") def test_register_device_success(): """Test that device registration returns 200/OK.""" client = TestClient(app.main.app) @@ -34,7 +70,7 @@ def test_register_device_success(): assert response.json()["success"] == True assert response.headers["content-type"] == "application/json" - +@pytest.mark.usefixtures("mock_jwt_decode") def test_register_device_already_exists(): """Test that existing device registration updates successfully.""" client = TestClient(app.main.app) @@ -61,7 +97,7 @@ def test_register_device_already_exists(): assert existing_device.is_active == True # Should be updated mock_save.assert_not_called() # Should not call save for existing device - +@pytest.mark.usefixtures("mock_jwt_decode") def test_register_device_missing_fields(): """Test that missing fields in registration request returns 422.""" client = TestClient(app.main.app) @@ -76,7 +112,7 @@ def test_register_device_missing_fields(): assert response.status_code == 422 - +@pytest.mark.usefixtures("mock_jwt_decode") def test_register_device_invalid_platform(): """Test that invalid platform returns 422.""" client = TestClient(app.main.app) @@ -94,7 +130,7 @@ def test_register_device_invalid_platform(): assert response.status_code == 422 - +@pytest.mark.usefixtures("mock_jwt_decode") def test_register_device_short_token(): """Test that short token returns 422.""" client = TestClient(app.main.app) @@ -112,7 +148,7 @@ def test_register_device_short_token(): assert response.status_code == 422 - +@pytest.mark.usefixtures("mock_jwt_decode") def test_unregister_device_success(): """Test that device unregistration returns 200/OK.""" client = TestClient(app.main.app) @@ -122,12 +158,12 @@ def test_unregister_device_success(): with patch("app.routers.fcm.get_async_write_session_scope") as mock_session_scope: mock_session_scope.return_value.__aenter__.return_value with patch("app.routers.fcm.update_device_token_is_active"): - response = client.request("DELETE", "/api/device/unregister", json=request_data) + response = client.post("/api/device/unregister", json=request_data) assert response.status_code == 200 assert response.json()["success"] == True - +@pytest.mark.usefixtures("mock_jwt_decode") def test_unregister_device_missing_token(): """Test that missing token field returns 422.""" client = TestClient(app.main.app) @@ -137,11 +173,11 @@ def test_unregister_device_missing_token(): with patch(DB_SESSION) as mock_session_scope: mock_session_scope.return_value.__aenter__.return_value - response = client.request("DELETE", "/api/device/unregister", json=request_data) + response = client.post("/api/device/unregister", json=request_data) assert response.status_code == 422 - +@pytest.mark.usefixtures("mock_jwt_decode") def test_register_device_without_user_id(): """Test that device registration without user_id is allowed (null user).""" client = TestClient(app.main.app) From 2664f47d65eb8564d404b8b81de35b4d9b6c5d81 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 5 Mar 2026 11:14:37 -0800 Subject: [PATCH 27/32] Include device id --- .../7d2194c5051e_push_notification_tokens.py | 5 ++- .../packages/wps-api/src/app/fcm/schema.py | 1 + .../packages/wps-api/src/app/routers/fcm.py | 7 ++-- .../src/app/tests/fcm/test_fcm_router.py | 41 +++++++++++++++++-- .../wps-shared/src/wps_shared/db/crud/fcm.py | 11 +---- .../src/wps_shared/db/models/fcm.py | 5 ++- .../src/wps_shared/tests/db/crud/test_fcm.py | 16 +++++--- 7 files changed, 60 insertions(+), 26 deletions(-) diff --git a/backend/packages/wps-api/alembic/versions/7d2194c5051e_push_notification_tokens.py b/backend/packages/wps-api/alembic/versions/7d2194c5051e_push_notification_tokens.py index b42a3ea85c..e64181d31e 100644 --- a/backend/packages/wps-api/alembic/versions/7d2194c5051e_push_notification_tokens.py +++ b/backend/packages/wps-api/alembic/versions/7d2194c5051e_push_notification_tokens.py @@ -5,10 +5,9 @@ Create Date: 2026-03-02 10:48:11.523814 """ -from alembic import op import sqlalchemy as sa +from alembic import op from sqlalchemy.dialects import postgresql - from wps_shared.db.models.common import TZTimeStamp # revision identifiers, used by Alembic. @@ -22,6 +21,7 @@ def upgrade(): op.create_table( "device_token", sa.Column("id", sa.Integer(), nullable=False), + sa.Column("device_id", sa.String(), nullable=False), sa.Column("user_id", sa.String(), nullable=True), sa.Column( "platform", postgresql.ENUM("android", "ios", name="platformenum"), nullable=False @@ -34,6 +34,7 @@ def upgrade(): comment="Device token management.", ) op.create_index(op.f('ix_device_token_id'), 'device_token', ['id'], unique=False) + op.create_index(op.f("ix_device_token_device_id"), "device_token", ["device_id"], unique=False) op.create_index(op.f('ix_device_token_platform'), 'device_token', ['platform'], unique=False) op.create_index(op.f('ix_device_token_token'), 'device_token', ['token'], unique=True) diff --git a/backend/packages/wps-api/src/app/fcm/schema.py b/backend/packages/wps-api/src/app/fcm/schema.py index 5cf0e03b22..cc8e013134 100644 --- a/backend/packages/wps-api/src/app/fcm/schema.py +++ b/backend/packages/wps-api/src/app/fcm/schema.py @@ -5,6 +5,7 @@ class RegisterDeviceRequest(BaseModel): user_id: Optional[str] = None + device_id: str token: str = Field(..., min_length=10) platform: str = Field(..., pattern="^(ios|android)$") diff --git a/backend/packages/wps-api/src/app/routers/fcm.py b/backend/packages/wps-api/src/app/routers/fcm.py index 455c7a085b..7e1f39fa67 100644 --- a/backend/packages/wps-api/src/app/routers/fcm.py +++ b/backend/packages/wps-api/src/app/routers/fcm.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException from wps_shared.auth import asa_authentication_required, audit_asa from wps_shared.db.crud.fcm import ( - get_device_by_token, + get_device_by_device_id, save_device_token, update_device_token_is_active, ) @@ -24,11 +24,11 @@ @router.post("/register") async def register_device(request: RegisterDeviceRequest): """ - Upsert a device token for a user. Called this at app start and whenever FCM token refreshes. + Upsert a device token for a device_id. """ logger.info("/device/register") async with get_async_write_session_scope() as session: - existing = await get_device_by_token(session, request.token) + existing = await get_device_by_device_id(session, request.device_id) if existing: existing.is_active = True existing.token = request.token @@ -37,6 +37,7 @@ async def register_device(request: RegisterDeviceRequest): else: device_token = DeviceToken( user_id=request.user_id, + device_id=request.device_id, token=request.token, platform=request.platform, is_active=True, diff --git a/backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py b/backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py index 867a09f743..c8d35077d8 100644 --- a/backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py +++ b/backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py @@ -10,15 +10,16 @@ from wps_shared.db.models.fcm import PlatformEnum DB_SESSION = "app.routers.fcm.get_async_write_session_scope" -GET_DEVICE_TOKEN = "app.routers.fcm.get_device_by_token" +GET_DEVICE_TOKEN = "app.routers.fcm.get_device_by_device_id" SAVE_DEVICE_TOKEN = "app.routers.fcm.save_device_token" API_DEVICE_REGISTER = "/api/device/register" API_DEVICE_UNREGISTER = "/api/device/unregister" MOCK_DEVICE_TOKEN = "abcdefghijklmonp" TEST_REGISTER_DEVICE_REQUEST = { "user_id": "test_idir", + "device_id": "test_device_id", "token": MOCK_DEVICE_TOKEN, - "platform": PlatformEnum.ANDROID.value, + "platform": PlatformEnum.android.value, } TEST_UNREGISTER_DEVICE_REQUEST = {"token": MOCK_DEVICE_TOKEN} @@ -54,6 +55,7 @@ def test_register_device_success(): # Test data request_data = { "user_id": "test-user-123", + "device_id": "test_device_id", "token": "test-fcm-token-456", "platform": "android", } @@ -75,7 +77,12 @@ def test_register_device_already_exists(): """Test that existing device registration updates successfully.""" client = TestClient(app.main.app) - request_data = {"user_id": "test-user-123", "token": "existing-fcm-token", "platform": "ios"} + request_data = { + "user_id": "test-user-123", + "device_id": "test_device_id", + "token": "existing-fcm-token", + "platform": "ios", + } with patch(DB_SESSION) as mock_session_scope: mock_session_scope.return_value.__aenter__.return_value @@ -83,7 +90,12 @@ def test_register_device_already_exists(): existing_device = type( "", (object,), - {"is_active": False, "token": "existing-fcm-token", "updated_at": datetime(2026, 1, 1)}, + { + "is_active": False, + "device_id": "test_device_id", + "token": "existing-fcm-token", + "updated_at": datetime(2026, 1, 1), + }, )() with ( @@ -135,6 +147,26 @@ def test_register_device_short_token(): """Test that short token returns 422.""" client = TestClient(app.main.app) + request_data = { + "user_id": "test-user-123", + "token": "short", # Less than 10 characters + "platform": "android", + "device_id": "test_device_id", + } + + with patch(DB_SESSION) as mock_session_scope: + mock_session_scope.return_value.__aenter__.return_value + + response = client.post(API_DEVICE_REGISTER, json=request_data) + + assert response.status_code == 422 + + +@pytest.mark.usefixtures("mock_jwt_decode") +def test_missing_device_id_returns_422(): + """Test that a missing device_id returns 422.""" + client = TestClient(app.main.app) + request_data = { "user_id": "test-user-123", "token": "short", # Less than 10 characters @@ -185,6 +217,7 @@ def test_register_device_without_user_id(): request_data = { "token": "test-fcm-token-789", "platform": "android", + "device_id": "test_device_id", } with patch(DB_SESSION) as mock_session_scope: diff --git a/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py b/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py index 37e4479ca9..253e8f72cd 100644 --- a/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py +++ b/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py @@ -14,15 +14,8 @@ def save_device_token(session: AsyncSession, device_token: DeviceToken): session.add(device_token) -async def get_device_by_token(session: AsyncSession, token: str): - """ - Lookup a DeviceToken by token value. - - :param session: An async database session - :param token: A token for a registered device. - :return: A DeviceToken object or None. - """ - return await session.scalar(select(DeviceToken).where(DeviceToken.token == token)) +async def get_device_by_device_id(session: AsyncSession, device_id: str): + return await session.scalar(select(DeviceToken).where(DeviceToken.device_id == device_id)) async def update_device_token_is_active(session: AsyncSession, token: str, is_active: bool) -> bool: diff --git a/backend/packages/wps-shared/src/wps_shared/db/models/fcm.py b/backend/packages/wps-shared/src/wps_shared/db/models/fcm.py index 7ddf25e26f..ba920e176d 100644 --- a/backend/packages/wps-shared/src/wps_shared/db/models/fcm.py +++ b/backend/packages/wps-shared/src/wps_shared/db/models/fcm.py @@ -8,8 +8,8 @@ class PlatformEnum(enum.Enum): - ANDROID = "android" - IOS = "ios" + android = "android" + ios = "ios" class DeviceToken(Base): @@ -18,6 +18,7 @@ class DeviceToken(Base): __tablename__ = "device_token" __table_args__ = {"comment": "Device token management."} id = Column(Integer, primary_key=True, index=True) + device_id = Column(String, index=True, nullable=False) user_id = Column(String, nullable=True) # Optional storage of IDIR for logged in users platform = Column(Enum(PlatformEnum), index=True, nullable=False) token = Column(String, unique=True, index=True, nullable=False) diff --git a/backend/packages/wps-shared/src/wps_shared/tests/db/crud/test_fcm.py b/backend/packages/wps-shared/src/wps_shared/tests/db/crud/test_fcm.py index f331e351d3..96156e58ce 100644 --- a/backend/packages/wps-shared/src/wps_shared/tests/db/crud/test_fcm.py +++ b/backend/packages/wps-shared/src/wps_shared/tests/db/crud/test_fcm.py @@ -7,7 +7,7 @@ from testcontainers.postgres import PostgresContainer from wps_shared.db.crud.fcm import ( deactivate_device_tokens, - get_device_by_token, + get_device_by_device_id, save_device_token, update_device_token_is_active, ) @@ -17,6 +17,7 @@ test_target_date = date(2025, 7, 15) test_completed_at = datetime(2025, 7, 15, 20, 45, 0, tzinfo=timezone.utc) +mock_device_id = "mock_device_id" mock_fcm_token = "abcdefghijklmnop" now = get_utc_now() @@ -38,8 +39,8 @@ async def engine(postgres_container): await conn.run_sync(DeviceToken.__table__.create) # Insert a mock device_token record await conn.execute( - text(f"""INSERT INTO device_token (user_id, platform, token, is_active, created_at, updated_at) - VALUES ('test_idir', 'ANDROID', '{mock_fcm_token}', True, '{now}', '{now}');""") + text(f"""INSERT INTO device_token (user_id, device_id, platform, token, is_active, created_at, updated_at) + VALUES ('test_idir', '{mock_device_id}', 'ANDROID', '{mock_fcm_token}', True, '{now}', '{now}');""") ) yield engine @@ -72,6 +73,7 @@ async def test_save_device_token(async_session: AsyncSession): mock_fcm_token2 = "qwertyuiopasdfg" device_token = DeviceToken( user_id="test_idir2", + device_id=mock_device_id, platform="IOS", token=mock_fcm_token2, is_active=True, @@ -89,15 +91,17 @@ async def test_save_device_token(async_session: AsyncSession): assert saved.platform == "IOS" assert saved.token == mock_fcm_token2 assert saved.is_active is True + assert saved.device_id == mock_device_id @pytest.mark.anyio -async def test_get_device_by_token(async_session: AsyncSession): +async def test_get_device_by_device_id(async_session: AsyncSession): """Test retrieving an existing device_token record by token.""" - device_token = await get_device_by_token(async_session, mock_fcm_token) + device_token = await get_device_by_device_id(async_session, mock_device_id) + assert device_token.device_id == mock_device_id assert device_token.user_id == "test_idir" - assert device_token.platform == PlatformEnum.ANDROID + assert device_token.platform == PlatformEnum.android assert device_token.token == mock_fcm_token assert device_token.is_active is True From 2fc8548daa4b8d36de62a6a0bd3fe31bc03173dc Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 5 Mar 2026 11:31:14 -0800 Subject: [PATCH 28/32] Test fixes --- .../wps-shared/src/wps_shared/tests/db/crud/test_fcm.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/packages/wps-shared/src/wps_shared/tests/db/crud/test_fcm.py b/backend/packages/wps-shared/src/wps_shared/tests/db/crud/test_fcm.py index 96156e58ce..b68c50b0fe 100644 --- a/backend/packages/wps-shared/src/wps_shared/tests/db/crud/test_fcm.py +++ b/backend/packages/wps-shared/src/wps_shared/tests/db/crud/test_fcm.py @@ -40,7 +40,7 @@ async def engine(postgres_container): # Insert a mock device_token record await conn.execute( text(f"""INSERT INTO device_token (user_id, device_id, platform, token, is_active, created_at, updated_at) - VALUES ('test_idir', '{mock_device_id}', 'ANDROID', '{mock_fcm_token}', True, '{now}', '{now}');""") + VALUES ('test_idir', '{mock_device_id}', '{PlatformEnum.android.value}', '{mock_fcm_token}', True, '{now}', '{now}');""") ) yield engine @@ -74,7 +74,7 @@ async def test_save_device_token(async_session: AsyncSession): device_token = DeviceToken( user_id="test_idir2", device_id=mock_device_id, - platform="IOS", + platform=PlatformEnum.ios.value, token=mock_fcm_token2, is_active=True, created_at=now, @@ -88,7 +88,7 @@ async def test_save_device_token(async_session: AsyncSession): ) saved = result.scalar_one() assert saved.user_id == "test_idir2" - assert saved.platform == "IOS" + assert saved.platform == PlatformEnum.ios.value assert saved.token == mock_fcm_token2 assert saved.is_active is True assert saved.device_id == mock_device_id From aec472024c94b0065d2ef279ca98cd4441e6db91 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 5 Mar 2026 11:50:31 -0800 Subject: [PATCH 29/32] Code smell --- backend/packages/wps-api/src/app/routers/fcm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/packages/wps-api/src/app/routers/fcm.py b/backend/packages/wps-api/src/app/routers/fcm.py index 7e1f39fa67..00b064ef67 100644 --- a/backend/packages/wps-api/src/app/routers/fcm.py +++ b/backend/packages/wps-api/src/app/routers/fcm.py @@ -47,7 +47,7 @@ async def register_device(request: RegisterDeviceRequest): return DeviceRequestResponse(success=True) -@router.post("/unregister") +@router.post("/unregister", responses={404: {"description": "Token not found."}}) async def unregister_device(request: UnregisterDeviceRequest): """ Mark a token inactive (e.g., user logged out or uninstalled). From fcb8a26238141b50f5bb3a1c282770e5ce8da23e Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 5 Mar 2026 13:16:52 -0800 Subject: [PATCH 30/32] Frontend device id --- .../asa-go/android/app/capacitor.build.gradle | 1 + mobile/asa-go/android/capacitor.settings.gradle | 3 +++ mobile/asa-go/ios/App/Podfile | 1 + mobile/asa-go/ios/App/Podfile.lock | 12 +++++++++--- mobile/asa-go/package.json | 1 + mobile/asa-go/src/App.tsx | 17 +++++++++++++++-- mobile/asa-go/src/api/pushNotificationsAPI.ts | 4 +++- mobile/asa-go/yarn.lock | 5 +++++ 8 files changed, 38 insertions(+), 6 deletions(-) diff --git a/mobile/asa-go/android/app/capacitor.build.gradle b/mobile/asa-go/android/app/capacitor.build.gradle index 7692f6ce80..fdfd35b6cb 100644 --- a/mobile/asa-go/android/app/capacitor.build.gradle +++ b/mobile/asa-go/android/app/capacitor.build.gradle @@ -11,6 +11,7 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { implementation project(':capacitor-firebase-messaging') implementation project(':capacitor-app') + implementation project(':capacitor-device') implementation project(':capacitor-filesystem') implementation project(':capacitor-geolocation') implementation project(':capacitor-haptics') diff --git a/mobile/asa-go/android/capacitor.settings.gradle b/mobile/asa-go/android/capacitor.settings.gradle index 4077eaf949..6c476df749 100644 --- a/mobile/asa-go/android/capacitor.settings.gradle +++ b/mobile/asa-go/android/capacitor.settings.gradle @@ -8,6 +8,9 @@ project(':capacitor-firebase-messaging').projectDir = new File('../node_modules/ include ':capacitor-app' project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') +include ':capacitor-device' +project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/device/android') + include ':capacitor-filesystem' project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android') diff --git a/mobile/asa-go/ios/App/Podfile b/mobile/asa-go/ios/App/Podfile index f53bcf1413..e9e386b1a2 100644 --- a/mobile/asa-go/ios/App/Podfile +++ b/mobile/asa-go/ios/App/Podfile @@ -13,6 +13,7 @@ def capacitor_pods pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorFirebaseMessaging', :path => '../../node_modules/@capacitor-firebase/messaging' pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' + pod 'CapacitorDevice', :path => '../../node_modules/@capacitor/device' pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem' pod 'CapacitorGeolocation', :path => '../../node_modules/@capacitor/geolocation' pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics' diff --git a/mobile/asa-go/ios/App/Podfile.lock b/mobile/asa-go/ios/App/Podfile.lock index 8983a84b87..a74c18f638 100644 --- a/mobile/asa-go/ios/App/Podfile.lock +++ b/mobile/asa-go/ios/App/Podfile.lock @@ -10,6 +10,8 @@ PODS: - CapacitorApp (7.0.0): - Capacitor - CapacitorCordova (7.1.0) + - CapacitorDevice (8.0.1): + - Capacitor - CapacitorEmailComposer (7.0.0): - Capacitor - CapacitorFilesystem (7.0.0): @@ -81,7 +83,7 @@ PODS: - GoogleUtilities/UserDefaults (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - IONGeolocationLib (1.0.0) + - IONGeolocationLib (1.0.2) - Keycloak (0.0.1): - AppAuth - Capacitor @@ -96,6 +98,7 @@ DEPENDENCIES: - "Capacitor (from `../../node_modules/@capacitor/ios`)" - "CapacitorApp (from `../../node_modules/@capacitor/app`)" - "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" + - "CapacitorDevice (from `../../node_modules/@capacitor/device`)" - CapacitorEmailComposer (from `../../node_modules/capacitor-email-composer`) - "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)" - "CapacitorFirebaseMessaging (from `../../node_modules/@capacitor-firebase/messaging`)" @@ -130,6 +133,8 @@ EXTERNAL SOURCES: :path: "../../node_modules/@capacitor/app" CapacitorCordova: :path: "../../node_modules/@capacitor/ios" + CapacitorDevice: + :path: "../../node_modules/@capacitor/device" CapacitorEmailComposer: :path: "../../node_modules/capacitor-email-composer" CapacitorFilesystem: @@ -160,6 +165,7 @@ SPEC CHECKSUMS: Capacitor: 68ff8eabbcce387e69767c13b5fbcc1c5399eabc CapacitorApp: 45cb7cbef4aa380b9236fd6980033eb5cde6fcd2 CapacitorCordova: 866217f32c1d25b326c568a10ea3ed0c36b13e29 + CapacitorDevice: ebd210e29ae690225291b8616e6cfaf419d2e866 CapacitorEmailComposer: 5f0ae4bd516997aba61034ba4387f2a67947ce21 CapacitorFilesystem: 2881ad012b5c8d0ffbfed1216aadfc2cca16d5b5 CapacitorFirebaseMessaging: 798b7f8af4318b97b11eb9b84c8ebe9920237c17 @@ -177,11 +183,11 @@ SPEC CHECKSUMS: FirebaseMessaging: b5f7bdc62b91b6102015991fb7bc6fa75f643908 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 - IONGeolocationLib: 81f33f88d025846946de2cf63b0c7628e7c6bc9d + IONGeolocationLib: adbb2a739b0ccda686d4a66f9bc05f8c4fd3d3c0 Keycloak: c39abe3ec71f672fbf306a7f37b85d3858ae7f00 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 -PODFILE CHECKSUM: f4aed5b4c66e22e15f5151a4c65a3d82525631d2 +PODFILE CHECKSUM: 628b81ca6723b5813c3d45a730d7dc8d60765b92 COCOAPODS: 1.16.2 diff --git a/mobile/asa-go/package.json b/mobile/asa-go/package.json index 646166ad7f..6a118ffe48 100644 --- a/mobile/asa-go/package.json +++ b/mobile/asa-go/package.json @@ -20,6 +20,7 @@ "@capacitor/android": "7.1.0", "@capacitor/app": "7.0.0", "@capacitor/core": "7.1.0", + "@capacitor/device": "^8.0.1", "@capacitor/filesystem": "^7.0.0", "@capacitor/geolocation": "^7.1.2", "@capacitor/haptics": "7.0.0", diff --git a/mobile/asa-go/src/App.tsx b/mobile/asa-go/src/App.tsx index cc6c73bdc0..41919bfbfc 100644 --- a/mobile/asa-go/src/App.tsx +++ b/mobile/asa-go/src/App.tsx @@ -41,6 +41,7 @@ import { useDispatch, useSelector } from "react-redux"; import { usePushNotifications } from "@/hooks/usePushNotifications"; import { Capacitor } from "@capacitor/core"; import { Platform, registerToken } from "@/api/pushNotificationsAPI"; +import { Device } from "@capacitor/device"; const App = () => { LicenseInfo.setLicenseKey(import.meta.env.VITE_MUI_LICENSE_KEY); @@ -111,8 +112,20 @@ const App = () => { }, [initPushNotifications, isAuthenticated]); useEffect(() => { - if (token) { - registerToken(Capacitor.getPlatform() as Platform, token, idir || ""); + async function handleTokenChange(t: string) { + const deviceId = await Device.getId(); + if (!isNil(deviceId?.identifier)) { + console.log(`DEBUG: Calling registerToken from App.tsx.`); + registerToken( + Capacitor.getPlatform() as Platform, + t, + deviceId.identifier, + idir || null, + ); + } + } + if (!isNil(token)) { + handleTokenChange(token); } }, [token, idir]); diff --git a/mobile/asa-go/src/api/pushNotificationsAPI.ts b/mobile/asa-go/src/api/pushNotificationsAPI.ts index d8593b787f..ff47c09266 100644 --- a/mobile/asa-go/src/api/pushNotificationsAPI.ts +++ b/mobile/asa-go/src/api/pushNotificationsAPI.ts @@ -9,12 +9,14 @@ interface DeviceRequestResponse { export async function registerToken( platform: Platform, token: string, + deviceId: string, userId: string | null, ): Promise { const url = "device/register"; const { data } = await axios.post(url, { platform, token, + device_id: deviceId, user_id: userId, }); return data; @@ -24,6 +26,6 @@ export async function unregisterToken( token: string, ): Promise { const url = "device/unregister"; - const { data } = await axios.delete(url, { data: { token } }); + const { data } = await axios.post(url, { data: { token } }); return data; } diff --git a/mobile/asa-go/yarn.lock b/mobile/asa-go/yarn.lock index bb52c79189..20b941967e 100644 --- a/mobile/asa-go/yarn.lock +++ b/mobile/asa-go/yarn.lock @@ -297,6 +297,11 @@ dependencies: tslib "^2.1.0" +"@capacitor/device@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@capacitor/device/-/device-8.0.1.tgz#344ab012bc1c858e6a9afe302ebe22a672827fdf" + integrity sha512-LHtf3ObK7dpFS3jJ0GNrTG0lHufNgD0tLBukeEAxeR28mu80dHQA1XxmQV0CWo+KKcI0hBdz4dfucRta3TdVxw== + "@capacitor/filesystem@^7.0.0": version "7.0.0" resolved "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-7.0.0.tgz" From 9b30978cc735ae2d3d1a5173d30c674da7e790be Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Thu, 5 Mar 2026 15:32:40 -0800 Subject: [PATCH 31/32] feedback --- mobile/asa-go/src/App.tsx | 7 ++++--- mobile/asa-go/src/api/pushNotificationsAPI.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/mobile/asa-go/src/App.tsx b/mobile/asa-go/src/App.tsx index 41919bfbfc..2768436e9d 100644 --- a/mobile/asa-go/src/App.tsx +++ b/mobile/asa-go/src/App.tsx @@ -114,14 +114,15 @@ const App = () => { useEffect(() => { async function handleTokenChange(t: string) { const deviceId = await Device.getId(); - if (!isNil(deviceId?.identifier)) { - console.log(`DEBUG: Calling registerToken from App.tsx.`); + try { registerToken( Capacitor.getPlatform() as Platform, t, - deviceId.identifier, + deviceId?.identifier, idir || null, ); + } catch (e) { + console.error("Failed to register push token", e); } } if (!isNil(token)) { diff --git a/mobile/asa-go/src/api/pushNotificationsAPI.ts b/mobile/asa-go/src/api/pushNotificationsAPI.ts index ff47c09266..9f583ac668 100644 --- a/mobile/asa-go/src/api/pushNotificationsAPI.ts +++ b/mobile/asa-go/src/api/pushNotificationsAPI.ts @@ -26,6 +26,6 @@ export async function unregisterToken( token: string, ): Promise { const url = "device/unregister"; - const { data } = await axios.post(url, { data: { token } }); + const { data } = await axios.post(url, { token }); return data; } From 08a1c4b3140174a338d87be08a6ffe932c758fb7 Mon Sep 17 00:00:00 2001 From: Darren Boss Date: Mon, 9 Mar 2026 08:30:33 -0700 Subject: [PATCH 32/32] Feedback --- .../alembic/versions/7d2194c5051e_push_notification_tokens.py | 1 + backend/packages/wps-api/src/app/routers/fcm.py | 1 + mobile/asa-go/src/App.tsx | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/packages/wps-api/alembic/versions/7d2194c5051e_push_notification_tokens.py b/backend/packages/wps-api/alembic/versions/7d2194c5051e_push_notification_tokens.py index e64181d31e..e04c94063d 100644 --- a/backend/packages/wps-api/alembic/versions/7d2194c5051e_push_notification_tokens.py +++ b/backend/packages/wps-api/alembic/versions/7d2194c5051e_push_notification_tokens.py @@ -44,3 +44,4 @@ def downgrade(): op.drop_index(op.f('ix_device_token_platform'), table_name='device_token') op.drop_index(op.f('ix_device_token_id'), table_name='device_token') op.drop_table('device_token') + op.execute("DROP TYPE platformenum;") diff --git a/backend/packages/wps-api/src/app/routers/fcm.py b/backend/packages/wps-api/src/app/routers/fcm.py index 00b064ef67..3fe6c92679 100644 --- a/backend/packages/wps-api/src/app/routers/fcm.py +++ b/backend/packages/wps-api/src/app/routers/fcm.py @@ -33,6 +33,7 @@ async def register_device(request: RegisterDeviceRequest): existing.is_active = True existing.token = request.token existing.updated_at = get_utc_now() + existing.user_id = request.user_id logger.info(f"Updated existing DeviceInfo record for token: {request.token}") else: device_token = DeviceToken( diff --git a/mobile/asa-go/src/App.tsx b/mobile/asa-go/src/App.tsx index 2768436e9d..63582e82a0 100644 --- a/mobile/asa-go/src/App.tsx +++ b/mobile/asa-go/src/App.tsx @@ -115,7 +115,7 @@ const App = () => { async function handleTokenChange(t: string) { const deviceId = await Device.getId(); try { - registerToken( + await registerToken( Capacitor.getPlatform() as Platform, t, deviceId?.identifier,