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("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.")}
+
+
+
+
+
+
+
+ {notification.title}
+
+ {notification.body}
+
+
+ {format_time(notification.inserted_at)}
+
+
+ <.icon name="hero-chevron-right" class="mt-2 size-4 shrink-0 opacity-45" />
+
+
+
+
+ {gettext("Load older notifications")}
+
+
+
+
+
+
{gettext("Delivery channels and quiet hours")}
+
+ {gettext("Notification text never contains chat messages or exact coordinates.")}
+
+
+
+
+ <.icon name="hero-bell-alert" class="size-5" />
+ {gettext("Enable push on this device")}
+
+
+ <%= 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)}
+
+
+ {gettext("Disable")}
+
+
+
+
+ <.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")}
+
+
+
+
+ {if subscription.active, do: gettext("Pause"), else: gettext("Resume")}
+
+
+ {gettext("Delete")}
+
+
+
+
+
+
+
+
+ {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("Latitude")}
+
+
+
+ {gettext("Longitude")}
+
+
+
+
+
+
+
+ {gettext("Categories")}
+
+ {gettext("Leave every category unchecked to receive all matching requests.")}
+
+
+
+
+
+ {category_name(category, Gettext.get_locale(WhoNeedHelpWeb.Gettext))}
+
+
+
+
+
+
+
+
+ <.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}
+
+
+
+
+
+
+ {gettext("Load more")}
+
+
+ """
+ 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
{gettext("Public reputation")}
+
+ {gettext(
+ "These are activity signals, not an identity, background, qualification, or safety check."
+ )}
+
{@reputation.completed}
@@ -196,7 +201,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
{@reputation.verified_handovers}
- {gettext("verified handovers")}
+ {gettext("code-confirmed handovers")}
diff --git a/lib/who_need_help_web/live/public_profile_live.ex b/lib/who_need_help_web/live/public_profile_live.ex
index 56c9530..d71d2bc 100644
--- a/lib/who_need_help_web/live/public_profile_live.ex
+++ b/lib/who_need_help_web/live/public_profile_live.ex
@@ -122,6 +122,11 @@ defmodule WhoNeedHelpWeb.PublicProfileLive do
{gettext("Public reputation")}
+
+ {gettext(
+ "These are activity signals, not an identity, background, qualification, or safety check."
+ )}
+
{@reputation.completed}
@@ -133,7 +138,7 @@ defmodule WhoNeedHelpWeb.PublicProfileLive do
{@reputation.verified_handovers}
-
{gettext("verified handovers")}
+
{gettext("code-confirmed handovers")}
{@reputation.rating || "—"}
diff --git a/lib/who_need_help_web/live/request_live/show.ex b/lib/who_need_help_web/live/request_live/show.ex
index aa84bc6..073291c 100644
--- a/lib/who_need_help_web/live/request_live/show.ex
+++ b/lib/who_need_help_web/live/request_live/show.ex
@@ -66,11 +66,16 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
case Help.get_request(socket.assigns.current_scope, request.id) do
{:ok, request} ->
previous_status = socket.assigns.request.status
+ previous_assignment = socket.assigns.assignment
socket =
socket
|> maybe_subscribe_assignment(request)
- |> maybe_put_status_transition_flash(previous_status, request.status)
+ |> maybe_put_status_transition_flash(
+ previous_status,
+ request.status,
+ previous_assignment
+ )
{:noreply, sync_tracking_after_request_update(socket, request)}
@@ -144,8 +149,10 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
end
end
- def handle_event("cancel-request", _, socket) do
- case Help.cancel_request(socket.assigns.current_scope, socket.assigns.request.id) do
+ def handle_event("cancel-request", params, socket) do
+ attrs = Map.get(params, "cancellation", %{})
+
+ case Help.cancel_request(socket.assigns.current_scope, socket.assigns.request.id, attrs) do
{:ok, _request} ->
{:noreply, put_flash(socket, :info, gettext("Request cancelled."))}
@@ -157,12 +164,37 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
def handle_event("start", _, socket),
do: transition(socket, &Help.start_assignment/2, gettext("Help is in progress."))
+ def handle_event("arrive", _, socket),
+ do:
+ transition(
+ socket,
+ &Help.arrive_assignment/2,
+ gettext("Arrival saved. The requester has been notified.")
+ )
+
def handle_event("confirm", _, socket),
do: transition(socket, &Help.confirm_completion/2, gettext("Your confirmation was saved."))
- def handle_event("withdraw", _, socket),
- do:
- transition(socket, &Help.withdraw_assignment/2, gettext("You withdrew from this request."))
+ def handle_event("withdraw", params, socket) do
+ attrs = Map.get(params, "withdrawal", %{})
+
+ case Help.withdraw_assignment(
+ socket.assigns.current_scope,
+ socket.assigns.assignment.id,
+ attrs
+ ) do
+ {:ok, _assignment} ->
+ {:noreply,
+ put_flash(
+ socket,
+ :info,
+ gettext("You left this match. The request is open for another helper if time remains.")
+ )}
+
+ {:error, reason} ->
+ {:noreply, put_flash(socket, :error, message(reason))}
+ end
+ end
def handle_event("verify-code", %{"handover" => %{"code" => code}}, socket) do
case Help.verify_handover(socket.assigns.current_scope, socket.assigns.assignment.id, code) do
@@ -445,9 +477,32 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
end
end
- defp maybe_put_status_transition_flash(socket, status, status), do: socket
+ defp maybe_put_status_transition_flash(socket, status, status, _previous_assignment), do: socket
- defp maybe_put_status_transition_flash(socket, _previous, :matched) do
+ defp maybe_put_status_transition_flash(
+ %{assigns: %{current_scope: %{user: %{id: user_id}}}} = socket,
+ previous,
+ :open,
+ %{helper_id: user_id}
+ )
+ when previous in [:matched, :in_progress] do
+ put_flash(
+ socket,
+ :info,
+ gettext("You left this match. The request is open for another helper if time remains.")
+ )
+ end
+
+ defp maybe_put_status_transition_flash(socket, previous, :open, _previous_assignment)
+ when previous in [:matched, :in_progress] do
+ put_flash(
+ socket,
+ :info,
+ gettext("The previous helper left. This request is open for a new helper again.")
+ )
+ end
+
+ defp maybe_put_status_transition_flash(socket, _previous, :matched, _previous_assignment) do
put_flash(
socket,
:info,
@@ -455,19 +510,20 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
)
end
- defp maybe_put_status_transition_flash(socket, _previous, :in_progress) do
+ defp maybe_put_status_transition_flash(socket, _previous, :in_progress, _previous_assignment) do
put_flash(socket, :info, gettext("Help is now in progress."))
end
- defp maybe_put_status_transition_flash(socket, _previous, :completed) do
+ defp maybe_put_status_transition_flash(socket, _previous, :completed, _previous_assignment) do
put_flash(socket, :info, gettext("Help is complete."))
end
- defp maybe_put_status_transition_flash(socket, _previous, :cancelled) do
+ defp maybe_put_status_transition_flash(socket, _previous, :cancelled, _previous_assignment) do
put_flash(socket, :info, gettext("This request was cancelled."))
end
- defp maybe_put_status_transition_flash(socket, _previous, _status), do: socket
+ defp maybe_put_status_transition_flash(socket, _previous, _status, _previous_assignment),
+ do: socket
defp restore_private_interaction(socket, true), do: socket
@@ -556,6 +612,20 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|> assign(:tracking_active, tracking_active)
|> assign(:message_form, to_form(%{"body" => ""}, as: :message))
|> assign(:handover_form, to_form(%{"code" => ""}, as: :handover))
+ |> assign(
+ :cancellation_form,
+ to_form(
+ %{"cancellation_reason" => "no_longer_needed", "cancellation_note" => ""},
+ as: :cancellation
+ )
+ )
+ |> assign(
+ :withdrawal_form,
+ to_form(
+ %{"withdrawal_reason" => "cannot_complete", "withdrawal_note" => ""},
+ as: :withdrawal
+ )
+ )
|> assign(:review_form, to_form(%{"rating" => "5", "comment" => ""}, as: :review))
|> assign(:report_form, report_form())
|> assign(
@@ -763,6 +833,37 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
else: not is_nil(assignment.helper_confirmed_at)
end
+ defp lifecycle_steps(assignment) do
+ [
+ {gettext("Helper accepted"), not is_nil(assignment.accepted_at)},
+ {gettext("Help started"), not is_nil(assignment.started_at)},
+ {gettext("Helper arrived"), not is_nil(assignment.arrived_at)},
+ {gettext("Handover verified"), not is_nil(assignment.handover_verified_at)},
+ {gettext("Both participants confirmed"),
+ not is_nil(assignment.requester_confirmed_at) and
+ not is_nil(assignment.helper_confirmed_at)},
+ {gettext("Completed"), assignment.status == :completed}
+ ]
+ end
+
+ defp cancellation_reason_options do
+ [
+ {gettext("Help is no longer needed"), "no_longer_needed"},
+ {gettext("Safety concern"), "safety_concern"},
+ {gettext("Plans changed"), "plans_changed"},
+ {gettext("Other"), "other"}
+ ]
+ end
+
+ defp withdrawal_reason_options do
+ [
+ {gettext("I can no longer complete it"), "cannot_complete"},
+ {gettext("Safety concern"), "safety_concern"},
+ {gettext("Requester is unreachable"), "requester_unreachable"},
+ {gettext("Other"), "other"}
+ ]
+ end
+
defp visibility_label(:approximate_public), do: gettext("Approximate publicly")
defp visibility_label(:hidden), do: gettext("Hidden")
defp visibility_label(:exact_for_active_match), do: gettext("Exact only for active match")
@@ -1063,13 +1164,41 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
{gettext("Waiting for a nearby volunteer.")}
-
- {gettext("Cancel request")}
-
+
+ {gettext("Cancel request")}
+
+
+ <.form
+ for={@cancellation_form}
+ id="open-request-cancellation-form"
+ phx-submit="cancel-request"
+ class="space-y-3"
+ >
+ <.input
+ field={@cancellation_form[:cancellation_reason]}
+ type="select"
+ label={gettext("Reason")}
+ options={cancellation_reason_options()}
+ />
+ <.input
+ field={@cancellation_form[:cancellation_note]}
+ type="textarea"
+ label={gettext("Note (optional)")}
+ maxlength="500"
+ />
+ <.button
+ class="btn btn-error btn-outline w-full"
+ phx-disable-with={gettext("Cancelling…")}
+ >
+ {gettext("Confirm cancellation")}
+
+
+
+
<% @participant -> %>
@@ -1121,6 +1250,22 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
+
{gettext("Progress")}
+
+ <.icon
+ name={if complete?, do: "hero-check-circle", else: "hero-clock"}
+ class={[
+ "size-5 shrink-0",
+ complete? && "text-success",
+ !complete? && "opacity-45"
+ ]}
+ />
+ {label}
+
+
{gettext("Requester confirmed")}
@@ -1181,6 +1326,16 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
>
{gettext("Start helping")}
+
+ {gettext("I have arrived")}
+
{gettext("Your completion confirmation is saved.")}
-
- {gettext("Withdraw from this request")}
-
-
+ {gettext("Withdraw from this request")}
+
+
+ <.form
+ for={@withdrawal_form}
+ id="assignment-withdrawal-form"
+ phx-submit="withdraw"
+ class="space-y-3"
+ >
+ <.input
+ field={@withdrawal_form[:withdrawal_reason]}
+ type="select"
+ label={gettext("Reason")}
+ options={withdrawal_reason_options()}
+ />
+ <.input
+ field={@withdrawal_form[:withdrawal_note]}
+ type="textarea"
+ label={gettext("Note (optional)")}
+ maxlength="500"
+ />
+
+ {gettext(
+ "Leaving ends this private match. If the request has not expired, it becomes available to other helpers."
+ )}
+
+ <.button
+ class="btn btn-error btn-outline w-full"
+ phx-disable-with={gettext("Leaving…")}
+ >
+ {gettext("Confirm withdrawal")}
+
+
+
+
+
- {gettext("Cancel request")}
-
+
+ {gettext("Cancel request")}
+
+
+ <.form
+ for={@cancellation_form}
+ id="matched-request-cancellation-form"
+ phx-submit="cancel-request"
+ class="space-y-3"
+ >
+ <.input
+ field={@cancellation_form[:cancellation_reason]}
+ type="select"
+ label={gettext("Reason")}
+ options={cancellation_reason_options()}
+ />
+ <.input
+ field={@cancellation_form[:cancellation_note]}
+ type="textarea"
+ label={gettext("Note (optional)")}
+ maxlength="500"
+ />
+
+ {gettext("Cancelling ends this match and stops live-location sharing.")}
+
+ <.button
+ class="btn btn-error btn-outline w-full"
+ phx-disable-with={gettext("Cancelling…")}
+ >
+ {gettext("Confirm cancellation")}
+
+
+
+
assign(:page_title, gettext("Support operations")) |> load()}
+ {:ok,
+ socket
+ |> assign(:page_title, gettext("Support operations"))
+ |> assign(:deletion_assessments, %{})
+ |> load()}
end
@impl true
@@ -25,6 +29,16 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
)
end
+ def handle_event("assess-deletion", %{"id" => id}, socket) do
+ case Support.deletion_assessment(socket.assigns.current_scope, id) do
+ {:ok, assessment} ->
+ {:noreply, update(socket, :deletion_assessments, &Map.put(&1, id, assessment))}
+
+ {:error, reason} ->
+ {:noreply, put_flash(socket, :error, error_message(reason))}
+ end
+ end
+
def handle_event("load-more-support", _params, socket) do
page =
Support.paginate_for_staff(socket.assigns.current_scope,
@@ -72,6 +86,10 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
defp error_message(:forbidden), do: gettext("Moderator access is required.")
defp error_message(:not_found), do: gettext("The selected record is no longer available.")
+ defp error_message(:not_deletion_request), do: gettext("This is not an account-deletion case.")
+ defp error_message(:account_not_linked), do: gettext("The request is not linked to an account.")
+ defp error_message(:contact_not_verified), do: gettext("Verify the requester contact first.")
+ defp error_message(:account_not_found), do: gettext("The linked account no longer exists.")
defp error_message(%Ecto.Changeset{}), do: gettext("Please check the submitted fields.")
defp error_message(_reason), do: gettext("Could not update the request. Please try again.")
@@ -239,6 +257,36 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
{request.subject}
{request.contact_email}
{request.details}
+
+
+ {gettext("Run deletion preflight")}
+
+
+
+ {if assessment.technically_idle,
+ do: gettext("No active product workflow was found."),
+ else: gettext("Active or unresolved product state must be handled first.")}
+
+
+
+ {blocker.kind}: {blocker.count}
+
+
+
+ {gettext(
+ "Automatic erasure is disabled: no jurisdiction-specific retention and anonymisation policy is configured. Do not mark the case resolved until the approved data action is completed and recorded."
+ )}
+
+
+
<.form
for={form}
phx-submit="moderate-support"
diff --git a/lib/who_need_help_web/router.ex b/lib/who_need_help_web/router.ex
index 963baf1..59dc002 100644
--- a/lib/who_need_help_web/router.ex
+++ b/lib/who_need_help_web/router.ex
@@ -62,6 +62,7 @@ defmodule WhoNeedHelpWeb.Router do
scope "/mobile", WhoNeedHelpWeb do
pipe_through :mobile
+ post "/push-devices", MobilePushDeviceController, :create
post "/tracking/:assignment_id/position", MobileTrackingController, :update
post "/tracking/:assignment_id/stop", MobileTrackingController, :stop
end
@@ -130,6 +131,7 @@ defmodule WhoNeedHelpWeb.Router do
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
+ get "/users/data-export", UserDataExportController, :show
get "/users/settings/confirm-email", UserSettingsController, :confirm_email_page
post "/users/settings/confirm-email", UserSettingsController, :confirm_email
post "/auth/google/link", GoogleAuthController, :start_link
@@ -161,11 +163,13 @@ defmodule WhoNeedHelpWeb.Router do
live "/profile", ProfileLive, :edit
live "/people/:id", PublicProfileLive, :show
live "/leaderboard", LeaderboardLive, :index
+ live "/notifications", NotificationLive, :index
end
live_session :moderation,
on_mount: [{WhoNeedHelpWeb.UserAuth, :ensure_moderator}] do
live "/moderation", ModerationLive, :index
+ live "/analytics", ProductAnalyticsLive, :index
live "/support/operations", SupportOperationsLive, :index
end
end
diff --git a/mix.exs b/mix.exs
index 1d4a4c2..e29c8f2 100644
--- a/mix.exs
+++ b/mix.exs
@@ -80,6 +80,8 @@ defmodule WhoNeedHelp.MixProject do
{:gen_smtp, "~> 1.3"},
{:req, "~> 0.5"},
{:assent, "~> 0.3.1"},
+ {:goth, "~> 1.4"},
+ {:web_push_elixir, "~> 0.8.0"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_metrics_prometheus_core, "~> 1.2.1"},
{:telemetry_poller, "~> 1.0"},
diff --git a/mix.lock b/mix.lock
index 2bc5651..493d070 100644
--- a/mix.lock
+++ b/mix.lock
@@ -24,10 +24,12 @@
"geo": {:hex, :geo, "4.1.0", "64ba89a64cc400b5b16dd2f5bd644cb141776eb8c2ac5a983332c8d944936c12", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "19edb2b3398ca9f701b573b1fb11bc90951ebd64f18b06bd1bf35abe509a2934"},
"geo_postgis": {:hex, :geo_postgis, "3.7.1", "614f25b42334a615bd54bb09c22030b1aac7bac8f829bd823ab1faccf093a324", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:geo, "~> 3.6 or ~> 4.0", [hex: :geo, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "c20d823c600d35b7fe9ddd5be03052bb7136c57d6f1775dbd46871545e405280"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
+ "goth": {:hex, :goth, "1.4.5", "ee37f96e3519bdecd603f20e7f10c758287088b6d77c0147cd5ee68cf224aade", [:mix], [{:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "0fc2dce5bd710651ed179053d0300ce3a5d36afbdde11e500d57f05f398d5ed5"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"},
"idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
+ "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"},
@@ -55,6 +57,7 @@
"telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
+ "web_push_elixir": {:hex, :web_push_elixir, "0.8.0", "15cfd26d9da0f2e6158c964940c486fd6d100593bd3616e40f0c947ecf2fb505", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9cd767358ff699d83c3e9fa6b670bdf0b5c2933d6bf3546ec8a041074642b87c"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"},
}
diff --git a/priv/repo/migrations/20260722190604_add_notifications_and_request_lifecycle.exs b/priv/repo/migrations/20260722190604_add_notifications_and_request_lifecycle.exs
new file mode 100644
index 0000000..13f0b10
--- /dev/null
+++ b/priv/repo/migrations/20260722190604_add_notifications_and_request_lifecycle.exs
@@ -0,0 +1,133 @@
+defmodule WhoNeedHelp.Repo.Migrations.AddNotificationsAndRequestLifecycle do
+ use Ecto.Migration
+
+ def change do
+ alter table(:help_requests) do
+ add :cancellation_reason, :string
+ add :cancellation_note, :text
+ end
+
+ alter table(:help_assignments) do
+ add :active, :boolean, null: false, default: true
+ add :arrived_at, :utc_datetime
+ add :withdrawal_reason, :string
+ add :withdrawal_note, :text
+ end
+
+ drop unique_index(:help_assignments, [:request_id])
+
+ create unique_index(:help_assignments, [:request_id],
+ where: "active",
+ name: :help_assignments_one_active_per_request
+ )
+
+ create index(:help_assignments, [:helper_id, :active, :inserted_at])
+
+ create table(:notifications, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :kind, :string, null: false
+ add :title, :string, null: false
+ add :body, :text, null: false
+ add :path, :string, null: false
+ add :data, :map, null: false, default: %{}
+ add :idempotency_key, :string, null: false
+ add :read_at, :utc_datetime
+ add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
+ timestamps(type: :utc_datetime)
+ end
+
+ create unique_index(:notifications, [:idempotency_key])
+ create index(:notifications, [:user_id, :inserted_at, :id])
+
+ create index(:notifications, [:user_id, :inserted_at],
+ where: "read_at IS NULL",
+ name: :notifications_user_unread_index
+ )
+
+ create table(:notification_preferences, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :push_enabled, :boolean, null: false, default: true
+ add :email_enabled, :boolean, null: false, default: true
+ add :nearby_push_enabled, :boolean, null: false, default: true
+ add :nearby_email_enabled, :boolean, null: false, default: false
+ add :message_push_enabled, :boolean, null: false, default: true
+ add :lifecycle_push_enabled, :boolean, null: false, default: true
+ add :quiet_hours_enabled, :boolean, null: false, default: false
+ add :quiet_start, :time
+ add :quiet_end, :time
+ add :time_zone, :string, null: false, default: "Etc/UTC"
+ add :utc_offset_minutes, :integer, null: false, default: 0
+ add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
+ timestamps(type: :utc_datetime)
+ end
+
+ create unique_index(:notification_preferences, [:user_id])
+
+ create table(:nearby_subscriptions, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :name, :string, null: false
+ add :active, :boolean, null: false, default: true
+ add :location_label, :string, null: false
+ add :center, :geometry, null: false
+ add :radius_meters, :integer, null: false, default: 5_000
+ add :category_ids, {:array, :binary_id}, null: false, default: []
+ add :urgencies, {:array, :string}, null: false, default: ["now", "today", "scheduled"]
+ add :available_days, {:array, :integer}, null: false, default: [1, 2, 3, 4, 5, 6, 7]
+ add :available_from, :time
+ add :available_until, :time
+ add :push_enabled, :boolean, null: false, default: true
+ add :email_enabled, :boolean, null: false, default: false
+ add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:nearby_subscriptions, [:user_id, :active])
+ create index(:nearby_subscriptions, [:center], using: :gist)
+
+ create constraint(:nearby_subscriptions, :nearby_subscriptions_radius_allowed,
+ check: "radius_meters IN (1000, 3000, 5000, 10000, 25000)"
+ )
+
+ create constraint(:nearby_subscriptions, :nearby_subscriptions_schedule_complete,
+ check:
+ "(available_from IS NULL AND available_until IS NULL) OR " <>
+ "(available_from IS NOT NULL AND available_until IS NOT NULL)"
+ )
+
+ create constraint(:nearby_subscriptions, :nearby_subscriptions_delivery_channel_required,
+ check: "push_enabled OR email_enabled"
+ )
+
+ create table(:push_devices, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :platform, :string, null: false
+ add :provider, :string, null: false
+ add :token, :text, null: false
+ add :token_digest, :binary, null: false
+ add :installation_id, :string, null: false
+ add :p256dh, :string
+ add :auth_secret, :string
+ add :device_label, :string
+ add :user_agent, :string
+ add :last_seen_at, :utc_datetime, null: false
+ add :disabled_at, :utc_datetime
+ add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
+ timestamps(type: :utc_datetime)
+ end
+
+ create unique_index(:push_devices, [:token_digest])
+ create unique_index(:push_devices, [:platform, :installation_id])
+ create index(:push_devices, [:user_id, :disabled_at, :last_seen_at])
+
+ create table(:product_daily_metrics, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :date, :date, null: false
+ add :metric, :string, null: false
+ add :dimension, :string, null: false, default: "all"
+ add :count, :bigint, null: false, default: 0
+ timestamps(type: :utc_datetime)
+ end
+
+ create unique_index(:product_daily_metrics, [:date, :metric, :dimension])
+ end
+end
diff --git a/priv/static/sw.js b/priv/static/sw.js
index 3ab327c..ca443a6 100644
--- a/priv/static/sw.js
+++ b/priv/static/sw.js
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "who-need-help-static-"
-const CACHE = `${CACHE_PREFIX}v4`
+const CACHE = `${CACHE_PREFIX}v5`
const OFFLINE_URL = "/offline.html"
const SHELL = [
OFFLINE_URL,
@@ -60,6 +60,52 @@ self.addEventListener("fetch", event => {
}
})
+self.addEventListener("push", event => {
+ let payload = {}
+
+ try {
+ payload = event.data?.json() || {}
+ } catch (_error) {
+ payload = {title: "Who Need Help", body: "Open the app to view an update."}
+ }
+
+ const title = String(payload.title || "Who Need Help").slice(0, 120)
+ const body = String(payload.body || "Open the app to view an update.").slice(0, 240)
+ const path = validPath(payload.path) ? payload.path : "/notifications"
+
+ event.waitUntil(self.registration.showNotification(title, {
+ body,
+ icon: "/images/pwa-192.png",
+ badge: "/images/favicon-48.png",
+ tag: String(payload.tag || "who-need-help-update").slice(0, 64),
+ data: {path}
+ }))
+})
+
+self.addEventListener("notificationclick", event => {
+ event.notification.close()
+ const path = validPath(event.notification.data?.path)
+ ? event.notification.data.path
+ : "/notifications"
+
+ event.waitUntil((async () => {
+ const target = new URL(path, self.location.origin).href
+ const windows = await self.clients.matchAll({type: "window", includeUncontrolled: true})
+ const existing = windows.find(client => new URL(client.url).origin === self.location.origin)
+
+ if (existing) {
+ await existing.navigate(target)
+ return existing.focus()
+ }
+
+ return self.clients.openWindow(target)
+ })())
+})
+
+function validPath(value) {
+ return typeof value === "string" && /^\/(?!\/)[A-Za-z0-9_/?=&.#%-]*$/.test(value)
+}
+
async function cacheFirst(event) {
const cached = await caches.match(event.request)
if (cached) return cached
diff --git a/scripts/android-build.sh b/scripts/android-build.sh
index d60b032..9456e80 100755
--- a/scripts/android-build.sh
+++ b/scripts/android-build.sh
@@ -27,6 +27,10 @@ exec docker build \
--build-arg "WNH_DEBUG_BASE_URL=$WNH_DEBUG_BASE_URL" \
--build-arg "WNH_TRACKING_MIN_TIME_MS=$WNH_TRACKING_MIN_TIME_MS" \
--build-arg "WNH_TRACKING_HTTP_TIMEOUT_MS=$WNH_TRACKING_HTTP_TIMEOUT_MS" \
+ --build-arg "WNH_FIREBASE_APPLICATION_ID=${WNH_FIREBASE_APPLICATION_ID:-}" \
+ --build-arg "WNH_FIREBASE_CLIENT_VALUE=${WNH_FIREBASE_API_KEY:-}" \
+ --build-arg "WNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID:-}" \
+ --build-arg "WNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID:-}" \
--target artifact \
--output "type=local,dest=$ROOT/android/dist" \
"$ROOT/android"
diff --git a/scripts/android-release-build.sh b/scripts/android-release-build.sh
index 431ec1c..8594cab 100755
--- a/scripts/android-release-build.sh
+++ b/scripts/android-release-build.sh
@@ -62,6 +62,10 @@ docker build \
--build-arg "WNH_TRACKING_HTTP_TIMEOUT_MS=$WNH_TRACKING_HTTP_TIMEOUT_MS" \
--build-arg "WNH_ANDROID_VERSION_CODE=$WNH_ANDROID_VERSION_CODE" \
--build-arg "WNH_ANDROID_VERSION_NAME=$WNH_ANDROID_VERSION_NAME" \
+ --build-arg "WNH_FIREBASE_APPLICATION_ID=${WNH_FIREBASE_APPLICATION_ID:-}" \
+ --build-arg "WNH_FIREBASE_CLIENT_VALUE=${WNH_FIREBASE_API_KEY:-}" \
+ --build-arg "WNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID:-}" \
+ --build-arg "WNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID:-}" \
--target release-artifact \
--output "type=local,dest=$OUTPUT_DIR" \
"$ROOT/android"
diff --git a/scripts/android-staging-build.sh b/scripts/android-staging-build.sh
index dbfed29..d7a9e66 100755
--- a/scripts/android-staging-build.sh
+++ b/scripts/android-staging-build.sh
@@ -19,6 +19,10 @@ exec docker build \
--build-arg "WNH_BASE_URL=$WNH_BASE_URL" \
--build-arg "WNH_TRACKING_MIN_TIME_MS=$WNH_TRACKING_MIN_TIME_MS" \
--build-arg "WNH_TRACKING_HTTP_TIMEOUT_MS=$WNH_TRACKING_HTTP_TIMEOUT_MS" \
+ --build-arg "WNH_FIREBASE_APPLICATION_ID=${WNH_FIREBASE_APPLICATION_ID:-}" \
+ --build-arg "WNH_FIREBASE_CLIENT_VALUE=${WNH_FIREBASE_API_KEY:-}" \
+ --build-arg "WNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID:-}" \
+ --build-arg "WNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID:-}" \
--target staging-artifact \
--output "type=local,dest=$ROOT/android/dist-staging" \
"$ROOT/android"
diff --git a/test/who_need_help/mutual_aid_flow_test.exs b/test/who_need_help/mutual_aid_flow_test.exs
index 3e9fdbe..bd6bdff 100644
--- a/test/who_need_help/mutual_aid_flow_test.exs
+++ b/test/who_need_help/mutual_aid_flow_test.exs
@@ -1,9 +1,13 @@
defmodule WhoNeedHelp.MutualAidFlowTest do
use WhoNeedHelp.DataCase, async: false
+ use Oban.Testing, repo: WhoNeedHelp.Repo
import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.{Accounts, Catalog, CatalogModeration, Help, Messaging, Tracking, Trust}
+ alias WhoNeedHelp.Help.Assignment
+ alias WhoNeedHelp.Notifications.Notification
+ alias WhoNeedHelp.Push.NearbyMatchWorker
alias WhoNeedHelp.Help.DiscoveryViewport
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Tracking.Position
@@ -75,6 +79,103 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
}
end
+ test "helper arrival, withdrawal, reopening, and replacement keep one active match", context do
+ replacement = user_fixture(display_name: "Replacement helper")
+ replacement_scope = user_scope_fixture(replacement)
+
+ {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
+ {:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
+ {:ok, assignment} = Help.start_assignment(context.helper_scope, assignment.id)
+
+ assert {:error, :invalid_transition} =
+ Help.arrive_assignment(context.requester_scope, assignment.id)
+
+ assert {:ok, arrived} = Help.arrive_assignment(context.helper_scope, assignment.id)
+ assert arrived.arrived_at
+
+ assert %Notification{kind: :helper_arrived, user_id: requester_id} =
+ Repo.get_by!(Notification,
+ kind: :helper_arrived,
+ user_id: context.requester.id
+ )
+
+ assert requester_id == context.requester.id
+
+ assert {:ok, withdrawn} =
+ Help.withdraw_assignment(context.helper_scope, assignment.id, %{
+ "withdrawal_reason" => "requester_unreachable",
+ "withdrawal_note" => "I could not reach the requester."
+ })
+
+ refute withdrawn.active
+ assert withdrawn.status == :cancelled
+ assert withdrawn.withdrawal_reason == :requester_unreachable
+ assert withdrawn.withdrawal_note == "I could not reach the requester."
+
+ reopened = Help.get_request!(request.id)
+ assert reopened.status == :open
+ assert is_nil(reopened.assignment)
+
+ assert %Notification{kind: :request_reopened} =
+ Repo.get_by!(Notification,
+ kind: :request_reopened,
+ user_id: context.requester.id
+ )
+
+ assert_enqueued(
+ worker: NearbyMatchWorker,
+ args: %{
+ "request_id" => request.id,
+ "event_key" => "reopened:#{assignment.id}"
+ }
+ )
+
+ assert {:error, :assignment_inactive} =
+ Messaging.send_message(context.helper_scope, assignment, %{
+ "body" => "This old match is closed."
+ })
+
+ assert {:ok, replacement_assignment} = Help.accept_request(replacement_scope, request.id)
+ assert replacement_assignment.id != assignment.id
+
+ assert [old_assignment, active_assignment] =
+ Assignment
+ |> where([current], current.request_id == ^request.id)
+ |> order_by([current], asc: current.inserted_at)
+ |> Repo.all()
+
+ refute old_assignment.active
+ assert active_assignment.active
+ assert active_assignment.helper_id == replacement.id
+ end
+
+ test "request cancellation records the selected reason and closes the active match", context do
+ {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
+ {:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
+
+ assert {:ok, cancelled} =
+ Help.cancel_request(context.requester_scope, request.id, %{
+ "cancellation_reason" => "safety_concern",
+ "cancellation_note" => "The situation no longer feels safe."
+ })
+
+ assert cancelled.status == :cancelled
+ assert cancelled.cancellation_reason == :safety_concern
+ assert cancelled.cancellation_note == "The situation no longer feels safe."
+
+ cancelled_assignment = Repo.get!(Assignment, assignment.id)
+ assert cancelled_assignment.status == :cancelled
+ assert cancelled_assignment.active
+
+ assert %Notification{kind: :request_cancelled, user_id: helper_id} =
+ Repo.get_by!(Notification,
+ kind: :request_cancelled,
+ user_id: context.helper.id
+ )
+
+ assert helper_id == context.helper.id
+ end
+
test "invalid proposal identifiers are rejected", context do
assert {:error, :not_found} = Catalog.vote(context.requester_scope, "not-a-uuid")
assert {:error, :not_found} = Catalog.unvote(context.requester_scope, "not-a-uuid")
diff --git a/test/who_need_help/notifications_test.exs b/test/who_need_help/notifications_test.exs
new file mode 100644
index 0000000..fc5c4c3
--- /dev/null
+++ b/test/who_need_help/notifications_test.exs
@@ -0,0 +1,362 @@
+defmodule WhoNeedHelp.NotificationsTest do
+ use WhoNeedHelp.DataCase, async: false
+ use Oban.Testing, repo: WhoNeedHelp.Repo
+
+ import WhoNeedHelp.AccountsFixtures
+ import Swoosh.TestAssertions
+
+ alias WhoNeedHelp.{Catalog, Help, Notifications, Repo, Trust}
+ alias WhoNeedHelp.Notifications.{NearbySubscription, Notification, PushDevice}
+ alias WhoNeedHelp.ProductAnalytics.DailyMetric
+ alias WhoNeedHelp.Push.{NearbyMatchWorker, NotificationDispatchWorker, NotificationEmailWorker}
+
+ setup do
+ category = Catalog.seed_defaults()
+ requester = user_fixture(display_name: "Nearby requester")
+ helper = user_fixture(display_name: "Nearby helper")
+
+ {:ok,
+ category: category,
+ requester: requester,
+ helper: helper,
+ requester_scope: user_scope_fixture(requester),
+ helper_scope: user_scope_fixture(helper)}
+ end
+
+ test "nearby subscriptions match by distance, category, urgency, and block state", context do
+ {:ok, subscription} =
+ Notifications.create_nearby_subscription(context.helper_scope, %{
+ "name" => "Central medicine",
+ "location_label" => "Private home area",
+ "latitude" => "50.4501",
+ "longitude" => "30.5234",
+ "radius_meters" => "3000",
+ "category_ids" => [context.category.id],
+ "urgencies" => ["now"],
+ "available_days" => Enum.map(1..7, &to_string/1),
+ "push_enabled" => "true",
+ "email_enabled" => "false"
+ })
+
+ {:ok, request} =
+ Help.create_request(context.requester_scope, request_attrs(context.category.id))
+
+ assert [matched] = Notifications.matching_nearby_subscriptions(request)
+ assert matched.id == subscription.id
+
+ {:ok, _block} = Trust.block(context.helper_scope, context.requester.id)
+ assert Notifications.matching_nearby_subscriptions(request) == []
+ end
+
+ test "far requests and non-matching urgency are not selected", context do
+ {:ok, _subscription} =
+ Notifications.create_nearby_subscription(context.helper_scope, %{
+ "name" => "Only scheduled nearby",
+ "location_label" => "Private center",
+ "latitude" => "50.4501",
+ "longitude" => "30.5234",
+ "radius_meters" => "1000",
+ "category_ids" => [context.category.id],
+ "urgencies" => ["scheduled"],
+ "available_days" => [1, 2, 3, 4, 5, 6, 7],
+ "push_enabled" => true,
+ "email_enabled" => false
+ })
+
+ {:ok, urgent_request} =
+ Help.create_request(context.requester_scope, request_attrs(context.category.id))
+
+ assert Notifications.matching_nearby_subscriptions(urgent_request) == []
+
+ far_attrs =
+ context.category.id
+ |> request_attrs()
+ |> Map.merge(%{
+ "urgency" => "scheduled",
+ "latitude" => "49.8397",
+ "longitude" => "24.0297"
+ })
+
+ {:ok, far_request} = Help.create_request(context.requester_scope, far_attrs)
+ assert Notifications.matching_nearby_subscriptions(far_request) == []
+ end
+
+ test "notification inbox is idempotent, user scoped, and records opens", context do
+ attrs = %{
+ kind: :request_accepted,
+ title: "A helper responded",
+ body: "Open the request to see the update.",
+ path: "/requests/#{Ecto.UUID.generate()}",
+ data: %{"request_id" => Ecto.UUID.generate()},
+ idempotency_key: "notification-test:#{Ecto.UUID.generate()}"
+ }
+
+ assert {:ok, first} = Notifications.notify_user(context.requester.id, attrs)
+ assert {:ok, replay} = Notifications.notify_user(context.requester.id, attrs)
+ assert replay.id == first.id
+ assert Notifications.unread_count(context.requester_scope) == 1
+ assert Notifications.unread_count(context.helper_scope) == 0
+
+ assert 1 ==
+ length(
+ all_enqueued(
+ worker: NotificationDispatchWorker,
+ args: %{"notification_id" => first.id}
+ )
+ )
+
+ assert {:error, :not_found} = Notifications.mark_read(context.helper_scope, first.id)
+ assert {:ok, opened} = Notifications.notification_opened(context.requester_scope, first.id)
+ assert opened.read_at
+ assert Notifications.unread_count(context.requester_scope) == 0
+
+ assert %DailyMetric{count: 1, dimension: "request_accepted"} =
+ Repo.get_by(DailyMetric,
+ date: Date.utc_today(),
+ metric: "notification.opened"
+ )
+ end
+
+ test "preferences enforce complete quiet hours and per-subscription push", context do
+ assert {:error, changeset} =
+ Notifications.update_preference(context.helper_scope, %{
+ "quiet_hours_enabled" => "true",
+ "quiet_start" => "22:00",
+ "quiet_end" => "",
+ "time_zone" => "Europe/Kyiv",
+ "utc_offset_minutes" => "180"
+ })
+
+ assert "can't be blank" in errors_on(changeset).quiet_end
+
+ assert {:ok, preference} =
+ Notifications.update_preference(context.helper_scope, %{
+ "push_enabled" => "true",
+ "nearby_push_enabled" => "true",
+ "quiet_hours_enabled" => "true",
+ "quiet_start" => "22:00",
+ "quiet_end" => "07:00",
+ "time_zone" => "Etc/UTC",
+ "utc_offset_minutes" => "0"
+ })
+
+ assert Notifications.quiet_now?(preference, ~U[2026-07-22 23:30:00Z])
+ refute Notifications.quiet_now?(preference, ~U[2026-07-22 12:00:00Z])
+
+ assert Notifications.next_quiet_end(preference, ~U[2026-07-22 23:30:00Z]) ==
+ ~U[2026-07-23 07:00:00Z]
+
+ notification = %Notification{kind: :nearby_request, data: %{"push_enabled" => false}}
+ refute Notifications.push_allowed?(preference, notification)
+
+ no_channel_changeset =
+ Notifications.change_nearby_subscription(%NearbySubscription{}, %{
+ "name" => "No channel",
+ "location_label" => "Private area",
+ "latitude" => "50.4501",
+ "longitude" => "30.5234",
+ "radius_meters" => "3000",
+ "urgencies" => ["now"],
+ "available_days" => Enum.to_list(1..7),
+ "push_enabled" => "false",
+ "email_enabled" => "false"
+ })
+
+ assert "select push, email, or both" in errors_on(no_channel_changeset).push_enabled
+ end
+
+ test "nearby email is durable, generic, and enabled when any matching alert opts in", context do
+ assert_email_sent()
+ assert_email_sent()
+
+ assert {:ok, _preference} =
+ Notifications.update_preference(context.helper_scope, %{
+ "push_enabled" => "false",
+ "email_enabled" => "true",
+ "nearby_email_enabled" => "true",
+ "quiet_hours_enabled" => "false",
+ "time_zone" => "Etc/UTC",
+ "utc_offset_minutes" => "0"
+ })
+
+ common = %{
+ "latitude" => "50.4501",
+ "longitude" => "30.5234",
+ "radius_meters" => "3000",
+ "category_ids" => [context.category.id],
+ "urgencies" => ["now"],
+ "available_days" => Enum.to_list(1..7),
+ "push_enabled" => "false"
+ }
+
+ assert {:ok, _subscription} =
+ Notifications.create_nearby_subscription(
+ context.helper_scope,
+ Map.merge(common, %{
+ "name" => "Private home label",
+ "location_label" => "Do not send this label",
+ "push_enabled" => "true",
+ "email_enabled" => "false"
+ })
+ )
+
+ assert {:ok, _subscription} =
+ Notifications.create_nearby_subscription(
+ context.helper_scope,
+ Map.merge(common, %{
+ "name" => "Second private label",
+ "location_label" => "Another private place",
+ "email_enabled" => "true"
+ })
+ )
+
+ assert {:ok, request} =
+ Help.create_request(context.requester_scope, request_attrs(context.category.id))
+
+ assert :ok =
+ perform_job(NearbyMatchWorker, %{
+ "request_id" => request.id,
+ "event_key" => "created"
+ })
+
+ notification =
+ Repo.get_by!(Notification,
+ user_id: context.helper.id,
+ kind: :nearby_request
+ )
+
+ assert notification.data["email_enabled"]
+ assert notification.data["push_enabled"]
+ refute inspect(notification) =~ "Private home label"
+ refute inspect(notification) =~ "Do not send this label"
+
+ assert :ok =
+ perform_job(NotificationDispatchWorker, %{"notification_id" => notification.id})
+
+ assert_enqueued(
+ worker: NotificationEmailWorker,
+ queue: :push,
+ args: %{"notification_id" => notification.id}
+ )
+
+ assert :ok =
+ perform_job(NotificationEmailWorker, %{"notification_id" => notification.id})
+
+ assert_email_sent(fn email ->
+ assert email.to == [{"", context.helper.email}]
+ assert email.subject == "New help request nearby"
+ assert email.text_body =~ "/requests/#{request.id}"
+ refute email.text_body =~ "Private home label"
+ refute email.text_body =~ "Do not send this label"
+ true
+ end)
+ end
+
+ test "device registration is idempotent and follows an authenticated shared installation",
+ context do
+ attrs = %{
+ "platform" => "web",
+ "provider" => "web_push",
+ "token" => "https://push.example/subscriptions/#{Ecto.UUID.generate()}",
+ "installation_id" => Ecto.UUID.generate(),
+ "p256dh" => "test-public-key",
+ "auth_secret" => "test-auth-secret",
+ "device_label" => "Test browser",
+ "user_agent" => "Who Need Help test"
+ }
+
+ assert {:ok, first} = Notifications.register_device(context.helper_scope, attrs)
+ assert {:ok, refreshed} = Notifications.register_device(context.helper_scope, attrs)
+ assert refreshed.id == first.id
+ assert refreshed.last_seen_at
+
+ assert {:ok, transferred} =
+ Notifications.register_device(context.requester_scope, attrs)
+
+ assert transferred.id == first.id
+ assert transferred.user_id == context.requester.id
+
+ assert Notifications.list_devices(context.helper_scope) == []
+ assert [%PushDevice{id: device_id}] = Notifications.list_devices(context.requester_scope)
+ assert device_id == first.id
+ assert {:ok, _disabled} = Notifications.disable_device(context.requester_scope, first.id)
+ assert Notifications.list_devices(context.requester_scope) == []
+ end
+
+ test "a token cannot be claimed from a different installation", context do
+ token = "https://push.example/subscriptions/#{Ecto.UUID.generate()}"
+
+ attrs = %{
+ "platform" => "web",
+ "provider" => "web_push",
+ "token" => token,
+ "installation_id" => Ecto.UUID.generate(),
+ "p256dh" => "test-public-key",
+ "auth_secret" => "test-auth-secret",
+ "device_label" => "Test browser"
+ }
+
+ assert {:ok, _device} = Notifications.register_device(context.helper_scope, attrs)
+
+ assert {:error, :already_registered} =
+ Notifications.register_device(
+ context.requester_scope,
+ Map.put(attrs, "installation_id", Ecto.UUID.generate())
+ )
+ end
+
+ test "notification paths reject external URLs and allow an internal message anchor", context do
+ base = %{
+ kind: :message_created,
+ title: "New message",
+ body: "Open the app.",
+ idempotency_key: "path-test:#{Ecto.UUID.generate()}"
+ }
+
+ assert {:error, changeset} =
+ Notifications.notify_user(
+ context.helper.id,
+ Map.put(base, :path, "https://evil.example/requests")
+ )
+
+ assert errors_on(changeset).path == ["has invalid format"]
+
+ assert {:error, changeset} =
+ Notifications.notify_user(
+ context.helper.id,
+ base
+ |> Map.put(:path, "//evil.example/requests")
+ |> Map.put(:idempotency_key, "path-network:#{Ecto.UUID.generate()}")
+ )
+
+ assert errors_on(changeset).path == ["has invalid format"]
+
+ assert {:ok, notification} =
+ Notifications.notify_user(
+ context.helper.id,
+ base
+ |> Map.put(:path, "/requests/#{Ecto.UUID.generate()}#messages")
+ |> Map.put(:idempotency_key, "path-anchor:#{Ecto.UUID.generate()}")
+ )
+
+ assert String.ends_with?(notification.path, "#messages")
+ end
+
+ defp request_attrs(category_id) do
+ %{
+ "title" => "Medicine is ready nearby",
+ "description" => "Please collect the reserved legal medicine and bring it nearby.",
+ "pickup_instructions" => "Use the private chat for exact details.",
+ "location_label" => "Central district",
+ "latitude" => "50.4505",
+ "longitude" => "30.5240",
+ "location_radius_meters" => "1000",
+ "urgency" => "now",
+ "location_visibility" => "approximate_public",
+ "structured_data" => %{"pickup_status" => "reserved"},
+ "expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
+ "category_id" => category_id,
+ "safety_confirmed" => true
+ }
+ end
+end
diff --git a/test/who_need_help/product_analytics_test.exs b/test/who_need_help/product_analytics_test.exs
new file mode 100644
index 0000000..652fc41
--- /dev/null
+++ b/test/who_need_help/product_analytics_test.exs
@@ -0,0 +1,34 @@
+defmodule WhoNeedHelp.ProductAnalyticsTest do
+ use WhoNeedHelp.DataCase, async: true
+
+ import WhoNeedHelp.AccountsFixtures
+
+ alias WhoNeedHelp.{ProductAnalytics, Repo}
+ alias WhoNeedHelp.ProductAnalytics.DailyMetric
+
+ test "accepts only fixed privacy-safe dimensions and exposes aggregates only to staff" do
+ assert {:error, :invalid_dimension} =
+ ProductAnalytics.increment("request.created", "50.4501,30.5234")
+
+ assert {:error, :invalid_dimension} =
+ ProductAnalytics.increment("account.registered", "person@example.com")
+
+ assert {:ok, _metric} = ProductAnalytics.increment("request.created", "now")
+ assert {:ok, _metric} = ProductAnalytics.increment("request.created", "now")
+
+ assert %DailyMetric{count: 2, dimension: "now"} =
+ Repo.get_by!(DailyMetric, metric: "request.created")
+
+ ordinary = user_fixture() |> user_scope_fixture()
+ assert ProductAnalytics.paginate(ordinary).entries == []
+
+ moderator =
+ user_fixture()
+ |> Ecto.Changeset.change(role: :moderator)
+ |> Repo.update!()
+ |> user_scope_fixture()
+
+ assert [%DailyMetric{metric: "request.created", count: 2}] =
+ ProductAnalytics.paginate(moderator).entries
+ end
+end
diff --git a/test/who_need_help/push_product_test.exs b/test/who_need_help/push_product_test.exs
index 58141b8..8c2d0db 100644
--- a/test/who_need_help/push_product_test.exs
+++ b/test/who_need_help/push_product_test.exs
@@ -4,8 +4,16 @@ defmodule WhoNeedHelp.PushProductTest do
import WhoNeedHelp.AccountsFixtures
- alias WhoNeedHelp.{Catalog, Help, Messaging, Push}
- alias WhoNeedHelp.Push.DeliveryWorker
+ alias WhoNeedHelp.{Catalog, Help, Messaging, Notifications, Push, Repo}
+ alias WhoNeedHelp.Notifications.Notification
+ alias WhoNeedHelp.Notifications.PushDevice
+
+ alias WhoNeedHelp.Push.{
+ DeliveryWorker,
+ DeviceDeliveryWorker,
+ FCMAdapter,
+ NotificationDispatchWorker
+ }
defmodule RecordingAdapter do
@behaviour Push
@@ -17,8 +25,25 @@ defmodule WhoNeedHelp.PushProductTest do
end
end
+ defmodule RecordingDeviceAdapter do
+ @behaviour WhoNeedHelp.Push.DeviceAdapter
+
+ @impl true
+ def deliver(notification, device, opts) do
+ send(Keyword.fetch!(opts, :test_pid), {:device_delivery, notification.id, device.id})
+ Keyword.fetch!(opts, :result)
+ end
+ end
+
setup do
- keys = [:push_product_enabled, :push_adapter, :push_delivery_options]
+ keys = [
+ :push_product_enabled,
+ :push_adapter,
+ :push_delivery_options,
+ :fcm_adapter,
+ :device_delivery_options
+ ]
+
previous = Map.new(keys, &{&1, Application.fetch_env(:who_need_help, &1)})
on_exit(fn ->
@@ -61,18 +86,39 @@ defmodule WhoNeedHelp.PushProductTest do
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
+ acceptance_key = "request-accepted:#{assignment.id}:#{context.requester.id}"
+ acceptance = Repo.get_by!(Notification, idempotency_key: acceptance_key)
+
+ assert acceptance.user_id == context.requester.id
+
+ assert acceptance.data == %{
+ "kind" => "request_accepted",
+ "assignment_id" => assignment.id,
+ "request_id" => request.id
+ }
+
+ assert_enqueued(
+ worker: NotificationDispatchWorker,
+ queue: :push,
+ args: %{"notification_id" => acceptance.id}
+ )
+
+ assert :ok =
+ perform_job(NotificationDispatchWorker, %{"notification_id" => acceptance.id})
+
assert_enqueued(
worker: DeliveryWorker,
queue: :push,
args: %{
- "idempotency_key" => "request-accepted:#{assignment.id}:#{context.requester.id}",
+ "idempotency_key" => acceptance_key,
"recipient" => "user:#{context.requester.id}",
"title" => "A helper responded",
"body" => "Open Who Need Help to see the request update.",
"data" => %{
"kind" => "request_accepted",
"assignment_id" => assignment.id,
- "request_id" => request.id
+ "request_id" => request.id,
+ "path" => "/requests/#{request.id}"
}
}
)
@@ -80,11 +126,20 @@ defmodule WhoNeedHelp.PushProductTest do
{:ok, message} =
Messaging.send_message(context.requester_scope, assignment, %{"body" => "Thank you"})
+ message_key = "message-created:#{message.id}:#{context.helper.id}"
+ notification = Repo.get_by!(Notification, idempotency_key: message_key)
+
+ assert notification.body == "Open Who Need Help to read the conversation."
+ refute inspect(notification) =~ "Thank you"
+
+ assert :ok =
+ perform_job(NotificationDispatchWorker, %{"notification_id" => notification.id})
+
assert_enqueued(
worker: DeliveryWorker,
queue: :push,
args: %{
- "idempotency_key" => "message-created:#{message.id}:#{context.helper.id}",
+ "idempotency_key" => message_key,
"recipient" => "user:#{context.helper.id}",
"title" => "New message",
"body" => "Open Who Need Help to read the conversation.",
@@ -92,7 +147,8 @@ defmodule WhoNeedHelp.PushProductTest do
"kind" => "message_created",
"assignment_id" => assignment.id,
"message_id" => message.id,
- "request_id" => request.id
+ "request_id" => request.id,
+ "path" => "/requests/#{request.id}#messages"
}
}
)
@@ -115,9 +171,16 @@ defmodule WhoNeedHelp.PushProductTest do
assert {:ok, replay} =
Push.enqueue_request_accepted(assignment_id, request_id, requester_id)
- assert replay.conflict?
assert replay.id == first.id
- assert 1 == length(all_enqueued(worker: DeliveryWorker, args: %{"idempotency_key" => key}))
+ assert Repo.aggregate(from(n in Notification, where: n.idempotency_key == ^key), :count) == 1
+
+ assert 1 ==
+ length(
+ all_enqueued(
+ worker: NotificationDispatchWorker,
+ args: %{"notification_id" => first.id}
+ )
+ )
end
test "disabled product boundary leaves domain events job-free", context do
@@ -129,6 +192,10 @@ defmodule WhoNeedHelp.PushProductTest do
{:ok, _message} =
Messaging.send_message(context.helper_scope, assignment, %{"body" => "On my way"})
+ for job <- all_enqueued(worker: NotificationDispatchWorker) do
+ assert :ok = perform_job(NotificationDispatchWorker, job.args)
+ end
+
assert [] == all_enqueued(worker: DeliveryWorker)
end
@@ -153,4 +220,76 @@ defmodule WhoNeedHelp.PushProductTest do
data: %{"kind" => "message_created"}
}}
end
+
+ test "FCM uses a privacy-safe data-only payload for Android deep links" do
+ notification = %Notification{
+ id: Ecto.UUID.generate(),
+ kind: :message_created,
+ title: "New message",
+ body: "Open Who Need Help to read the conversation.",
+ path: "/requests/#{Ecto.UUID.generate()}#messages",
+ data: %{"kind" => "message_created", "request_id" => Ecto.UUID.generate()}
+ }
+
+ payload =
+ FCMAdapter.payload(notification, %PushDevice{token: "firebase-installation-id"})
+
+ message = payload["message"]
+ refute Map.has_key?(message, "notification")
+ assert message["token"] == "firebase-installation-id"
+ assert message["data"]["title"] == "New message"
+ assert message["data"]["body"] == "Open Who Need Help to read the conversation."
+ assert message["data"]["path"] == notification.path
+ refute inspect(payload) =~ "exact"
+ end
+
+ test "device delivery retries transient failures and disables rejected registrations",
+ context do
+ Application.put_env(:who_need_help, :fcm_adapter, RecordingDeviceAdapter)
+
+ {:ok, device} =
+ Notifications.register_device(context.helper_scope, %{
+ "platform" => "android",
+ "provider" => "fcm",
+ "token" => "firebase-installation-#{Ecto.UUID.generate()}",
+ "installation_id" => Ecto.UUID.generate(),
+ "device_label" => "Android test"
+ })
+
+ {:ok, notification} =
+ Notifications.notify_user(context.helper.id, %{
+ kind: :message_created,
+ title: "New message",
+ body: "Open the app.",
+ path: "/requests/#{Ecto.UUID.generate()}#messages",
+ idempotency_key: "device-worker:#{Ecto.UUID.generate()}"
+ })
+
+ Application.put_env(:who_need_help, :device_delivery_options, %{
+ fcm: [test_pid: self(), result: {:error, {:retryable, 503, nil}}]
+ })
+
+ assert {:error, {:retryable, 503, nil}} =
+ perform_job(DeviceDeliveryWorker, %{
+ "notification_id" => notification.id,
+ "device_id" => device.id
+ })
+
+ assert_received {:device_delivery, notification_id, device_id}
+ assert notification_id == notification.id
+ assert device_id == device.id
+ assert is_nil(Repo.reload!(device).disabled_at)
+
+ Application.put_env(:who_need_help, :device_delivery_options, %{
+ fcm: [test_pid: self(), result: {:error, {:rejected, 404, nil}}]
+ })
+
+ assert {:cancel, :device_rejected} =
+ perform_job(DeviceDeliveryWorker, %{
+ "notification_id" => notification.id,
+ "device_id" => device.id
+ })
+
+ assert Repo.reload!(device).disabled_at
+ end
end
diff --git a/test/who_need_help/support_and_content_removal_test.exs b/test/who_need_help/support_and_content_removal_test.exs
index 83115cc..7434723 100644
--- a/test/who_need_help/support_and_content_removal_test.exs
+++ b/test/who_need_help/support_and_content_removal_test.exs
@@ -4,7 +4,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
import WhoNeedHelp.AccountsFixtures
import Swoosh.TestAssertions
- alias WhoNeedHelp.{ContentRemoval, Repo, Support}
+ alias WhoNeedHelp.{Catalog, ContentRemoval, Help, Repo, Support}
alias WhoNeedHelp.ContentRemoval.Notice
alias WhoNeedHelp.Support.SupportRequest
alias WhoNeedHelp.Trust.AuditEvent
@@ -211,6 +211,43 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert request.contact_verified_at
end
+ test "deletion preflight is moderator-only, read-only, and reports active product state" do
+ user = user_fixture()
+ scope = user_scope_fixture(user)
+ moderator_scope = moderator_scope()
+
+ {:ok, deletion_request} = Support.create_account_deletion_request(scope)
+ category = Catalog.seed_defaults()
+
+ {:ok, _help_request} =
+ Help.create_request(scope, %{
+ "title" => "Active request before deletion",
+ "description" => "This request must be resolved before account deletion.",
+ "location_label" => "Private lifecycle area",
+ "latitude" => "50.4501",
+ "longitude" => "30.5234",
+ "location_radius_meters" => "1000",
+ "urgency" => "now",
+ "location_visibility" => "approximate_public",
+ "structured_data" => %{"pickup_status" => "reserved"},
+ "expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
+ "category_id" => category.id,
+ "safety_confirmed" => true
+ })
+
+ assert {:error, :forbidden} =
+ Support.deletion_assessment(scope, deletion_request.id)
+
+ assert {:ok, assessment} =
+ Support.deletion_assessment(moderator_scope, deletion_request.id)
+
+ assert assessment.blocking_counts.active_help_requests == 1
+ refute assessment.technically_idle
+ refute assessment.execution_available
+ assert assessment.execution_blocker == :retention_policy_not_configured
+ assert Repo.get!(SupportRequest, deletion_request.id).status == :open
+ end
+
test "content-removal ownership lookup is scoped to the submitting account" do
owner = user_fixture()
outsider = user_fixture()
diff --git a/test/who_need_help_web/controllers/google_auth_controller_test.exs b/test/who_need_help_web/controllers/google_auth_controller_test.exs
index cc0ba00..398ca21 100644
--- a/test/who_need_help_web/controllers/google_auth_controller_test.exs
+++ b/test/who_need_help_web/controllers/google_auth_controller_test.exs
@@ -24,15 +24,16 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
} = get_session(conn, "google_auth_flow")
end
- test "requires adult and safety consent before starting Google registration", %{conn: conn} do
+ test "starts Google registration and defers the one consent step until identity is known", %{
+ conn: conn
+ } do
conn =
post(conn, ~p"/auth/google/register", %{
- "google_registration" => %{"locale" => "en", "terms_accepted" => "false"}
+ "google_registration" => %{"locale" => "en"}
})
- assert redirected_to(conn) == ~p"/users/register"
- assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "18 or older"
- assert is_nil(get_session(conn, "google_auth_flow"))
+ assert redirected_to(conn) =~ "https://accounts.google.example/authorize?"
+ assert %{"flow" => "register", "locale" => "en"} = get_session(conn, "google_auth_flow")
end
test "registers, confirms, links, and logs in a new verified Google user", %{conn: conn} do
@@ -47,6 +48,10 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
|> get(
~p"/auth/google/callback?code=one-time-code&state=google-test-state&uid=google-123&email=#{email}&name=Google%20Helper"
)
+ |> recycle()
+ |> post(~p"/auth/google/complete-registration", %{
+ "google_registration" => %{"locale" => "ru", "terms_accepted" => "true"}
+ })
assert redirected_to(conn) == ~p"/"
assert get_session(conn, :user_token)
@@ -83,7 +88,6 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
)
assert redirected_to(conn) == ~p"/auth/google/complete"
- assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Confirm it once"
refute get_session(conn, :user_token)
refute Repo.get_by(AuthIdentity, provider: :google, provider_uid: "new-google")
diff --git a/test/who_need_help_web/controllers/mobile_push_device_controller_test.exs b/test/who_need_help_web/controllers/mobile_push_device_controller_test.exs
new file mode 100644
index 0000000..1d3f73d
--- /dev/null
+++ b/test/who_need_help_web/controllers/mobile_push_device_controller_test.exs
@@ -0,0 +1,55 @@
+defmodule WhoNeedHelpWeb.MobilePushDeviceControllerTest do
+ use WhoNeedHelpWeb.ConnCase, async: true
+
+ alias WhoNeedHelp.Notifications
+
+ setup :register_and_log_in_user
+
+ test "registers an Android FCM device for the authenticated account", %{
+ conn: conn,
+ scope: scope
+ } do
+ params = %{
+ platform: "android",
+ provider: "fcm",
+ token: "fcm-token-#{Ecto.UUID.generate()}",
+ installation_id: Ecto.UUID.generate(),
+ device_label: "Android · FCM",
+ user_agent: "WhoNeedHelpAndroid test"
+ }
+
+ response =
+ conn
+ |> put_req_header("accept", "application/json")
+ |> post(~p"/mobile/push-devices", params)
+ |> json_response(201)
+
+ assert response["platform"] == "android"
+ assert response["provider"] == "fcm"
+ assert [%{id: id}] = Notifications.list_devices(scope)
+ assert id == response["id"]
+ end
+
+ test "rejects an invalid platform/provider pair", %{conn: conn} do
+ conn =
+ conn
+ |> put_req_header("accept", "application/json")
+ |> post(~p"/mobile/push-devices", %{
+ platform: "web",
+ provider: "fcm",
+ token: "fcm-token-#{Ecto.UUID.generate()}",
+ installation_id: Ecto.UUID.generate()
+ })
+
+ assert json_response(conn, 422) == %{"error" => "invalid_device"}
+ end
+
+ test "requires an authenticated session", %{conn: _logged_in_conn} do
+ conn =
+ build_conn()
+ |> put_req_header("accept", "application/json")
+ |> post(~p"/mobile/push-devices", %{})
+
+ assert response(conn, 401) == ""
+ end
+end
diff --git a/test/who_need_help_web/controllers/page_controller_test.exs b/test/who_need_help_web/controllers/page_controller_test.exs
index 20d34b8..2932e5e 100644
--- a/test/who_need_help_web/controllers/page_controller_test.exs
+++ b/test/who_need_help_web/controllers/page_controller_test.exs
@@ -196,7 +196,7 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
body = response(conn, 200)
assert body =~ ~s(const CACHE_PREFIX = "who-need-help-static-")
- assert body =~ ~s(const CACHE = `${CACHE_PREFIX}v4`)
+ assert body =~ ~s(const CACHE = `${CACHE_PREFIX}v5`)
assert body =~ ~s(const OFFLINE_URL = "/offline.html")
assert body =~ ~s(event.request.mode === "navigate")
assert body =~ ~S|key.startsWith(CACHE_PREFIX) && key !== CACHE|
diff --git a/test/who_need_help_web/controllers/user_data_export_controller_test.exs b/test/who_need_help_web/controllers/user_data_export_controller_test.exs
new file mode 100644
index 0000000..91ea302
--- /dev/null
+++ b/test/who_need_help_web/controllers/user_data_export_controller_test.exs
@@ -0,0 +1,78 @@
+defmodule WhoNeedHelpWeb.UserDataExportControllerTest do
+ use WhoNeedHelpWeb.ConnCase, async: true
+
+ import WhoNeedHelp.AccountsFixtures
+
+ alias WhoNeedHelp.{Catalog, Help, Messaging, Notifications, Repo}
+
+ test "requires authentication", %{conn: conn} do
+ conn = get(conn, ~p"/users/data-export")
+ assert redirected_to(conn) == ~p"/users/log-in"
+ end
+
+ test "downloads an allow-listed account export without credentials or counterpart messages", %{
+ conn: conn
+ } do
+ category = Catalog.seed_defaults()
+ requester = user_fixture(display_name: "Export requester")
+
+ helper =
+ user_fixture(display_name: "Export helper")
+ |> Ecto.Changeset.change(moderation_note: "Internal operator note must stay private")
+ |> Repo.update!()
+
+ requester_scope = user_scope_fixture(requester)
+ helper_scope = user_scope_fixture(helper)
+
+ {:ok, request} =
+ Help.create_request(requester_scope, %{
+ "title" => "Medicine is reserved",
+ "description" => "Please collect my reserved legal medicine.",
+ "pickup_instructions" => "Use the private chat.",
+ "location_label" => "Private export area",
+ "latitude" => "50.4501",
+ "longitude" => "30.5234",
+ "location_radius_meters" => "1000",
+ "urgency" => "now",
+ "location_visibility" => "approximate_public",
+ "structured_data" => %{"pickup_status" => "reserved"},
+ "expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
+ "category_id" => category.id,
+ "safety_confirmed" => true
+ })
+
+ {:ok, assignment} = Help.accept_request(helper_scope, request.id)
+
+ {:ok, _own_message} =
+ Messaging.send_message(helper_scope, assignment, %{"body" => "On my way"})
+
+ {:ok, _counterpart_message} =
+ Messaging.send_message(requester_scope, assignment, %{"body" => "Private counterpart text"})
+
+ {:ok, _device} =
+ Notifications.register_device(helper_scope, %{
+ "platform" => "android",
+ "provider" => "fcm",
+ "token" => "secret-firebase-installation-#{Ecto.UUID.generate()}",
+ "installation_id" => Ecto.UUID.generate(),
+ "device_label" => "My phone"
+ })
+
+ conn = conn |> log_in_user(helper) |> get(~p"/users/data-export")
+ assert response_content_type(conn, :json)
+ assert get_resp_header(conn, "cache-control") == ["no-store"]
+ assert [disposition] = get_resp_header(conn, "content-disposition")
+ assert disposition =~ "attachment"
+
+ export = Jason.decode!(response(conn, 200))
+ assert export["format"] == "who-need-help-account-export"
+ assert export["account"]["email"] == helper.email
+ assert [%{"body" => "On my way"}] = export["match_messages_sent"]
+ assert [%{"device_label" => "My phone"} = device] = export["push_devices"]
+ refute Map.has_key?(device, "token")
+ refute response(conn, 200) =~ "Private counterpart text"
+ refute response(conn, 200) =~ "secret-firebase-installation"
+ refute response(conn, 200) =~ "hashed_password"
+ refute response(conn, 200) =~ "Internal operator note"
+ end
+end
diff --git a/test/who_need_help_web/live/mutual_aid_live_test.exs b/test/who_need_help_web/live/mutual_aid_live_test.exs
index 2b323e0..80c8cec 100644
--- a/test/who_need_help_web/live/mutual_aid_live_test.exs
+++ b/test/who_need_help_web/live/mutual_aid_live_test.exs
@@ -4,7 +4,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
import Phoenix.LiveViewTest
import WhoNeedHelp.AccountsFixtures
- alias WhoNeedHelp.{Accounts, Activities, Catalog, Help, Messaging, Trust}
+ alias WhoNeedHelp.{Accounts, Activities, Catalog, Help, Messaging, Notifications, Trust}
alias WhoNeedHelp.Repo
setup :register_and_log_in_user
@@ -15,6 +15,80 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "Repeated help between the same pair"
end
+ test "notification center manages inbox, preferences, and a private nearby alert", %{
+ conn: conn,
+ user: user,
+ scope: scope
+ } do
+ category = Catalog.seed_defaults()
+
+ {:ok, notification} =
+ Notifications.notify_user(user.id, %{
+ kind: :support_update,
+ title: "Support request updated",
+ body: "Open the support page to review the update.",
+ path: "/support",
+ idempotency_key: "live-notification:#{Ecto.UUID.generate()}"
+ })
+
+ {:ok, view, html} = live(conn, ~p"/notifications")
+ assert html =~ "Notifications"
+ assert html =~ "Support request updated"
+ assert html =~ "1 unread"
+
+ view |> element("button[phx-click='mark-all-read']") |> render_click()
+ assert render(view) =~ "0 unread"
+ assert Notifications.unread_count(scope) == 0
+ assert Repo.get!(WhoNeedHelp.Notifications.Notification, notification.id).read_at
+
+ view
+ |> form("#notification-preferences-form", %{
+ "notification_preference" => %{
+ "push_enabled" => "true",
+ "nearby_push_enabled" => "true",
+ "message_push_enabled" => "true",
+ "lifecycle_push_enabled" => "true",
+ "email_enabled" => "true",
+ "nearby_email_enabled" => "true",
+ "quiet_hours_enabled" => "true",
+ "quiet_start" => "22:00",
+ "quiet_end" => "07:00",
+ "time_zone" => "Etc/UTC",
+ "utc_offset_minutes" => "0"
+ }
+ })
+ |> render_submit()
+
+ assert render(view) =~ "Notification preferences saved."
+
+ view
+ |> form("#nearby-subscription-form", %{
+ "nearby_subscription" => %{
+ "name" => "Medicine near home",
+ "location_label" => "Private home area",
+ "latitude" => "50.4501",
+ "longitude" => "30.5234",
+ "radius_meters" => "3000",
+ "category_ids" => [category.id],
+ "urgencies" => ["now", "today"],
+ "available_days" => ["1", "2", "3", "4", "5", "6", "7"],
+ "available_from" => "",
+ "available_until" => "",
+ "push_enabled" => "true",
+ "email_enabled" => "true"
+ }
+ })
+ |> render_submit()
+
+ html = render(view)
+ assert html =~ "Nearby alert created."
+ assert html =~ "Medicine near home"
+ assert html =~ "Private home area · 3 km"
+ assert html =~ "Push"
+ assert html =~ "Email"
+ assert has_element?(view, "button[phx-click='toggle-subscription']", "Pause")
+ end
+
test "public profile reveals trust data without exposing private account fields", %{conn: conn} do
category = Catalog.seed_defaults()
@@ -792,8 +866,13 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert render(helper_view) =~ "Help is now in progress."
requester_view
- |> element("button[phx-click='cancel-request']")
- |> render_click()
+ |> form("#matched-request-cancellation-form", %{
+ "cancellation" => %{
+ "cancellation_reason" => "no_longer_needed",
+ "cancellation_note" => ""
+ }
+ })
+ |> render_submit()
for view <- [requester_view, helper_view] do
html = render(view)