From 2159e6cda7e5dc90cb2813bcfc660e87f4de64b6 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Thu, 23 Jul 2026 00:59:42 +0300 Subject: [PATCH] feat: complete notifications and request lifecycle --- .env.example | 22 + README.md | 18 +- android/Dockerfile | 24 + android/README.md | 19 + android/app/build.gradle.kts | 49 ++ android/app/proguard-rules.pro | 4 +- android/app/src/main/AndroidManifest.xml | 17 + .../org/whoneedhelp/mobile/MainActivity.java | 130 ++- .../org/whoneedhelp/mobile/PushRoute.java | 30 + .../whoneedhelp/mobile/PushTokenStore.java | 93 +++ .../mobile/WhoNeedHelpApplication.java | 32 + .../mobile/WhoNeedHelpMessagingService.java | 93 +++ android/app/src/main/res/values/strings.xml | 2 + .../org/whoneedhelp/mobile/PushRouteTest.java | 23 + assets/css/app.css | 9 + assets/js/hooks.js | 203 ++++- compose.yaml | 6 + config/config.exs | 8 +- config/runtime.exs | 99 +++ docs/architecture.md | 18 +- docs/support-and-content-removal.md | 24 +- docs/verification.md | 78 +- e2e/tests/activity-moderation.spec.ts | 23 + e2e/tests/mutual-aid.spec.ts | 52 +- e2e/tests/notifications-data.spec.ts | 126 +++ e2e/tests/request-discovery.spec.ts | 1 + e2e/tests/zz-resilience.spec.ts | 108 ++- lib/who_need_help/accounts/data_export.ex | 454 +++++++++++ lib/who_need_help/accounts/data_lifecycle.ex | 116 +++ lib/who_need_help/accounts/user.ex | 4 + lib/who_need_help/application.ex | 13 +- lib/who_need_help/help.ex | 211 ++++- lib/who_need_help/help/assignment.ex | 22 +- lib/who_need_help/help/help_request.ex | 20 +- lib/who_need_help/notifications.ex | 505 ++++++++++++ .../notifications/email_notifier.ex | 40 + .../notifications/nearby_subscription.ex | 123 +++ .../notifications/notification.ex | 49 ++ lib/who_need_help/notifications/preference.ex | 68 ++ .../notifications/push_device.ex | 91 +++ lib/who_need_help/product_analytics.ex | 88 +++ .../product_analytics/daily_metric.ex | 24 + lib/who_need_help/push.ex | 73 +- lib/who_need_help/push/device_adapter.ex | 8 + .../push/device_delivery_worker.ex | 64 ++ lib/who_need_help/push/fcm_adapter.ex | 111 +++ lib/who_need_help/push/nearby_match_worker.ex | 43 + .../push/notification_dispatch_worker.ex | 102 +++ .../push/notification_email_worker.ex | 38 + lib/who_need_help/push/web_push_adapter.ex | 64 ++ lib/who_need_help/support.ex | 12 + lib/who_need_help_web/components/layouts.ex | 13 + .../controllers/google_auth_controller.ex | 55 +- .../mobile_push_device_controller.ex | 43 + .../user_data_export_controller.ex | 20 + .../user_registration_controller.ex | 3 +- .../user_registration_html/new.html.heex | 28 +- .../user_session_html/new.html.heex | 14 +- .../user_settings_html/edit.html.heex | 3 + .../live/leaderboard_live.ex | 4 +- lib/who_need_help_web/live/moderation_live.ex | 12 +- .../live/notification_live.ex | 737 ++++++++++++++++++ .../live/product_analytics_live.ex | 91 +++ lib/who_need_help_web/live/profile_live.ex | 7 +- .../live/public_profile_live.ex | 7 +- .../live/request_live/show.ex | 273 ++++++- .../live/support_operations_live.ex | 50 +- lib/who_need_help_web/router.ex | 4 + mix.exs | 2 + mix.lock | 3 + ...dd_notifications_and_request_lifecycle.exs | 133 ++++ priv/static/sw.js | 48 +- scripts/android-build.sh | 4 + scripts/android-release-build.sh | 4 + scripts/android-staging-build.sh | 4 + test/who_need_help/mutual_aid_flow_test.exs | 101 +++ test/who_need_help/notifications_test.exs | 362 +++++++++ test/who_need_help/product_analytics_test.exs | 34 + test/who_need_help/push_product_test.exs | 157 +++- .../support_and_content_removal_test.exs | 39 +- .../google_auth_controller_test.exs | 16 +- .../mobile_push_device_controller_test.exs | 55 ++ .../controllers/page_controller_test.exs | 2 +- .../user_data_export_controller_test.exs | 78 ++ .../live/mutual_aid_live_test.exs | 85 +- 85 files changed, 5898 insertions(+), 247 deletions(-) create mode 100644 android/app/src/main/java/org/whoneedhelp/mobile/PushRoute.java create mode 100644 android/app/src/main/java/org/whoneedhelp/mobile/PushTokenStore.java create mode 100644 android/app/src/main/java/org/whoneedhelp/mobile/WhoNeedHelpApplication.java create mode 100644 android/app/src/main/java/org/whoneedhelp/mobile/WhoNeedHelpMessagingService.java create mode 100644 android/app/src/test/java/org/whoneedhelp/mobile/PushRouteTest.java create mode 100644 e2e/tests/notifications-data.spec.ts create mode 100644 lib/who_need_help/accounts/data_export.ex create mode 100644 lib/who_need_help/accounts/data_lifecycle.ex create mode 100644 lib/who_need_help/notifications.ex create mode 100644 lib/who_need_help/notifications/email_notifier.ex create mode 100644 lib/who_need_help/notifications/nearby_subscription.ex create mode 100644 lib/who_need_help/notifications/notification.ex create mode 100644 lib/who_need_help/notifications/preference.ex create mode 100644 lib/who_need_help/notifications/push_device.ex create mode 100644 lib/who_need_help/product_analytics.ex create mode 100644 lib/who_need_help/product_analytics/daily_metric.ex create mode 100644 lib/who_need_help/push/device_adapter.ex create mode 100644 lib/who_need_help/push/device_delivery_worker.ex create mode 100644 lib/who_need_help/push/fcm_adapter.ex create mode 100644 lib/who_need_help/push/nearby_match_worker.ex create mode 100644 lib/who_need_help/push/notification_dispatch_worker.ex create mode 100644 lib/who_need_help/push/notification_email_worker.ex create mode 100644 lib/who_need_help/push/web_push_adapter.ex create mode 100644 lib/who_need_help_web/controllers/mobile_push_device_controller.ex create mode 100644 lib/who_need_help_web/controllers/user_data_export_controller.ex create mode 100644 lib/who_need_help_web/live/notification_live.ex create mode 100644 lib/who_need_help_web/live/product_analytics_live.ex create mode 100644 priv/repo/migrations/20260722190604_add_notifications_and_request_lifecycle.exs create mode 100644 test/who_need_help/notifications_test.exs create mode 100644 test/who_need_help/product_analytics_test.exs create mode 100644 test/who_need_help_web/controllers/mobile_push_device_controller_test.exs create mode 100644 test/who_need_help_web/controllers/user_data_export_controller_test.exs diff --git a/.env.example b/.env.example index ed29b85..6d9690b 100644 --- a/.env.example +++ b/.env.example @@ -79,6 +79,13 @@ WNH_TRACKING_MIN_TIME_MS=5000 WNH_TRACKING_HTTP_TIMEOUT_MS=15000 WNH_ANDROID_VERSION_CODE=1 WNH_ANDROID_VERSION_NAME=0.1.0 +# Public Firebase Android client configuration. These values are embedded in +# the APK and are not service-account credentials. Set all four per environment +# to enable native FCM registration, or leave all four empty to disable it. +WNH_FIREBASE_APPLICATION_ID= +WNH_FIREBASE_API_KEY= +WNH_FIREBASE_PROJECT_ID= +WNH_FIREBASE_GCM_SENDER_ID= # Public identifier of the locally held Google Play upload key. The private # keystore and its randomized password live outside the repository under # ~/.config/who_need_help/android-release/. @@ -124,6 +131,21 @@ PUSH_HTTP_RECEIVE_TIMEOUT_MS= PUSH_HTTP_CONNECT_TIMEOUT_MS= PUSH_HTTP_RETRY_DELAY_MS= +# Direct browser Web Push. Generate one VAPID key pair per environment and +# keep the private key only in that environment's .env. The subject must be a +# mailto: or HTTPS contact owned by the operator. +WEB_PUSH_VAPID_PUBLIC_KEY= +WEB_PUSH_VAPID_PRIVATE_KEY= +WEB_PUSH_VAPID_SUBJECT= + +# Native Android push through Firebase Cloud Messaging. Either mount the +# service-account JSON read-only and set its absolute in-container path, or put +# standard Base64 of that JSON in the single environment file. Never set both. +# Leave all three values empty to disable FCM. +FCM_PROJECT_ID= +FCM_SERVICE_ACCOUNT_FILE= +FCM_SERVICE_ACCOUNT_JSON_BASE64= + POSTGRES_DB=who_need_help POSTGRES_USER=postgres POSTGRES_PASSWORD=replace-with-a-local-or-deployment-secret diff --git a/README.md b/README.md index 3ba8b96..d12bc58 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,9 @@ local Codex CLI authenticated with their ChatGPT subscription. exact coordinates visible only to approved participants. Activities never affect urgent-helper reputation; Activity and Activity-message reports expose only the linked evidence to audited moderators. -- Request lifecycle: `open → matched → in_progress → completed`, plus cancel - and expiry paths. +- Request lifecycle: `open → matched → in_progress → completed`, with explicit + start, arrival, handover, both-party confirmation, requester cancellation, + helper withdrawal/reopening, replacement-helper, and expiry paths. - PostgreSQL/PostGIS locations, viewport-scoped request discovery, server-side map clustering, MapLibre map, private matched chat, Phoenix PubSub/Presence, and optional consent-driven live location sharing. The browser loads only @@ -54,12 +55,22 @@ local Codex CLI authenticated with their ChatGPT subscription. - Double-blind reviews, public trust summaries, and a helper leaderboard that prioritizes unique location-supported and handover-verified counterparts before raw totals. +- A private notification inbox, category/radius/urgency/availability-based + nearby-help subscriptions, quiet hours, per-channel preferences, browser Web + Push registrations, email alerts, and Android FCM device registrations. + Remote payloads contain navigation metadata and generic text, never chat + bodies or exact coordinates; Oban retries transient delivery failures and + disables rejected device registrations. - Bidirectional discovery blocks, scoped reports, account/request/category moderation, abuse-signal review, and audited moderator access to only the conversation linked by a report. - Separate public support and content-removal intake, including moderation appeals, account deletion/data requests, a URL-only TAKE IT DOWN form, verified-contact status links, operator alerts, and audited staff queues. + Authenticated users can download an allow-listed JSON data export that omits + password/session/push credentials and counterpart message bodies. A + moderator-only deletion preflight reports active workflows without performing + an unapproved destructive action. - Optional GPS evidence derived from browser accuracy envelopes. Raw current positions are deleted on stop, terminal match state, or participant block. - PostgreSQL-backed cross-replica action-limit policies configured by the @@ -69,7 +80,8 @@ local Codex CLI authenticated with their ChatGPT subscription. tokens are not stored. - EN/UK/RU UI foundation and installable PWA metadata/service worker. - Native Android WebView client with the same authenticated LiveView, map, - private chat, and a user-started location foreground service. Its persistent + private chat, consent-based FCM registration/deep links, and a user-started + location foreground service. Its persistent notification exposes Stop, it continues while the Activity is minimized, and it retains only the current point. Reproducible Docker targets export distinct local and public-staging debug APKs; production signing and store diff --git a/android/Dockerfile b/android/Dockerfile index d4fe494..ce025fd 100644 --- a/android/Dockerfile +++ b/android/Dockerfile @@ -45,6 +45,10 @@ ARG WNH_TRACKING_MIN_TIME_MS ARG WNH_TRACKING_HTTP_TIMEOUT_MS ARG WNH_ANDROID_VERSION_CODE=1 ARG WNH_ANDROID_VERSION_NAME=0.1.0 +ARG WNH_FIREBASE_APPLICATION_ID +ARG WNH_FIREBASE_CLIENT_VALUE +ARG WNH_FIREBASE_PROJECT_ID +ARG WNH_FIREBASE_GCM_SENDER_ID RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \ --mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \ @@ -54,6 +58,10 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \ "-PWNH_TRACKING_HTTP_TIMEOUT_MS=${WNH_TRACKING_HTTP_TIMEOUT_MS}" \ "-PWNH_ANDROID_VERSION_CODE=${WNH_ANDROID_VERSION_CODE}" \ "-PWNH_ANDROID_VERSION_NAME=${WNH_ANDROID_VERSION_NAME}" \ + "-PWNH_FIREBASE_APPLICATION_ID=${WNH_FIREBASE_APPLICATION_ID}" \ + "-PWNH_FIREBASE_API_KEY=${WNH_FIREBASE_CLIENT_VALUE}" \ + "-PWNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID}" \ + "-PWNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID}" \ testDebugUnitTest lintDebug assembleDebug assembleDebugAndroidTest FROM android-base AS emulator @@ -150,6 +158,10 @@ ARG WNH_TRACKING_MIN_TIME_MS ARG WNH_TRACKING_HTTP_TIMEOUT_MS ARG WNH_ANDROID_VERSION_CODE=1 ARG WNH_ANDROID_VERSION_NAME=0.1.0 +ARG WNH_FIREBASE_APPLICATION_ID +ARG WNH_FIREBASE_CLIENT_VALUE +ARG WNH_FIREBASE_PROJECT_ID +ARG WNH_FIREBASE_GCM_SENDER_ID RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \ --mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \ @@ -160,6 +172,10 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \ "-PWNH_TRACKING_HTTP_TIMEOUT_MS=${WNH_TRACKING_HTTP_TIMEOUT_MS}" \ "-PWNH_ANDROID_VERSION_CODE=${WNH_ANDROID_VERSION_CODE}" \ "-PWNH_ANDROID_VERSION_NAME=${WNH_ANDROID_VERSION_NAME}" \ + "-PWNH_FIREBASE_APPLICATION_ID=${WNH_FIREBASE_APPLICATION_ID}" \ + "-PWNH_FIREBASE_API_KEY=${WNH_FIREBASE_CLIENT_VALUE}" \ + "-PWNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID}" \ + "-PWNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID}" \ "-PWNH_TEST_BUILD_TYPE=staging" \ testStagingUnitTest lintStaging assembleStaging assembleStagingAndroidTest @@ -205,6 +221,10 @@ ARG WNH_TRACKING_MIN_TIME_MS ARG WNH_TRACKING_HTTP_TIMEOUT_MS ARG WNH_ANDROID_VERSION_CODE ARG WNH_ANDROID_VERSION_NAME +ARG WNH_FIREBASE_APPLICATION_ID +ARG WNH_FIREBASE_CLIENT_VALUE +ARG WNH_FIREBASE_PROJECT_ID +ARG WNH_FIREBASE_GCM_SENDER_ID RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \ --mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \ @@ -219,6 +239,10 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \ "-PWNH_TRACKING_HTTP_TIMEOUT_MS=${WNH_TRACKING_HTTP_TIMEOUT_MS}" \ "-PWNH_ANDROID_VERSION_CODE=${WNH_ANDROID_VERSION_CODE}" \ "-PWNH_ANDROID_VERSION_NAME=${WNH_ANDROID_VERSION_NAME}" \ + "-PWNH_FIREBASE_APPLICATION_ID=${WNH_FIREBASE_APPLICATION_ID}" \ + "-PWNH_FIREBASE_API_KEY=${WNH_FIREBASE_CLIENT_VALUE}" \ + "-PWNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID}" \ + "-PWNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID}" \ testReleaseUnitTest lintRelease assembleRelease bundleRelease \ && "${ANDROID_HOME}/build-tools/37.0.0/apksigner" \ verify --verbose --print-certs \ diff --git a/android/README.md b/android/README.md index 139c626..24ae4c1 100644 --- a/android/README.md +++ b/android/README.md @@ -11,6 +11,15 @@ The native tracking bridge uses `WebViewCompat.addWebMessageListener` with the exact configured origin and rejects messages outside the main frame. It does not expose a legacy `addJavascriptInterface` object to every frame. +Remote notifications are opt-in. The Android bridge requests the Android 13+ +notification permission, enables Firebase Messaging only after consent, and +registers the Firebase Installation ID through the authenticated same-origin +`/mobile/push-devices` endpoint. Data-only FCM messages are rendered by the app +and may deep-link only to a validated relative path on the configured Who Need +Help origin. Notification payloads do not contain chat text or exact location. +Disabling the current device removes its server registration and unregisters +the Firebase Installation; registration can be enabled again explicitly. + ## Verified build configuration - Android Gradle Plugin 9.3.0 @@ -55,8 +64,18 @@ WNH_BASE_URL=https://your-final-origin.example WNH_ANDROID_VERSION_CODE=1 WNH_ANDROID_VERSION_NAME=0.1.0 WNH_ANDROID_SIGNING_KEY_ALIAS=who-need-help-upload +WNH_FIREBASE_APPLICATION_ID=1:123456789:android:example +WNH_FIREBASE_API_KEY=the-public-firebase-android-client-key +WNH_FIREBASE_PROJECT_ID=your-firebase-project +WNH_FIREBASE_GCM_SENDER_ID=123456789 ``` +The four Firebase Android client values are public application configuration, +not the server credential. They must be either all present or all empty. Server +delivery separately requires `FCM_PROJECT_ID` and exactly one service-account +source in the Phoenix environment; never put that private JSON in the Android +build. + ```sh WNH_ENV_FILE=.env.production ./scripts/android-release-build.sh ``` diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2046dc1..f138649 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,6 +16,10 @@ val instrumentationBuildType = providers.gradleProperty("WNH_TEST_BUILD_TYPE").orElse("debug") val androidVersionCode = providers.gradleProperty("WNH_ANDROID_VERSION_CODE").orElse("1") val androidVersionName = providers.gradleProperty("WNH_ANDROID_VERSION_NAME").orElse("0.1.0") +val firebaseApplicationId = providers.gradleProperty("WNH_FIREBASE_APPLICATION_ID").orElse("") +val firebaseApiKey = providers.gradleProperty("WNH_FIREBASE_API_KEY").orElse("") +val firebaseProjectId = providers.gradleProperty("WNH_FIREBASE_PROJECT_ID").orElse("") +val firebaseSenderId = providers.gradleProperty("WNH_FIREBASE_GCM_SENDER_ID").orElse("") val releaseSigningStoreFile = providers.environmentVariable("WNH_ANDROID_SIGNING_STORE_FILE").orNull val releaseSigningPasswordFile = @@ -25,6 +29,29 @@ val releaseSigningKeyAlias = fun nonBlank(value: String?): String? = value?.trim()?.takeIf(String::isNotEmpty) +fun quotedBuildConfig(value: String): String = + "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" + +val firebaseInputs = + listOf( + firebaseApplicationId.get(), + firebaseApiKey.get(), + firebaseProjectId.get(), + firebaseSenderId.get() + ) +val firebaseConfigured = firebaseInputs.all { it.isNotBlank() } +val firebasePartiallyConfigured = firebaseInputs.any { it.isNotBlank() } + +fun validateFirebaseConfiguration() { + if (firebasePartiallyConfigured && !firebaseConfigured) { + throw GradleException( + "WNH_FIREBASE_APPLICATION_ID, WNH_FIREBASE_API_KEY, " + + "WNH_FIREBASE_PROJECT_ID, and WNH_FIREBASE_GCM_SENDER_ID " + + "must either all be set or all be empty" + ) + } +} + val releaseSigningInputs = listOf( nonBlank(releaseSigningStoreFile), @@ -87,6 +114,23 @@ android { "TRACKING_HTTP_TIMEOUT_MS", "${trackingHttpTimeoutMs.get()}L" ) + buildConfigField("boolean", "FIREBASE_CONFIGURED", firebaseConfigured.toString()) + buildConfigField( + "String", + "FIREBASE_APPLICATION_ID", + quotedBuildConfig(firebaseApplicationId.get()) + ) + buildConfigField("String", "FIREBASE_API_KEY", quotedBuildConfig(firebaseApiKey.get())) + buildConfigField( + "String", + "FIREBASE_PROJECT_ID", + quotedBuildConfig(firebaseProjectId.get()) + ) + buildConfigField( + "String", + "FIREBASE_GCM_SENDER_ID", + quotedBuildConfig(firebaseSenderId.get()) + ) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -176,6 +220,7 @@ android { tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }.configureEach { doFirst { + validateFirebaseConfiguration() if (name == "preReleaseBuild" && !releaseSigningConfigured) { val detail = if (releaseSigningPartiallyConfigured) { @@ -214,6 +259,7 @@ tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }. tasks.matching { it.name == "preDebugBuild" }.configureEach { doFirst { + validateFirebaseConfiguration() val value = debugBaseUrl.orNull.orEmpty() val uri = runCatching { URI(value) }.getOrNull() @@ -258,7 +304,10 @@ tasks.withType().configureEach { dependencies { implementation("androidx.activity:activity:1.13.0") + implementation("androidx.fragment:fragment:1.8.9") implementation("androidx.webkit:webkit:1.16.0") + implementation(platform("com.google.firebase:firebase-bom:34.16.0")) + implementation("com.google.firebase:firebase-messaging") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test:core:1.7.0") androidTestImplementation("androidx.test:runner:1.7.0") diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index eb059fe..362db94 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -1 +1,3 @@ -# The app uses only Android framework APIs. Keep rules are intentionally empty. +# Firebase Messaging is consumed through a manifest-declared service. The +# Firebase libraries provide their own consumer rules; keep only our service. +-keep class org.whoneedhelp.mobile.WhoNeedHelpMessagingService { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 07aeac4..54de1d6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + + + + + + + + diff --git a/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java b/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java index f991133..b34c4e2 100644 --- a/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java +++ b/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java @@ -33,6 +33,8 @@ import androidx.webkit.WebMessageCompat; import androidx.webkit.WebViewCompat; import androidx.webkit.WebViewFeature; +import com.google.firebase.messaging.FirebaseMessaging; + import java.util.ArrayList; import java.util.Collections; import java.util.UUID; @@ -48,6 +50,7 @@ public final class MainActivity extends ComponentActivity { private String pendingLocationOrigin; private ActivityResultLauncher locationPermissionLauncher; private ActivityResultLauncher nativeTrackingPermissionLauncher; + private ActivityResultLauncher pushNotificationPermissionLauncher; private PendingNativeTracking pendingNativeTracking; private AlertDialog pageLoadErrorDialog; private boolean mainFrameLoadFailed; @@ -87,6 +90,16 @@ public final class MainActivity extends ComponentActivity { } } ); + pushNotificationPermissionLauncher = registerForActivityResult( + new ActivityResultContracts.RequestPermission(), + granted -> { + if (Boolean.TRUE.equals(granted)) { + registerPushInstallation(); + } else { + dispatchNativePushError("permission_denied"); + } + } + ); webView = new WebView(this); webView.setLayoutParams( new ViewGroup.LayoutParams( @@ -112,7 +125,7 @@ public final class MainActivity extends ComponentActivity { cookieManager.setAcceptCookie(true); cookieManager.setAcceptThirdPartyCookies(webView, false); - configureNativeTrackingBridge(); + configureNativeBridge(); webView.setWebViewClient(new TrustedWebViewClient()); webView.setWebChromeClient(new LocationWebChromeClient()); getOnBackPressedDispatcher().addCallback( @@ -280,7 +293,7 @@ public final class MainActivity extends ComponentActivity { nativeTrackingPermissionLauncher.launch(permissions.toArray(new String[0])); } - private void configureNativeTrackingBridge() { + private void configureNativeBridge() { if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { return; } @@ -304,12 +317,12 @@ public final class MainActivity extends ComponentActivity { return; } - handleNativeTrackingMessage(message.getData()); + handleNativeMessage(message.getData()); } ); } - private void handleNativeTrackingMessage(String payload) { + private void handleNativeMessage(String payload) { if (payload == null) { dispatchNativeTrackingError(); return; @@ -326,6 +339,17 @@ public final class MainActivity extends ComponentActivity { ); } else if ("stop".equals(action)) { stopService(new Intent(MainActivity.this, TrackingService.class)); + } else if ("enable_push".equals(action)) { + enablePush(); + } else if ("push_registered".equals(action)) { + PushTokenStore.markRegistered( + this, + message.optString("device_id", "") + ); + } else if ("disable_push".equals(action)) { + disablePush(); + } else if ("push_token_request".equals(action)) { + dispatchPendingPushToken(); } else { dispatchNativeTrackingError(); } @@ -334,6 +358,102 @@ public final class MainActivity extends ComponentActivity { } } + private void enablePush() { + if (!BuildConfig.FIREBASE_CONFIGURED) { + dispatchNativePushError("not_configured"); + return; + } + + PushTokenStore.setRequested(this, true); + FirebaseMessaging.getInstance().setAutoInitEnabled(true); + + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + pushNotificationPermissionLauncher.launch( + Manifest.permission.POST_NOTIFICATIONS + ); + return; + } + + registerPushInstallation(); + } + + private void registerPushInstallation() { + FirebaseMessaging.getInstance().register().addOnCompleteListener(this, task -> { + if (!task.isSuccessful()) { + dispatchNativePushError("token_unavailable"); + return; + } + + dispatchPendingPushTokenSoon(250); + dispatchPendingPushTokenSoon(1_000); + dispatchPendingPushTokenSoon(3_000); + }); + } + + private void dispatchPendingPushTokenSoon(long delayMilliseconds) { + if (webView != null) { + webView.postDelayed(this::dispatchPendingPushToken, delayMilliseconds); + } + } + + private void disablePush() { + PushTokenStore.clearRegistration(this); + + if (!BuildConfig.FIREBASE_CONFIGURED) { + return; + } + + FirebaseMessaging messaging = FirebaseMessaging.getInstance(); + messaging.setAutoInitEnabled(false); + messaging.unregister().addOnFailureListener( + error -> Log.w(LOG_TAG, "FCM unregister failed", error) + ); + } + + private void dispatchPendingPushToken() { + if (webView == null || !BuildConfig.FIREBASE_CONFIGURED) { + return; + } + + String token = PushTokenStore.pendingToken(this); + if (token == null || token.isBlank()) { + return; + } + + try { + JSONObject detail = new JSONObject() + .put("token", token) + .put("installation_id", PushTokenStore.installationId(this)) + .put("device_label", "Android · FCM") + .put("server_device_id", PushTokenStore.serverDeviceId(this)); + webView.evaluateJavascript( + "window.dispatchEvent(new CustomEvent('wnh:native-push-token',{detail:" + + detail + + "}))", + null + ); + } catch (JSONException exception) { + dispatchNativePushError("token_unavailable"); + } + } + + private void dispatchNativePushError(String reason) { + if (webView == null) { + return; + } + + webView.evaluateJavascript( + "window.dispatchEvent(new CustomEvent('wnh:native-push-error',{detail:{reason:" + + JSONObject.quote(reason) + + "}}))", + null + ); + } + private void startPendingNativeTracking() { PendingNativeTracking pending = pendingNativeTracking; pendingNativeTracking = null; @@ -537,6 +657,8 @@ public final class MainActivity extends ComponentActivity { Log.d(LOG_TAG, "Main-frame load finished: path=" + safeLogPath(Uri.parse(url))); scheduleMapDiagnostics(view, Uri.parse(url)); } + + dispatchPendingPushToken(); } private void scheduleMapDiagnostics(WebView view, Uri uri) { diff --git a/android/app/src/main/java/org/whoneedhelp/mobile/PushRoute.java b/android/app/src/main/java/org/whoneedhelp/mobile/PushRoute.java new file mode 100644 index 0000000..a9003e4 --- /dev/null +++ b/android/app/src/main/java/org/whoneedhelp/mobile/PushRoute.java @@ -0,0 +1,30 @@ +package org.whoneedhelp.mobile; + +import java.net.URI; +import java.net.URISyntaxException; + +final class PushRoute { + private PushRoute() {} + + static String resolve(String baseUrl, String path, boolean debugBuild) { + if ( + path == null + || path.isBlank() + || !path.startsWith("/") + || path.startsWith("//") + || path.contains("\\") + ) { + return null; + } + + try { + TrustedOrigin origin = TrustedOrigin.parse(baseUrl, debugBuild); + URI base = new URI(origin.startUrl() + "/"); + URI resolved = base.resolve(path); + String candidate = resolved.toString(); + return origin.matches(candidate) ? candidate : null; + } catch (IllegalArgumentException | URISyntaxException exception) { + return null; + } + } +} diff --git a/android/app/src/main/java/org/whoneedhelp/mobile/PushTokenStore.java b/android/app/src/main/java/org/whoneedhelp/mobile/PushTokenStore.java new file mode 100644 index 0000000..b13286c --- /dev/null +++ b/android/app/src/main/java/org/whoneedhelp/mobile/PushTokenStore.java @@ -0,0 +1,93 @@ +package org.whoneedhelp.mobile; + +import android.content.Context; +import android.content.SharedPreferences; + +import java.util.UUID; + +final class PushTokenStore { + private static final String PREFERENCES = "who_need_help_push"; + private static final String INSTALLATION_ID = "installation_id"; + private static final String TOKEN = "fcm_token"; + private static final String REQUESTED = "requested"; + private static final String DIRTY = "registration_dirty"; + private static final String SERVER_DEVICE_ID = "server_device_id"; + + private PushTokenStore() {} + + static synchronized String installationId(Context context) { + SharedPreferences preferences = preferences(context); + String existing = preferences.getString(INSTALLATION_ID, null); + + if (existing != null && !existing.isBlank()) { + return existing; + } + + String generated = UUID.randomUUID().toString(); + preferences.edit().putString(INSTALLATION_ID, generated).apply(); + return generated; + } + + static void setRequested(Context context, boolean value) { + preferences(context).edit().putBoolean(REQUESTED, value).apply(); + } + + static boolean requested(Context context) { + return preferences(context).getBoolean(REQUESTED, false); + } + + static void storeToken(Context context, String value) { + if (value == null || value.isBlank()) { + return; + } + + preferences(context) + .edit() + .putString(TOKEN, value) + .putBoolean(DIRTY, true) + .remove(SERVER_DEVICE_ID) + .apply(); + } + + static String pendingToken(Context context) { + SharedPreferences preferences = preferences(context); + + if (!requested(context) || !preferences.getBoolean(DIRTY, false)) { + return null; + } + + return preferences.getString(TOKEN, null); + } + + static void markRegistered(Context context, String deviceId) { + SharedPreferences.Editor editor = preferences(context) + .edit() + .putBoolean(DIRTY, false); + + if (deviceId == null || deviceId.isBlank()) { + editor.remove(SERVER_DEVICE_ID); + } else { + editor.putString(SERVER_DEVICE_ID, deviceId); + } + + editor.apply(); + } + + static String serverDeviceId(Context context) { + return preferences(context).getString(SERVER_DEVICE_ID, null); + } + + static void clearRegistration(Context context) { + preferences(context) + .edit() + .putBoolean(REQUESTED, false) + .putBoolean(DIRTY, false) + .remove(TOKEN) + .remove(SERVER_DEVICE_ID) + .apply(); + } + + private static SharedPreferences preferences(Context context) { + return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE); + } +} diff --git a/android/app/src/main/java/org/whoneedhelp/mobile/WhoNeedHelpApplication.java b/android/app/src/main/java/org/whoneedhelp/mobile/WhoNeedHelpApplication.java new file mode 100644 index 0000000..aa51236 --- /dev/null +++ b/android/app/src/main/java/org/whoneedhelp/mobile/WhoNeedHelpApplication.java @@ -0,0 +1,32 @@ +package org.whoneedhelp.mobile; + +import android.app.Application; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; + +public final class WhoNeedHelpApplication extends Application { + @Override + public void onCreate() { + super.onCreate(); + + if (!BuildConfig.FIREBASE_CONFIGURED) { + return; + } + + if (FirebaseApp.getApps(this).isEmpty()) { + FirebaseOptions options = new FirebaseOptions.Builder() + .setApplicationId(BuildConfig.FIREBASE_APPLICATION_ID) + .setApiKey(BuildConfig.FIREBASE_API_KEY) + .setProjectId(BuildConfig.FIREBASE_PROJECT_ID) + .setGcmSenderId(BuildConfig.FIREBASE_GCM_SENDER_ID) + .build(); + FirebaseApp.initializeApp(this, options); + } + + if (PushTokenStore.requested(this)) { + FirebaseMessaging.getInstance().setAutoInitEnabled(true); + } + } +} diff --git a/android/app/src/main/java/org/whoneedhelp/mobile/WhoNeedHelpMessagingService.java b/android/app/src/main/java/org/whoneedhelp/mobile/WhoNeedHelpMessagingService.java new file mode 100644 index 0000000..8cd5582 --- /dev/null +++ b/android/app/src/main/java/org/whoneedhelp/mobile/WhoNeedHelpMessagingService.java @@ -0,0 +1,93 @@ +package org.whoneedhelp.mobile; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; + +import androidx.core.app.NotificationCompat; +import androidx.annotation.NonNull; + +import com.google.firebase.messaging.FirebaseMessagingService; +import com.google.firebase.messaging.RemoteMessage; + +import java.util.Map; + +public final class WhoNeedHelpMessagingService extends FirebaseMessagingService { + private static final String CHANNEL_ID = "who_need_help_updates"; + + @Override + public void onRegistered(@NonNull String installationId) { + PushTokenStore.storeToken(this, installationId); + } + + @Override + public void onUnregistered(@NonNull String installationId) { + PushTokenStore.clearRegistration(this); + } + + @Override + public void onMessageReceived(RemoteMessage message) { + Map data = message.getData(); + String route = PushRoute.resolve( + BuildConfig.BASE_URL, + data.get("path"), + BuildConfig.DEBUG + ); + + if (route == null) { + return; + } + + String title = limited(data.get("title"), getString(R.string.app_name), 120); + String body = limited(data.get("body"), "Open Who Need Help for the update.", 240); + String notificationId = limited(data.get("notification_id"), route, 160); + + createChannel(); + + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(route), this, MainActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + PendingIntent pendingIntent = PendingIntent.getActivity( + this, + notificationId.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(body) + .setStyle(new NotificationCompat.BigTextStyle().bigText(body)) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_DEFAULT); + + getSystemService(NotificationManager.class) + .notify(notificationId.hashCode(), builder.build()); + } + + private void createChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + getString(R.string.updates_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ); + channel.setDescription(getString(R.string.updates_channel_description)); + getSystemService(NotificationManager.class).createNotificationChannel(channel); + } + + private static String limited(String value, String fallback, int maximum) { + String normalized = value == null ? "" : value.trim(); + if (normalized.isEmpty()) { + normalized = fallback; + } + return normalized.length() <= maximum ? normalized : normalized.substring(0, maximum); + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 3285fe7..db81873 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -19,4 +19,6 @@ New updates are paused. Tap Stop sharing to retry deleting the current position. Location is unavailable Enable device location, then return to the request and try again. + Help request updates + Private request, message, and nearby-help notifications. diff --git a/android/app/src/test/java/org/whoneedhelp/mobile/PushRouteTest.java b/android/app/src/test/java/org/whoneedhelp/mobile/PushRouteTest.java new file mode 100644 index 0000000..faa5f37 --- /dev/null +++ b/android/app/src/test/java/org/whoneedhelp/mobile/PushRouteTest.java @@ -0,0 +1,23 @@ +package org.whoneedhelp.mobile; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +public final class PushRouteTest { + @Test + public void acceptsOnlyRelativeSameOriginPaths() { + assertEquals( + "https://help.example/requests/123#messages", + PushRoute.resolve( + "https://help.example", + "/requests/123#messages", + false + ) + ); + assertNull(PushRoute.resolve("https://help.example", "https://evil.test", false)); + assertNull(PushRoute.resolve("https://help.example", "//evil.test/requests", false)); + assertNull(PushRoute.resolve("https://help.example", "/\\evil", false)); + } +} diff --git a/assets/css/app.css b/assets/css/app.css index 1d1ba73..3f65c65 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -158,6 +158,15 @@ html { var(--color-base-200); } +/* MapLibre's default attribution link differs from its surrounding text only by + a subtle color. Keep the required attribution visibly recognizable as a link. */ +.maplibregl-ctrl-attrib-inner a { + color: inherit; + text-decoration-line: underline; + text-decoration-thickness: 1px; + text-underline-offset: 0.12em; +} + .request-discovery-toolbar { display: flex; align-items: center; diff --git a/assets/js/hooks.js b/assets/js/hooks.js index f11dcfd..d7dfcc4 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -575,6 +575,200 @@ export const mountStaticAidMaps = root => { } export const Hooks = { + NotificationTimeZone: { + mounted() { + const timeZoneInput = this.el.querySelector("[data-time-zone]") + const offsetInput = this.el.querySelector("[data-utc-offset]") + + if (timeZoneInput) { + timeZoneInput.value = Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC" + } + + if (offsetInput) offsetInput.value = String(-new Date().getTimezoneOffset()) + } + }, + + PushNotifications: { + mounted() { + this.button = this.el.querySelector("[data-enable-push]") + this.status = this.el.querySelector("[data-push-status]") + this.native = typeof window.WhoNeedHelpAndroid?.postMessage === "function" + + if (this.native && this.button) this.button.disabled = false + + this.setStatus = message => { + if (this.status) this.status.textContent = message + } + + this.decodeApplicationServerKey = value => { + const padding = "=".repeat((4 - value.length % 4) % 4) + const base64 = (value + padding).replace(/-/g, "+").replace(/_/g, "/") + const raw = window.atob(base64) + return Uint8Array.from([...raw].map(character => character.charCodeAt(0))) + } + + this.installationId = () => { + const key = "wnh.push.installation-id" + const existing = window.localStorage.getItem(key) + if (existing) return existing + + const generated = typeof window.crypto?.randomUUID === "function" + ? window.crypto.randomUUID() + : `web-${Date.now()}-${Array.from(window.crypto.getRandomValues(new Uint32Array(4))) + .map(value => value.toString(16).padStart(8, "0")) + .join("")}` + window.localStorage.setItem(key, generated) + return generated + } + + this.postNative = payload => { + if (!this.native) return + window.WhoNeedHelpAndroid.postMessage(JSON.stringify(payload)) + } + + this.registerDevice = async payload => { + const csrfToken = document.querySelector("meta[name='csrf-token']")?.content || "" + const response = await window.fetch("/mobile/push-devices", { + method: "POST", + credentials: "same-origin", + headers: { + "accept": "application/json", + "content-type": "application/json", + "x-csrf-token": csrfToken + }, + body: JSON.stringify(payload) + }) + + if (!response.ok) throw new Error(`device_registration_${response.status}`) + + const device = await response.json() + window.localStorage.setItem("wnh.push.server-device-id", String(device.id)) + if (payload.platform === "android") { + this.postNative({action: "push_registered", device_id: device.id}) + } + this.pushEvent("refresh-push-devices", {}, () => {}) + return device + } + + this.nativeToken = async event => { + const detail = event.detail || {} + if (!detail.token || !detail.installation_id) return + + try { + await this.registerDevice({ + platform: "android", + provider: "fcm", + token: detail.token, + installation_id: detail.installation_id, + device_label: String(detail.device_label || "Android · FCM").slice(0, 120), + user_agent: navigator.userAgent.slice(0, 500) + }) + this.setStatus("Push notifications are enabled on this Android device.") + } catch (error) { + console.warn("Android push registration failed", error) + this.setStatus("The Android push token could not be saved. Please try again.") + } finally { + if (this.button) this.button.disabled = false + } + } + + this.nativeError = event => { + const reason = event.detail?.reason + this.setStatus( + reason === "permission_denied" + ? "Notification permission was not granted." + : "Android push notifications are not available yet." + ) + if (this.button) this.button.disabled = false + } + + this.disableCurrentDevice = async event => { + const button = event.target.closest("[data-disable-device]") + if (!button) return + + const currentId = window.localStorage.getItem("wnh.push.server-device-id") + if (!currentId || currentId !== button.dataset.disableDevice) return + + window.localStorage.removeItem("wnh.push.server-device-id") + if (this.native) { + this.postNative({action: "disable_push"}) + return + } + + try { + const registration = await navigator.serviceWorker?.ready + const subscription = await registration?.pushManager?.getSubscription() + await subscription?.unsubscribe() + } catch (error) { + console.warn("Browser push unsubscribe failed", error) + } + } + + this.enable = async () => { + const publicKey = String(this.el.dataset.vapidPublicKey || "") + + if (this.native) { + this.button.disabled = true + this.setStatus("Waiting for Android notification permission and token…") + this.postNative({action: "enable_push"}) + return + } + + if (!publicKey || !("serviceWorker" in navigator) || !("PushManager" in window)) { + this.setStatus("Push notifications are unavailable in this browser.") + return + } + + this.button.disabled = true + + try { + const permission = await Notification.requestPermission() + if (permission !== "granted") { + this.setStatus("Notification permission was not granted.") + return + } + + const registration = await navigator.serviceWorker.ready + const subscription = await registration.pushManager.getSubscription() || + await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: this.decodeApplicationServerKey(publicKey) + }) + const json = subscription.toJSON() + + await this.registerDevice({ + platform: "web", + provider: "web_push", + token: json.endpoint, + installation_id: this.installationId(), + p256dh: json.keys?.p256dh, + auth_secret: json.keys?.auth, + device_label: `${navigator.platform || "Browser"} · Web Push`.slice(0, 120), + user_agent: navigator.userAgent.slice(0, 500) + }) + this.setStatus("Push notifications are enabled on this device.") + } catch (error) { + console.warn("Push notification registration failed", error) + this.setStatus("Push notifications could not be enabled.") + } finally { + this.button.disabled = false + } + } + + window.addEventListener("wnh:native-push-token", this.nativeToken) + window.addEventListener("wnh:native-push-error", this.nativeError) + this.el.addEventListener("click", this.disableCurrentDevice) + this.button?.addEventListener("click", this.enable) + if (this.native) this.postNative({action: "push_token_request"}) + }, + destroyed() { + window.removeEventListener("wnh:native-push-token", this.nativeToken) + window.removeEventListener("wnh:native-push-error", this.nativeError) + this.el.removeEventListener("click", this.disableCurrentDevice) + this.button?.removeEventListener("click", this.enable) + } + }, + DateTimePicker: { mounted() { this.hiddenInput = this.el.querySelector("[data-datetime-value]") @@ -997,10 +1191,14 @@ export const Hooks = { "approximate_public" this.radius = () => { + const allowed = String(this.el.dataset.allowedRadii || "500,1000,2000") + .split(",") + .map(Number) + .filter(Number.isFinite) const value = Number( this.el.querySelector("[data-location-radius-input]:checked")?.value || 1000 ) - return [500, 1000, 2000].includes(value) ? value : 1000 + return allowed.includes(value) ? value : allowed[0] || 1000 } this.coordinates = () => { @@ -1246,7 +1444,8 @@ export const Hooks = { latitude: position.coords.latitude, longitude: position.coords.longitude } - const selected = this.mode() === "approximate_public" + const selected = this.mode() === "approximate_public" && + this.el.dataset.privateCenter !== "true" ? privacySafeAreaCenter(raw.latitude, raw.longitude, this.radius()) : raw diff --git a/compose.yaml b/compose.yaml index f6e0214..88146ca 100644 --- a/compose.yaml +++ b/compose.yaml @@ -50,6 +50,12 @@ x-app-environment: &app-environment PUSH_HTTP_RECEIVE_TIMEOUT_MS: ${PUSH_HTTP_RECEIVE_TIMEOUT_MS:-} PUSH_HTTP_CONNECT_TIMEOUT_MS: ${PUSH_HTTP_CONNECT_TIMEOUT_MS:-} PUSH_HTTP_RETRY_DELAY_MS: ${PUSH_HTTP_RETRY_DELAY_MS:-} + WEB_PUSH_VAPID_PUBLIC_KEY: ${WEB_PUSH_VAPID_PUBLIC_KEY:-} + WEB_PUSH_VAPID_PRIVATE_KEY: ${WEB_PUSH_VAPID_PRIVATE_KEY:-} + WEB_PUSH_VAPID_SUBJECT: ${WEB_PUSH_VAPID_SUBJECT:-} + FCM_PROJECT_ID: ${FCM_PROJECT_ID:-} + FCM_SERVICE_ACCOUNT_FILE: ${FCM_SERVICE_ACCOUNT_FILE:-} + FCM_SERVICE_ACCOUNT_JSON_BASE64: ${FCM_SERVICE_ACCOUNT_JSON_BASE64:-} OBAN_MAINTENANCE_CONCURRENCY: ${OBAN_MAINTENANCE_CONCURRENCY:-2} OBAN_PUSH_CONCURRENCY: ${OBAN_PUSH_CONCURRENCY:-1} diff --git a/config/config.exs b/config/config.exs index ad202df..255ca30 100644 --- a/config/config.exs +++ b/config/config.exs @@ -34,7 +34,13 @@ config :who_need_help, e2e_routes: false, secure_cookies: false, rate_limit_policies: %{}, - map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png" + map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + web_push_public_key: nil, + fcm_goth_source: nil, + device_delivery_options: %{ + web_push: [], + fcm: [] + } config :who_need_help, WhoNeedHelp.Repo, types: WhoNeedHelp.PostgrexTypes diff --git a/config/runtime.exs b/config/runtime.exs index c835665..c9d4058 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -272,6 +272,105 @@ config :who_need_help, ), push_delivery_options: Keyword.delete(push_configuration, :adapter) +web_push_configuration = + case { + System.get_env("WEB_PUSH_VAPID_PUBLIC_KEY"), + System.get_env("WEB_PUSH_VAPID_PRIVATE_KEY"), + System.get_env("WEB_PUSH_VAPID_SUBJECT") + } do + {public_key, private_key, subject} + when is_binary(public_key) and public_key != "" and is_binary(private_key) and + private_key != "" and is_binary(subject) and subject != "" -> + unless String.starts_with?(subject, ["mailto:", "https://"]) do + raise "WEB_PUSH_VAPID_SUBJECT must start with mailto: or https://." + end + + [public_key: public_key, private_key: private_key, subject: subject] + + {public_key, private_key, subject} + when public_key in [nil, ""] and private_key in [nil, ""] and subject in [nil, ""] -> + [] + + _partial_configuration -> + raise """ + WEB_PUSH_VAPID_PUBLIC_KEY, WEB_PUSH_VAPID_PRIVATE_KEY, and WEB_PUSH_VAPID_SUBJECT \ + must either all be set or all be empty. + """ + end + +if web_push_configuration != [] do + config :web_push_elixir, + vapid_public_key: Keyword.fetch!(web_push_configuration, :public_key), + vapid_private_key: Keyword.fetch!(web_push_configuration, :private_key), + vapid_subject: Keyword.fetch!(web_push_configuration, :subject) +end + +fcm_credentials = + case { + System.get_env("FCM_SERVICE_ACCOUNT_FILE"), + System.get_env("FCM_SERVICE_ACCOUNT_JSON_BASE64") + } do + {credentials_file, encoded} + when is_binary(credentials_file) and credentials_file != "" and encoded in [nil, ""] -> + unless Path.type(credentials_file) == :absolute and File.regular?(credentials_file) do + raise "FCM_SERVICE_ACCOUNT_FILE must be an absolute path to a readable regular file." + end + + credentials_file |> File.read!() |> Jason.decode!() + + {credentials_file, encoded} + when credentials_file in [nil, ""] and is_binary(encoded) and encoded != "" -> + case Base.decode64(encoded) do + {:ok, json} -> Jason.decode!(json) + :error -> raise "FCM_SERVICE_ACCOUNT_JSON_BASE64 must contain standard Base64." + end + + {credentials_file, encoded} when credentials_file in [nil, ""] and encoded in [nil, ""] -> + nil + + _both_configured -> + raise "Set only one of FCM_SERVICE_ACCOUNT_FILE or FCM_SERVICE_ACCOUNT_JSON_BASE64." + end + +if fcm_credentials && fcm_credentials["type"] != "service_account" do + raise "The configured FCM credentials must be a Google service-account document." +end + +fcm_configuration = + case {System.get_env("FCM_PROJECT_ID"), fcm_credentials} do + {project_id, credentials} + when is_binary(project_id) and project_id != "" and is_map(credentials) -> + %{ + project_id: project_id, + source: + {:service_account, credentials, + scopes: ["https://www.googleapis.com/auth/firebase.messaging"]} + } + + {project_id, nil} when project_id in [nil, ""] -> + nil + + _partial_configuration -> + raise "FCM_PROJECT_ID and one FCM credential source must be configured together." + end + +config :who_need_help, + web_push_public_key: Keyword.get(web_push_configuration, :public_key), + fcm_goth_source: fcm_configuration && fcm_configuration.source, + device_delivery_options: %{ + web_push: [], + fcm: + if(fcm_configuration, + do: [ + project_id: fcm_configuration.project_id, + goth_name: WhoNeedHelp.Goth, + receive_timeout: 10_000, + connect_timeout: 5_000 + ], + else: [] + ) + } + if config_env() == :prod and app_role in [:web, :worker, :combined] do metrics_token = System.get_env("METRICS_TOKEN") || diff --git a/docs/architecture.md b/docs/architecture.md index d04c8e6..b05f674 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,8 +72,15 @@ and are not represented as complete. - `Trust`: reviews, reports, blocks, leaderboard/reputation projections, abuse signals, moderator audit events, and shared rate-limit policies. - `Push`: privacy-safe product event construction, unique durable Oban jobs, - and a provider-neutral delivery adapter. Current events cover request - acceptance and new matched-chat messages. + provider-neutral gateway delivery, direct Web Push and FCM device delivery, + invalid-registration cleanup, and request/message/lifecycle/nearby events. +- `Notifications`: the private inbox, device registry, user preferences, quiet + hours, durable email delivery, and PostGIS-backed nearby subscriptions. + Subscription centers and push credentials are never part of public discovery + results. +- `ProductAnalytics`: daily aggregate counters from a fixed metric allow-list. + It stores no user identifier, coordinate, request/chat text, email, or device + credential. Contexts normally call each other through public functions. A small number of documented trust-and-safety transactions update related schemas together when @@ -101,9 +108,10 @@ queues, plugins, or peer leadership so transactions can insert unique jobs. The worker and combined roles start queue consumers and scheduled-job plugins. PostgreSQL coordinates queues and leadership, so no Redis dependency is introduced. The worker runs only the queues used by product code: -`maintenance` for expiry/probes and `push` for provider-neutral delivery. -Their per-worker concurrency is configured independently; no unused default -queue is started. +`maintenance` for expiry/probes and `push` for notification matching, +dispatch, direct Web Push/FCM, optional gateway delivery, and notification +email. Their per-worker concurrency is configured independently; no unused +default queue is started. ## Geospatial data diff --git a/docs/support-and-content-removal.md b/docs/support-and-content-removal.md index 0084360..916309c 100644 --- a/docs/support-and-content-removal.md +++ b/docs/support-and-content-removal.md @@ -110,14 +110,24 @@ account settings, and the public `/account/delete` route remains usable after an app is uninstalled. The workflow verifies the contact and creates an audited account-lifecycle request. -Actual erasure/anonymization and export are not automated because the operator -has not yet selected a jurisdiction-specific retention policy for safety, +An authenticated user can download `/users/data-export`. The JSON export uses +explicit field allow-lists and includes data the account supplied or generated +through its own use of the product. It excludes password hashes, session and +OAuth tokens, push tokens and Web Push keys, and messages written by the other +participant. The response is an attachment with `Cache-Control: no-store`. + +The restricted support workspace can run a read-only deletion preflight for a +verified, account-linked deletion case. It reports active help requests, +assignments, tracking sessions, activities/memberships, unresolved reports, and +open trust signals. It does not change those records or the support case. + +Actual erasure/anonymization remains intentionally non-executable because the +operator has not selected a jurisdiction-specific retention policy for safety, fraud, disputes, and legal records. An operator must not mark a request resolved -until the applicable data action has actually been completed and communicated. -Before public launch, legal review must define which linked records are erased, -anonymized, or retained and for how long; only then should a destructive -execution routine be implemented and tested against backups and relational -constraints. +until the applicable approved data action has actually been completed and +communicated. Before enabling a destructive executor, legal review must define +which linked records are erased, anonymized, or retained and for how long; that +executor must then be tested against backups and relational constraints. Google Play's current policy requires both an in-app path and an external web resource when an app allows account creation: diff --git a/docs/verification.md b/docs/verification.md index 6446a20..ee7b214 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -1,8 +1,51 @@ # Who Need Help — implementation verification -Observed through 2026-07-21 in the local workspace. This report separates observed +Observed through 2026-07-22 in the local workspace. This report separates observed results from product limits and unknown production properties. +## Local completion audit on 2026-07-22 + +The following results describe the uncommitted local workspace only. No test or +production deployment, repository push, or Devpost edit was performed as part of +this audit. + +- `mix precommit` passed compilation with warnings treated as errors, formatting, + strict Credo and Sobelow checks, and all 337 ExUnit tests. +- The isolated Playwright suite passed all 42 scenarios in Chromium, Firefox, and + WebKit. It covers the requester/helper lifecycle, matched and Activity chat, + consent-driven location sharing, helper withdrawal and replacement, activity + leave/rejoin, moderation, notification preferences, nearby alerts, data export, + accessibility, and serving-node failure/reconnection. Evidence is retained at + `output/e2e/20260722212235-234952`. +- A fresh focused Chromium replay of nearby alerts, private notification inbox, + preferences, and data export passed in + `output/e2e/20260722213500-500926`. A separate headed Chrome session then + completed email-only registration through isolated Mailpit and rendered the + connected notifications/nearby-alert UI with zero console errors or warnings. +- The Android Docker build passed JVM unit tests, lint, debug APK assembly, debug + instrumentation APK assembly, and the configured Android test target. +- A 30-second isolated load run used 88 concurrent virtual users, completed 13,672 + iterations and 38,586 HTTP requests, and recorded 35,118/35,118 successful + checks with zero failed HTTP requests. Observed HTTP latency was 2.19 ms average + and 6.42 ms p95; authenticated paths were 7.07 ms average and 9.68 ms p95. + Minimum observed database connection headroom was 76. These are workstation + measurements, not minimum server requirements. Evidence is retained at + `output/performance/goal-local-20260722`. +- The 50,000-row-per-table PostGIS benchmark measured the viewport query at about + 10.985 ms, clustering at about 21.164 ms, concentrated leaderboard aggregation + at 48.566 ms, and reputation aggregation at 34.601 ms. Evidence is retained at + `output/db-scale/20260722204033-3485619`. +- Direct Web Push and FCM adapters, private payload shape, durable retries, + invalid-device cleanup, browser/device registration lifecycle, and Android deep + links are implemented and locally tested. Delivery through an external Web Push + endpoint or a physical Android device remains unverified because this local + audit had no VAPID/FCM credentials or registered external device. +- Account export is implemented as an authenticated allowlisted JSON download. + Account-deletion requests now have a moderator-only, read-only relationship + preflight. Destructive erasure/anonymisation is intentionally not enabled until + a jurisdiction-specific retention policy and operator approval workflow are + defined. + ## Verified MVP capabilities | Requirement | Status | Observed evidence | Limit | @@ -16,15 +59,16 @@ results from product limits and unknown production properties. | Consent-driven live tracking | Implemented and cross-client verified | On API 37, Android started `TrackingService` as a location foreground service with a persistent Stop notification. After Home minimized the Activity, an emulator coordinate change reached PostGIS. Notification Stop removed the service, notification, active session, and raw position. | Browsers stop with the page. Android has no `ACCESS_BACKGROUND_LOCATION`, unattended start, or route history. | | Privacy settings | Implemented and browser-verified | The profile exposed hidden, approximate public, exact for active match, and explicit exact-public options. Blocking and current-position cleanup have automated tests. | Exact public location remains a user opt-in; legal privacy and retention text still requires jurisdiction-specific review before launch. | | Reputation and anti-abuse | Implemented at MVP level | Handover codes, two-party completion, double-blind reviews, unique-counterpart ranking, optional movement/proximity evidence, reports, blocks, abuse signals, and moderator audit paths have automated tests. | The system is not bot-proof and does not claim identity verification. No punitive numeric policy is enabled without measured and approved thresholds. | -| Account registration and sign-in | Implemented and browser-verified | Email registration sends a confirmation magic link and does not require a password. Confirmed users can keep using magic links or add a password in settings. Google OpenID Connect registration, sign-in, link, unlink, replay prevention, verified-email enforcement, and account-ownership rules are covered by the 285-test suite. A headed Chrome run against the public test domain created a new account through the real Google provider, stored one confirmed/terms-accepted user and one Google identity, logged out, and logged back in without a second completion step or duplicate row. The same account then completed the isolated Mailpit magic-link flow; the one-time login token was consumed and only a session token remained. | Test email is deliberately captured in its own Mailpit. A production UniSender delivery-format message reached Gmail, but a real production authentication email and the production Google callback remain unexercised until the tested release is explicitly promoted. | +| Account registration and sign-in | Implemented and browser-verified | Email registration is a single passwordless flow: it records the display name and acceptance once, sends a confirmation link, and does not duplicate a user on subsequent sign-in. Confirmed users can keep using magic links or add a password in settings. Google OpenID Connect registration, sign-in, link, unlink, replay prevention, verified-email enforcement, and account-ownership rules are covered by the 337-test suite. Earlier public-test-domain verification exercised the real Google provider without creating a duplicate row; the final local headed-Chrome replay exercised isolated Mailpit registration again. | Local test email is deliberately captured in Mailpit. The current delivery code is provider-neutral SMTP; no production SMTP delivery or production Google callback was exercised by the 2026-07-22 local audit. | +| Notifications and nearby alerts | Implemented and browser-verified | Users can configure push/email preferences, quiet hours, category/urgency/day/time filters, a private matching center, and 1/3/5/10/25 km radii. Durable inbox notifications and Oban delivery jobs are tested; public notification payloads omit chat text, exact coordinates, and the private saved-area label. The focused Chromium replay completed subscription, matching request, inbox delivery, navigation, and export. | External Web Push/FCM delivery depends on deployment credentials and real registered devices; those external boundaries were not exercised in the final local audit. | | Social profiles | Manual links implemented; optional GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record. The local protocol drill also performs real HTTP token/user exchanges without returning an access token to the application. | GitHub OAuth credentials are intentionally absent and are not required for registration or the help flow. The real external provider redirect/callback remains disabled and unverified; other providers remain manual/unverified. | -| Support and content removal | Implemented and browser-verified | Public support, account deletion, general removal, and TAKE IT DOWN forms create separate audited queues; private email status links verify public contacts; authenticated submissions use the account email; moderator-only operations can update status and notify verified contacts. TAKE IT DOWN accepts URLs/text only and records a 48-hour review due time. | The current product hosts no user media and does not claim TAKE IT DOWN coverage. Staffing, jurisdiction-specific legal classification, final retention rules, actual account erasure/export, and identical-media-copy handling remain operational/legal work. | +| Support and content removal | Implemented and browser-verified | Public support, account deletion, general removal, and TAKE IT DOWN forms create separate audited queues; private email status links verify public contacts; authenticated submissions use the account email; moderator-only operations can update status and notify verified contacts. TAKE IT DOWN accepts URLs/text only and records a 48-hour review due time. Authenticated users can download an allowlisted JSON export, and moderators can run a read-only deletion relationship preflight. | The current product hosts no user media and does not claim TAKE IT DOWN coverage. Staffing, jurisdiction-specific legal classification, final retention rules, destructive account erasure/anonymisation, and identical-media-copy handling remain operational/legal work. | | Voluntary thanks | Implemented as an external optional link | A helper can expose an optional link after completion; the UI states that the platform does not process the payment. | The platform does not provide payments, escrow, refunds, tax reporting, or payment guarantees. | -| Android client | Local and public-staging clients implemented and emulator-verified | The native packages `org.whoneedhelp.mobile.debug` and `org.whoneedhelp.mobile.staging` launch the same authenticated LiveView app. Seven lifecycle, permission, deep-link, foreground tracking, recoverable main-page failure, notification-Stop, and Activity-destruction tests passed on each of API 30, 34, and 37. The API 37 staging smoke asserted the public home and Safety DOM over HTTPS. A run-scoped Android/browser staging test passed login, private chat in both directions, foreground tracking, live marker appearance/removal, and exact cleanup. | Production signing, Play Store publication, verified Android App Links, unattended/background-permission tracking, and iOS are not implemented. | +| Android client | Local and public-staging clients implemented and emulator-verified | The native packages `org.whoneedhelp.mobile.debug` and `org.whoneedhelp.mobile.staging` launch the same authenticated LiveView app. Existing lifecycle, permission, deep-link, foreground tracking, recoverable main-page failure, notification-Stop, and Activity-destruction tests remain in place. The final local build also covers consent-based FCM token registration, data-only notification routing, and request/notification deep links. | Production signing, Play Store publication, verified Android App Links, physical-device FCM delivery, unattended/background-permission tracking, and iOS are not implemented. | | Multiple web/worker instances | Implemented and locally failure/rollout-verified | The final isolated Compose drill passed BEAM crashes and sequential replacement with 3 web/2 worker replicas: all five nodes joined, PubSub passed, and 744/744 readiness requests succeeded. The project-owned kind cluster replaced all 2 web/2 worker pod UIDs under `maxUnavailable=0`; all four replacement pods joined, PubSub passed, and 363/363 samples ultimately succeeded. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. | | Local observability | Implemented and protocol-verified | Pinned Prometheus scraped the exact 3 web and 2 worker targets with a file Bearer credential; Grafana provisioned a healthy datasource and ten-panel web/worker/BEAM/Ecto/Oban dashboard; Alertmanager delivered firing and resolved webhooks for an induced scoped replica stop. | Local delivery does not establish production retention, notification-provider reliability, on-call policy, or measured alert thresholds. | | Encrypted local backup | Implemented and failure-verified | Pinned Restic streamed PostgreSQL custom format into pinned local MinIO with no host plaintext dump, passed full-data checking and a fresh-database restore, rejected a corrupted repository, and published no snapshot for an interrupted upload. The one-run MinIO project and volume were removed after retaining the non-secret evidence. | The drill proves the local mechanism, not off-site durability, database HA, or a production RPO/RTO/retention policy. | -| External protocol boundaries | Implemented and locally failure-verified | The production release used its configured Assent/Req and Swoosh/gen_smtp clients against internal-only mocks. GitHub OAuth, Google OIDC discovery/authorization/token/JWKS with nonce and PKCE, and SMTP success/rejection/retry/replay/timeout paths passed. The HTTP push boundary passed disabled, retry, rejection, timeout, and idempotency paths. Request acceptance and new-chat transactions created durable jobs processed by two Oban worker replicas; the chat event completed on Oban attempt 2 after an injected temporary failure. A separate public test-domain run exercised the real Google OIDC provider, and a production UniSender Go delivery-format message reached Gmail. | The real GitHub provider, the production Google callback, production authentication-email delivery, FCM/APNs token registration, and device delivery remain unverified. SMTP exactly-once delivery is not claimed. | +| External protocol boundaries | Implemented and locally failure-verified | Assent/Req and Swoosh/gen_smtp paths have internal success/rejection/retry/replay/timeout coverage. Google OIDC discovery/authorization/token/JWKS with nonce and PKCE and the optional GitHub boundary are locally exercised through mocks; an earlier public test run exercised real Google OIDC. The current push code includes provider-neutral HTTP delivery plus direct standards-based Web Push and FCM adapters with durable Oban retries and invalid-device cleanup. UniSender observations below are retained only as historical evidence and do not describe current wiring. | The real GitHub provider, production Google callback, production authentication-email delivery, external Web Push endpoint, physical-device FCM delivery, and APNs remain unverified. SMTP exactly-once delivery is not claimed. | ## Reproducible checks @@ -1158,15 +1202,15 @@ None of the observations below describe the current delivery path. and 587 timed out for UniSender Go; control attempts to other public SMTP providers also timed out. This observation does not establish where the filtering occurs. -- `WhoNeedHelp.Email.UnisenderGoAdapter` now maps the application's existing - Swoosh messages to the provider's HTTPS `email/send.json` contract. Six +- At that time, `WhoNeedHelp.Email.UnisenderGoAdapter` mapped the application's + existing Swoosh messages to the provider's HTTPS `email/send.json` contract. Six focused tests passed for the exact request shape (including explicit `track_read=0` and `track_links=0`) and API-key header, success, redacted recipient rejection, structured API errors, invalid responses, and rejection of unsupported or provider-invalid messages before network I/O. - Runtime configuration and production - environment validation can select either `smtp` or `unisender_go` without - requiring SMTP settings in API mode. + The then-current runtime configuration and production environment validation + could select either `smtp` or `unisender_go` without requiring SMTP settings + in API mode. That selectable API path has since been removed. - Authoritative DNS and the provider UI both showed the sending domain as verified with DKIM active, while the delegated link domain showed configured. A second real Web API message was accepted for one recipient with no rejected @@ -1203,16 +1247,20 @@ None of the observations below describe the current delivery path. Google OAuth client on its exact HTTPS callback origin after the tested release is explicitly promoted. The test client and callback have already completed real registration and returning-user login. -- Configure and verify a real mobile push provider and device-token lifecycle - if native push is required. The provider-neutral HTTP boundary and product - jobs are tested; FCM/APNs device delivery is not. +- Configure environment-specific VAPID and Firebase credentials, then verify a + real browser subscription and Android device against each deployed origin. + Direct Web Push/FCM adapters, registration lifecycle, private payload shape, + retries, invalid-device cleanup, and Android deep-link handling are + implemented and locally tested; real provider/device delivery is not yet + observed. APNs and iOS are outside the current scope. - Load-test representative data and traffic, then set measured pool, resource, autoscaling, and action-limit policies. - Publish jurisdiction-specific emergency contacts, privacy, retention, prohibited-items, and voluntary-payment guidance after legal review. - The UI now has authenticated and external account-deletion/data-request - intake, contact verification, case status, and an audited operator queue. - Actual erasure/anonymization and export remain manual until a legally reviewed + intake, contact verification, case status, an audited operator queue, an + authenticated allow-listed JSON export, and a read-only deletion preflight. + Actual erasure/anonymization remains non-executable until a legally reviewed retention policy defines the treatment of linked safety and dispute records. - Staff and monitor the implemented moderation/support queues and establish an incident-response/on-call process for real users. diff --git a/e2e/tests/activity-moderation.spec.ts b/e2e/tests/activity-moderation.spec.ts index bdd4141..6e75f01 100644 --- a/e2e/tests/activity-moderation.spec.ts +++ b/e2e/tests/activity-moderation.spec.ts @@ -115,6 +115,29 @@ test("activity approval, privacy controls, reporting, and moderation work end to await organizer.page.getByRole("button", { name: "Send", exact: true }).click(); await expect(participant.page.getByText(organizerMessageText)).toBeVisible(); + await participant.page.getByRole("button", { name: "Leave activity" }).click(); + await expect(participant.page.getByText("You left this activity")).toBeVisible(); + await expect( + participant.page.getByText( + "You are no longer a participant and cannot access the group chat or exact meeting point.", + ), + ).toBeVisible(); + await expect( + participant.page.getByRole("heading", { name: "Approved group chat" }), + ).toHaveCount(0); + await expect(participant.page.locator("#activity-message-form")).toHaveCount(0); + + await participant.page.getByRole("button", { name: "Request to join again" }).click(); + await expect(participant.page.getByText("Approval pending")).toBeVisible(); + await expect( + participant.page.getByRole("heading", { name: "Approved group chat" }), + ).toHaveCount(0); + await organizerControls.getByRole("button", { name: "Approve" }).click(); + await expect( + participant.page.getByRole("heading", { name: "Approved group chat" }), + ).toBeVisible(); + await expect(participant.page.getByText(organizerMessageText)).toBeVisible(); + const organizerMessage = participant.page .locator("#activity-messages article") .filter({ hasText: organizerMessageText }); diff --git a/e2e/tests/mutual-aid.spec.ts b/e2e/tests/mutual-aid.spec.ts index 2d398bf..930d10e 100644 --- a/e2e/tests/mutual-aid.spec.ts +++ b/e2e/tests/mutual-aid.spec.ts @@ -25,6 +25,7 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r ); const requesterEmail = projectEmail("requester", testInfo.project.name); const helperEmail = projectEmail("helper", testInfo.project.name); + const replacementEmail = projectEmail("replacement-helper", testInfo.project.name); const requester = runID && fixturePassword ? await loginWithPassword(browser, requesterEmail, fixturePassword) @@ -33,8 +34,18 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r runID && fixturePassword ? await loginWithPassword(browser, helperEmail, fixturePassword) : await registerAndConfirm(browser, request, helperEmail, "E2E Helper"); + const replacement = + runID && fixturePassword + ? await loginWithPassword(browser, replacementEmail, fixturePassword) + : await registerAndConfirm( + browser, + request, + replacementEmail, + "E2E Replacement Helper", + ); const assertRequesterClean = captureBrowserFailures(requester.page); const assertHelperClean = captureBrowserFailures(helper.page); + const assertReplacementClean = captureBrowserFailures(replacement.page); await gotoLiveView(requester.page, "/requests/new"); await selectOptionContaining(requester.page, "Category", "Medicine pickup"); @@ -166,18 +177,47 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r const roadsideURL = requester.page.url(); await gotoLiveView(helper.page, roadsideURL); + const withdrawalSummary = helper.page + .locator("summary") + .filter({ hasText: "Withdraw from this request" }); await clickUntilVisible( helper.page.getByRole("button", { name: "I can help" }), - helper.page.getByRole("button", { name: "Withdraw from this request" }), + withdrawalSummary, ); - await clickUntilVisible( - helper.page.getByRole("button", { name: "Withdraw from this request" }), - helper.page.getByText("This request was cancelled."), - ); - await expect(requester.page.getByText("Cancelled", { exact: true })).toBeVisible(); + await withdrawalSummary.click(); + const withdrawalForm = helper.page.locator("#assignment-withdrawal-form"); + await withdrawalForm.getByLabel("Reason").selectOption("requester_unreachable"); + await withdrawalForm + .getByLabel("Note (optional)") + .fill("I could not reach the requester during the E2E workflow."); + await withdrawalForm.getByRole("button", { name: "Confirm withdrawal" }).click(); + + await expect( + helper.page.getByText( + "You left this match. The request is open for another helper if time remains.", + ), + ).toBeVisible(); + await expect(helper.page.getByRole("heading", { name: "Private match chat" })).toHaveCount(0); + await expect(helper.page.getByRole("button", { name: "I can help" })).toBeVisible(); + await expect(requester.page.getByText("Open", { exact: true })).toBeVisible(); + await expect( + requester.page.getByText( + "The previous helper left. This request is open for a new helper again.", + ), + ).toBeVisible(); + + await gotoLiveView(replacement.page, roadsideURL); + await replacement.page.getByRole("button", { name: "I can help" }).click(); + await expect( + replacement.page.getByRole("heading", { name: "Private match chat" }), + ).toBeVisible(); + await expect(requester.page.getByText("Helper: E2E Replacement Helper")).toBeVisible(); + await expect(helper.page.getByRole("heading", { name: "Private match chat" })).toHaveCount(0); assertRequesterClean(); assertHelperClean(); + assertReplacementClean(); await requester.context.close(); await helper.context.close(); + await replacement.context.close(); }); diff --git a/e2e/tests/notifications-data.spec.ts b/e2e/tests/notifications-data.spec.ts new file mode 100644 index 0000000..8852108 --- /dev/null +++ b/e2e/tests/notifications-data.spec.ts @@ -0,0 +1,126 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; +import { + captureBrowserFailures, + gotoLiveView, + gotoWithTransientRetry, + projectEmail, + projectText, + registerAndConfirm, + selectOptionContaining, + setRequestLocation, +} from "./helpers"; + +test("nearby alerts, private notification inbox, preferences, and data export work end to end", async ({ + browser, + request, +}, testInfo) => { + const scope = `${testInfo.project.name}-${Date.now()}-${process.pid}`; + const subscriber = await registerAndConfirm( + browser, + request, + projectEmail(`notification-subscriber-${scope}`, testInfo.project.name), + "E2E Notification Subscriber", + ); + const requester = await registerAndConfirm( + browser, + request, + projectEmail(`notification-requester-${scope}`, testInfo.project.name), + "E2E Notification Requester", + ); + const assertSubscriberClean = captureBrowserFailures(subscriber.page); + const assertRequesterClean = captureBrowserFailures(requester.page); + const alertName = projectText("Urgent medicine nearby", scope); + const privateArea = projectText("Private subscriber center", scope); + const requestTitle = projectText("Nearby notification request", scope); + + await gotoLiveView(subscriber.page, "/notifications"); + const preferenceForm = subscriber.page.locator("#notification-preferences-form"); + await preferenceForm.getByLabel("Allow email notifications").check(); + await preferenceForm.getByLabel("Nearby requests by email").check(); + await preferenceForm.getByLabel("Use quiet hours").check(); + await preferenceForm.getByLabel("From").fill("22:00"); + await preferenceForm.getByLabel("Until").fill("07:00"); + await preferenceForm.getByRole("button", { name: "Save preferences" }).click(); + await expect(subscriber.page.getByText("Notification preferences saved.")).toBeVisible(); + + const subscriptionForm = subscriber.page.locator("#nearby-subscription-form"); + await subscriptionForm.getByLabel("Alert name").fill(alertName); + await subscriptionForm.getByLabel("Private area label").fill(privateArea); + await subscriptionForm + .getByText("Enter coordinates manually", { exact: true }) + .click(); + await subscriptionForm.getByLabel("Latitude").fill("50.4501"); + await subscriptionForm.getByLabel("Longitude").fill("30.5234"); + await subscriptionForm + .locator('input[name="nearby_subscription[radius_meters]"][value="3000"]') + .check(); + await subscriptionForm.getByLabel("Email notification").check(); + await subscriptionForm.getByRole("button", { name: "Create nearby alert" }).click(); + + await expect(subscriber.page.getByText("Nearby alert created.")).toBeVisible(); + const savedAlert = subscriber.page + .getByRole("heading", { name: "Your nearby alerts" }) + .locator(".."); + await expect(savedAlert.getByRole("heading", { name: alertName })).toBeVisible(); + await expect(savedAlert.getByText(`${privateArea} · 3 km`)).toBeVisible(); + await expect(savedAlert.getByText("Push", { exact: true })).toBeVisible(); + await expect(savedAlert.getByText("Email", { exact: true })).toBeVisible(); + + const accessibility = await new AxeBuilder({ page: subscriber.page }).analyze(); + expect( + accessibility.violations, + accessibility.violations + .map((violation) => `${violation.id}: ${violation.help}`) + .join("\n"), + ).toEqual([]); + + await gotoLiveView(requester.page, "/requests/new"); + await selectOptionContaining(requester.page, "Category", "Medicine pickup"); + await requester.page.getByLabel("Medicine pickup status").selectOption("reserved"); + await requester.page.getByLabel("Short title").fill(requestTitle); + await requester.page + .getByLabel("What help do you need?") + .fill("Please collect the legal medicine that is already reserved."); + await requester.page.getByLabel("Urgency").selectOption("now"); + await requester.page.getByRole("button", { name: "In 3 hours" }).click(); + await setRequestLocation(requester.page, { + label: "Public notification test area", + mode: "approximate_public", + latitude: "50.4501", + longitude: "30.5234", + radiusMeters: 1000, + }); + await requester.page.locator("#request-form input[type=checkbox]").check(); + await requester.page.getByRole("button", { name: "Publish request" }).click(); + await expect(requester.page.getByRole("heading", { name: requestTitle })).toBeVisible(); + const requestURL = requester.page.url(); + + const inbox = subscriber.page + .getByRole("heading", { name: "Inbox" }) + .locator("xpath=ancestor::section"); + const notification = inbox.getByRole("button", { name: /New help request nearby/ }); + await expect(notification).toBeVisible({ timeout: 20_000 }); + await expect(inbox.getByText(privateArea)).toHaveCount(0); + await notification.click(); + await expect(subscriber.page).toHaveURL(requestURL); + await expect(subscriber.page.getByRole("heading", { name: requestTitle })).toBeVisible(); + + await gotoWithTransientRetry(subscriber.page, "/users/settings"); + await expect(subscriber.page.getByRole("heading", { name: "Account Settings" })).toBeVisible(); + assertSubscriberClean(); + assertRequesterClean(); + + const exportLink = subscriber.page.getByRole("link", { name: "Download my data" }); + await expect(exportLink).toHaveAttribute("href", "/users/data-export"); + const exportURL = new URL("/users/data-export", subscriber.page.url()).toString(); + const exportResponse = await subscriber.context.request.get(exportURL); + expect(exportResponse.status()).toBe(200); + expect(exportResponse.headers()["content-disposition"]).toMatch( + /^attachment; filename="who-need-help-account-export-\d{4}-\d{2}-\d{2}\.json"$/, + ); + expect((await exportResponse.json()).format).toBe("who-need-help-account-export"); + + await subscriber.context.close(); + await requester.context.close(); +}); diff --git a/e2e/tests/request-discovery.spec.ts b/e2e/tests/request-discovery.spec.ts index 7de0b08..653dc8f 100644 --- a/e2e/tests/request-discovery.spec.ts +++ b/e2e/tests/request-discovery.spec.ts @@ -31,6 +31,7 @@ async function createMedicineRequest( await page.locator("#request-form input[type=checkbox]").check(); await page.getByRole("button", { name: "Publish request" }).click(); await expect(page.getByRole("heading", { name: title })).toBeVisible(); + await waitForMapReady(page); } test("request discovery searches the viewport, clusters dense points, and remembers layout", async ({ diff --git a/e2e/tests/zz-resilience.spec.ts b/e2e/tests/zz-resilience.spec.ts index 7773cc5..520a85d 100644 --- a/e2e/tests/zz-resilience.spec.ts +++ b/e2e/tests/zz-resilience.spec.ts @@ -1,7 +1,15 @@ import { expect, test } from "@playwright/test"; -import { gotoLiveView, projectEmail, registerAndConfirm } from "./helpers"; +import { + gotoLiveView, + projectEmail, + projectText, + registerAndConfirm, + selectOptionContaining, + setRequestLocation, + waitForLiveViewConnected, +} from "./helpers"; -test("a disconnected LiveView announces recovery and clears it after reconnect", async ({ +test("a disconnected LiveView recovers without leaving a stale error", async ({ browser, request, }, testInfo) => { @@ -11,43 +19,75 @@ test("a disconnected LiveView announces recovery and clears it after reconnect", projectEmail("resilience", testInfo.project.name), "E2E Resilience", ); + const title = projectText("Connection recovery request", testInfo.project.name); + + await gotoLiveView(user.page, "/requests/new"); + await selectOptionContaining(user.page, "Category", "Medicine pickup"); + await user.page.getByLabel("Medicine pickup status").selectOption("reserved"); + await user.page.getByLabel("Short title").fill(title); + await user.page + .getByLabel("What help do you need?") + .fill("A reserved medicine pickup used to verify visible connection recovery."); + await user.page.getByLabel("Urgency").selectOption("now"); + await user.page.getByRole("button", { name: "In 3 hours" }).click(); + await setRequestLocation(user.page, { + label: "Connection recovery area", + mode: "hidden", + }); + await user.page.locator("#request-form input[type=checkbox]").last().check(); + await user.page.getByRole("button", { name: "Publish request" }).click(); + await expect(user.page.getByRole("heading", { name: title })).toBeVisible(); + await waitForLiveViewConnected(user.page); + + const runtimeNode = await user.page + .locator("#e2e-runtime-node") + .getAttribute("data-node"); + expect(runtimeNode).toBeTruthy(); + + const serverError = user.page.locator("#server-error"); + await expect(serverError).toHaveAttribute("role", "alert"); + await expect(serverError).toContainText("Attempting to reconnect"); + await user.page.evaluate(() => { + const state = { sawDisconnected: false }; + ( + window as typeof window & { + __wnhRecoveryState?: { sawDisconnected: boolean }; + } + ).__wnhRecoveryState = state; + + window.addEventListener("phx:page-loading-start", (event) => { + const detail = (event as CustomEvent<{ kind?: string }>).detail; + if (detail?.kind === "error") state.sawDisconnected = true; + }); + }); + + const crash = await request.post("/__e2e__/crash-node", { + data: { + confirmation: "crash-exact-e2e-node", + node: runtimeNode, + }, + }); + expect(crash.status()).toBe(202); - await gotoLiveView(user.page, "/requests"); await expect - .poll(() => - user.page.evaluate(() => { - const liveSocket = ( - window as typeof window & { - liveSocket?: { isConnected: () => boolean }; - } - ).liveSocket; - return liveSocket?.isConnected() ?? false; - }), + .poll( + () => + user.page.evaluate( + () => + ( + window as typeof window & { + __wnhRecoveryState?: { sawDisconnected: boolean }; + } + ).__wnhRecoveryState?.sawDisconnected ?? false, + ), + { timeout: 30_000 }, ) .toBe(true); - await user.context.setOffline(true); - await user.page.evaluate(() => { - const liveSocket = ( - window as typeof window & { - liveSocket?: { socket?: { conn?: { close: () => void } } }; - } - ).liveSocket; - liveSocket?.socket?.conn?.close(); - }); - await expect(user.page.getByText("We can't find the internet")).toBeVisible(); - await expect(user.page.getByText("Attempting to reconnect").first()).toBeVisible(); - await user.context.setOffline(false); - await user.page.evaluate(() => { - const liveSocket = ( - window as typeof window & { - liveSocket?: { connect: () => void }; - } - ).liveSocket; - liveSocket?.connect(); - }); - await expect(user.page.getByText("We can't find the internet")).toBeHidden(); - await expect(user.page.getByRole("heading", { name: "Who needs help?" })).toBeVisible(); + await waitForLiveViewConnected(user.page); + await expect(serverError).toBeHidden(); + await expect(user.page.locator("#client-error")).toBeHidden(); + await expect(user.page.getByRole("heading", { name: title })).toBeVisible(); await user.context.close(); }); diff --git a/lib/who_need_help/accounts/data_export.ex b/lib/who_need_help/accounts/data_export.ex new file mode 100644 index 0000000..67e5051 --- /dev/null +++ b/lib/who_need_help/accounts/data_export.ex @@ -0,0 +1,454 @@ +defmodule WhoNeedHelp.Accounts.DataExport do + @moduledoc """ + Builds an authenticated, machine-readable copy of data associated with one account. + + The export uses explicit allow-lists. Password hashes, session and OAuth tokens, + push-provider credentials, Web Push keys, and counterpart message bodies are never + selected. + """ + + import Ecto.Query + + alias WhoNeedHelp.Accounts.{AuthIdentity, Scope, SocialIdentity, User} + alias WhoNeedHelp.Activities.{Activity, Message, Participant} + alias WhoNeedHelp.Catalog.{CategoryProposal, CategoryVote} + alias WhoNeedHelp.ContentRemoval.Notice + alias WhoNeedHelp.Help.{Assignment, HelpRequest} + alias WhoNeedHelp.Messaging.Message, as: MatchMessage + alias WhoNeedHelp.Notifications.{NearbySubscription, Notification, Preference, PushDevice} + alias WhoNeedHelp.Repo + alias WhoNeedHelp.Support.SupportRequest + alias WhoNeedHelp.Tracking.{Position, TrackingSession} + alias WhoNeedHelp.Trust.{AbuseSignal, AuditEvent, Block, Report, Review} + + @version 1 + + def build(%Scope{user: %User{id: user_id} = user}) do + exported_at = DateTime.utc_now(:second) + + %{ + "format" => "who-need-help-account-export", + "version" => @version, + "exported_at" => exported_at, + "account" => + record(user, [ + :id, + :email, + :display_name, + :bio, + :locale, + :location_visibility, + :direct_message_policy, + :role, + :moderation_status, + :tip_url, + :confirmed_at, + :accepted_terms_at, + :inserted_at, + :updated_at + ]), + "authentication_identities" => + owned(AuthIdentity, :user_id, user_id, [ + :id, + :provider, + :provider_uid, + :email, + :inserted_at, + :updated_at + ]), + "social_identities" => + owned(SocialIdentity, :user_id, user_id, [ + :id, + :provider, + :provider_uid, + :profile_url, + :handle, + :verified_at, + :inserted_at, + :updated_at + ]), + "help_requests" => help_requests(user_id), + "help_assignments_as_helper" => assignments(user_id), + "match_messages_sent" => + owned(MatchMessage, :sender_id, user_id, [ + :id, + :assignment_id, + :body, + :read_at, + :inserted_at, + :updated_at + ]), + "activities_created" => activities(user_id), + "activity_participation" => + owned(Participant, :user_id, user_id, [ + :id, + :activity_id, + :role, + :status, + :reviewed_at, + :left_at, + :inserted_at, + :updated_at + ]), + "activity_messages_sent" => + owned(Message, :sender_id, user_id, [ + :id, + :activity_id, + :body, + :inserted_at, + :updated_at + ]), + "category_proposals" => + owned(CategoryProposal, :proposer_id, user_id, [ + :id, + :parent_id, + :merged_into_id, + :proposed_name, + :reason, + :mode, + :status, + :reviewed_at, + :moderation_note, + :inserted_at, + :updated_at + ]), + "category_votes" => + owned(CategoryVote, :user_id, user_id, [:id, :proposal_id, :inserted_at]), + "notification_preferences" => + one(Preference, :user_id, user_id, [ + :push_enabled, + :email_enabled, + :nearby_push_enabled, + :nearby_email_enabled, + :message_push_enabled, + :lifecycle_push_enabled, + :quiet_hours_enabled, + :quiet_start, + :quiet_end, + :time_zone, + :utc_offset_minutes, + :inserted_at, + :updated_at + ]), + "nearby_subscriptions" => nearby_subscriptions(user_id), + "push_devices" => + owned(PushDevice, :user_id, user_id, [ + :id, + :platform, + :provider, + :device_label, + :user_agent, + :last_seen_at, + :disabled_at, + :inserted_at, + :updated_at + ]), + "notifications" => + owned(Notification, :user_id, user_id, [ + :id, + :kind, + :title, + :body, + :path, + :data, + :read_at, + :inserted_at, + :updated_at + ]), + "support_requests" => + owned(SupportRequest, :requester_id, user_id, [ + :id, + :reference, + :kind, + :status, + :contact_email, + :subject, + :details, + :contact_verified_at, + :resolution_note, + :reviewed_at, + :response_sent_at, + :inserted_at, + :updated_at + ]), + "content_removal_notices" => + owned(Notice, :requester_id, user_id, [ + :id, + :reference, + :regime, + :category, + :status, + :submitter_name, + :contact_email, + :relationship, + :content_locations, + :explanation, + :legal_basis, + :contact_verified_at, + :resolution_note, + :reviewed_at, + :response_due_at, + :acknowledgement_sent_at, + :decision_sent_at, + :inserted_at, + :updated_at + ]), + "blocks_created" => owned(Block, :blocker_id, user_id, [:id, :blocked_id, :inserted_at]), + "reports_submitted" => + owned(Report, :reporter_id, user_id, [ + :id, + :reason, + :details, + :status, + :resolution_note, + :request_id, + :assignment_id, + :message_id, + :activity_id, + :activity_message_id, + :reviewed_at, + :inserted_at, + :updated_at + ]), + "reviews_authored" => + owned(Review, :reviewer_id, user_id, [ + :id, + :assignment_id, + :reviewee_id, + :rating, + :comment, + :revealed_at, + :inserted_at, + :updated_at + ]), + "reviews_received_and_revealed" => revealed_reviews(user_id), + "tracking_sessions" => tracking_sessions(user_id), + "audit_events_as_actor" => + owned(AuditEvent, :actor_id, user_id, [ + :id, + :action, + :target_type, + :target_id, + :metadata, + :inserted_at + ]), + "automated_trust_signals" => + owned(AbuseSignal, :subject_id, user_id, [ + :id, + :kind, + :status, + :assignment_id, + :metadata, + :reviewed_at, + :review_note, + :inserted_at, + :updated_at + ]) + } + |> normalize() + end + + def encode(%Scope{} = scope), do: Jason.encode(build(scope), pretty: true) + + defp help_requests(user_id) do + HelpRequest + |> where([item], item.requester_id == ^user_id) + |> ordered() + |> Repo.all() + |> Enum.map(fn request -> + request + |> record([ + :id, + :category_id, + :title, + :description, + :pickup_instructions, + :structured_data, + :location_label, + :location_radius_meters, + :status, + :urgency, + :location_visibility, + :expires_at, + :cancelled_at, + :cancellation_reason, + :cancellation_note, + :completed_at, + :hidden_at, + :hidden_reason, + :inserted_at, + :updated_at + ]) + |> Map.put(:coordinates, coordinates(request.location)) + end) + end + + defp assignments(user_id) do + owned(Assignment, :helper_id, user_id, [ + :id, + :request_id, + :status, + :active, + :handover_verified_at, + :requester_confirmed_at, + :helper_confirmed_at, + :proximity_observed_at, + :helper_movement_observed_at, + :accepted_at, + :started_at, + :arrived_at, + :completed_at, + :withdrawal_reason, + :withdrawal_note, + :inserted_at, + :updated_at + ]) + end + + defp activities(user_id) do + Activity + |> where([item], item.creator_id == ^user_id) + |> ordered() + |> Repo.all() + |> Enum.map(fn activity -> + activity + |> record([ + :id, + :category_id, + :title, + :description, + :structured_data, + :location_label, + :status, + :location_visibility, + :starts_at, + :join_deadline, + :capacity, + :cancelled_at, + :completed_at, + :hidden_at, + :hidden_reason, + :inserted_at, + :updated_at + ]) + |> Map.put(:coordinates, coordinates(activity.location)) + end) + end + + defp nearby_subscriptions(user_id) do + NearbySubscription + |> where([item], item.user_id == ^user_id) + |> ordered() + |> Repo.all() + |> Enum.map(fn subscription -> + subscription + |> record([ + :id, + :name, + :active, + :location_label, + :radius_meters, + :category_ids, + :urgencies, + :available_days, + :available_from, + :available_until, + :push_enabled, + :email_enabled, + :inserted_at, + :updated_at + ]) + |> Map.put(:coordinates, coordinates(subscription.center)) + end) + end + + defp revealed_reviews(user_id) do + Review + |> where([review], review.reviewee_id == ^user_id and not is_nil(review.revealed_at)) + |> ordered() + |> Repo.all() + |> Enum.map( + &record(&1, [ + :id, + :assignment_id, + :reviewer_id, + :rating, + :comment, + :revealed_at, + :inserted_at, + :updated_at + ]) + ) + end + + defp tracking_sessions(user_id) do + TrackingSession + |> where([session], session.user_id == ^user_id) + |> ordered() + |> Repo.all() + |> Enum.map(fn session -> + position = Repo.get_by(Position, tracking_session_id: session.id) + + session + |> record([ + :id, + :assignment_id, + :active, + :visibility, + :started_at, + :ended_at, + :distance_meters, + :sample_count, + :movement_observed_at, + :inserted_at, + :updated_at + ]) + |> Map.put(:current_position, position_export(position)) + end) + end + + defp position_export(nil), do: nil + + defp position_export(position) do + position + |> record([:id, :accuracy_meters, :captured_at, :inserted_at, :updated_at]) + |> Map.put(:coordinates, coordinates(position.position)) + end + + defp owned(schema, owner_field, user_id, fields) do + schema + |> where([item], field(item, ^owner_field) == ^user_id) + |> ordered() + |> Repo.all() + |> Enum.map(&record(&1, fields)) + end + + defp one(schema, owner_field, user_id, fields) do + case Repo.get_by(schema, [{owner_field, user_id}]) do + nil -> nil + item -> record(item, fields) + end + end + + defp ordered(query), do: order_by(query, [item], asc: item.inserted_at, asc: item.id) + defp record(struct, fields), do: Map.take(struct, fields) + + defp coordinates(%Geo.Point{coordinates: {longitude, latitude}}), + do: %{latitude: latitude, longitude: longitude} + + defp coordinates(_other), do: nil + + defp normalize(%DateTime{} = value), do: DateTime.to_iso8601(value) + defp normalize(%NaiveDateTime{} = value), do: NaiveDateTime.to_iso8601(value) + defp normalize(%Date{} = value), do: Date.to_iso8601(value) + defp normalize(%Time{} = value), do: Time.to_iso8601(value) + defp normalize(%Decimal{} = value), do: Decimal.to_string(value) + defp normalize(value) when is_atom(value), do: Atom.to_string(value) + defp normalize(value) when is_list(value), do: Enum.map(value, &normalize/1) + + defp normalize(value) when is_map(value) do + Map.new(value, fn {key, nested} -> {to_string(key), normalize(nested)} end) + end + + defp normalize(value), do: value +end diff --git a/lib/who_need_help/accounts/data_lifecycle.ex b/lib/who_need_help/accounts/data_lifecycle.ex new file mode 100644 index 0000000..a2f5c06 --- /dev/null +++ b/lib/who_need_help/accounts/data_lifecycle.ex @@ -0,0 +1,116 @@ +defmodule WhoNeedHelp.Accounts.DataLifecycle do + @moduledoc """ + Read-only preflight for an account-deletion case. + + This module intentionally does not erase or anonymise records. The applicable + retention policy is not encoded in the project, so an automatic destructive + action would make an unverified legal assumption. Operators get a repeatable, + audited checklist of live product state before a policy-backed executor is added. + """ + + import Ecto.Query + + alias WhoNeedHelp.Accounts.{Scope, User} + alias WhoNeedHelp.Activities.{Activity, Participant} + alias WhoNeedHelp.Help.{Assignment, HelpRequest} + alias WhoNeedHelp.Repo + alias WhoNeedHelp.Support.SupportRequest + alias WhoNeedHelp.Tracking.TrackingSession + alias WhoNeedHelp.Trust.{AbuseSignal, Report} + + def deletion_assessment(%Scope{user: moderator}, %SupportRequest{} = request) do + cond do + request.kind != :account_deletion -> + {:error, :not_deletion_request} + + is_nil(request.requester_id) -> + {:error, :account_not_linked} + + is_nil(request.contact_verified_at) -> + {:error, :contact_not_verified} + + true -> + user = Repo.get(User, request.requester_id) + + if user do + counts = blocking_counts(user.id) + + blockers = + counts + |> Enum.filter(fn {_name, count} -> count > 0 end) + |> Enum.map(fn {name, count} -> %{kind: name, count: count} end) + + {:ok, + %{ + case_id: request.id, + reference: request.reference, + account_id: user.id, + account_role: user.role, + contact_verified: true, + assessed_by: moderator.id, + assessed_at: DateTime.utc_now(:second), + blocking_counts: counts, + blockers: blockers, + technically_idle: blockers == [], + execution_available: false, + execution_blocker: :retention_policy_not_configured + }} + else + {:error, :account_not_found} + end + end + end + + defp blocking_counts(user_id) do + %{ + active_help_requests: + HelpRequest + |> where( + [request], + request.requester_id == ^user_id and + request.status in [:open, :matched, :in_progress] + ) + |> count(), + active_helper_assignments: + Assignment + |> where( + [assignment], + assignment.helper_id == ^user_id and assignment.active and + assignment.status in [:accepted, :in_progress] + ) + |> count(), + active_tracking_sessions: + TrackingSession + |> where([session], session.user_id == ^user_id and session.active) + |> count(), + open_activities: + Activity + |> where([activity], activity.creator_id == ^user_id and activity.status == :open) + |> count(), + active_activity_memberships: + Participant + |> join(:inner, [participant], activity in Activity, + on: activity.id == participant.activity_id + ) + |> where( + [participant, activity], + participant.user_id == ^user_id and activity.status == :open and + participant.status in [:requested, :approved] + ) + |> Repo.aggregate(:count), + unresolved_reports: + Report + |> where( + [report], + report.reporter_id == ^user_id and report.status in [:open, :reviewing] + ) + |> count(), + open_trust_signals: + AbuseSignal + |> where([signal], signal.subject_id == ^user_id and signal.status == :open) + |> count() + } + end + + defp count(query), do: Repo.aggregate(query, :count) +end diff --git a/lib/who_need_help/accounts/user.ex b/lib/who_need_help/accounts/user.ex index 6569bf1..988d53f 100644 --- a/lib/who_need_help/accounts/user.ex +++ b/lib/who_need_help/accounts/user.ex @@ -33,6 +33,10 @@ defmodule WhoNeedHelp.Accounts.User do field :accepted_terms_at, :utc_datetime field :terms_accepted, :boolean, virtual: true, default: false has_many :social_identities, WhoNeedHelp.Accounts.SocialIdentity + has_many :notifications, WhoNeedHelp.Notifications.Notification + has_one :notification_preference, WhoNeedHelp.Notifications.Preference + has_many :nearby_subscriptions, WhoNeedHelp.Notifications.NearbySubscription + has_many :push_devices, WhoNeedHelp.Notifications.PushDevice timestamps(type: :utc_datetime) end diff --git a/lib/who_need_help/application.ex b/lib/who_need_help/application.ex index cfe0a82..9179f42 100644 --- a/lib/who_need_help/application.ex +++ b/lib/who_need_help/application.ex @@ -14,12 +14,13 @@ defmodule WhoNeedHelp.Application do :persistent_term.put({WhoNeedHelp, :e2e_boot_id}, boot_id) end - common_children = [ - WhoNeedHelpWeb.Telemetry, - WhoNeedHelp.Repo, - {DNSCluster, query: Application.get_env(:who_need_help, :dns_cluster_query) || :ignore}, - {Phoenix.PubSub, name: WhoNeedHelp.PubSub} - ] + common_children = + [ + WhoNeedHelpWeb.Telemetry, + WhoNeedHelp.Repo, + {DNSCluster, query: Application.get_env(:who_need_help, :dns_cluster_query) || :ignore}, + {Phoenix.PubSub, name: WhoNeedHelp.PubSub} + ] ++ WhoNeedHelp.Push.supervisor_children() oban_config = Application.fetch_env!(:who_need_help, Oban) oban_client_config = Keyword.merge(oban_config, queues: [], plugins: [], peer: false) diff --git a/lib/who_need_help/help.ex b/lib/who_need_help/help.ex index 720679c..dff9adb 100644 --- a/lib/who_need_help/help.ex +++ b/lib/who_need_help/help.ex @@ -9,7 +9,9 @@ defmodule WhoNeedHelp.Help do alias WhoNeedHelp.Catalog.Category alias WhoNeedHelp.Help.{Assignment, DiscoveryViewport, HelpRequest} alias WhoNeedHelp.Pagination + alias WhoNeedHelp.ProductAnalytics alias WhoNeedHelp.Push + alias WhoNeedHelp.Push.NearbyMatchWorker alias WhoNeedHelp.Repo alias WhoNeedHelp.Trust alias WhoNeedHelp.Trust.Block @@ -276,7 +278,8 @@ defmodule WhoNeedHelp.Help do Trust.audit(user.id, "request.created", "request", request.id, %{ "urgency" => to_string(request.urgency), "category_id" => request.category_id - }) do + }), + {:ok, _nearby_job} <- NearbyMatchWorker.enqueue(request.id) do {:ok, request} end end) @@ -286,6 +289,7 @@ defmodule WhoNeedHelp.Help do end with {:ok, request} <- result do + _ = ProductAnalytics.increment("request.created", to_string(request.urgency)) broadcast({:request_created, get_request!(request.id)}) {:ok, request} end @@ -330,6 +334,7 @@ defmodule WhoNeedHelp.Help do |> Assignment.changeset(%{ request_id: request.id, helper_id: helper.id, + active: true, accepted_at: now, handover_code_hash: code_hash(code) }) @@ -359,6 +364,7 @@ defmodule WhoNeedHelp.Help do case result do {:ok, assignment} -> + _ = ProductAnalytics.increment("request.accepted") request = get_request!(assignment.request_id) broadcast({:request_updated, request}) @@ -377,6 +383,10 @@ defmodule WhoNeedHelp.Help do transition_assignment(user, assignment_id, :start) end + def arrive_assignment(%Scope{user: user}, assignment_id) do + transition_assignment(user, assignment_id, :arrive) + end + def confirm_completion(%Scope{user: user}, assignment_id) do transition_assignment(user, assignment_id, :confirm) end @@ -413,6 +423,7 @@ defmodule WhoNeedHelp.Help do |> Assignment.changeset(%{handover_verified_at: now}) |> maybe_complete(request) |> audit_assignment_transition(user.id, "handover.verified", request.id) + |> notify_handover_verified(request) end else nil -> {:error, :not_found} @@ -425,7 +436,7 @@ defmodule WhoNeedHelp.Help do |> after_transition() end - def cancel_request(%Scope{user: user}, request_id) do + def cancel_request(%Scope{user: user}, request_id, attrs \\ %{}) do with {:ok, request_id} <- cast_id(request_id), {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :cancel_request) do Repo.transact(fn -> @@ -444,17 +455,20 @@ defmodule WhoNeedHelp.Help do assignment = Assignment - |> where([assignment], assignment.request_id == ^request.id) + |> where([assignment], assignment.request_id == ^request.id and assignment.active) |> lock("FOR UPDATE") |> Repo.one() with {:ok, request} <- request - |> Ecto.Changeset.change(status: :cancelled, cancelled_at: now) + |> HelpRequest.cancellation_changeset(cancellation_attrs(attrs, now)) |> Repo.update(), {:ok, _assignment} <- cancel_assignment(assignment), {:ok, _audit} <- - Trust.audit(user.id, "request.cancelled", "request", request.id) do + Trust.audit(user.id, "request.cancelled", "request", request.id, %{ + "reason" => to_string(request.cancellation_reason) + }), + {:ok, _notification} <- notify_request_cancelled(request, assignment) do {:ok, request} end @@ -467,6 +481,9 @@ defmodule WhoNeedHelp.Help do end |> case do {:ok, request} -> + _ = + ProductAnalytics.increment("request.cancelled", to_string(request.cancellation_reason)) + WhoNeedHelp.Tracking.cleanup_finished_sessions() request = get_request!(request.id) broadcast({:request_updated, request}) @@ -477,7 +494,7 @@ defmodule WhoNeedHelp.Help do end end - def withdraw_assignment(%Scope{user: user}, assignment_id) do + def withdraw_assignment(%Scope{user: user}, assignment_id, attrs \\ %{}) do with {:ok, assignment_id} <- cast_id(assignment_id), {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :withdraw_assignment) do Repo.transact(fn -> @@ -487,19 +504,28 @@ defmodule WhoNeedHelp.Help do if assignment.helper_id == user.id and assignment.status in [:accepted, :in_progress] do now = DateTime.utc_now(:second) + reopen? = DateTime.after?(request.expires_at, now) with {:ok, assignment} <- assignment - |> Assignment.changeset(%{status: :cancelled}) + |> Assignment.withdrawal_changeset(withdrawal_attrs(attrs)) |> Repo.update(), {:ok, _request} <- request - |> Ecto.Changeset.change(status: :cancelled, cancelled_at: now) + |> Ecto.Changeset.change( + status: if(reopen?, do: :open, else: :expired), + cancelled_at: nil + ) |> Repo.update(), {:ok, _audit} <- Trust.audit(user.id, "assignment.withdrawn", "assignment", assignment.id, %{ - "request_id" => request.id - }) do + "request_id" => request.id, + "reason" => to_string(assignment.withdrawal_reason), + "request_reopened" => reopen? + }), + {:ok, _notification} <- notify_assignment_withdrawn(request, assignment, reopen?), + {:ok, _nearby_job} <- + maybe_enqueue_reopened_request(request.id, assignment.id, reopen?) do {:ok, assignment} end else @@ -515,6 +541,7 @@ defmodule WhoNeedHelp.Help do |> after_transition() |> case do {:ok, _assignment} = result -> + _ = ProductAnalytics.increment("assignment.withdrawn") WhoNeedHelp.Tracking.cleanup_finished_sessions() result @@ -647,6 +674,41 @@ defmodule WhoNeedHelp.Help do "assignment", assignment.id, %{"request_id" => request.id} + ), + {:ok, _notification} <- + Push.enqueue_lifecycle( + :assignment_started, + assignment.id, + request.id, + request.requester_id, + "Your helper started", + "Open Who Need Help to follow the request status." + ) do + {:ok, assignment} + end + + {:arrive, :in_progress, helper_id} + when helper_id == assignment.helper_id and is_nil(assignment.arrived_at) -> + with {:ok, assignment} <- + assignment + |> Assignment.changeset(%{arrived_at: now}) + |> Repo.update(), + {:ok, _audit} <- + Trust.audit( + user.id, + "assignment.arrived", + "assignment", + assignment.id, + %{"request_id" => request.id} + ), + {:ok, _notification} <- + Push.enqueue_lifecycle( + :helper_arrived, + assignment.id, + request.id, + request.requester_id, + "Your helper arrived", + "Open Who Need Help to coordinate the handover safely." ) do {:ok, assignment} end @@ -680,6 +742,7 @@ defmodule WhoNeedHelp.Help do :error -> {:error, :not_found} end |> after_transition() + |> record_assignment_metric(action) end defp maybe_complete(changeset, request) do @@ -710,7 +773,7 @@ defmodule WhoNeedHelp.Help do defp locked_assignment(id) do Assignment - |> where([a], a.id == ^id) + |> where([a], a.id == ^id and a.active) |> lock("FOR UPDATE") |> Repo.one() end @@ -752,7 +815,9 @@ defmodule WhoNeedHelp.Help do "status" => to_string(assignment.status) }), {:ok, _completion_audit} <- - maybe_audit_completion(actor_id, assignment, request_id) do + maybe_audit_completion(actor_id, assignment, request_id), + {:ok, _notifications} <- + maybe_notify_completion(assignment, request_id) do {:ok, assignment} end end @@ -775,6 +840,128 @@ defmodule WhoNeedHelp.Help do |> Repo.update() end + defp cancellation_attrs(attrs, now) do + attrs + |> normalize_attrs() + |> Map.put_new("cancellation_reason", "no_longer_needed") + |> Map.put("status", "cancelled") + |> Map.put("cancelled_at", now) + end + + defp withdrawal_attrs(attrs) do + attrs + |> normalize_attrs() + |> Map.put_new("withdrawal_reason", "cannot_complete") + |> Map.put("status", "cancelled") + |> Map.put("active", false) + end + + defp normalize_attrs(attrs) when is_map(attrs) do + Map.new(attrs, fn {key, value} -> {to_string(key), value} end) + end + + defp notify_request_cancelled(_request, nil), do: {:ok, :no_helper} + + defp notify_request_cancelled(request, assignment) do + Push.enqueue_lifecycle( + :request_cancelled, + request.id, + request.id, + assignment.helper_id, + "Request cancelled", + "The requester cancelled this request. Open Who Need Help for details." + ) + end + + defp notify_assignment_withdrawn(request, _assignment, true) do + Push.enqueue_lifecycle( + :request_reopened, + request.id, + request.id, + request.requester_id, + "Your request needs a new helper", + "The previous helper withdrew, so the request is open again." + ) + end + + defp notify_assignment_withdrawn(request, _assignment, false) do + Push.enqueue_lifecycle( + :request_cancelled, + request.id, + request.id, + request.requester_id, + "Helper withdrew after expiry", + "The helper withdrew and the request is no longer open because it expired." + ) + end + + defp maybe_enqueue_reopened_request(request_id, assignment_id, true) do + NearbyMatchWorker.enqueue(request_id, "reopened:#{assignment_id}") + end + + defp maybe_enqueue_reopened_request(_request_id, _assignment_id, false), + do: {:ok, :not_reopened} + + defp notify_handover_verified({:ok, assignment} = result, request) do + case Push.enqueue_lifecycle( + :handover_verified, + assignment.id, + request.id, + request.requester_id, + "Handover code verified", + "The helper verified the one-time handover code." + ) do + {:ok, _notification} -> result + {:error, reason} -> {:error, reason} + end + end + + defp notify_handover_verified(other, _request), do: other + + defp maybe_notify_completion(%Assignment{status: :completed} = assignment, request_id) do + request = Repo.get!(HelpRequest, request_id) + + with {:ok, _requester_notification} <- + Push.enqueue_lifecycle( + :request_completed, + assignment.id, + request_id, + request.requester_id, + "Help completed", + "Both participants confirmed completion and the handover was verified." + ), + {:ok, _helper_notification} <- + Push.enqueue_lifecycle( + :request_completed, + assignment.id, + request_id, + assignment.helper_id, + "Help completed", + "Both participants confirmed completion and the handover was verified." + ) do + {:ok, :notified} + end + end + + defp maybe_notify_completion(_assignment, _request_id), do: {:ok, :not_completed} + + defp record_assignment_metric({:ok, _assignment} = result, :start) do + _ = ProductAnalytics.increment("assignment.started") + result + end + + defp record_assignment_metric({:ok, _assignment} = result, :arrive) do + _ = ProductAnalytics.increment("assignment.arrived") + result + end + + defp record_assignment_metric({:ok, %Assignment{status: :completed}} = result, :confirm) do + _ = ProductAnalytics.increment("request.completed") + result + end + + defp record_assignment_metric(result, _action), do: result + defp broadcast(event) do Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @topic, event) diff --git a/lib/who_need_help/help/assignment.ex b/lib/who_need_help/help/assignment.ex index 63c6d9e..08e7495 100644 --- a/lib/who_need_help/help/assignment.ex +++ b/lib/who_need_help/help/assignment.ex @@ -10,6 +10,7 @@ defmodule WhoNeedHelp.Help.Assignment do values: [:accepted, :in_progress, :completed, :cancelled], default: :accepted + field :active, :boolean, default: true field :handover_code_hash, :binary field :handover_verified_at, :utc_datetime field :requester_confirmed_at, :utc_datetime @@ -18,7 +19,13 @@ defmodule WhoNeedHelp.Help.Assignment do field :helper_movement_observed_at, :utc_datetime field :accepted_at, :utc_datetime field :started_at, :utc_datetime + field :arrived_at, :utc_datetime field :completed_at, :utc_datetime + + field :withdrawal_reason, Ecto.Enum, + values: [:cannot_complete, :safety_concern, :requester_unreachable, :other] + + field :withdrawal_note, :string belongs_to :request, WhoNeedHelp.Help.HelpRequest belongs_to :helper, WhoNeedHelp.Accounts.User has_many :messages, WhoNeedHelp.Messaging.Message @@ -33,6 +40,7 @@ defmodule WhoNeedHelp.Help.Assignment do :request_id, :helper_id, :status, + :active, :handover_code_hash, :handover_verified_at, :requester_confirmed_at, @@ -41,9 +49,21 @@ defmodule WhoNeedHelp.Help.Assignment do :helper_movement_observed_at, :accepted_at, :started_at, + :arrived_at, + :withdrawal_reason, + :withdrawal_note, :completed_at ]) |> validate_required([:request_id, :helper_id, :status, :accepted_at, :handover_code_hash]) - |> unique_constraint(:request_id) + |> validate_length(:withdrawal_note, max: 500) + |> unique_constraint(:request_id, name: :help_assignments_one_active_per_request) + end + + def withdrawal_changeset(assignment, attrs) do + assignment + |> cast(attrs, [:status, :active, :withdrawal_reason, :withdrawal_note]) + |> validate_required([:status, :active, :withdrawal_reason]) + |> validate_inclusion(:status, [:cancelled]) + |> validate_length(:withdrawal_note, max: 500) end end diff --git a/lib/who_need_help/help/help_request.ex b/lib/who_need_help/help/help_request.ex index 0deb218..a8508b5 100644 --- a/lib/who_need_help/help/help_request.ex +++ b/lib/who_need_help/help/help_request.ex @@ -26,13 +26,23 @@ defmodule WhoNeedHelp.Help.HelpRequest do field :expires_at, :utc_datetime field :cancelled_at, :utc_datetime + + field :cancellation_reason, Ecto.Enum, + values: [:no_longer_needed, :safety_concern, :plans_changed, :other] + + field :cancellation_note, :string field :completed_at, :utc_datetime field :hidden_at, :utc_datetime field :hidden_reason, :string field :safety_confirmed, :boolean, virtual: true, default: false belongs_to :requester, WhoNeedHelp.Accounts.User belongs_to :category, WhoNeedHelp.Catalog.Category - has_one :assignment, WhoNeedHelp.Help.Assignment, foreign_key: :request_id + + has_one :assignment, WhoNeedHelp.Help.Assignment, + foreign_key: :request_id, + where: [active: true] + + has_many :assignment_history, WhoNeedHelp.Help.Assignment, foreign_key: :request_id timestamps(type: :utc_datetime) end @@ -79,6 +89,14 @@ defmodule WhoNeedHelp.Help.HelpRequest do |> validate_length(:hidden_reason, max: 1_000) end + def cancellation_changeset(request, attrs) do + request + |> cast(attrs, [:status, :cancelled_at, :cancellation_reason, :cancellation_note]) + |> validate_required([:status, :cancelled_at, :cancellation_reason]) + |> validate_inclusion(:status, [:cancelled]) + |> validate_length(:cancellation_note, max: 500) + end + defp put_location(changeset, attrs) do case get_field(changeset, :location_visibility) do :hidden -> diff --git a/lib/who_need_help/notifications.ex b/lib/who_need_help/notifications.ex new file mode 100644 index 0000000..6ee9f16 --- /dev/null +++ b/lib/who_need_help/notifications.ex @@ -0,0 +1,505 @@ +defmodule WhoNeedHelp.Notifications do + @moduledoc """ + User-owned notification inbox, nearby-help subscriptions, and device registrations. + + Exact subscription centers and device delivery credentials are private. Public + request discovery never reads or exposes them. + """ + + import Ecto.Query + + alias Ecto.Changeset + alias WhoNeedHelp.Accounts.{Scope, User} + alias WhoNeedHelp.Help.HelpRequest + alias WhoNeedHelp.Notifications.{NearbySubscription, Notification, Preference, PushDevice} + alias WhoNeedHelp.Pagination + alias WhoNeedHelp.Push.NotificationDispatchWorker + alias WhoNeedHelp.Repo + alias WhoNeedHelp.Trust.Block + + @topic_prefix "notifications:user:" + + def subscribe(%Scope{user: %User{id: user_id}}), do: subscribe_user(user_id) + + def subscribe_user(user_id) when is_binary(user_id) do + Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, @topic_prefix <> user_id) + end + + def paginate_notifications(%Scope{user: %User{id: user_id}}, options \\ []) do + limit = Pagination.limit(options) + cursor = Pagination.cursor(options) + + Notification + |> where([notification], notification.user_id == ^user_id) + |> before_notification(cursor) + |> order_by([notification], desc: notification.inserted_at, desc: notification.id) + |> limit(^(limit + 1)) + |> Repo.all() + |> Pagination.page(limit, &{&1.inserted_at, &1.id}) + end + + def unread_count(%Scope{user: %User{id: user_id}}) do + Notification + |> where([notification], notification.user_id == ^user_id) + |> where([notification], is_nil(notification.read_at)) + |> Repo.aggregate(:count) + end + + def mark_read(%Scope{user: %User{id: user_id}}, notification_id) do + with {:ok, notification_id} <- Ecto.UUID.cast(notification_id), + %Notification{} = notification <- + Repo.get_by(Notification, id: notification_id, user_id: user_id) do + now = DateTime.utc_now(:second) + + case notification + |> Changeset.change(read_at: notification.read_at || now) + |> Repo.update() do + {:ok, notification} -> + broadcast(user_id, {:notification_read, notification.id}) + {:ok, notification} + + other -> + other + end + else + _missing_or_invalid -> {:error, :not_found} + end + end + + def mark_all_read(%Scope{user: %User{id: user_id}}) do + now = DateTime.utc_now(:second) + + {count, _} = + Notification + |> where([notification], notification.user_id == ^user_id) + |> where([notification], is_nil(notification.read_at)) + |> Repo.update_all(set: [read_at: now, updated_at: now]) + + broadcast(user_id, {:notifications_read, count}) + {:ok, count} + end + + def notify_user(user_id, attrs) when is_binary(user_id) and is_map(attrs) do + attrs = Map.put(attrs, :user_id, user_id) + changeset = Notification.changeset(%Notification{}, attrs) + idempotency_key = Map.get(attrs, :idempotency_key) || Map.get(attrs, "idempotency_key") + + Repo.transact(fn -> + case Repo.insert(changeset, + on_conflict: :nothing, + conflict_target: [:idempotency_key] + ) do + {:ok, _inserted_or_conflicted} -> + notification = Repo.get_by!(Notification, idempotency_key: idempotency_key) + + with {:ok, _job} <- enqueue_dispatch(notification), do: {:ok, notification} + + {:error, %Changeset{} = changeset} -> + {:error, changeset} + end + end) + end + + def broadcast_created(%Notification{} = notification) do + broadcast(notification.user_id, {:notification_created, notification}) + :ok + end + + def notification_opened(%Scope{} = scope, notification_id) do + case mark_read(scope, notification_id) do + {:ok, notification} = result -> + _ = + WhoNeedHelp.ProductAnalytics.increment( + "notification.opened", + to_string(notification.kind) + ) + + result + + other -> + other + end + end + + def get_preference(%Scope{user: %User{id: user_id}}) do + Repo.get_by(Preference, user_id: user_id) || %Preference{user_id: user_id} + end + + def change_preference(%Preference{} = preference, attrs \\ %{}) do + Preference.changeset(preference, attrs) + end + + def update_preference(%Scope{user: %User{id: user_id}}, attrs) do + preference = Repo.get_by(Preference, user_id: user_id) || %Preference{user_id: user_id} + + preference + |> Preference.changeset(put_attr(attrs, :user_id, user_id)) + |> Repo.insert_or_update() + end + + def list_nearby_subscriptions(%Scope{user: %User{id: user_id}}) do + NearbySubscription + |> where([subscription], subscription.user_id == ^user_id) + |> order_by([subscription], desc: subscription.active, asc: subscription.name) + |> Repo.all() + |> Repo.preload(:user) + end + + def change_nearby_subscription(%NearbySubscription{} = subscription, attrs \\ %{}) do + NearbySubscription.changeset(subscription, attrs) + end + + def create_nearby_subscription(%Scope{user: %User{id: user_id}}, attrs) do + %NearbySubscription{user_id: user_id} + |> NearbySubscription.changeset(put_attr(attrs, :user_id, user_id)) + |> Repo.insert() + end + + def update_nearby_subscription( + %Scope{user: %User{id: user_id}}, + subscription_id, + attrs + ) do + with {:ok, subscription_id} <- Ecto.UUID.cast(subscription_id), + %NearbySubscription{} = subscription <- + Repo.get_by(NearbySubscription, id: subscription_id, user_id: user_id) do + subscription + |> NearbySubscription.changeset(put_attr(attrs, :user_id, user_id)) + |> Repo.update() + else + _missing_or_invalid -> {:error, :not_found} + end + end + + def delete_nearby_subscription(%Scope{user: %User{id: user_id}}, subscription_id) do + with {:ok, subscription_id} <- Ecto.UUID.cast(subscription_id), + %NearbySubscription{} = subscription <- + Repo.get_by(NearbySubscription, id: subscription_id, user_id: user_id) do + Repo.delete(subscription) + else + _missing_or_invalid -> {:error, :not_found} + end + end + + def register_device(%Scope{user: %User{id: user_id}}, attrs) do + now = DateTime.utc_now(:second) + + attrs = + attrs + |> put_attr(:user_id, user_id) + |> put_attr(:last_seen_at, now) + |> put_attr(:disabled_at, nil) + + changeset = + %PushDevice{user_id: user_id} + |> PushDevice.changeset(attrs) + + with true <- changeset.valid?, + digest when is_binary(digest) <- Changeset.get_field(changeset, :token_digest), + platform when platform in [:web, :android] <- Changeset.get_field(changeset, :platform), + installation_id when is_binary(installation_id) <- + Changeset.get_field(changeset, :installation_id) do + Repo.transact(fn -> + installation_device = locked_device_by_installation(platform, installation_id) + token_device = locked_device_by_token(digest) + + cond do + installation_device && token_device && installation_device.id != token_device.id -> + {:error, :already_registered} + + installation_device -> + installation_device + |> PushDevice.changeset(attrs) + |> Repo.update() + + token_device && token_device.user_id == user_id -> + token_device + |> PushDevice.changeset(attrs) + |> Repo.update() + + token_device -> + {:error, :already_registered} + + true -> + Repo.insert(changeset) + end + end) + else + false -> {:error, changeset} + nil -> {:error, changeset} + _invalid -> {:error, changeset} + end + end + + def list_devices(%Scope{user: %User{id: user_id}}) do + PushDevice + |> where([device], device.user_id == ^user_id and is_nil(device.disabled_at)) + |> order_by([device], desc: device.last_seen_at) + |> Repo.all() + end + + def disable_device(%Scope{user: %User{id: user_id}}, device_id) do + with {:ok, device_id} <- Ecto.UUID.cast(device_id), + %PushDevice{} = device <- Repo.get_by(PushDevice, id: device_id, user_id: user_id) do + device + |> Changeset.change(disabled_at: DateTime.utc_now(:second)) + |> Repo.update() + else + _missing_or_invalid -> {:error, :not_found} + end + end + + def active_devices(user_id) do + PushDevice + |> where([device], device.user_id == ^user_id and is_nil(device.disabled_at)) + |> order_by([device], desc: device.last_seen_at) + |> Repo.all() + end + + def disable_invalid_device(%PushDevice{} = device) do + device + |> Changeset.change(disabled_at: DateTime.utc_now(:second)) + |> Repo.update() + end + + defp locked_device_by_installation(platform, installation_id) do + PushDevice + |> where( + [device], + device.platform == ^platform and device.installation_id == ^installation_id + ) + |> lock("FOR UPDATE") + |> Repo.one() + end + + defp locked_device_by_token(digest) do + PushDevice + |> where([device], device.token_digest == ^digest) + |> lock("FOR UPDATE") + |> Repo.one() + end + + def matching_nearby_subscriptions(%HelpRequest{location: nil}), do: [] + + def matching_nearby_subscriptions(%HelpRequest{} = request) do + now = DateTime.utc_now(:second) + category_id = Ecto.UUID.dump!(request.category_id) + + NearbySubscription + |> join(:inner, [subscription], user in User, on: user.id == subscription.user_id) + |> join(:left, [subscription, _user], preference in Preference, + on: preference.user_id == subscription.user_id + ) + |> join(:left, [subscription, _user, _preference], outgoing_block in Block, + on: + outgoing_block.blocker_id == subscription.user_id and + outgoing_block.blocked_id == ^request.requester_id + ) + |> join(:left, [subscription, _user, _preference, _outgoing_block], incoming_block in Block, + on: + incoming_block.blocker_id == ^request.requester_id and + incoming_block.blocked_id == subscription.user_id + ) + |> where( + [subscription, user, _preference, _outgoing_block, _incoming_block], + subscription.active and user.id != ^request.requester_id and + user.moderation_status == :active and not is_nil(user.confirmed_at) and + not is_nil(user.accepted_terms_at) + ) + |> where( + [subscription, _user, _preference, _outgoing_block, _incoming_block], + fragment( + "ST_DWithin(?::geography, ?::geography, ?)", + subscription.center, + ^request.location, + subscription.radius_meters + ) + ) + |> where( + [subscription, _user, _preference, _outgoing_block, _incoming_block], + fragment( + "cardinality(?) = 0 OR ? = ANY(?)", + subscription.category_ids, + ^category_id, + subscription.category_ids + ) + ) + |> where( + [subscription, _user, _preference, _outgoing_block, _incoming_block], + fragment("? = ANY(?)", ^to_string(request.urgency), subscription.urgencies) + ) + |> where( + [_subscription, _user, _preference, outgoing_block, incoming_block], + is_nil(outgoing_block.id) and is_nil(incoming_block.id) + ) + |> select([subscription, _user, preference, _outgoing_block, _incoming_block], { + subscription, + preference + }) + |> Repo.all() + |> Enum.filter(fn {subscription, preference} -> + available_now?(subscription, preference || %Preference{}, now) + end) + |> Enum.map(&elem(&1, 0)) + |> merge_user_subscriptions() + end + + def nearby_notification_attrs( + %HelpRequest{} = request, + %NearbySubscription{} = subscription, + event_key \\ "created" + ) do + %{ + kind: :nearby_request, + title: "New help request nearby", + body: "A request matching one of your nearby-help alerts is available.", + path: "/requests/#{request.id}", + data: %{ + "request_id" => request.id, + "category_id" => request.category_id, + "urgency" => to_string(request.urgency), + "push_enabled" => subscription.push_enabled, + "email_enabled" => subscription.email_enabled + }, + idempotency_key: + "nearby-request:#{request.id}:#{subscription.user_id}:#{safe_event_key(event_key)}" + } + end + + def push_allowed?(%Preference{} = preference, %Notification{kind: kind} = notification) do + preference.push_enabled and + case kind do + :nearby_request -> + preference.nearby_push_enabled and + Map.get(notification.data, "push_enabled", true) + + :message_created -> + preference.message_push_enabled + + _other -> + preference.lifecycle_push_enabled + end + end + + def email_allowed?( + %Preference{} = preference, + %Notification{kind: :nearby_request} = notification + ) do + preference.email_enabled and preference.nearby_email_enabled and + Map.get(notification.data, "email_enabled", false) + end + + def email_allowed?(%Preference{}, %Notification{}), do: false + + def quiet_now?(%Preference{quiet_hours_enabled: false}, _now), do: false + + def quiet_now?( + %Preference{quiet_start: nil, quiet_end: nil}, + _now + ), + do: false + + def quiet_now?(%Preference{} = preference, %DateTime{} = now) do + local_time = + now + |> DateTime.add(preference.utc_offset_minutes * 60, :second) + |> DateTime.to_time() + |> Time.truncate(:second) + + time_between?(local_time, preference.quiet_start, preference.quiet_end) + end + + def next_quiet_end(%Preference{} = preference, %DateTime{} = now) do + local_now = DateTime.add(now, preference.utc_offset_minutes * 60, :second) + local_date = DateTime.to_date(local_now) + local_time = local_now |> DateTime.to_time() |> Time.truncate(:second) + + end_date = + if Time.compare(preference.quiet_start, preference.quiet_end) == :lt or + Time.compare(local_time, preference.quiet_end) == :lt do + local_date + else + Date.add(local_date, 1) + end + + {:ok, naive} = NaiveDateTime.new(end_date, preference.quiet_end) + + naive + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.add(-preference.utc_offset_minutes * 60, :second) + end + + defp available_now?( + %NearbySubscription{} = subscription, + %Preference{} = preference, + %DateTime{} = now + ) do + local = DateTime.add(now, preference.utc_offset_minutes * 60, :second) + day = local |> DateTime.to_date() |> Date.day_of_week() + time = local |> DateTime.to_time() |> Time.truncate(:second) + + day in subscription.available_days and + case {subscription.available_from, subscription.available_until} do + {nil, nil} -> true + {from, until} -> time_between?(time, from, until) + end + end + + defp time_between?(time, from, until) do + case Time.compare(from, until) do + :lt -> Time.compare(time, from) != :lt and Time.compare(time, until) == :lt + :gt -> Time.compare(time, from) != :lt or Time.compare(time, until) == :lt + :eq -> true + end + end + + defp enqueue_dispatch(notification) do + notification.id + |> then(&NotificationDispatchWorker.new(%{notification_id: &1})) + |> Oban.insert() + end + + defp safe_event_key(event_key) do + event_key + |> to_string() + |> String.replace(~r/[^a-zA-Z0-9:_-]/u, "-") + |> String.slice(0, 100) + end + + defp merge_user_subscriptions(subscriptions) do + subscriptions + |> Enum.group_by(& &1.user_id) + |> Enum.map(fn {_user_id, matches} -> + base = Enum.min_by(matches, & &1.id) + + %{ + base + | push_enabled: Enum.any?(matches, & &1.push_enabled), + email_enabled: Enum.any?(matches, & &1.email_enabled) + } + end) + end + + defp before_notification(query, nil), do: query + + defp before_notification(query, {inserted_at, id}) do + where( + query, + [notification], + notification.inserted_at < ^inserted_at or + (notification.inserted_at == ^inserted_at and notification.id < ^id) + ) + end + + defp broadcast(user_id, event) do + Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @topic_prefix <> user_id, event) + end + + defp put_attr(attrs, key, value) when is_map(attrs) and is_atom(key) do + if Enum.any?(Map.keys(attrs), &is_binary/1) do + Map.put(attrs, Atom.to_string(key), value) + else + Map.put(attrs, key, value) + end + end +end diff --git a/lib/who_need_help/notifications/email_notifier.ex b/lib/who_need_help/notifications/email_notifier.ex new file mode 100644 index 0000000..bbe8847 --- /dev/null +++ b/lib/who_need_help/notifications/email_notifier.ex @@ -0,0 +1,40 @@ +defmodule WhoNeedHelp.Notifications.EmailNotifier do + @moduledoc false + + use Gettext, backend: WhoNeedHelpWeb.Gettext + + import Swoosh.Email + + alias WhoNeedHelp.Accounts.User + alias WhoNeedHelp.Mailer + alias WhoNeedHelp.Notifications.Notification + + def deliver(%User{} = user, %Notification{kind: :nearby_request} = notification) do + with_user_locale(user, fn -> + from = Application.fetch_env!(:who_need_help, :mailer_from) + url = WhoNeedHelpWeb.Endpoint.url() <> notification.path + + email = + new() + |> to(user.email) + |> from({from[:name], from[:address]}) + |> subject(gettext("New help request nearby")) + |> text_body( + gettext( + "A request matching one of your nearby-help alerts is available.\n\nOpen Who Need Help to review the public details:\n%{url}\n\nYou can change nearby alerts and email delivery in Notification settings.", + url: url + ) + ) + + Mailer.deliver(email) + end) + end + + defp with_user_locale(%User{locale: locale}, fun) when locale in ~w(en uk ru) do + Gettext.with_locale(WhoNeedHelpWeb.Gettext, locale, fun) + end + + defp with_user_locale(_user, fun) do + Gettext.with_locale(WhoNeedHelpWeb.Gettext, "en", fun) + end +end diff --git a/lib/who_need_help/notifications/nearby_subscription.ex b/lib/who_need_help/notifications/nearby_subscription.ex new file mode 100644 index 0000000..1ce435a --- /dev/null +++ b/lib/who_need_help/notifications/nearby_subscription.ex @@ -0,0 +1,123 @@ +defmodule WhoNeedHelp.Notifications.NearbySubscription do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + @allowed_radii [1_000, 3_000, 5_000, 10_000, 25_000] + @allowed_urgencies ~w(now today scheduled) + + schema "nearby_subscriptions" do + field :name, :string + field :active, :boolean, default: true + field :location_label, :string + field :center, Geo.PostGIS.Geometry + field :radius_meters, :integer, default: 5_000 + field :category_ids, {:array, :binary_id}, default: [] + field :urgencies, {:array, :string}, default: @allowed_urgencies + field :available_days, {:array, :integer}, default: [1, 2, 3, 4, 5, 6, 7] + field :available_from, :time + field :available_until, :time + field :push_enabled, :boolean, default: true + field :email_enabled, :boolean, default: false + belongs_to :user, WhoNeedHelp.Accounts.User + timestamps(type: :utc_datetime) + end + + def changeset(subscription, attrs) do + subscription + |> cast(attrs, [ + :user_id, + :name, + :active, + :location_label, + :radius_meters, + :category_ids, + :urgencies, + :available_days, + :available_from, + :available_until, + :push_enabled, + :email_enabled + ]) + |> put_center(attrs) + |> validate_required([ + :user_id, + :name, + :active, + :location_label, + :center, + :radius_meters, + :urgencies, + :available_days, + :push_enabled, + :email_enabled + ]) + |> validate_length(:name, min: 2, max: 80) + |> validate_length(:location_label, min: 2, max: 255) + |> validate_inclusion(:radius_meters, @allowed_radii) + |> validate_subset(:urgencies, @allowed_urgencies) + |> validate_subset(:available_days, 1..7) + |> validate_length(:category_ids, max: 50) + |> validate_schedule() + |> validate_delivery_channel() + |> check_constraint(:push_enabled, + name: :nearby_subscriptions_delivery_channel_required, + message: "select push, email, or both" + ) + end + + def allowed_radii, do: @allowed_radii + def allowed_urgencies, do: @allowed_urgencies + + defp put_center(changeset, attrs) do + latitude = attrs["latitude"] || attrs[:latitude] + longitude = attrs["longitude"] || attrs[:longitude] + + with {:ok, latitude} <- parse_coordinate(latitude), + {:ok, longitude} <- parse_coordinate(longitude), + true <- latitude >= -90 and latitude <= 90 and longitude >= -180 and longitude <= 180 do + put_change(changeset, :center, %Geo.Point{ + coordinates: {longitude, latitude}, + srid: 4326 + }) + else + _invalid when not is_nil(latitude) or not is_nil(longitude) -> + add_error(changeset, :center, "select a valid location") + + _missing -> + changeset + end + end + + defp parse_coordinate(value) when is_float(value), do: {:ok, value} + defp parse_coordinate(value) when is_integer(value), do: {:ok, value * 1.0} + + defp parse_coordinate(value) when is_binary(value) do + case Float.parse(value) do + {coordinate, ""} -> {:ok, coordinate} + _invalid -> :error + end + end + + defp parse_coordinate(_value), do: :error + + defp validate_schedule(changeset) do + from = get_field(changeset, :available_from) + until = get_field(changeset, :available_until) + + if (is_nil(from) and is_nil(until)) or (not is_nil(from) and not is_nil(until)) do + changeset + else + add_error(changeset, :available_until, "set both availability times or leave both empty") + end + end + + defp validate_delivery_channel(changeset) do + if get_field(changeset, :push_enabled) or get_field(changeset, :email_enabled) do + changeset + else + add_error(changeset, :push_enabled, "select push, email, or both") + end + end +end diff --git a/lib/who_need_help/notifications/notification.ex b/lib/who_need_help/notifications/notification.ex new file mode 100644 index 0000000..4c0a8b9 --- /dev/null +++ b/lib/who_need_help/notifications/notification.ex @@ -0,0 +1,49 @@ +defmodule WhoNeedHelp.Notifications.Notification do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @kinds [ + :nearby_request, + :request_accepted, + :request_reopened, + :request_cancelled, + :assignment_started, + :helper_arrived, + :handover_verified, + :request_completed, + :message_created, + :review_revealed, + :support_update + ] + + @type t :: %__MODULE__{} + + schema "notifications" do + field :kind, Ecto.Enum, values: @kinds + field :title, :string + field :body, :string + field :path, :string + field :data, :map, default: %{} + field :idempotency_key, :string + field :read_at, :utc_datetime + belongs_to :user, WhoNeedHelp.Accounts.User + timestamps(type: :utc_datetime) + end + + def changeset(notification, attrs) do + notification + |> cast(attrs, [:user_id, :kind, :title, :body, :path, :data, :idempotency_key, :read_at]) + |> validate_required([:user_id, :kind, :title, :body, :path, :idempotency_key]) + |> validate_length(:title, min: 1, max: 120) + |> validate_length(:body, min: 1, max: 240) + |> validate_length(:path, min: 1, max: 500) + |> validate_format(:path, ~r|^/(?!/)[A-Za-z0-9_/?=&.#%-]*$|) + |> validate_length(:idempotency_key, min: 1, max: 255) + |> unique_constraint(:idempotency_key) + end + + def kinds, do: @kinds +end diff --git a/lib/who_need_help/notifications/preference.ex b/lib/who_need_help/notifications/preference.ex new file mode 100644 index 0000000..347291f --- /dev/null +++ b/lib/who_need_help/notifications/preference.ex @@ -0,0 +1,68 @@ +defmodule WhoNeedHelp.Notifications.Preference do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "notification_preferences" do + field :push_enabled, :boolean, default: true + field :email_enabled, :boolean, default: true + field :nearby_push_enabled, :boolean, default: true + field :nearby_email_enabled, :boolean, default: false + field :message_push_enabled, :boolean, default: true + field :lifecycle_push_enabled, :boolean, default: true + field :quiet_hours_enabled, :boolean, default: false + field :quiet_start, :time + field :quiet_end, :time + field :time_zone, :string, default: "Etc/UTC" + field :utc_offset_minutes, :integer, default: 0 + belongs_to :user, WhoNeedHelp.Accounts.User + timestamps(type: :utc_datetime) + end + + def changeset(preference, attrs) do + preference + |> cast(attrs, [ + :user_id, + :push_enabled, + :email_enabled, + :nearby_push_enabled, + :nearby_email_enabled, + :message_push_enabled, + :lifecycle_push_enabled, + :quiet_hours_enabled, + :quiet_start, + :quiet_end, + :time_zone, + :utc_offset_minutes + ]) + |> validate_required([ + :user_id, + :push_enabled, + :email_enabled, + :nearby_push_enabled, + :nearby_email_enabled, + :message_push_enabled, + :lifecycle_push_enabled, + :quiet_hours_enabled, + :time_zone, + :utc_offset_minutes + ]) + |> validate_length(:time_zone, min: 1, max: 64) + |> validate_number(:utc_offset_minutes, + greater_than_or_equal_to: -840, + less_than_or_equal_to: 840 + ) + |> validate_quiet_hours() + |> unique_constraint(:user_id) + end + + defp validate_quiet_hours(changeset) do + if get_field(changeset, :quiet_hours_enabled) do + validate_required(changeset, [:quiet_start, :quiet_end]) + else + changeset + end + end +end diff --git a/lib/who_need_help/notifications/push_device.ex b/lib/who_need_help/notifications/push_device.ex new file mode 100644 index 0000000..ffde20b --- /dev/null +++ b/lib/who_need_help/notifications/push_device.ex @@ -0,0 +1,91 @@ +defmodule WhoNeedHelp.Notifications.PushDevice do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @type t :: %__MODULE__{} + + schema "push_devices" do + field :platform, Ecto.Enum, values: [:web, :android] + field :provider, Ecto.Enum, values: [:web_push, :fcm] + field :token, :string, redact: true + field :token_digest, :binary + field :installation_id, :string, redact: true + field :p256dh, :string, redact: true + field :auth_secret, :string, redact: true + field :device_label, :string + field :user_agent, :string + field :last_seen_at, :utc_datetime + field :disabled_at, :utc_datetime + belongs_to :user, WhoNeedHelp.Accounts.User + timestamps(type: :utc_datetime) + end + + def changeset(device, attrs) do + device + |> cast(attrs, [ + :user_id, + :platform, + :provider, + :token, + :installation_id, + :p256dh, + :auth_secret, + :device_label, + :user_agent, + :last_seen_at, + :disabled_at + ]) + |> validate_required([ + :user_id, + :platform, + :provider, + :token, + :installation_id, + :last_seen_at + ]) + |> validate_length(:token, min: 16, max: 4_096) + |> validate_length(:installation_id, min: 16, max: 120) + |> validate_length(:p256dh, max: 512) + |> validate_length(:auth_secret, max: 512) + |> validate_length(:device_label, max: 120) + |> validate_length(:user_agent, max: 500) + |> validate_provider_fields() + |> validate_platform_provider_pair() + |> put_token_digest() + |> unique_constraint(:token_digest) + |> unique_constraint([:platform, :installation_id], + name: :push_devices_platform_installation_id_index + ) + end + + defp validate_provider_fields(changeset) do + case get_field(changeset, :provider) do + :web_push -> validate_required(changeset, [:p256dh, :auth_secret]) + :fcm -> changeset + _unknown -> changeset + end + end + + defp validate_platform_provider_pair(changeset) do + case {get_field(changeset, :platform), get_field(changeset, :provider)} do + {:web, :web_push} -> changeset + {:android, :fcm} -> changeset + {nil, _provider} -> changeset + {_platform, nil} -> changeset + _invalid -> add_error(changeset, :provider, "does not match the device platform") + end + end + + defp put_token_digest(changeset) do + case get_field(changeset, :token) do + token when is_binary(token) and token != "" -> + put_change(changeset, :token_digest, :crypto.hash(:sha256, token)) + + _missing -> + changeset + end + end +end diff --git a/lib/who_need_help/product_analytics.ex b/lib/who_need_help/product_analytics.ex new file mode 100644 index 0000000..e9abd8f --- /dev/null +++ b/lib/who_need_help/product_analytics.ex @@ -0,0 +1,88 @@ +defmodule WhoNeedHelp.ProductAnalytics do + @moduledoc """ + Privacy-minimised aggregate product counters. + + Counters deliberately contain no user identifier, exact coordinate, request + description, chat message, medicine name, email address, or device token. + """ + + import Ecto.Query + + alias WhoNeedHelp.Accounts + alias WhoNeedHelp.Accounts.Scope + alias WhoNeedHelp.Pagination + alias WhoNeedHelp.ProductAnalytics.DailyMetric + alias WhoNeedHelp.Repo + + @allowed_dimensions %{ + "account.registered" => ~w(email google), + "request.created" => ~w(now today scheduled), + "request.accepted" => ~w(all), + "assignment.started" => ~w(all), + "assignment.arrived" => ~w(all), + "request.completed" => ~w(all), + "request.cancelled" => ~w(no_longer_needed safety_concern plans_changed other), + "assignment.withdrawn" => ~w(all), + "notification.opened" => ~w( + nearby_request request_accepted request_reopened request_cancelled assignment_started + helper_arrived handover_verified request_completed message_created review_revealed + support_update + ) + } + + @allowed_metrics Map.keys(@allowed_dimensions) + + def increment(metric, dimension \\ "all") + + def increment(metric, dimension) when metric in @allowed_metrics and is_binary(dimension) do + if dimension in Map.fetch!(@allowed_dimensions, metric) do + now = DateTime.utc_now(:second) + + %DailyMetric{ + date: DateTime.to_date(now), + metric: metric, + dimension: dimension, + count: 1, + inserted_at: now, + updated_at: now + } + |> Repo.insert( + conflict_target: [:date, :metric, :dimension], + on_conflict: [inc: [count: 1], set: [updated_at: now]] + ) + else + {:error, :invalid_dimension} + end + end + + def increment(_metric, _dimension), do: {:error, :invalid_metric} + + def paginate(%Scope{user: user}, options \\ []) do + if Accounts.moderator_authorized?(user) do + limit = Pagination.limit(options) + cursor = Pagination.cursor(options) + + DailyMetric + |> before(cursor) + |> order_by([metric], desc: metric.inserted_at, desc: metric.id) + |> limit(^(limit + 1)) + |> Repo.all() + |> Pagination.page(limit, &{&1.inserted_at, &1.id}) + else + %Pagination.Page{} + end + end + + def allowed_metrics, do: @allowed_metrics + + defp before(query, nil), do: query + + defp before(query, {inserted_at, id}) do + where( + query, + [metric], + metric.inserted_at < ^inserted_at or + (metric.inserted_at == ^inserted_at and metric.id < ^id) + ) + end +end diff --git a/lib/who_need_help/product_analytics/daily_metric.ex b/lib/who_need_help/product_analytics/daily_metric.ex new file mode 100644 index 0000000..c15262f --- /dev/null +++ b/lib/who_need_help/product_analytics/daily_metric.ex @@ -0,0 +1,24 @@ +defmodule WhoNeedHelp.ProductAnalytics.DailyMetric do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + + schema "product_daily_metrics" do + field :date, :date + field :metric, :string + field :dimension, :string, default: "all" + field :count, :integer, default: 0 + timestamps(type: :utc_datetime) + end + + def changeset(metric, attrs) do + metric + |> cast(attrs, [:date, :metric, :dimension, :count]) + |> validate_required([:date, :metric, :dimension, :count]) + |> validate_format(:metric, ~r/^[a-z][a-z0-9_.-]{1,79}$/) + |> validate_length(:dimension, min: 1, max: 120) + |> validate_number(:count, greater_than_or_equal_to: 0) + |> unique_constraint([:date, :metric, :dimension]) + end +end diff --git a/lib/who_need_help/push.ex b/lib/who_need_help/push.ex index fe45499..79f6514 100644 --- a/lib/who_need_help/push.ex +++ b/lib/who_need_help/push.ex @@ -1,13 +1,15 @@ defmodule WhoNeedHelp.Push do @moduledoc """ - Provider-neutral boundary and durable product-event enqueueing for remote push. + Durable product-event enqueueing for remote notifications. - The product deliberately remains disabled until a complete HTTP adapter - configuration is supplied. Product events target a stable user identifier; - the selected provider is responsible for resolving that identifier to one or - more registered devices. + Registered browser and Android devices use direct Web Push and FCM delivery. + An optional provider-neutral HTTP gateway remains available for deployments + that resolve a stable user recipient outside the application. Every remote + channel stays inactive until its complete provider configuration exists. """ + alias WhoNeedHelp.Notifications + alias WhoNeedHelp.Notifications.{Notification, PushDevice} alias WhoNeedHelp.Push.DeliveryWorker @type notification :: %{ @@ -26,12 +28,20 @@ defmodule WhoNeedHelp.Push do def enabled?, do: Application.get_env(:who_need_help, :push_product_enabled, false) == true + def supervisor_children do + case Application.get_env(:who_need_help, :fcm_goth_source) do + nil -> [] + source -> [{Goth, name: WhoNeedHelp.Goth, source: source}] + end + end + def enqueue_request_accepted(assignment_id, request_id, requester_id) do - enqueue(%{ - idempotency_key: "request-accepted:#{assignment_id}:#{requester_id}", - recipient: user_recipient(requester_id), + Notifications.notify_user(requester_id, %{ + kind: :request_accepted, title: "A helper responded", body: "Open Who Need Help to see the request update.", + path: "/requests/#{request_id}", + idempotency_key: "request-accepted:#{assignment_id}:#{requester_id}", data: %{ "kind" => "request_accepted", "assignment_id" => assignment_id, @@ -41,11 +51,12 @@ defmodule WhoNeedHelp.Push do end def enqueue_message_created(message_id, assignment_id, request_id, recipient_id) do - enqueue(%{ - idempotency_key: "message-created:#{message_id}:#{recipient_id}", - recipient: user_recipient(recipient_id), + Notifications.notify_user(recipient_id, %{ + kind: :message_created, title: "New message", body: "Open Who Need Help to read the conversation.", + path: "/requests/#{request_id}#messages", + idempotency_key: "message-created:#{message_id}:#{recipient_id}", data: %{ "kind" => "message_created", "assignment_id" => assignment_id, @@ -55,6 +66,18 @@ defmodule WhoNeedHelp.Push do }) end + def enqueue_lifecycle(kind, event_id, request_id, recipient_id, title, body) + when is_atom(kind) do + Notifications.notify_user(recipient_id, %{ + kind: kind, + title: title, + body: body, + path: "/requests/#{request_id}", + idempotency_key: "#{kind}:#{event_id}:#{recipient_id}", + data: %{"kind" => to_string(kind), "request_id" => request_id} + }) + end + def enqueue(notification) do if enabled?() do notification @@ -84,7 +107,33 @@ defmodule WhoNeedHelp.Push do Application.get_env(:who_need_help, :push_delivery_options, []) end - defp user_recipient(user_id), do: "user:#{user_id}" + def gateway_enabled?, do: enabled?() + + def deliver_device(%Notification{} = notification, %PushDevice{} = device, opts \\ []) do + adapter = + Keyword.get_lazy(opts, :adapter, fn -> + configured_device_adapter(device.provider) + end) + + adapter.deliver(notification, device, Keyword.delete(opts, :adapter)) + end + + def configured_device_adapter(:web_push) do + Application.get_env( + :who_need_help, + :web_push_adapter, + WhoNeedHelp.Push.WebPushAdapter + ) + end + + def configured_device_adapter(:fcm) do + Application.get_env(:who_need_help, :fcm_adapter, WhoNeedHelp.Push.FCMAdapter) + end + + def device_delivery_options(provider) do + Application.get_env(:who_need_help, :device_delivery_options, %{}) + |> Map.get(provider, []) + end defp validate(%{ idempotency_key: idempotency_key, diff --git a/lib/who_need_help/push/device_adapter.ex b/lib/who_need_help/push/device_adapter.ex new file mode 100644 index 0000000..96ad839 --- /dev/null +++ b/lib/who_need_help/push/device_adapter.ex @@ -0,0 +1,8 @@ +defmodule WhoNeedHelp.Push.DeviceAdapter do + @moduledoc false + + alias WhoNeedHelp.Notifications.{Notification, PushDevice} + + @callback deliver(Notification.t(), PushDevice.t(), keyword()) :: + {:ok, map()} | {:error, term()} +end diff --git a/lib/who_need_help/push/device_delivery_worker.ex b/lib/who_need_help/push/device_delivery_worker.ex new file mode 100644 index 0000000..4ea8d9c --- /dev/null +++ b/lib/who_need_help/push/device_delivery_worker.ex @@ -0,0 +1,64 @@ +defmodule WhoNeedHelp.Push.DeviceDeliveryWorker do + @moduledoc false + + use Oban.Worker, + queue: :push, + max_attempts: 8, + unique: [ + period: :infinity, + fields: [:args, :worker], + keys: [:notification_id, :device_id] + ] + + alias WhoNeedHelp.Notifications + alias WhoNeedHelp.Notifications.{Notification, PushDevice} + alias WhoNeedHelp.Push + alias WhoNeedHelp.Repo + + @impl Oban.Worker + def perform(%Oban.Job{ + args: %{"notification_id" => notification_id, "device_id" => device_id} + }) do + notification = Repo.get(Notification, notification_id) + device = Repo.get(PushDevice, device_id) + + cond do + is_nil(notification) -> + {:cancel, :notification_missing} + + is_nil(device) or not is_nil(device.disabled_at) -> + {:cancel, :device_unavailable} + + notification.user_id != device.user_id -> + {:cancel, :ownership_mismatch} + + true -> + deliver(notification, device) + end + end + + defp deliver(notification, device) do + case Push.deliver_device( + notification, + device, + Push.device_delivery_options(device.provider) + ) do + {:ok, _receipt} -> + :ok + + {:error, :expired} -> + _ = Notifications.disable_invalid_device(device) + :ok + + {:error, {:rejected, status, _body}} when status in [400, 401, 403, 404, 410] -> + _ = Notifications.disable_invalid_device(device) + {:cancel, :device_rejected} + + {:error, {:invalid_configuration, _field} = reason} -> + {:cancel, reason} + + {:error, reason} -> + {:error, reason} + end + end +end diff --git a/lib/who_need_help/push/fcm_adapter.ex b/lib/who_need_help/push/fcm_adapter.ex new file mode 100644 index 0000000..1ff5051 --- /dev/null +++ b/lib/who_need_help/push/fcm_adapter.ex @@ -0,0 +1,111 @@ +defmodule WhoNeedHelp.Push.FCMAdapter do + @moduledoc false + @behaviour WhoNeedHelp.Push.DeviceAdapter + + alias WhoNeedHelp.Notifications.{Notification, PushDevice} + + @retryable_statuses [408, 425, 429, 500, 502, 503, 504] + + @impl true + def deliver(%Notification{} = notification, %PushDevice{} = device, opts) do + with {:ok, project_id} <- fetch_binary(opts, :project_id), + {:ok, token} <- fetch_access_token(opts), + {:ok, endpoint} <- endpoint(opts, project_id) do + payload = payload(notification, device) + + case Req.post(endpoint, + json: payload, + headers: [{"authorization", "Bearer " <> token}], + retry: false, + receive_timeout: Keyword.get(opts, :receive_timeout, 10_000), + connect_options: [timeout: Keyword.get(opts, :connect_timeout, 5_000)] + ) do + {:ok, %Req.Response{status: status, body: %{"name" => name}}} + when status in 200..299 -> + {:ok, %{id: name, duplicate: false}} + + {:ok, %Req.Response{status: status, body: body}} when status in @retryable_statuses -> + {:error, {:retryable, status, sanitize(body)}} + + {:ok, %Req.Response{status: status, body: body}} -> + {:error, {:rejected, status, sanitize(body)}} + + {:error, error} -> + {:error, {:transport, transport_reason(error)}} + end + end + end + + @doc false + def payload(%Notification{} = notification, %PushDevice{} = device) do + %{ + "message" => %{ + "token" => device.token, + "data" => + notification.data + |> stringify_values() + |> Map.put("path", notification.path) + |> Map.put("notification_id", notification.id) + |> Map.put("title", notification.title) + |> Map.put("body", notification.body), + "android" => %{ + "priority" => priority(notification.kind), + "ttl" => "300s" + } + } + } + end + + defp fetch_access_token(opts) do + case Keyword.get(opts, :access_token) do + token when is_binary(token) and token != "" -> + {:ok, token} + + _missing -> + goth_name = Keyword.get(opts, :goth_name, WhoNeedHelp.Goth) + + case Goth.fetch(goth_name) do + {:ok, %{token: token}} -> {:ok, token} + {:error, reason} -> {:error, {:oauth, reason}} + end + end + catch + :exit, reason -> {:error, {:oauth, reason}} + end + + defp endpoint(opts, project_id) do + case Keyword.get(opts, :endpoint) do + nil -> {:ok, "https://fcm.googleapis.com/v1/projects/#{project_id}/messages:send"} + endpoint when is_binary(endpoint) and endpoint != "" -> {:ok, endpoint} + _invalid -> {:error, {:invalid_configuration, :endpoint}} + end + end + + defp fetch_binary(opts, key) do + case Keyword.get(opts, key) do + value when is_binary(value) and value != "" -> {:ok, value} + _invalid -> {:error, {:invalid_configuration, key}} + end + end + + defp stringify_values(data) do + Map.new(data, fn {key, value} -> {to_string(key), stringify_value(value)} end) + end + + defp stringify_value(value) when is_binary(value), do: value + defp stringify_value(value) when is_atom(value) or is_number(value), do: to_string(value) + defp stringify_value(value), do: Jason.encode!(value) + + defp priority(kind) when kind in [:nearby_request, :request_accepted, :message_created], + do: "high" + + defp priority(_kind), do: "normal" + + defp sanitize(%{"error" => error}) when is_map(error), + do: Map.take(error, ["code", "status"]) + + defp sanitize(_body), do: nil + + defp transport_reason(%Req.TransportError{reason: reason}), do: reason + defp transport_reason(%{__struct__: module}), do: module +end diff --git a/lib/who_need_help/push/nearby_match_worker.ex b/lib/who_need_help/push/nearby_match_worker.ex new file mode 100644 index 0000000..55b77e0 --- /dev/null +++ b/lib/who_need_help/push/nearby_match_worker.ex @@ -0,0 +1,43 @@ +defmodule WhoNeedHelp.Push.NearbyMatchWorker do + @moduledoc false + + use Oban.Worker, + queue: :push, + unique: [period: :infinity, fields: [:args, :worker], keys: [:request_id, :event_key]] + + alias WhoNeedHelp.Help.HelpRequest + alias WhoNeedHelp.Notifications + alias WhoNeedHelp.Repo + + def enqueue(request_id, event_key \\ "created") do + %{request_id: request_id, event_key: event_key} + |> new() + |> Oban.insert() + end + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"request_id" => request_id} = args}) do + event_key = Map.get(args, "event_key", "created") + + case Repo.get(HelpRequest, request_id) do + %HelpRequest{status: :open, hidden_at: nil} = request -> + request + |> Notifications.matching_nearby_subscriptions() + |> Enum.reduce_while(:ok, fn subscription, :ok -> + case Notifications.notify_user( + subscription.user_id, + Notifications.nearby_notification_attrs(request, subscription, event_key) + ) do + {:ok, _notification} -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + + %HelpRequest{} -> + :ok + + nil -> + {:cancel, :request_missing} + end + end +end diff --git a/lib/who_need_help/push/notification_dispatch_worker.ex b/lib/who_need_help/push/notification_dispatch_worker.ex new file mode 100644 index 0000000..935b3a4 --- /dev/null +++ b/lib/who_need_help/push/notification_dispatch_worker.ex @@ -0,0 +1,102 @@ +defmodule WhoNeedHelp.Push.NotificationDispatchWorker do + @moduledoc false + + use Oban.Worker, + queue: :push, + unique: [period: :infinity, fields: [:args, :worker], keys: [:notification_id]] + + alias WhoNeedHelp.Notifications + alias WhoNeedHelp.Notifications.{Notification, Preference} + alias WhoNeedHelp.Push + alias WhoNeedHelp.Push.{DeliveryWorker, DeviceDeliveryWorker, NotificationEmailWorker} + alias WhoNeedHelp.Repo + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"notification_id" => notification_id}} = job) do + with %Notification{} = notification <- Repo.get(Notification, notification_id) do + if job.attempt == 1, do: Notifications.broadcast_created(notification) + now = DateTime.utc_now(:second) + + preference = + Repo.get_by(Preference, user_id: notification.user_id) || + %Preference{user_id: notification.user_id} + + push_allowed? = Notifications.push_allowed?(preference, notification) + email_allowed? = Notifications.email_allowed?(preference, notification) + + cond do + not push_allowed? and not email_allowed? -> + :ok + + Notifications.quiet_now?(preference, now) -> + seconds = + preference + |> Notifications.next_quiet_end(now) + |> DateTime.diff(now, :second) + |> max(1) + + {:snooze, seconds} + + true -> + case dispatch_push(notification, push_allowed?) do + :ok -> dispatch_email(notification, email_allowed?) + {:error, _reason} = error -> error + end + end + else + nil -> {:cancel, :notification_missing} + end + end + + defp dispatch_push(_notification, false), do: :ok + + defp dispatch_push(notification, true) do + devices = Notifications.active_devices(notification.user_id) + + cond do + devices != [] -> + Enum.reduce_while(devices, :ok, fn device, :ok -> + case %{notification_id: notification.id, device_id: device.id} + |> DeviceDeliveryWorker.new() + |> Oban.insert() do + {:ok, _job} -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + + Push.gateway_enabled?() -> + gateway_notification(notification) + |> DeliveryWorker.new() + |> Oban.insert() + |> case do + {:ok, _job} -> :ok + {:error, reason} -> {:error, reason} + end + + true -> + :ok + end + end + + defp dispatch_email(_notification, false), do: :ok + + defp dispatch_email(notification, true) do + notification.id + |> then(&NotificationEmailWorker.new(%{notification_id: &1})) + |> Oban.insert() + |> case do + {:ok, _job} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp gateway_notification(notification) do + %{ + idempotency_key: notification.idempotency_key, + recipient: "user:#{notification.user_id}", + title: notification.title, + body: notification.body, + data: Map.put(notification.data, "path", notification.path) + } + end +end diff --git a/lib/who_need_help/push/notification_email_worker.ex b/lib/who_need_help/push/notification_email_worker.ex new file mode 100644 index 0000000..c76767d --- /dev/null +++ b/lib/who_need_help/push/notification_email_worker.ex @@ -0,0 +1,38 @@ +defmodule WhoNeedHelp.Push.NotificationEmailWorker do + @moduledoc false + + use Oban.Worker, + queue: :push, + max_attempts: 8, + unique: [period: :infinity, fields: [:args, :worker], keys: [:notification_id]] + + alias WhoNeedHelp.Notifications + alias WhoNeedHelp.Notifications.{EmailNotifier, Notification, Preference} + alias WhoNeedHelp.Repo + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"notification_id" => notification_id}}) do + notification = Repo.get(Notification, notification_id) + + case notification do + nil -> + {:cancel, :notification_missing} + + %Notification{} = notification -> + notification = Repo.preload(notification, :user) + + preference = + Repo.get_by(Preference, user_id: notification.user_id) || + %Preference{user_id: notification.user_id} + + if Notifications.email_allowed?(preference, notification) do + case EmailNotifier.deliver(notification.user, notification) do + {:ok, _metadata} -> :ok + {:error, reason} -> {:error, reason} + end + else + {:cancel, :email_disabled} + end + end + end +end diff --git a/lib/who_need_help/push/web_push_adapter.ex b/lib/who_need_help/push/web_push_adapter.ex new file mode 100644 index 0000000..bf4fa00 --- /dev/null +++ b/lib/who_need_help/push/web_push_adapter.ex @@ -0,0 +1,64 @@ +defmodule WhoNeedHelp.Push.WebPushAdapter do + @moduledoc false + @behaviour WhoNeedHelp.Push.DeviceAdapter + + alias WhoNeedHelp.Notifications.{Notification, PushDevice} + + @impl true + def deliver(%Notification{} = notification, %PushDevice{} = device, _opts) do + subscription = + Jason.encode!(%{ + "endpoint" => device.token, + "keys" => %{"p256dh" => device.p256dh, "auth" => device.auth_secret} + }) + + message = + Jason.encode!(%{ + "title" => notification.title, + "body" => notification.body, + "path" => notification.path, + "tag" => notification.idempotency_key, + "data" => notification.data + }) + + try do + case WebPushElixir.send_notification(subscription, message, + ttl: 300, + urgency: urgency(notification.kind), + topic: topic(notification.idempotency_key) + ) do + {:ok, response} -> + {:ok, + %{ + id: "web-push:#{notification.id}:#{device.id}", + duplicate: false, + status: Map.get(response, :status) + }} + + {:error, :expired} -> + {:error, :expired} + + {:error, {:http_error, status, _body}} + when status in [408, 425, 429, 500, 502, 503, 504] -> + {:error, {:retryable, status, nil}} + + {:error, {:http_error, status, _body}} -> + {:error, {:rejected, status, nil}} + end + rescue + KeyError -> {:error, {:invalid_configuration, :vapid_keys}} + ArgumentError -> {:error, {:invalid_configuration, :vapid_keys}} + end + end + + defp urgency(kind) when kind in [:nearby_request, :request_accepted, :message_created], + do: :high + + defp urgency(_kind), do: :normal + + defp topic(idempotency_key) do + :crypto.hash(:sha256, idempotency_key) + |> Base.url_encode64(padding: false) + |> binary_part(0, 32) + end +end diff --git a/lib/who_need_help/support.ex b/lib/who_need_help/support.ex index 71bda36..78ca8a2 100644 --- a/lib/who_need_help/support.ex +++ b/lib/who_need_help/support.ex @@ -4,6 +4,7 @@ defmodule WhoNeedHelp.Support do import Ecto.Query alias WhoNeedHelp.Accounts + alias WhoNeedHelp.Accounts.DataLifecycle alias WhoNeedHelp.Accounts.{Scope, User} alias WhoNeedHelp.Pagination alias WhoNeedHelp.Repo @@ -161,6 +162,17 @@ defmodule WhoNeedHelp.Support do end end + def deletion_assessment(%Scope{user: moderator} = scope, id) do + with true <- Accounts.moderator_authorized?(moderator), + {:ok, id} <- Ecto.UUID.cast(id), + %SupportRequest{} = request <- Repo.get(SupportRequest, id) do + DataLifecycle.deletion_assessment(scope, request) + else + false -> {:error, :forbidden} + _ -> {:error, :not_found} + end + end + defp notify_received({:ok, request}) do _ = Notifier.deliver_received(request, status_url(request)) _ = Notifier.deliver_operator_alert(request) diff --git a/lib/who_need_help_web/components/layouts.ex b/lib/who_need_help_web/components/layouts.ex index da57400..1272152 100644 --- a/lib/who_need_help_web/components/layouts.ex +++ b/lib/who_need_help_web/components/layouts.ex @@ -76,6 +76,13 @@ defmodule WhoNeedHelpWeb.Layouts do <.theme_toggle /> <%= if @current_scope do %> + <.link + navigate={~p"/notifications"} + class="btn btn-ghost btn-sm btn-square" + aria-label={gettext("Notifications and nearby alerts")} + > + <.icon name="hero-bell" class="size-5" /> + <.link navigate={~p"/requests/new"} class="btn btn-primary btn-sm whitespace-nowrap"> {gettext("Ask for help")} @@ -117,6 +124,9 @@ defmodule WhoNeedHelpWeb.Layouts do
  • <.link navigate={~p"/profile"}>{gettext("Profile")}
  • +
  • + <.link navigate={~p"/notifications"}>{gettext("Notifications")} +
  • <.link href={~p"/users/settings"}>{gettext("Account settings")}
  • @@ -290,6 +300,9 @@ defmodule WhoNeedHelpWeb.Layouts do
  • <.link navigate={~p"/profile"}>{gettext("Profile")}
  • +
  • + <.link navigate={~p"/notifications"}>{gettext("Notifications")} +
  • <.link href={~p"/users/settings"}>{gettext("Account settings")}
  • diff --git a/lib/who_need_help_web/controllers/google_auth_controller.ex b/lib/who_need_help_web/controllers/google_auth_controller.ex index da279d8..d3f1f44 100644 --- a/lib/who_need_help_web/controllers/google_auth_controller.ex +++ b/lib/who_need_help_web/controllers/google_auth_controller.ex @@ -3,7 +3,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do require Logger - alias WhoNeedHelp.{Accounts, GoogleAuth, Repo, Trust} + alias WhoNeedHelp.{Accounts, GoogleAuth, ProductAnalytics, Repo, Trust} alias WhoNeedHelp.Trust.RateLimiter alias WhoNeedHelpWeb.{GoogleAuthPending, UserAuth} @@ -26,23 +26,8 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do def start_registration(conn, %{"google_registration" => params}) when is_map(params) do locale = normalize_locale(params["locale"]) - terms_accepted = params["terms_accepted"] in [true, "true", "on", "1"] - if terms_accepted do - start_flow( - conn, - "register", - %{"locale" => locale, "terms_accepted" => true}, - ~p"/users/register" - ) - else - conn - |> put_flash( - :error, - gettext("Confirm that you are 18 or older and accept the safety rules first.") - ) - |> redirect(to: ~p"/users/register") - end + start_flow(conn, "register", %{"locale" => locale}, ~p"/users/register") end def start_registration(conn, _params) do @@ -111,6 +96,8 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do "locale" => normalize_locale(params["locale"] || pending.locale), "terms_accepted" => true }) do + _ = ProductAnalytics.increment("account.registered", "google") + conn |> GoogleAuthPending.delete() |> put_flash(:info, gettext("Your account was created with Google.")) @@ -278,37 +265,9 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do end defp finish_flow(conn, "register", flow, identity_attrs) do - with {:ok, _limit} <- RateLimiter.check(:registration_email, identity_attrs.email), - {:ok, {user, _identity}} <- - Accounts.register_user_by_google(identity_attrs, %{ - "locale" => flow["locale"], - "terms_accepted" => flow["terms_accepted"] - }) do - conn - |> put_flash(:info, gettext("Your account was created with Google.")) - |> UserAuth.log_in_user(user) - else - {:error, :email_already_registered} -> - case GoogleAuthPending.put(conn, identity_attrs, flow_locale(flow)) do - {:ok, conn} -> - conn - |> put_flash( - :info, - gettext("This email already has an account. Confirm it once to connect Google.") - ) - |> redirect(to: ~p"/auth/google/complete") - - {:error, _reason} -> - google_account_unavailable(conn, ~p"/users/log-in") - end - - {:error, :rate_limited} -> - conn - |> put_flash( - :error, - gettext("Too many registration attempts in the configured time window.") - ) - |> redirect(to: ~p"/users/register") + case GoogleAuthPending.put(conn, identity_attrs, flow_locale(flow)) do + {:ok, conn} -> + redirect(conn, to: ~p"/auth/google/complete") {:error, _reason} -> google_account_unavailable(conn, ~p"/users/register") diff --git a/lib/who_need_help_web/controllers/mobile_push_device_controller.ex b/lib/who_need_help_web/controllers/mobile_push_device_controller.ex new file mode 100644 index 0000000..876673a --- /dev/null +++ b/lib/who_need_help_web/controllers/mobile_push_device_controller.ex @@ -0,0 +1,43 @@ +defmodule WhoNeedHelpWeb.MobilePushDeviceController do + use WhoNeedHelpWeb, :controller + + alias WhoNeedHelp.Accounts.Scope + alias WhoNeedHelp.{Notifications, Trust} + + def create(conn, params) do + with %Scope{user: user} = scope when not is_nil(user) <- conn.assigns.current_scope, + {:ok, _limit} <- Trust.authorize_action(scope, :register_push_device), + {:ok, device} <- Notifications.register_device(scope, params) do + conn + |> put_status(:created) + |> json(%{ + id: device.id, + platform: to_string(device.platform), + provider: to_string(device.provider) + }) + else + nil -> + send_resp(conn, :unauthorized, "") + + %Scope{user: nil} -> + send_resp(conn, :unauthorized, "") + + {:error, :account_not_eligible} -> + send_resp(conn, :forbidden, "") + + {:error, :rate_limited} -> + send_resp(conn, :too_many_requests, "") + + {:error, :already_registered} -> + send_resp(conn, :conflict, "") + + {:error, %Ecto.Changeset{}} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "invalid_device"}) + + {:error, _reason} -> + send_resp(conn, :unprocessable_entity, "") + end + end +end diff --git a/lib/who_need_help_web/controllers/user_data_export_controller.ex b/lib/who_need_help_web/controllers/user_data_export_controller.ex new file mode 100644 index 0000000..d141703 --- /dev/null +++ b/lib/who_need_help_web/controllers/user_data_export_controller.ex @@ -0,0 +1,20 @@ +defmodule WhoNeedHelpWeb.UserDataExportController do + use WhoNeedHelpWeb, :controller + + alias WhoNeedHelp.Accounts.DataExport + + def show(conn, _params) do + {:ok, json} = DataExport.encode(conn.assigns.current_scope) + date = Date.utc_today() |> Date.to_iso8601() + + conn + |> put_resp_header("cache-control", "no-store") + |> put_resp_header("pragma", "no-cache") + |> put_resp_header( + "content-disposition", + ~s(attachment; filename="who-need-help-account-export-#{date}.json") + ) + |> put_resp_content_type("application/json") + |> send_resp(:ok, json) + end +end diff --git a/lib/who_need_help_web/controllers/user_registration_controller.ex b/lib/who_need_help_web/controllers/user_registration_controller.ex index f32c2fa..d3c22e6 100644 --- a/lib/who_need_help_web/controllers/user_registration_controller.ex +++ b/lib/who_need_help_web/controllers/user_registration_controller.ex @@ -3,7 +3,7 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do require Logger - alias WhoNeedHelp.{Accounts, GoogleAuth} + alias WhoNeedHelp.{Accounts, GoogleAuth, ProductAnalytics} alias WhoNeedHelp.Accounts.User alias WhoNeedHelp.Trust.RateLimiter @@ -27,6 +27,7 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do result <- Accounts.register_user(user_params) do case result do {:ok, user} -> + _ = ProductAnalytics.increment("account.registered", "email") deliver_registration_instructions(conn, user) registration_response(conn) diff --git a/lib/who_need_help_web/controllers/user_registration_html/new.html.heex b/lib/who_need_help_web/controllers/user_registration_html/new.html.heex index 0944516..80f7bc5 100644 --- a/lib/who_need_help_web/controllers/user_registration_html/new.html.heex +++ b/lib/who_need_help_web/controllers/user_registration_html/new.html.heex @@ -25,43 +25,21 @@

    <.form + :if={@google_auth_enabled} for={%{}} as={:google_registration} action={~p"/auth/google/register"} id="google_registration_form" - class="space-y-3" > - <.input - type="checkbox" - id="google_registration_terms" - name="google_registration[terms_accepted]" - value="false" - label={gettext("I am 18 or older and accept the Terms and Safety Rules")} - required - /> -

    - {gettext("Read the")} - <.link href={~p"/terms"} class="link font-semibold">{gettext("Terms")}, - <.link href={~p"/privacy"} class="link font-semibold">{gettext("Privacy Policy")} - {gettext("and")} - <.link href={~p"/safety"} class="link font-semibold">{gettext("Safety Rules")} - {gettext("before confirming.")} -

    - <.google_auth_button - label={gettext("Sign up with Google")} - disabled={!@google_auth_enabled} - /> -

    - {gettext("Google sign-in will be available after the operator configures it.")} -

    + <.google_auth_button label={gettext("Sign up with Google")} /> -
    {gettext("or sign up with email")}
    +
    {gettext("or sign up with email")}
    <.form :let={f} diff --git a/lib/who_need_help_web/controllers/user_session_html/new.html.heex b/lib/who_need_help_web/controllers/user_session_html/new.html.heex index 61c279d..a8ce35f 100644 --- a/lib/who_need_help_web/controllers/user_session_html/new.html.heex +++ b/lib/who_need_help_web/controllers/user_session_html/new.html.heex @@ -69,22 +69,18 @@
    <.form - :if={!@current_scope} + :if={!@current_scope && @google_auth_enabled} for={%{}} as={:google_login} action={~p"/auth/google/login"} id="google_login_form" > - <.google_auth_button - label={gettext("Continue with Google")} - disabled={!@google_auth_enabled} - /> -

    - {gettext("Google sign-in will be available after the operator configures it.")} -

    + <.google_auth_button label={gettext("Continue with Google")} /> -
    {gettext("or use email")}
    +
    + {gettext("or use email")} +

    + <.link href={~p"/users/data-export"} class="btn btn-outline"> + {gettext("Download my data")} + <.link navigate={~p"/account/delete"} class="btn btn-error btn-outline"> {gettext("Request account deletion")} diff --git a/lib/who_need_help_web/live/leaderboard_live.ex b/lib/who_need_help_web/live/leaderboard_live.ex index 3ed908d..c7b9eba 100644 --- a/lib/who_need_help_web/live/leaderboard_live.ex +++ b/lib/who_need_help_web/live/leaderboard_live.ex @@ -43,7 +43,7 @@ defmodule WhoNeedHelpWeb.LeaderboardLive do

    {gettext( - "Ranking prioritizes unique people helped with optional location-supported evidence, then unique verified handovers. Repeated help between the same pair does not inflate the primary score." + "Ranking prioritizes unique people helped with optional location-supported evidence, then unique code-confirmed handovers. These are activity signals, not identity or safety checks. Repeated help between the same pair does not inflate the primary score." )}

    @@ -54,7 +54,7 @@ defmodule WhoNeedHelpWeb.LeaderboardLive do {gettext("Rank")} {gettext("Helper")} {gettext("Location-supported people")} - {gettext("Verified people")} + {gettext("Code-confirmed people")} {gettext("Unique people")} {gettext("Total")} {gettext("Rating")} diff --git a/lib/who_need_help_web/live/moderation_live.ex b/lib/who_need_help_web/live/moderation_live.ex index 6228de2..cc1fb35 100644 --- a/lib/who_need_help_web/live/moderation_live.ex +++ b/lib/who_need_help_web/live/moderation_live.ex @@ -289,7 +289,17 @@ defmodule WhoNeedHelpWeb.ModerationLive do ~H"""
    {gettext("RESTRICTED WORKSPACE")}
    -

    {gettext("Moderation")}

    +
    +

    {gettext("Moderation")}

    +
    + <.link navigate={~p"/analytics"} class="btn btn-outline btn-sm"> + {gettext("Product analytics")} + + <.link navigate={~p"/support/operations"} class="btn btn-outline btn-sm"> + {gettext("Support operations")} + +
    +

    {gettext("Decisions and access to reported chat evidence are written to the audit log.")}

    diff --git a/lib/who_need_help_web/live/notification_live.ex b/lib/who_need_help_web/live/notification_live.ex new file mode 100644 index 0000000..036d5d5 --- /dev/null +++ b/lib/who_need_help_web/live/notification_live.ex @@ -0,0 +1,737 @@ +defmodule WhoNeedHelpWeb.NotificationLive do + use WhoNeedHelpWeb, :live_view + + alias WhoNeedHelp.Catalog + alias WhoNeedHelp.Catalog.Category + alias WhoNeedHelp.Notifications + alias WhoNeedHelp.Notifications.NearbySubscription + + @impl true + def mount(_params, _session, socket) do + if connected?(socket), do: Notifications.subscribe(socket.assigns.current_scope) + + {:ok, + socket + |> assign(:page_title, gettext("Notifications")) + |> assign(:categories, Catalog.list_categories()) + |> assign(:web_push_public_key, Application.get_env(:who_need_help, :web_push_public_key)) + |> assign(:subscription_form, new_subscription_form()) + |> load_notification_state()} + end + + @impl true + def handle_info({:notification_created, _notification}, socket) do + {:noreply, load_notifications(socket)} + end + + def handle_info({event, _value}, socket) + when event in [:notification_read, :notifications_read] do + {:noreply, load_notifications(socket)} + end + + @impl true + def handle_event("mark-all-read", _params, socket) do + {:ok, _count} = Notifications.mark_all_read(socket.assigns.current_scope) + {:noreply, load_notifications(socket)} + end + + def handle_event("open-notification", %{"id" => id}, socket) do + case Notifications.notification_opened(socket.assigns.current_scope, id) do + {:ok, notification} -> + {:noreply, push_navigate(socket, to: notification.path)} + + {:error, :not_found} -> + {:noreply, put_flash(socket, :error, gettext("Notification not found."))} + end + end + + def handle_event("load-more-notifications", _params, socket) do + page = + Notifications.paginate_notifications(socket.assigns.current_scope, + after: socket.assigns.notifications_cursor + ) + + known = MapSet.new(socket.assigns.notifications, & &1.id) + entries = Enum.reject(page.entries, &MapSet.member?(known, &1.id)) + + {:noreply, + socket + |> update(:notifications, &(&1 ++ entries)) + |> assign(:notifications_cursor, page.next_cursor)} + end + + def handle_event("save-preferences", %{"notification_preference" => params}, socket) do + case Notifications.update_preference(socket.assigns.current_scope, params) do + {:ok, preference} -> + {:noreply, + socket + |> assign(:preference_form, preference_form(preference)) + |> put_flash(:info, gettext("Notification preferences saved."))} + + {:error, changeset} -> + {:noreply, + assign(socket, :preference_form, to_form(changeset, as: :notification_preference))} + end + end + + def handle_event("validate-subscription", %{"nearby_subscription" => params}, socket) do + changeset = + %NearbySubscription{} + |> Notifications.change_nearby_subscription(params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :subscription_form, to_form(changeset))} + end + + def handle_event("create-subscription", %{"nearby_subscription" => params}, socket) do + case Notifications.create_nearby_subscription(socket.assigns.current_scope, params) do + {:ok, _subscription} -> + {:noreply, + socket + |> assign(:subscription_form, new_subscription_form()) + |> load_subscriptions() + |> put_flash(:info, gettext("Nearby alert created."))} + + {:error, changeset} -> + {:noreply, assign(socket, :subscription_form, to_form(changeset))} + end + end + + def handle_event("toggle-subscription", %{"id" => id, "active" => active}, socket) do + case Notifications.update_nearby_subscription(socket.assigns.current_scope, id, %{ + active: active == "true" + }) do + {:ok, _subscription} -> {:noreply, load_subscriptions(socket)} + {:error, _reason} -> {:noreply, put_flash(socket, :error, gettext("Alert was not found."))} + end + end + + def handle_event("delete-subscription", %{"id" => id}, socket) do + case Notifications.delete_nearby_subscription(socket.assigns.current_scope, id) do + {:ok, _subscription} -> + {:noreply, + socket + |> load_subscriptions() + |> put_flash(:info, gettext("Nearby alert deleted."))} + + {:error, _reason} -> + {:noreply, put_flash(socket, :error, gettext("Alert was not found."))} + end + end + + def handle_event("refresh-push-devices", _params, socket) do + {:reply, %{ok: true}, load_devices(socket)} + end + + def handle_event("disable-device", %{"id" => id}, socket) do + case Notifications.disable_device(socket.assigns.current_scope, id) do + {:ok, _device} -> + {:noreply, + socket + |> load_devices() + |> put_flash(:info, gettext("Push notifications disabled for that device."))} + + {:error, _reason} -> + {:noreply, put_flash(socket, :error, gettext("Device was not found."))} + end + end + + defp load_notification_state(socket) do + preference = Notifications.get_preference(socket.assigns.current_scope) + + socket + |> assign(:preference_form, preference_form(preference)) + |> load_notifications() + |> load_subscriptions() + |> load_devices() + end + + defp load_notifications(socket) do + page = Notifications.paginate_notifications(socket.assigns.current_scope) + + socket + |> assign(:notifications, page.entries) + |> assign(:notifications_cursor, page.next_cursor) + |> assign(:unread_count, Notifications.unread_count(socket.assigns.current_scope)) + end + + defp load_subscriptions(socket) do + assign( + socket, + :subscriptions, + Notifications.list_nearby_subscriptions(socket.assigns.current_scope) + ) + end + + defp load_devices(socket) do + assign(socket, :devices, Notifications.list_devices(socket.assigns.current_scope)) + end + + defp preference_form(preference), + do: + preference + |> Notifications.change_preference() + |> to_form(as: :notification_preference) + + defp new_subscription_form do + %NearbySubscription{} + |> Notifications.change_nearby_subscription(%{ + "radius_meters" => 5_000, + "urgencies" => NearbySubscription.allowed_urgencies(), + "available_days" => [1, 2, 3, 4, 5, 6, 7], + "push_enabled" => true, + "email_enabled" => false + }) + |> to_form() + end + + defp category_name(category, locale), do: Category.name(category, locale) + + defp selected?(form, field, value) do + form[field].value + |> List.wrap() + |> Enum.map(&to_string/1) + |> Enum.member?(to_string(value)) + end + + defp coordinate_value(form, key), do: Map.get(form.params, key, "") + + defp format_time(datetime) do + Calendar.strftime(datetime, "%Y-%m-%d %H:%M UTC") + end + + @impl true + def render(assigns) do + ~H""" + +
    +
    +
    +

    + {gettext("Stay available without refreshing")} +

    +

    {gettext("Notifications")}

    +

    + {gettext( + "Follow request updates and choose which nearby needs should reach this device." + )} +

    +
    + +
    + +
    +
    +

    {gettext("Inbox")}

    + + {ngettext("%{count} unread", "%{count} unread", @unread_count, count: @unread_count)} + +
    + +
    + <.icon name="hero-bell-slash" class="mx-auto size-8 text-base-content/45" /> +

    {gettext("No notifications yet")}

    +

    + {gettext("Request, message, and nearby-alert updates will appear here.")} +

    +
    + +
    + +
    + + +
    + +
    +
    +

    {gettext("Delivery channels and quiet hours")}

    +

    + {gettext("Notification text never contains chat messages or exact coordinates.")} +

    + +
    + +

    + <%= if @web_push_public_key do %> + {gettext("Your browser will ask for notification permission.")} + <% else %> + {gettext("Web Push keys are not configured in this local environment yet.")} + <% end %> +

    +
    + +
    +
    +
    +

    + {device.device_label || gettext("Web browser")} +

    +

    {format_time(device.last_seen_at)}

    +
    + +
    +
    + + <.form + for={@preference_form} + phx-submit="save-preferences" + id="notification-preferences-form" + phx-hook="NotificationTimeZone" + class="mt-6 space-y-4" + > + + + <.input + field={@preference_form[:push_enabled]} + type="checkbox" + label={gettext("Allow push notifications")} + /> + <.input + field={@preference_form[:nearby_push_enabled]} + type="checkbox" + label={gettext("Nearby requests")} + /> + <.input + field={@preference_form[:message_push_enabled]} + type="checkbox" + label={gettext("New private messages")} + /> + <.input + field={@preference_form[:lifecycle_push_enabled]} + type="checkbox" + label={gettext("Acceptance, arrival, completion, and cancellation")} + /> +
    {gettext("Email")}
    + <.input + field={@preference_form[:email_enabled]} + type="checkbox" + label={gettext("Allow email notifications")} + /> + <.input + field={@preference_form[:nearby_email_enabled]} + type="checkbox" + label={gettext("Nearby requests by email")} + /> + <.input + field={@preference_form[:quiet_hours_enabled]} + type="checkbox" + label={gettext("Use quiet hours")} + /> +
    + <.input field={@preference_form[:quiet_start]} type="time" label={gettext("From")} /> + <.input field={@preference_form[:quiet_end]} type="time" label={gettext("Until")} /> +
    + <.button class="btn btn-primary w-full">{gettext("Save preferences")} + +
    + +
    +

    {gettext("Your nearby alerts")}

    +

    + {gettext("The saved center is private and is used only to find matching requests.")} +

    + +
    + {gettext("No nearby alerts configured yet.")} +
    + +
    +
    +
    +
    +

    {subscription.name}

    +

    + {subscription.location_label} · {div(subscription.radius_meters, 1_000)} km +

    +
    + + {gettext("Push")} + + + {gettext("Email")} + +
    +
    + + {if subscription.active, do: gettext("Active"), else: gettext("Paused")} + +
    +
    + + +
    +
    +
    +
    +
    + +
    +

    {gettext("Create a nearby alert")}

    +

    + {gettext("Choose an area, categories, urgency, and the times when you are available.")} +

    + + <.form + for={@subscription_form} + id="nearby-subscription-form" + phx-change="validate-subscription" + phx-submit="create-subscription" + class="mt-6 space-y-5" + > + <.input + field={@subscription_form[:name]} + label={gettext("Alert name")} + placeholder={gettext("Urgent medicine near home")} + /> + <.input + field={@subscription_form[:location_label]} + label={gettext("Private area label")} + placeholder={gettext("Home area")} + /> + +
    + +
    +
    +
    +
    + <.icon name="hero-hand-raised" class="size-4 shrink-0" /> + {gettext("Click the map or drag the green pin to move your private alert area")} +
    +
    + +
    +

    + {gettext("Choose the private center of your alert area.")} +

    +
    + {gettext("Enter coordinates manually")} +
    + + +
    +
    +
    + +
    + {gettext("Categories")} +

    + {gettext("Leave every category unchecked to receive all matching requests.")} +

    +
    + +
    +
    + +
    +
    + {gettext("Urgency")} +
    + +
    +
    +
    + {gettext("Available days")} +
    + +
    +
    +
    + +
    + <.input + field={@subscription_form[:available_from]} + type="time" + label={gettext("Available from (optional)")} + /> + <.input + field={@subscription_form[:available_until]} + type="time" + label={gettext("Available until (optional)")} + /> +
    +
    + {gettext("Delivery channels")} +

    + {gettext("Choose at least one way to receive this nearby alert.")} +

    +
    + <.input + field={@subscription_form[:push_enabled]} + type="checkbox" + label={gettext("Push notification")} + /> + <.input + field={@subscription_form[:email_enabled]} + type="checkbox" + label={gettext("Email notification")} + /> +
    +
    + <.button class="btn btn-primary btn-lg w-full">{gettext("Create nearby alert")} + +
    +
    +
    + """ + end +end diff --git a/lib/who_need_help_web/live/product_analytics_live.ex b/lib/who_need_help_web/live/product_analytics_live.ex new file mode 100644 index 0000000..647ce0c --- /dev/null +++ b/lib/who_need_help_web/live/product_analytics_live.ex @@ -0,0 +1,91 @@ +defmodule WhoNeedHelpWeb.ProductAnalyticsLive do + use WhoNeedHelpWeb, :live_view + + alias WhoNeedHelp.ProductAnalytics + + @impl true + def mount(_params, _session, socket) do + page = ProductAnalytics.paginate(socket.assigns.current_scope) + + {:ok, + socket + |> assign(:page_title, gettext("Product analytics")) + |> assign(:metrics, page.entries) + |> assign(:metrics_cursor, page.next_cursor)} + end + + @impl true + def handle_event("load-more", _params, socket) do + page = + ProductAnalytics.paginate(socket.assigns.current_scope, + after: socket.assigns.metrics_cursor + ) + + existing_ids = MapSet.new(socket.assigns.metrics, & &1.id) + + {:noreply, + socket + |> assign( + :metrics, + socket.assigns.metrics ++ Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id)) + ) + |> assign(:metrics_cursor, page.next_cursor)} + end + + @impl true + def render(assigns) do + ~H""" + +
    {gettext("RESTRICTED WORKSPACE")}
    +
    +
    +

    {gettext("Product analytics")}

    +

    + {gettext( + "Daily aggregate counters only. No user ID, email, coordinate, request text, chat text, medicine name, or device credential is stored here." + )} +

    +
    + <.link navigate={~p"/moderation"} class="btn btn-outline btn-sm"> + {gettext("Back to moderation")} + +
    + +
    + + + + + + + + + + + + + + + + + + + + +
    {gettext("Date")}{gettext("Metric")}{gettext("Dimension")}{gettext("Count")}
    + {gettext("No aggregate events recorded yet.")} +
    {metric.date}{metric.metric}{metric.dimension}{metric.count}
    +
    + + +
    + """ + end +end diff --git a/lib/who_need_help_web/live/profile_live.ex b/lib/who_need_help_web/live/profile_live.ex index 91030f3..2527fcb 100644 --- a/lib/who_need_help_web/live/profile_live.ex +++ b/lib/who_need_help_web/live/profile_live.ex @@ -183,6 +183,11 @@ defmodule WhoNeedHelpWeb.ProfileLive do