feat: complete notifications and request lifecycle

This commit is contained in:
SimpleTest 2026-07-23 00:59:42 +03:00
parent e2bc8acf97
commit 2159e6cda7
85 changed files with 5898 additions and 247 deletions

View File

@ -79,6 +79,13 @@ WNH_TRACKING_MIN_TIME_MS=5000
WNH_TRACKING_HTTP_TIMEOUT_MS=15000 WNH_TRACKING_HTTP_TIMEOUT_MS=15000
WNH_ANDROID_VERSION_CODE=1 WNH_ANDROID_VERSION_CODE=1
WNH_ANDROID_VERSION_NAME=0.1.0 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 # Public identifier of the locally held Google Play upload key. The private
# keystore and its randomized password live outside the repository under # keystore and its randomized password live outside the repository under
# ~/.config/who_need_help/android-release/. # ~/.config/who_need_help/android-release/.
@ -124,6 +131,21 @@ 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=
# 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_DB=who_need_help
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=replace-with-a-local-or-deployment-secret POSTGRES_PASSWORD=replace-with-a-local-or-deployment-secret

View File

@ -43,8 +43,9 @@ local Codex CLI authenticated with their ChatGPT subscription.
exact coordinates visible only to approved participants. Activities never exact coordinates visible only to approved participants. Activities never
affect urgent-helper reputation; Activity and Activity-message reports expose affect urgent-helper reputation; Activity and Activity-message reports expose
only the linked evidence to audited moderators. only the linked evidence to audited moderators.
- Request lifecycle: `open → matched → in_progress → completed`, plus cancel - Request lifecycle: `open → matched → in_progress → completed`, with explicit
and expiry paths. 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 - PostgreSQL/PostGIS locations, viewport-scoped request discovery, server-side
map clustering, MapLibre map, private matched chat, Phoenix PubSub/Presence, map clustering, MapLibre map, private matched chat, Phoenix PubSub/Presence,
and optional consent-driven live location sharing. The browser loads only 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 - Double-blind reviews, public trust summaries, and a helper leaderboard that
prioritizes unique location-supported and handover-verified counterparts prioritizes unique location-supported and handover-verified counterparts
before raw totals. 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 - Bidirectional discovery blocks, scoped reports, account/request/category
moderation, abuse-signal review, and audited moderator access to only the moderation, abuse-signal review, and audited moderator access to only the
conversation linked by a report. conversation linked by a report.
- Separate public support and content-removal intake, including moderation - Separate public support and content-removal intake, including moderation
appeals, account deletion/data requests, a URL-only TAKE IT DOWN form, appeals, account deletion/data requests, a URL-only TAKE IT DOWN form,
verified-contact status links, operator alerts, and audited staff queues. 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 - Optional GPS evidence derived from browser accuracy envelopes. Raw current
positions are deleted on stop, terminal match state, or participant block. positions are deleted on stop, terminal match state, or participant block.
- PostgreSQL-backed cross-replica action-limit policies configured by the - 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. tokens are not stored.
- EN/UK/RU UI foundation and installable PWA metadata/service worker. - EN/UK/RU UI foundation and installable PWA metadata/service worker.
- Native Android WebView client with the same authenticated LiveView, map, - 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 notification exposes Stop, it continues while the Activity is minimized, and
it retains only the current point. Reproducible Docker targets export it retains only the current point. Reproducible Docker targets export
distinct local and public-staging debug APKs; production signing and store distinct local and public-staging debug APKs; production signing and store

View File

@ -45,6 +45,10 @@ ARG WNH_TRACKING_MIN_TIME_MS
ARG WNH_TRACKING_HTTP_TIMEOUT_MS ARG WNH_TRACKING_HTTP_TIMEOUT_MS
ARG WNH_ANDROID_VERSION_CODE=1 ARG WNH_ANDROID_VERSION_CODE=1
ARG WNH_ANDROID_VERSION_NAME=0.1.0 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 \ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
--mount=type=cache,target=/home/gradle/.android,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_TRACKING_HTTP_TIMEOUT_MS=${WNH_TRACKING_HTTP_TIMEOUT_MS}" \
"-PWNH_ANDROID_VERSION_CODE=${WNH_ANDROID_VERSION_CODE}" \ "-PWNH_ANDROID_VERSION_CODE=${WNH_ANDROID_VERSION_CODE}" \
"-PWNH_ANDROID_VERSION_NAME=${WNH_ANDROID_VERSION_NAME}" \ "-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 testDebugUnitTest lintDebug assembleDebug assembleDebugAndroidTest
FROM android-base AS emulator FROM android-base AS emulator
@ -150,6 +158,10 @@ ARG WNH_TRACKING_MIN_TIME_MS
ARG WNH_TRACKING_HTTP_TIMEOUT_MS ARG WNH_TRACKING_HTTP_TIMEOUT_MS
ARG WNH_ANDROID_VERSION_CODE=1 ARG WNH_ANDROID_VERSION_CODE=1
ARG WNH_ANDROID_VERSION_NAME=0.1.0 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 \ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
--mount=type=cache,target=/home/gradle/.android,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_TRACKING_HTTP_TIMEOUT_MS=${WNH_TRACKING_HTTP_TIMEOUT_MS}" \
"-PWNH_ANDROID_VERSION_CODE=${WNH_ANDROID_VERSION_CODE}" \ "-PWNH_ANDROID_VERSION_CODE=${WNH_ANDROID_VERSION_CODE}" \
"-PWNH_ANDROID_VERSION_NAME=${WNH_ANDROID_VERSION_NAME}" \ "-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" \ "-PWNH_TEST_BUILD_TYPE=staging" \
testStagingUnitTest lintStaging assembleStaging assembleStagingAndroidTest testStagingUnitTest lintStaging assembleStaging assembleStagingAndroidTest
@ -205,6 +221,10 @@ ARG WNH_TRACKING_MIN_TIME_MS
ARG WNH_TRACKING_HTTP_TIMEOUT_MS ARG WNH_TRACKING_HTTP_TIMEOUT_MS
ARG WNH_ANDROID_VERSION_CODE ARG WNH_ANDROID_VERSION_CODE
ARG WNH_ANDROID_VERSION_NAME 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 \ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
--mount=type=cache,target=/home/gradle/.android,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_TRACKING_HTTP_TIMEOUT_MS=${WNH_TRACKING_HTTP_TIMEOUT_MS}" \
"-PWNH_ANDROID_VERSION_CODE=${WNH_ANDROID_VERSION_CODE}" \ "-PWNH_ANDROID_VERSION_CODE=${WNH_ANDROID_VERSION_CODE}" \
"-PWNH_ANDROID_VERSION_NAME=${WNH_ANDROID_VERSION_NAME}" \ "-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 \ testReleaseUnitTest lintRelease assembleRelease bundleRelease \
&& "${ANDROID_HOME}/build-tools/37.0.0/apksigner" \ && "${ANDROID_HOME}/build-tools/37.0.0/apksigner" \
verify --verbose --print-certs \ verify --verbose --print-certs \

View File

@ -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 exact configured origin and rejects messages outside the main frame. It does
not expose a legacy `addJavascriptInterface` object to every frame. 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 ## Verified build configuration
- Android Gradle Plugin 9.3.0 - 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_CODE=1
WNH_ANDROID_VERSION_NAME=0.1.0 WNH_ANDROID_VERSION_NAME=0.1.0
WNH_ANDROID_SIGNING_KEY_ALIAS=who-need-help-upload 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 ```sh
WNH_ENV_FILE=.env.production ./scripts/android-release-build.sh WNH_ENV_FILE=.env.production ./scripts/android-release-build.sh
``` ```

View File

@ -16,6 +16,10 @@ val instrumentationBuildType =
providers.gradleProperty("WNH_TEST_BUILD_TYPE").orElse("debug") providers.gradleProperty("WNH_TEST_BUILD_TYPE").orElse("debug")
val androidVersionCode = providers.gradleProperty("WNH_ANDROID_VERSION_CODE").orElse("1") val androidVersionCode = providers.gradleProperty("WNH_ANDROID_VERSION_CODE").orElse("1")
val androidVersionName = providers.gradleProperty("WNH_ANDROID_VERSION_NAME").orElse("0.1.0") 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 = val releaseSigningStoreFile =
providers.environmentVariable("WNH_ANDROID_SIGNING_STORE_FILE").orNull providers.environmentVariable("WNH_ANDROID_SIGNING_STORE_FILE").orNull
val releaseSigningPasswordFile = val releaseSigningPasswordFile =
@ -25,6 +29,29 @@ val releaseSigningKeyAlias =
fun nonBlank(value: String?): String? = value?.trim()?.takeIf(String::isNotEmpty) 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 = val releaseSigningInputs =
listOf( listOf(
nonBlank(releaseSigningStoreFile), nonBlank(releaseSigningStoreFile),
@ -87,6 +114,23 @@ android {
"TRACKING_HTTP_TIMEOUT_MS", "TRACKING_HTTP_TIMEOUT_MS",
"${trackingHttpTimeoutMs.get()}L" "${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" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@ -176,6 +220,7 @@ android {
tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }.configureEach { tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }.configureEach {
doFirst { doFirst {
validateFirebaseConfiguration()
if (name == "preReleaseBuild" && !releaseSigningConfigured) { if (name == "preReleaseBuild" && !releaseSigningConfigured) {
val detail = val detail =
if (releaseSigningPartiallyConfigured) { if (releaseSigningPartiallyConfigured) {
@ -214,6 +259,7 @@ tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }.
tasks.matching { it.name == "preDebugBuild" }.configureEach { tasks.matching { it.name == "preDebugBuild" }.configureEach {
doFirst { doFirst {
validateFirebaseConfiguration()
val value = debugBaseUrl.orNull.orEmpty() val value = debugBaseUrl.orNull.orEmpty()
val uri = runCatching { URI(value) }.getOrNull() val uri = runCatching { URI(value) }.getOrNull()
@ -258,7 +304,10 @@ tasks.withType<JavaCompile>().configureEach {
dependencies { dependencies {
implementation("androidx.activity:activity:1.13.0") implementation("androidx.activity:activity:1.13.0")
implementation("androidx.fragment:fragment:1.8.9")
implementation("androidx.webkit:webkit:1.16.0") 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") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test:core:1.7.0") androidTestImplementation("androidx.test:core:1.7.0")
androidTestImplementation("androidx.test:runner:1.7.0") androidTestImplementation("androidx.test:runner:1.7.0")

View File

@ -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 { *; }

View File

@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<application <application
android:name=".WhoNeedHelpApplication"
android:allowBackup="false" android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false" android:fullBackupContent="false"
@ -18,6 +19,15 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.WhoNeedHelp" android:theme="@style/Theme.WhoNeedHelp"
android:usesCleartextTraffic="${usesCleartextTraffic}"> android:usesCleartextTraffic="${usesCleartextTraffic}">
<meta-data
android:name="firebase_messaging_auto_init_enabled"
android:value="false" />
<meta-data
android:name="firebase_analytics_collection_enabled"
android:value="false" />
<meta-data
android:name="firebase_messaging_installation_id_enabled"
android:value="true" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="keyboardHidden|orientation|screenSize" android:configChanges="keyboardHidden|orientation|screenSize"
@ -41,5 +51,12 @@
android:exported="false" android:exported="false"
android:foregroundServiceType="location" android:foregroundServiceType="location"
android:stopWithTask="false" /> android:stopWithTask="false" />
<service
android:name=".WhoNeedHelpMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>

View File

@ -33,6 +33,8 @@ import androidx.webkit.WebMessageCompat;
import androidx.webkit.WebViewCompat; import androidx.webkit.WebViewCompat;
import androidx.webkit.WebViewFeature; import androidx.webkit.WebViewFeature;
import com.google.firebase.messaging.FirebaseMessaging;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.UUID; import java.util.UUID;
@ -48,6 +50,7 @@ public final class MainActivity extends ComponentActivity {
private String pendingLocationOrigin; private String pendingLocationOrigin;
private ActivityResultLauncher<String[]> locationPermissionLauncher; private ActivityResultLauncher<String[]> locationPermissionLauncher;
private ActivityResultLauncher<String[]> nativeTrackingPermissionLauncher; private ActivityResultLauncher<String[]> nativeTrackingPermissionLauncher;
private ActivityResultLauncher<String> pushNotificationPermissionLauncher;
private PendingNativeTracking pendingNativeTracking; private PendingNativeTracking pendingNativeTracking;
private AlertDialog pageLoadErrorDialog; private AlertDialog pageLoadErrorDialog;
private boolean mainFrameLoadFailed; 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 = new WebView(this);
webView.setLayoutParams( webView.setLayoutParams(
new ViewGroup.LayoutParams( new ViewGroup.LayoutParams(
@ -112,7 +125,7 @@ public final class MainActivity extends ComponentActivity {
cookieManager.setAcceptCookie(true); cookieManager.setAcceptCookie(true);
cookieManager.setAcceptThirdPartyCookies(webView, false); cookieManager.setAcceptThirdPartyCookies(webView, false);
configureNativeTrackingBridge(); configureNativeBridge();
webView.setWebViewClient(new TrustedWebViewClient()); webView.setWebViewClient(new TrustedWebViewClient());
webView.setWebChromeClient(new LocationWebChromeClient()); webView.setWebChromeClient(new LocationWebChromeClient());
getOnBackPressedDispatcher().addCallback( getOnBackPressedDispatcher().addCallback(
@ -280,7 +293,7 @@ public final class MainActivity extends ComponentActivity {
nativeTrackingPermissionLauncher.launch(permissions.toArray(new String[0])); nativeTrackingPermissionLauncher.launch(permissions.toArray(new String[0]));
} }
private void configureNativeTrackingBridge() { private void configureNativeBridge() {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
return; return;
} }
@ -304,12 +317,12 @@ public final class MainActivity extends ComponentActivity {
return; return;
} }
handleNativeTrackingMessage(message.getData()); handleNativeMessage(message.getData());
} }
); );
} }
private void handleNativeTrackingMessage(String payload) { private void handleNativeMessage(String payload) {
if (payload == null) { if (payload == null) {
dispatchNativeTrackingError(); dispatchNativeTrackingError();
return; return;
@ -326,6 +339,17 @@ public final class MainActivity extends ComponentActivity {
); );
} else if ("stop".equals(action)) { } else if ("stop".equals(action)) {
stopService(new Intent(MainActivity.this, TrackingService.class)); 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 { } else {
dispatchNativeTrackingError(); 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() { private void startPendingNativeTracking() {
PendingNativeTracking pending = pendingNativeTracking; PendingNativeTracking pending = pendingNativeTracking;
pendingNativeTracking = null; 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))); Log.d(LOG_TAG, "Main-frame load finished: path=" + safeLogPath(Uri.parse(url)));
scheduleMapDiagnostics(view, Uri.parse(url)); scheduleMapDiagnostics(view, Uri.parse(url));
} }
dispatchPendingPushToken();
} }
private void scheduleMapDiagnostics(WebView view, Uri uri) { private void scheduleMapDiagnostics(WebView view, Uri uri) {

View File

@ -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;
}
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}
}

View File

@ -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<String, String> 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);
}
}

View File

@ -19,4 +19,6 @@
<string name="tracking_stop_failed_detail">New updates are paused. Tap Stop sharing to retry deleting the current position.</string> <string name="tracking_stop_failed_detail">New updates are paused. Tap Stop sharing to retry deleting the current position.</string>
<string name="tracking_location_unavailable">Location is unavailable</string> <string name="tracking_location_unavailable">Location is unavailable</string>
<string name="tracking_location_unavailable_detail">Enable device location, then return to the request and try again.</string> <string name="tracking_location_unavailable_detail">Enable device location, then return to the request and try again.</string>
<string name="updates_channel_name">Help request updates</string>
<string name="updates_channel_description">Private request, message, and nearby-help notifications.</string>
</resources> </resources>

View File

@ -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));
}
}

View File

@ -158,6 +158,15 @@ html {
var(--color-base-200); 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 { .request-discovery-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -575,6 +575,200 @@ export const mountStaticAidMaps = root => {
} }
export const Hooks = { 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: { DateTimePicker: {
mounted() { mounted() {
this.hiddenInput = this.el.querySelector("[data-datetime-value]") this.hiddenInput = this.el.querySelector("[data-datetime-value]")
@ -997,10 +1191,14 @@ export const Hooks = {
"approximate_public" "approximate_public"
this.radius = () => { this.radius = () => {
const allowed = String(this.el.dataset.allowedRadii || "500,1000,2000")
.split(",")
.map(Number)
.filter(Number.isFinite)
const value = Number( const value = Number(
this.el.querySelector("[data-location-radius-input]:checked")?.value || 1000 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 = () => { this.coordinates = () => {
@ -1246,7 +1444,8 @@ export const Hooks = {
latitude: position.coords.latitude, latitude: position.coords.latitude,
longitude: position.coords.longitude 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()) ? privacySafeAreaCenter(raw.latitude, raw.longitude, this.radius())
: raw : raw

View File

@ -50,6 +50,12 @@ x-app-environment: &app-environment
PUSH_HTTP_RECEIVE_TIMEOUT_MS: ${PUSH_HTTP_RECEIVE_TIMEOUT_MS:-} PUSH_HTTP_RECEIVE_TIMEOUT_MS: ${PUSH_HTTP_RECEIVE_TIMEOUT_MS:-}
PUSH_HTTP_CONNECT_TIMEOUT_MS: ${PUSH_HTTP_CONNECT_TIMEOUT_MS:-} PUSH_HTTP_CONNECT_TIMEOUT_MS: ${PUSH_HTTP_CONNECT_TIMEOUT_MS:-}
PUSH_HTTP_RETRY_DELAY_MS: ${PUSH_HTTP_RETRY_DELAY_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_MAINTENANCE_CONCURRENCY: ${OBAN_MAINTENANCE_CONCURRENCY:-2}
OBAN_PUSH_CONCURRENCY: ${OBAN_PUSH_CONCURRENCY:-1} OBAN_PUSH_CONCURRENCY: ${OBAN_PUSH_CONCURRENCY:-1}

View File

@ -34,7 +34,13 @@ config :who_need_help,
e2e_routes: false, e2e_routes: false,
secure_cookies: false, secure_cookies: false,
rate_limit_policies: %{}, 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 config :who_need_help, WhoNeedHelp.Repo, types: WhoNeedHelp.PostgrexTypes

View File

@ -272,6 +272,105 @@ config :who_need_help,
), ),
push_delivery_options: Keyword.delete(push_configuration, :adapter) 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 if config_env() == :prod and app_role in [:web, :worker, :combined] do
metrics_token = metrics_token =
System.get_env("METRICS_TOKEN") || System.get_env("METRICS_TOKEN") ||

View File

@ -72,8 +72,15 @@ and are not represented as complete.
- `Trust`: reviews, reports, blocks, leaderboard/reputation projections, - `Trust`: reviews, reports, blocks, leaderboard/reputation projections,
abuse signals, moderator audit events, and shared rate-limit policies. abuse signals, moderator audit events, and shared rate-limit policies.
- `Push`: privacy-safe product event construction, unique durable Oban jobs, - `Push`: privacy-safe product event construction, unique durable Oban jobs,
and a provider-neutral delivery adapter. Current events cover request provider-neutral gateway delivery, direct Web Push and FCM device delivery,
acceptance and new matched-chat messages. 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 Contexts normally call each other through public functions. A small number of
documented trust-and-safety transactions update related schemas together when 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. The worker and combined roles start queue consumers and scheduled-job plugins.
PostgreSQL coordinates queues and leadership, so no Redis dependency is PostgreSQL coordinates queues and leadership, so no Redis dependency is
introduced. The worker runs only the queues used by product code: introduced. The worker runs only the queues used by product code:
`maintenance` for expiry/probes and `push` for provider-neutral delivery. `maintenance` for expiry/probes and `push` for notification matching,
Their per-worker concurrency is configured independently; no unused default dispatch, direct Web Push/FCM, optional gateway delivery, and notification
queue is started. email. Their per-worker concurrency is configured independently; no unused
default queue is started.
## Geospatial data ## Geospatial data

View File

@ -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 app is uninstalled. The workflow verifies the contact and creates an audited
account-lifecycle request. account-lifecycle request.
Actual erasure/anonymization and export are not automated because the operator An authenticated user can download `/users/data-export`. The JSON export uses
has not yet selected a jurisdiction-specific retention policy for safety, 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 fraud, disputes, and legal records. An operator must not mark a request resolved
until the applicable data action has actually been completed and communicated. until the applicable approved data action has actually been completed and
Before public launch, legal review must define which linked records are erased, communicated. Before enabling a destructive executor, legal review must define
anonymized, or retained and for how long; only then should a destructive which linked records are erased, anonymized, or retained and for how long; that
execution routine be implemented and tested against backups and relational executor must then be tested against backups and relational constraints.
constraints.
Google Play's current policy requires both an in-app path and an external web Google Play's current policy requires both an in-app path and an external web
resource when an app allows account creation: resource when an app allows account creation:

View File

@ -1,8 +1,51 @@
# Who Need Help — implementation verification # 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. 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 ## Verified MVP capabilities
| Requirement | Status | Observed evidence | Limit | | 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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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 ## 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 and 587 timed out for UniSender Go; control attempts to other public SMTP
providers also timed out. This observation does not establish where the providers also timed out. This observation does not establish where the
filtering occurs. filtering occurs.
- `WhoNeedHelp.Email.UnisenderGoAdapter` now maps the application's existing - At that time, `WhoNeedHelp.Email.UnisenderGoAdapter` mapped the application's
Swoosh messages to the provider's HTTPS `email/send.json` contract. Six existing Swoosh messages to the provider's HTTPS `email/send.json` contract. Six
focused tests passed for the exact request shape (including explicit focused tests passed for the exact request shape (including explicit
`track_read=0` and `track_links=0`) and API-key header, success, redacted `track_read=0` and `track_links=0`) and API-key header, success, redacted
recipient rejection, structured API errors, invalid responses, and rejection recipient rejection, structured API errors, invalid responses, and rejection
of unsupported or provider-invalid messages before network I/O. of unsupported or provider-invalid messages before network I/O.
Runtime configuration and production The then-current runtime configuration and production environment validation
environment validation can select either `smtp` or `unisender_go` without could select either `smtp` or `unisender_go` without requiring SMTP settings
requiring SMTP settings in API mode. in API mode. That selectable API path has since been removed.
- Authoritative DNS and the provider UI both showed the sending domain as - Authoritative DNS and the provider UI both showed the sending domain as
verified with DKIM active, while the delegated link domain showed configured. 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 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 Google OAuth client on its exact HTTPS callback origin after the tested
release is explicitly promoted. The test client and callback have already release is explicitly promoted. The test client and callback have already
completed real registration and returning-user login. completed real registration and returning-user login.
- Configure and verify a real mobile push provider and device-token lifecycle - Configure environment-specific VAPID and Firebase credentials, then verify a
if native push is required. The provider-neutral HTTP boundary and product real browser subscription and Android device against each deployed origin.
jobs are tested; FCM/APNs device delivery is not. 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, - Load-test representative data and traffic, then set measured pool, resource,
autoscaling, and action-limit policies. autoscaling, and action-limit policies.
- Publish jurisdiction-specific emergency contacts, privacy, retention, - Publish jurisdiction-specific emergency contacts, privacy, retention,
prohibited-items, and voluntary-payment guidance after legal review. prohibited-items, and voluntary-payment guidance after legal review.
- The UI now has authenticated and external account-deletion/data-request - The UI now has authenticated and external account-deletion/data-request
intake, contact verification, case status, and an audited operator queue. intake, contact verification, case status, an audited operator queue, an
Actual erasure/anonymization and export remain manual until a legally reviewed 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. retention policy defines the treatment of linked safety and dispute records.
- Staff and monitor the implemented moderation/support queues and establish an - Staff and monitor the implemented moderation/support queues and establish an
incident-response/on-call process for real users. incident-response/on-call process for real users.

View File

@ -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 organizer.page.getByRole("button", { name: "Send", exact: true }).click();
await expect(participant.page.getByText(organizerMessageText)).toBeVisible(); 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 const organizerMessage = participant.page
.locator("#activity-messages article") .locator("#activity-messages article")
.filter({ hasText: organizerMessageText }); .filter({ hasText: organizerMessageText });

View File

@ -25,6 +25,7 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r
); );
const requesterEmail = projectEmail("requester", testInfo.project.name); const requesterEmail = projectEmail("requester", testInfo.project.name);
const helperEmail = projectEmail("helper", testInfo.project.name); const helperEmail = projectEmail("helper", testInfo.project.name);
const replacementEmail = projectEmail("replacement-helper", testInfo.project.name);
const requester = const requester =
runID && fixturePassword runID && fixturePassword
? await loginWithPassword(browser, requesterEmail, fixturePassword) ? await loginWithPassword(browser, requesterEmail, fixturePassword)
@ -33,8 +34,18 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r
runID && fixturePassword runID && fixturePassword
? await loginWithPassword(browser, helperEmail, fixturePassword) ? await loginWithPassword(browser, helperEmail, fixturePassword)
: await registerAndConfirm(browser, request, helperEmail, "E2E Helper"); : 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 assertRequesterClean = captureBrowserFailures(requester.page);
const assertHelperClean = captureBrowserFailures(helper.page); const assertHelperClean = captureBrowserFailures(helper.page);
const assertReplacementClean = captureBrowserFailures(replacement.page);
await gotoLiveView(requester.page, "/requests/new"); await gotoLiveView(requester.page, "/requests/new");
await selectOptionContaining(requester.page, "Category", "Medicine pickup"); 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(); const roadsideURL = requester.page.url();
await gotoLiveView(helper.page, roadsideURL); await gotoLiveView(helper.page, roadsideURL);
const withdrawalSummary = helper.page
.locator("summary")
.filter({ hasText: "Withdraw from this request" });
await clickUntilVisible( await clickUntilVisible(
helper.page.getByRole("button", { name: "I can help" }), helper.page.getByRole("button", { name: "I can help" }),
helper.page.getByRole("button", { name: "Withdraw from this request" }), withdrawalSummary,
); );
await clickUntilVisible( await withdrawalSummary.click();
helper.page.getByRole("button", { name: "Withdraw from this request" }), const withdrawalForm = helper.page.locator("#assignment-withdrawal-form");
helper.page.getByText("This request was cancelled."), await withdrawalForm.getByLabel("Reason").selectOption("requester_unreachable");
); await withdrawalForm
await expect(requester.page.getByText("Cancelled", { exact: true })).toBeVisible(); .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(); assertRequesterClean();
assertHelperClean(); assertHelperClean();
assertReplacementClean();
await requester.context.close(); await requester.context.close();
await helper.context.close(); await helper.context.close();
await replacement.context.close();
}); });

View File

@ -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();
});

View File

@ -31,6 +31,7 @@ async function createMedicineRequest(
await page.locator("#request-form input[type=checkbox]").check(); await page.locator("#request-form input[type=checkbox]").check();
await page.getByRole("button", { name: "Publish request" }).click(); await page.getByRole("button", { name: "Publish request" }).click();
await expect(page.getByRole("heading", { name: title })).toBeVisible(); await expect(page.getByRole("heading", { name: title })).toBeVisible();
await waitForMapReady(page);
} }
test("request discovery searches the viewport, clusters dense points, and remembers layout", async ({ test("request discovery searches the viewport, clusters dense points, and remembers layout", async ({

View File

@ -1,7 +1,15 @@
import { expect, test } from "@playwright/test"; 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, browser,
request, request,
}, testInfo) => { }, testInfo) => {
@ -11,43 +19,75 @@ test("a disconnected LiveView announces recovery and clears it after reconnect",
projectEmail("resilience", testInfo.project.name), projectEmail("resilience", testInfo.project.name),
"E2E Resilience", "E2E Resilience",
); );
const title = projectText("Connection recovery request", testInfo.project.name);
await gotoLiveView(user.page, "/requests"); await gotoLiveView(user.page, "/requests/new");
await expect await selectOptionContaining(user.page, "Category", "Medicine pickup");
.poll(() => await user.page.getByLabel("Medicine pickup status").selectOption("reserved");
user.page.evaluate(() => { await user.page.getByLabel("Short title").fill(title);
const liveSocket = ( 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 & { window as typeof window & {
liveSocket?: { isConnected: () => boolean }; __wnhRecoveryState?: { sawDisconnected: boolean };
} }
).liveSocket; ).__wnhRecoveryState = state;
return liveSocket?.isConnected() ?? false;
}), 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 expect
.poll(
() =>
user.page.evaluate(
() =>
(
window as typeof window & {
__wnhRecoveryState?: { sawDisconnected: boolean };
}
).__wnhRecoveryState?.sawDisconnected ?? false,
),
{ timeout: 30_000 },
) )
.toBe(true); .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 waitForLiveViewConnected(user.page);
await user.page.evaluate(() => { await expect(serverError).toBeHidden();
const liveSocket = ( await expect(user.page.locator("#client-error")).toBeHidden();
window as typeof window & { await expect(user.page.getByRole("heading", { name: title })).toBeVisible();
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 user.context.close(); await user.context.close();
}); });

View File

@ -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

View File

@ -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

View File

@ -33,6 +33,10 @@ defmodule WhoNeedHelp.Accounts.User do
field :accepted_terms_at, :utc_datetime field :accepted_terms_at, :utc_datetime
field :terms_accepted, :boolean, virtual: true, default: false field :terms_accepted, :boolean, virtual: true, default: false
has_many :social_identities, WhoNeedHelp.Accounts.SocialIdentity 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) timestamps(type: :utc_datetime)
end end

View File

@ -14,12 +14,13 @@ defmodule WhoNeedHelp.Application do
:persistent_term.put({WhoNeedHelp, :e2e_boot_id}, boot_id) :persistent_term.put({WhoNeedHelp, :e2e_boot_id}, boot_id)
end end
common_children = [ common_children =
[
WhoNeedHelpWeb.Telemetry, WhoNeedHelpWeb.Telemetry,
WhoNeedHelp.Repo, WhoNeedHelp.Repo,
{DNSCluster, query: Application.get_env(:who_need_help, :dns_cluster_query) || :ignore}, {DNSCluster, query: Application.get_env(:who_need_help, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: WhoNeedHelp.PubSub} {Phoenix.PubSub, name: WhoNeedHelp.PubSub}
] ] ++ WhoNeedHelp.Push.supervisor_children()
oban_config = Application.fetch_env!(:who_need_help, Oban) oban_config = Application.fetch_env!(:who_need_help, Oban)
oban_client_config = Keyword.merge(oban_config, queues: [], plugins: [], peer: false) oban_client_config = Keyword.merge(oban_config, queues: [], plugins: [], peer: false)

View File

@ -9,7 +9,9 @@ defmodule WhoNeedHelp.Help do
alias WhoNeedHelp.Catalog.Category alias WhoNeedHelp.Catalog.Category
alias WhoNeedHelp.Help.{Assignment, DiscoveryViewport, HelpRequest} alias WhoNeedHelp.Help.{Assignment, DiscoveryViewport, HelpRequest}
alias WhoNeedHelp.Pagination alias WhoNeedHelp.Pagination
alias WhoNeedHelp.ProductAnalytics
alias WhoNeedHelp.Push alias WhoNeedHelp.Push
alias WhoNeedHelp.Push.NearbyMatchWorker
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
alias WhoNeedHelp.Trust alias WhoNeedHelp.Trust
alias WhoNeedHelp.Trust.Block alias WhoNeedHelp.Trust.Block
@ -276,7 +278,8 @@ defmodule WhoNeedHelp.Help do
Trust.audit(user.id, "request.created", "request", request.id, %{ Trust.audit(user.id, "request.created", "request", request.id, %{
"urgency" => to_string(request.urgency), "urgency" => to_string(request.urgency),
"category_id" => request.category_id "category_id" => request.category_id
}) do }),
{:ok, _nearby_job} <- NearbyMatchWorker.enqueue(request.id) do
{:ok, request} {:ok, request}
end end
end) end)
@ -286,6 +289,7 @@ defmodule WhoNeedHelp.Help do
end end
with {:ok, request} <- result do with {:ok, request} <- result do
_ = ProductAnalytics.increment("request.created", to_string(request.urgency))
broadcast({:request_created, get_request!(request.id)}) broadcast({:request_created, get_request!(request.id)})
{:ok, request} {:ok, request}
end end
@ -330,6 +334,7 @@ defmodule WhoNeedHelp.Help do
|> Assignment.changeset(%{ |> Assignment.changeset(%{
request_id: request.id, request_id: request.id,
helper_id: helper.id, helper_id: helper.id,
active: true,
accepted_at: now, accepted_at: now,
handover_code_hash: code_hash(code) handover_code_hash: code_hash(code)
}) })
@ -359,6 +364,7 @@ defmodule WhoNeedHelp.Help do
case result do case result do
{:ok, assignment} -> {:ok, assignment} ->
_ = ProductAnalytics.increment("request.accepted")
request = get_request!(assignment.request_id) request = get_request!(assignment.request_id)
broadcast({:request_updated, request}) broadcast({:request_updated, request})
@ -377,6 +383,10 @@ defmodule WhoNeedHelp.Help do
transition_assignment(user, assignment_id, :start) transition_assignment(user, assignment_id, :start)
end 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 def confirm_completion(%Scope{user: user}, assignment_id) do
transition_assignment(user, assignment_id, :confirm) transition_assignment(user, assignment_id, :confirm)
end end
@ -413,6 +423,7 @@ defmodule WhoNeedHelp.Help do
|> Assignment.changeset(%{handover_verified_at: now}) |> Assignment.changeset(%{handover_verified_at: now})
|> maybe_complete(request) |> maybe_complete(request)
|> audit_assignment_transition(user.id, "handover.verified", request.id) |> audit_assignment_transition(user.id, "handover.verified", request.id)
|> notify_handover_verified(request)
end end
else else
nil -> {:error, :not_found} nil -> {:error, :not_found}
@ -425,7 +436,7 @@ defmodule WhoNeedHelp.Help do
|> after_transition() |> after_transition()
end 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), with {:ok, request_id} <- cast_id(request_id),
{:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :cancel_request) do {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :cancel_request) do
Repo.transact(fn -> Repo.transact(fn ->
@ -444,17 +455,20 @@ defmodule WhoNeedHelp.Help do
assignment = assignment =
Assignment Assignment
|> where([assignment], assignment.request_id == ^request.id) |> where([assignment], assignment.request_id == ^request.id and assignment.active)
|> lock("FOR UPDATE") |> lock("FOR UPDATE")
|> Repo.one() |> Repo.one()
with {:ok, request} <- with {:ok, request} <-
request request
|> Ecto.Changeset.change(status: :cancelled, cancelled_at: now) |> HelpRequest.cancellation_changeset(cancellation_attrs(attrs, now))
|> Repo.update(), |> Repo.update(),
{:ok, _assignment} <- cancel_assignment(assignment), {:ok, _assignment} <- cancel_assignment(assignment),
{:ok, _audit} <- {: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} {:ok, request}
end end
@ -467,6 +481,9 @@ defmodule WhoNeedHelp.Help do
end end
|> case do |> case do
{:ok, request} -> {:ok, request} ->
_ =
ProductAnalytics.increment("request.cancelled", to_string(request.cancellation_reason))
WhoNeedHelp.Tracking.cleanup_finished_sessions() WhoNeedHelp.Tracking.cleanup_finished_sessions()
request = get_request!(request.id) request = get_request!(request.id)
broadcast({:request_updated, request}) broadcast({:request_updated, request})
@ -477,7 +494,7 @@ defmodule WhoNeedHelp.Help do
end end
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), with {:ok, assignment_id} <- cast_id(assignment_id),
{:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :withdraw_assignment) do {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :withdraw_assignment) do
Repo.transact(fn -> Repo.transact(fn ->
@ -487,19 +504,28 @@ defmodule WhoNeedHelp.Help do
if assignment.helper_id == user.id and if assignment.helper_id == user.id and
assignment.status in [:accepted, :in_progress] do assignment.status in [:accepted, :in_progress] do
now = DateTime.utc_now(:second) now = DateTime.utc_now(:second)
reopen? = DateTime.after?(request.expires_at, now)
with {:ok, assignment} <- with {:ok, assignment} <-
assignment assignment
|> Assignment.changeset(%{status: :cancelled}) |> Assignment.withdrawal_changeset(withdrawal_attrs(attrs))
|> Repo.update(), |> Repo.update(),
{:ok, _request} <- {:ok, _request} <-
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(), |> Repo.update(),
{:ok, _audit} <- {:ok, _audit} <-
Trust.audit(user.id, "assignment.withdrawn", "assignment", assignment.id, %{ Trust.audit(user.id, "assignment.withdrawn", "assignment", assignment.id, %{
"request_id" => request.id "request_id" => request.id,
}) do "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} {:ok, assignment}
end end
else else
@ -515,6 +541,7 @@ defmodule WhoNeedHelp.Help do
|> after_transition() |> after_transition()
|> case do |> case do
{:ok, _assignment} = result -> {:ok, _assignment} = result ->
_ = ProductAnalytics.increment("assignment.withdrawn")
WhoNeedHelp.Tracking.cleanup_finished_sessions() WhoNeedHelp.Tracking.cleanup_finished_sessions()
result result
@ -647,6 +674,41 @@ defmodule WhoNeedHelp.Help do
"assignment", "assignment",
assignment.id, assignment.id,
%{"request_id" => request.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 ) do
{:ok, assignment} {:ok, assignment}
end end
@ -680,6 +742,7 @@ defmodule WhoNeedHelp.Help do
:error -> {:error, :not_found} :error -> {:error, :not_found}
end end
|> after_transition() |> after_transition()
|> record_assignment_metric(action)
end end
defp maybe_complete(changeset, request) do defp maybe_complete(changeset, request) do
@ -710,7 +773,7 @@ defmodule WhoNeedHelp.Help do
defp locked_assignment(id) do defp locked_assignment(id) do
Assignment Assignment
|> where([a], a.id == ^id) |> where([a], a.id == ^id and a.active)
|> lock("FOR UPDATE") |> lock("FOR UPDATE")
|> Repo.one() |> Repo.one()
end end
@ -752,7 +815,9 @@ defmodule WhoNeedHelp.Help do
"status" => to_string(assignment.status) "status" => to_string(assignment.status)
}), }),
{:ok, _completion_audit} <- {: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} {:ok, assignment}
end end
end end
@ -775,6 +840,128 @@ defmodule WhoNeedHelp.Help do
|> Repo.update() |> Repo.update()
end 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 defp broadcast(event) do
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @topic, event) Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @topic, event)

View File

@ -10,6 +10,7 @@ defmodule WhoNeedHelp.Help.Assignment do
values: [:accepted, :in_progress, :completed, :cancelled], values: [:accepted, :in_progress, :completed, :cancelled],
default: :accepted default: :accepted
field :active, :boolean, default: true
field :handover_code_hash, :binary field :handover_code_hash, :binary
field :handover_verified_at, :utc_datetime field :handover_verified_at, :utc_datetime
field :requester_confirmed_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 :helper_movement_observed_at, :utc_datetime
field :accepted_at, :utc_datetime field :accepted_at, :utc_datetime
field :started_at, :utc_datetime field :started_at, :utc_datetime
field :arrived_at, :utc_datetime
field :completed_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 :request, WhoNeedHelp.Help.HelpRequest
belongs_to :helper, WhoNeedHelp.Accounts.User belongs_to :helper, WhoNeedHelp.Accounts.User
has_many :messages, WhoNeedHelp.Messaging.Message has_many :messages, WhoNeedHelp.Messaging.Message
@ -33,6 +40,7 @@ defmodule WhoNeedHelp.Help.Assignment do
:request_id, :request_id,
:helper_id, :helper_id,
:status, :status,
:active,
:handover_code_hash, :handover_code_hash,
:handover_verified_at, :handover_verified_at,
:requester_confirmed_at, :requester_confirmed_at,
@ -41,9 +49,21 @@ defmodule WhoNeedHelp.Help.Assignment do
:helper_movement_observed_at, :helper_movement_observed_at,
:accepted_at, :accepted_at,
:started_at, :started_at,
:arrived_at,
:withdrawal_reason,
:withdrawal_note,
:completed_at :completed_at
]) ])
|> validate_required([:request_id, :helper_id, :status, :accepted_at, :handover_code_hash]) |> 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
end end

View File

@ -26,13 +26,23 @@ defmodule WhoNeedHelp.Help.HelpRequest do
field :expires_at, :utc_datetime field :expires_at, :utc_datetime
field :cancelled_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 :completed_at, :utc_datetime
field :hidden_at, :utc_datetime field :hidden_at, :utc_datetime
field :hidden_reason, :string field :hidden_reason, :string
field :safety_confirmed, :boolean, virtual: true, default: false field :safety_confirmed, :boolean, virtual: true, default: false
belongs_to :requester, WhoNeedHelp.Accounts.User belongs_to :requester, WhoNeedHelp.Accounts.User
belongs_to :category, WhoNeedHelp.Catalog.Category 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) timestamps(type: :utc_datetime)
end end
@ -79,6 +89,14 @@ defmodule WhoNeedHelp.Help.HelpRequest do
|> validate_length(:hidden_reason, max: 1_000) |> validate_length(:hidden_reason, max: 1_000)
end 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 defp put_location(changeset, attrs) do
case get_field(changeset, :location_visibility) do case get_field(changeset, :location_visibility) do
:hidden -> :hidden ->

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,13 +1,15 @@
defmodule WhoNeedHelp.Push do defmodule WhoNeedHelp.Push do
@moduledoc """ @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 Registered browser and Android devices use direct Web Push and FCM delivery.
configuration is supplied. Product events target a stable user identifier; An optional provider-neutral HTTP gateway remains available for deployments
the selected provider is responsible for resolving that identifier to one or that resolve a stable user recipient outside the application. Every remote
more registered devices. channel stays inactive until its complete provider configuration exists.
""" """
alias WhoNeedHelp.Notifications
alias WhoNeedHelp.Notifications.{Notification, PushDevice}
alias WhoNeedHelp.Push.DeliveryWorker alias WhoNeedHelp.Push.DeliveryWorker
@type notification :: %{ @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 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 def enqueue_request_accepted(assignment_id, request_id, requester_id) do
enqueue(%{ Notifications.notify_user(requester_id, %{
idempotency_key: "request-accepted:#{assignment_id}:#{requester_id}", kind: :request_accepted,
recipient: user_recipient(requester_id),
title: "A helper responded", title: "A helper responded",
body: "Open Who Need Help to see the request update.", body: "Open Who Need Help to see the request update.",
path: "/requests/#{request_id}",
idempotency_key: "request-accepted:#{assignment_id}:#{requester_id}",
data: %{ data: %{
"kind" => "request_accepted", "kind" => "request_accepted",
"assignment_id" => assignment_id, "assignment_id" => assignment_id,
@ -41,11 +51,12 @@ defmodule WhoNeedHelp.Push do
end end
def enqueue_message_created(message_id, assignment_id, request_id, recipient_id) do def enqueue_message_created(message_id, assignment_id, request_id, recipient_id) do
enqueue(%{ Notifications.notify_user(recipient_id, %{
idempotency_key: "message-created:#{message_id}:#{recipient_id}", kind: :message_created,
recipient: user_recipient(recipient_id),
title: "New message", title: "New message",
body: "Open Who Need Help to read the conversation.", body: "Open Who Need Help to read the conversation.",
path: "/requests/#{request_id}#messages",
idempotency_key: "message-created:#{message_id}:#{recipient_id}",
data: %{ data: %{
"kind" => "message_created", "kind" => "message_created",
"assignment_id" => assignment_id, "assignment_id" => assignment_id,
@ -55,6 +66,18 @@ defmodule WhoNeedHelp.Push do
}) })
end 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 def enqueue(notification) do
if enabled?() do if enabled?() do
notification notification
@ -84,7 +107,33 @@ defmodule WhoNeedHelp.Push do
Application.get_env(:who_need_help, :push_delivery_options, []) Application.get_env(:who_need_help, :push_delivery_options, [])
end 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(%{ defp validate(%{
idempotency_key: idempotency_key, idempotency_key: idempotency_key,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -4,6 +4,7 @@ defmodule WhoNeedHelp.Support do
import Ecto.Query import Ecto.Query
alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.DataLifecycle
alias WhoNeedHelp.Accounts.{Scope, User} alias WhoNeedHelp.Accounts.{Scope, User}
alias WhoNeedHelp.Pagination alias WhoNeedHelp.Pagination
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
@ -161,6 +162,17 @@ defmodule WhoNeedHelp.Support do
end end
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 defp notify_received({:ok, request}) do
_ = Notifier.deliver_received(request, status_url(request)) _ = Notifier.deliver_received(request, status_url(request))
_ = Notifier.deliver_operator_alert(request) _ = Notifier.deliver_operator_alert(request)

View File

@ -76,6 +76,13 @@ defmodule WhoNeedHelpWeb.Layouts do
<.theme_toggle /> <.theme_toggle />
<%= if @current_scope do %> <%= 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>
<.link navigate={~p"/requests/new"} class="btn btn-primary btn-sm whitespace-nowrap"> <.link navigate={~p"/requests/new"} class="btn btn-primary btn-sm whitespace-nowrap">
{gettext("Ask for help")} {gettext("Ask for help")}
</.link> </.link>
@ -117,6 +124,9 @@ defmodule WhoNeedHelpWeb.Layouts do
<li> <li>
<.link navigate={~p"/profile"}>{gettext("Profile")}</.link> <.link navigate={~p"/profile"}>{gettext("Profile")}</.link>
</li> </li>
<li>
<.link navigate={~p"/notifications"}>{gettext("Notifications")}</.link>
</li>
<li> <li>
<.link href={~p"/users/settings"}>{gettext("Account settings")}</.link> <.link href={~p"/users/settings"}>{gettext("Account settings")}</.link>
</li> </li>
@ -290,6 +300,9 @@ defmodule WhoNeedHelpWeb.Layouts do
<li> <li>
<.link navigate={~p"/profile"}>{gettext("Profile")}</.link> <.link navigate={~p"/profile"}>{gettext("Profile")}</.link>
</li> </li>
<li>
<.link navigate={~p"/notifications"}>{gettext("Notifications")}</.link>
</li>
<li> <li>
<.link href={~p"/users/settings"}>{gettext("Account settings")}</.link> <.link href={~p"/users/settings"}>{gettext("Account settings")}</.link>
</li> </li>

View File

@ -3,7 +3,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
require Logger require Logger
alias WhoNeedHelp.{Accounts, GoogleAuth, Repo, Trust} alias WhoNeedHelp.{Accounts, GoogleAuth, ProductAnalytics, Repo, Trust}
alias WhoNeedHelp.Trust.RateLimiter alias WhoNeedHelp.Trust.RateLimiter
alias WhoNeedHelpWeb.{GoogleAuthPending, UserAuth} alias WhoNeedHelpWeb.{GoogleAuthPending, UserAuth}
@ -26,23 +26,8 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
def start_registration(conn, %{"google_registration" => params}) when is_map(params) do def start_registration(conn, %{"google_registration" => params}) when is_map(params) do
locale = normalize_locale(params["locale"]) 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}, ~p"/users/register")
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
end end
def start_registration(conn, _params) do def start_registration(conn, _params) do
@ -111,6 +96,8 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
"locale" => normalize_locale(params["locale"] || pending.locale), "locale" => normalize_locale(params["locale"] || pending.locale),
"terms_accepted" => true "terms_accepted" => true
}) do }) do
_ = ProductAnalytics.increment("account.registered", "google")
conn conn
|> GoogleAuthPending.delete() |> GoogleAuthPending.delete()
|> put_flash(:info, gettext("Your account was created with Google.")) |> put_flash(:info, gettext("Your account was created with Google."))
@ -278,37 +265,9 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
end end
defp finish_flow(conn, "register", flow, identity_attrs) do 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 case GoogleAuthPending.put(conn, identity_attrs, flow_locale(flow)) do
{:ok, conn} -> {:ok, conn} ->
conn redirect(conn, to: ~p"/auth/google/complete")
|> 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")
{:error, _reason} -> {:error, _reason} ->
google_account_unavailable(conn, ~p"/users/register") google_account_unavailable(conn, ~p"/users/register")

View File

@ -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

View File

@ -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

View File

@ -3,7 +3,7 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
require Logger require Logger
alias WhoNeedHelp.{Accounts, GoogleAuth} alias WhoNeedHelp.{Accounts, GoogleAuth, ProductAnalytics}
alias WhoNeedHelp.Accounts.User alias WhoNeedHelp.Accounts.User
alias WhoNeedHelp.Trust.RateLimiter alias WhoNeedHelp.Trust.RateLimiter
@ -27,6 +27,7 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
result <- Accounts.register_user(user_params) do result <- Accounts.register_user(user_params) do
case result do case result do
{:ok, user} -> {:ok, user} ->
_ = ProductAnalytics.increment("account.registered", "email")
deliver_registration_instructions(conn, user) deliver_registration_instructions(conn, user)
registration_response(conn) registration_response(conn)

View File

@ -25,43 +25,21 @@
</p> </p>
<.form <.form
:if={@google_auth_enabled}
for={%{}} for={%{}}
as={:google_registration} as={:google_registration}
action={~p"/auth/google/register"} action={~p"/auth/google/register"}
id="google_registration_form" id="google_registration_form"
class="space-y-3"
> >
<input <input
type="hidden" type="hidden"
name="google_registration[locale]" name="google_registration[locale]"
value={Gettext.get_locale(WhoNeedHelpWeb.Gettext)} value={Gettext.get_locale(WhoNeedHelpWeb.Gettext)}
/> />
<.input <.google_auth_button label={gettext("Sign up with Google")} />
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
/>
<p class="text-xs text-base-content/60">
{gettext("Read the")}
<.link href={~p"/terms"} class="link font-semibold">{gettext("Terms")}</.link>,
<.link href={~p"/privacy"} class="link font-semibold">{gettext("Privacy Policy")}</.link>
{gettext("and")}
<.link href={~p"/safety"} class="link font-semibold">{gettext("Safety Rules")}</.link>
{gettext("before confirming.")}
</p>
<.google_auth_button
label={gettext("Sign up with Google")}
disabled={!@google_auth_enabled}
/>
<p :if={!@google_auth_enabled} class="text-center text-xs text-base-content/65">
{gettext("Google sign-in will be available after the operator configures it.")}
</p>
</.form> </.form>
<div class="divider">{gettext("or sign up with email")}</div> <div :if={@google_auth_enabled} class="divider">{gettext("or sign up with email")}</div>
<.form <.form
:let={f} :let={f}

View File

@ -69,22 +69,18 @@
<div id="standard-login-options"> <div id="standard-login-options">
<.form <.form
:if={!@current_scope} :if={!@current_scope && @google_auth_enabled}
for={%{}} for={%{}}
as={:google_login} as={:google_login}
action={~p"/auth/google/login"} action={~p"/auth/google/login"}
id="google_login_form" id="google_login_form"
> >
<.google_auth_button <.google_auth_button label={gettext("Continue with Google")} />
label={gettext("Continue with Google")}
disabled={!@google_auth_enabled}
/>
<p :if={!@google_auth_enabled} class="mt-2 text-center text-xs text-base-content/65">
{gettext("Google sign-in will be available after the operator configures it.")}
</p>
</.form> </.form>
<div :if={!@current_scope} class="divider">{gettext("or use email")}</div> <div :if={!@current_scope && @google_auth_enabled} class="divider">
{gettext("or use email")}
</div>
<p <p
:if={@pending_google_email && !@current_scope} :if={@pending_google_email && !@current_scope}

View File

@ -119,6 +119,9 @@
)} )}
</p> </p>
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">
<.link href={~p"/users/data-export"} class="btn btn-outline">
{gettext("Download my data")}
</.link>
<.link navigate={~p"/account/delete"} class="btn btn-error btn-outline"> <.link navigate={~p"/account/delete"} class="btn btn-error btn-outline">
{gettext("Request account deletion")} {gettext("Request account deletion")}
</.link> </.link>

View File

@ -43,7 +43,7 @@ defmodule WhoNeedHelpWeb.LeaderboardLive do
</h1> </h1>
<p class="mt-3 text-base-content/65"> <p class="mt-3 text-base-content/65">
{gettext( {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."
)} )}
</p> </p>
@ -54,7 +54,7 @@ defmodule WhoNeedHelpWeb.LeaderboardLive do
<th>{gettext("Rank")}</th> <th>{gettext("Rank")}</th>
<th>{gettext("Helper")}</th> <th>{gettext("Helper")}</th>
<th>{gettext("Location-supported people")}</th> <th>{gettext("Location-supported people")}</th>
<th>{gettext("Verified people")}</th> <th>{gettext("Code-confirmed people")}</th>
<th>{gettext("Unique people")}</th> <th>{gettext("Unique people")}</th>
<th>{gettext("Total")}</th> <th>{gettext("Total")}</th>
<th>{gettext("Rating")}</th> <th>{gettext("Rating")}</th>

View File

@ -289,7 +289,17 @@ defmodule WhoNeedHelpWeb.ModerationLive do
~H""" ~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}> <Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="text-sm font-semibold text-warning">{gettext("RESTRICTED WORKSPACE")}</div> <div class="text-sm font-semibold text-warning">{gettext("RESTRICTED WORKSPACE")}</div>
<div class="flex flex-wrap items-end justify-between gap-3">
<h1 class="mt-1 text-4xl font-black">{gettext("Moderation")}</h1> <h1 class="mt-1 text-4xl font-black">{gettext("Moderation")}</h1>
<div class="flex flex-wrap gap-2">
<.link navigate={~p"/analytics"} class="btn btn-outline btn-sm">
{gettext("Product analytics")}
</.link>
<.link navigate={~p"/support/operations"} class="btn btn-outline btn-sm">
{gettext("Support operations")}
</.link>
</div>
</div>
<p class="mt-2 text-base-content/60"> <p class="mt-2 text-base-content/60">
{gettext("Decisions and access to reported chat evidence are written to the audit log.")} {gettext("Decisions and access to reported chat evidence are written to the audit log.")}
</p> </p>

View File

@ -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"""
<Layouts.app flash={@flash} current_scope={@current_scope} page_width={:reading}>
<div class="space-y-8">
<header class="flex flex-wrap items-end justify-between gap-4">
<div>
<p class="text-sm font-bold uppercase tracking-[.16em] text-success">
{gettext("Stay available without refreshing")}
</p>
<h1 class="mt-2 text-4xl font-black">{gettext("Notifications")}</h1>
<p class="mt-2 max-w-2xl text-base-content/65">
{gettext(
"Follow request updates and choose which nearby needs should reach this device."
)}
</p>
</div>
<button
:if={@unread_count > 0}
type="button"
phx-click="mark-all-read"
class="btn btn-outline btn-sm"
>
{gettext("Mark all read")}
</button>
</header>
<section class="rounded-3xl border border-base-300 bg-base-100 p-5 sm:p-6">
<div class="flex items-center justify-between gap-4">
<h2 class="text-xl font-black">{gettext("Inbox")}</h2>
<span class="badge badge-success badge-outline">
{ngettext("%{count} unread", "%{count} unread", @unread_count, count: @unread_count)}
</span>
</div>
<div :if={@notifications == []} class="mt-5 rounded-2xl bg-base-200 p-6 text-center">
<.icon name="hero-bell-slash" class="mx-auto size-8 text-base-content/45" />
<p class="mt-3 font-bold">{gettext("No notifications yet")}</p>
<p class="mt-1 text-sm text-base-content/70">
{gettext("Request, message, and nearby-alert updates will appear here.")}
</p>
</div>
<div :if={@notifications != []} class="mt-4 divide-y divide-base-300">
<button
:for={notification <- @notifications}
type="button"
phx-click="open-notification"
phx-value-id={notification.id}
class={[
"flex w-full items-start gap-3 px-2 py-4 text-left transition hover:bg-base-200",
is_nil(notification.read_at) && "font-semibold"
]}
>
<span class={[
"mt-2 size-2 shrink-0 rounded-full",
is_nil(notification.read_at) && "bg-success",
notification.read_at && "bg-transparent"
]} />
<span class="min-w-0 flex-1">
<span class="block">{notification.title}</span>
<span class="mt-1 block text-sm font-normal text-base-content/65">
{notification.body}
</span>
<span class="mt-2 block text-xs font-normal text-base-content/45">
{format_time(notification.inserted_at)}
</span>
</span>
<.icon name="hero-chevron-right" class="mt-2 size-4 shrink-0 opacity-45" />
</button>
</div>
<button
:if={@notifications_cursor}
type="button"
phx-click="load-more-notifications"
class="btn btn-ghost btn-sm mt-4 w-full"
>
{gettext("Load older notifications")}
</button>
</section>
<section class="grid gap-6 lg:grid-cols-[.9fr_1.1fr]">
<div
id="push-device-registration"
phx-hook="PushNotifications"
data-vapid-public-key={@web_push_public_key || ""}
class="rounded-3xl border border-base-300 p-5 sm:p-6"
>
<h2 class="text-xl font-black">{gettext("Delivery channels and quiet hours")}</h2>
<p class="mt-2 text-sm text-base-content/60">
{gettext("Notification text never contains chat messages or exact coordinates.")}
</p>
<div class="mt-5 rounded-2xl bg-base-200 p-4">
<button
type="button"
data-enable-push
disabled={is_nil(@web_push_public_key)}
class="btn btn-success w-full"
>
<.icon name="hero-bell-alert" class="size-5" />
{gettext("Enable push on this device")}
</button>
<p data-push-status role="status" aria-live="polite" class="mt-2 text-xs">
<%= 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 %>
</p>
</div>
<div :if={@devices != []} class="mt-4 space-y-2">
<div
:for={device <- @devices}
class="flex items-center justify-between gap-3 rounded-2xl border border-base-300 p-3"
>
<div class="min-w-0">
<p class="truncate text-sm font-bold">
{device.device_label || gettext("Web browser")}
</p>
<p class="text-xs text-base-content/55">{format_time(device.last_seen_at)}</p>
</div>
<button
type="button"
phx-click="disable-device"
phx-value-id={device.id}
data-disable-device={device.id}
class="btn btn-outline btn-xs border-error text-base-content"
>
{gettext("Disable")}
</button>
</div>
</div>
<.form
for={@preference_form}
phx-submit="save-preferences"
id="notification-preferences-form"
phx-hook="NotificationTimeZone"
class="mt-6 space-y-4"
>
<input
type="hidden"
name="notification_preference[time_zone]"
value={@preference_form[:time_zone].value || "Etc/UTC"}
data-time-zone
/>
<input
type="hidden"
name="notification_preference[utc_offset_minutes]"
value={@preference_form[:utc_offset_minutes].value || 0}
data-utc-offset
/>
<.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")}
/>
<div class="divider text-xs uppercase tracking-wider">{gettext("Email")}</div>
<.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")}
/>
<div class="grid grid-cols-2 gap-3">
<.input field={@preference_form[:quiet_start]} type="time" label={gettext("From")} />
<.input field={@preference_form[:quiet_end]} type="time" label={gettext("Until")} />
</div>
<.button class="btn btn-primary w-full">{gettext("Save preferences")}</.button>
</.form>
</div>
<div class="rounded-3xl border border-base-300 p-5 sm:p-6">
<h2 class="text-xl font-black">{gettext("Your nearby alerts")}</h2>
<p class="mt-2 text-sm text-base-content/60">
{gettext("The saved center is private and is used only to find matching requests.")}
</p>
<div :if={@subscriptions == []} class="mt-4 rounded-2xl bg-base-200 p-4 text-sm">
{gettext("No nearby alerts configured yet.")}
</div>
<div class="mt-4 space-y-3">
<div
:for={subscription <- @subscriptions}
class="rounded-2xl border border-base-300 p-4"
>
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="font-bold">{subscription.name}</h3>
<p class="mt-1 text-sm text-base-content/60">
{subscription.location_label} · {div(subscription.radius_meters, 1_000)} km
</p>
<div class="mt-2 flex flex-wrap gap-1">
<span :if={subscription.push_enabled} class="badge badge-sm badge-outline">
{gettext("Push")}
</span>
<span :if={subscription.email_enabled} class="badge badge-sm badge-outline">
{gettext("Email")}
</span>
</div>
</div>
<span class={[
"badge",
subscription.active && "badge-success",
!subscription.active && "badge-ghost"
]}>
{if subscription.active, do: gettext("Active"), else: gettext("Paused")}
</span>
</div>
<div class="mt-3 flex gap-2">
<button
type="button"
phx-click="toggle-subscription"
phx-value-id={subscription.id}
phx-value-active={to_string(!subscription.active)}
class="btn btn-outline btn-xs"
>
{if subscription.active, do: gettext("Pause"), else: gettext("Resume")}
</button>
<button
type="button"
phx-click="delete-subscription"
phx-value-id={subscription.id}
data-confirm={gettext("Delete this nearby alert?")}
class="btn btn-outline btn-xs border-error text-base-content"
>
{gettext("Delete")}
</button>
</div>
</div>
</div>
</div>
</section>
<section class="rounded-3xl border border-base-300 p-5 sm:p-6">
<h2 class="text-2xl font-black">{gettext("Create a nearby alert")}</h2>
<p class="mt-2 text-sm text-base-content/60">
{gettext("Choose an area, categories, urgency, and the times when you are available.")}
</p>
<.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")}
/>
<div
id="nearby-subscription-location"
phx-hook="RequestLocationPicker"
data-location-mode="approximate_public"
data-private-center="true"
data-allowed-radii="1000,3000,5000,10000,25000"
data-map-target="#nearby-subscription-map"
data-latitude-target="#nearby-subscription-latitude"
data-longitude-target="#nearby-subscription-longitude"
data-status-target="#nearby-subscription-status"
data-map-ready-label={gettext("Choose the private center of your alert area.")}
data-area-selected-label={gettext("Alert area selected. Drag the pin to move it.")}
data-point-selected-label={gettext("Alert area selected.")}
data-area-marker-label={gettext("Drag to move the alert area")}
data-point-marker-label={gettext("Drag to move the alert area")}
data-hidden-label=""
data-location-unavailable-label={gettext("Location is unavailable in this browser.")}
data-map-retry-label={gettext("Try map again")}
data-location-denied-label={gettext("Location permission was denied.")}
class="request-location-workspace"
>
<input
type="radio"
value="approximate_public"
checked
class="hidden"
data-location-mode-input
/>
<div class="request-location-grid">
<div class="request-location-map-shell">
<div
id="nearby-subscription-map"
phx-update="ignore"
data-map-unavailable-label={
gettext("The map is unavailable. Enter coordinates manually.")
}
data-map-retry-label={gettext("Try map again")}
class="aid-map request-location-map"
/>
<div class="request-location-map-hint">
<.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")}
</div>
</div>
<aside class="request-location-controls">
<button
type="button"
data-use-current-location
class="btn btn-outline btn-success w-full"
>
<.icon name="hero-paper-airplane" class="size-5" />
{gettext("Use my location")}
</button>
<fieldset class="request-location-radius mt-4">
<legend class="mb-2 text-sm font-bold">{gettext("Alert radius")}</legend>
<div class="grid grid-cols-2 gap-2 lg:grid-cols-1">
<label :for={
{label, value} <- [
{"1 km", "1000"},
{"3 km", "3000"},
{"5 km", "5000"},
{"10 km", "10000"},
{"25 km", "25000"}
]
}>
<input
type="radio"
name="nearby_subscription[radius_meters]"
value={value}
checked={selected?(@subscription_form, :radius_meters, value)}
data-location-radius-input
/>
<span>{label}</span>
</label>
</div>
</fieldset>
<div class="request-location-privacy mt-4">
<.icon name="hero-lock-closed" class="size-6 shrink-0" />
<div>
<strong>{gettext("Private matching center")}</strong>
<p>{gettext("Other users never see this saved point.")}</p>
</div>
</div>
</aside>
</div>
<p
id="nearby-subscription-status"
role="status"
aria-live="polite"
class="mt-3 min-h-5 text-sm font-medium text-base-content/65"
>
{gettext("Choose the private center of your alert area.")}
</p>
<details
id="nearby-subscription-location-manual"
phx-update="ignore"
class="request-location-manual mt-3"
>
<summary>{gettext("Enter coordinates manually")}</summary>
<div class="mt-3 grid grid-cols-2 gap-3">
<label class="fieldset">
<span class="label">{gettext("Latitude")}</span>
<input
id="nearby-subscription-latitude"
name="nearby_subscription[latitude]"
type="number"
step="any"
min="-90"
max="90"
value={coordinate_value(@subscription_form, "latitude")}
class="input w-full"
data-coordinate-input
/>
</label>
<label class="fieldset">
<span class="label">{gettext("Longitude")}</span>
<input
id="nearby-subscription-longitude"
name="nearby_subscription[longitude]"
type="number"
step="any"
min="-180"
max="180"
value={coordinate_value(@subscription_form, "longitude")}
class="input w-full"
data-coordinate-input
/>
</label>
</div>
</details>
</div>
<fieldset>
<legend class="font-bold">{gettext("Categories")}</legend>
<p class="mt-1 text-sm text-base-content/60">
{gettext("Leave every category unchecked to receive all matching requests.")}
</p>
<div class="mt-3 grid gap-2 sm:grid-cols-2">
<label
:for={category <- @categories}
class="flex items-start gap-3 rounded-2xl border border-base-300 p-3"
>
<input
type="checkbox"
name="nearby_subscription[category_ids][]"
value={category.id}
checked={selected?(@subscription_form, :category_ids, category.id)}
class="checkbox checkbox-success checkbox-sm mt-0.5"
/>
<span class="text-sm font-semibold">
{category_name(category, Gettext.get_locale(WhoNeedHelpWeb.Gettext))}
</span>
</label>
</div>
</fieldset>
<div class="grid gap-5 md:grid-cols-2">
<fieldset>
<legend class="font-bold">{gettext("Urgency")}</legend>
<div class="mt-3 space-y-2">
<label
:for={
{label, value} <- [
{gettext("Now"), "now"},
{gettext("Today"), "today"},
{gettext("Scheduled"), "scheduled"}
]
}
class="flex items-center gap-2"
>
<input
type="checkbox"
name="nearby_subscription[urgencies][]"
value={value}
checked={selected?(@subscription_form, :urgencies, value)}
class="checkbox checkbox-success checkbox-sm"
/>
<span>{label}</span>
</label>
</div>
</fieldset>
<fieldset>
<legend class="font-bold">{gettext("Available days")}</legend>
<div class="mt-3 grid grid-cols-4 gap-2">
<label
:for={
{label, value} <- [
{gettext("Mon"), 1},
{gettext("Tue"), 2},
{gettext("Wed"), 3},
{gettext("Thu"), 4},
{gettext("Fri"), 5},
{gettext("Sat"), 6},
{gettext("Sun"), 7}
]
}
class="flex items-center gap-2"
>
<input
type="checkbox"
name="nearby_subscription[available_days][]"
value={value}
checked={selected?(@subscription_form, :available_days, value)}
class="checkbox checkbox-success checkbox-sm"
/>
<span class="text-sm">{label}</span>
</label>
</div>
</fieldset>
</div>
<div class="grid gap-4 md:grid-cols-2">
<.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)")}
/>
</div>
<fieldset class="rounded-2xl border border-base-300 p-4">
<legend class="px-1 font-bold">{gettext("Delivery channels")}</legend>
<p class="mb-3 text-sm text-base-content/60">
{gettext("Choose at least one way to receive this nearby alert.")}
</p>
<div class="grid gap-3 sm:grid-cols-2">
<.input
field={@subscription_form[:push_enabled]}
type="checkbox"
label={gettext("Push notification")}
/>
<.input
field={@subscription_form[:email_enabled]}
type="checkbox"
label={gettext("Email notification")}
/>
</div>
</fieldset>
<.button class="btn btn-primary btn-lg w-full">{gettext("Create nearby alert")}</.button>
</.form>
</section>
</div>
</Layouts.app>
"""
end
end

View File

@ -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"""
<Layouts.app flash={@flash} current_scope={@current_scope} page_width={:reading}>
<div class="text-sm font-semibold text-warning">{gettext("RESTRICTED WORKSPACE")}</div>
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 class="mt-1 text-4xl font-black">{gettext("Product analytics")}</h1>
<p class="mt-2 text-base-content/60">
{gettext(
"Daily aggregate counters only. No user ID, email, coordinate, request text, chat text, medicine name, or device credential is stored here."
)}
</p>
</div>
<.link navigate={~p"/moderation"} class="btn btn-outline btn-sm">
{gettext("Back to moderation")}
</.link>
</div>
<div class="mt-8 overflow-x-auto rounded-3xl border border-base-300">
<table class="table">
<thead>
<tr>
<th>{gettext("Date")}</th>
<th>{gettext("Metric")}</th>
<th>{gettext("Dimension")}</th>
<th>{gettext("Count")}</th>
</tr>
</thead>
<tbody>
<tr :if={@metrics == []}>
<td colspan="4" class="py-10 text-center text-base-content/60">
{gettext("No aggregate events recorded yet.")}
</td>
</tr>
<tr :for={metric <- @metrics}>
<td>{metric.date}</td>
<td class="font-mono text-xs">{metric.metric}</td>
<td>{metric.dimension}</td>
<td class="font-bold">{metric.count}</td>
</tr>
</tbody>
</table>
</div>
<button
:if={@metrics_cursor}
type="button"
phx-click="load-more"
class="btn btn-outline btn-sm mt-4"
>
{gettext("Load more")}
</button>
</Layouts.app>
"""
end
end

View File

@ -183,6 +183,11 @@ defmodule WhoNeedHelpWeb.ProfileLive do
<aside class="space-y-5"> <aside class="space-y-5">
<div class="rounded-3xl bg-success p-6 text-success-content"> <div class="rounded-3xl bg-success p-6 text-success-content">
<h2 class="font-bold">{gettext("Public reputation")}</h2> <h2 class="font-bold">{gettext("Public reputation")}</h2>
<p class="mt-1 text-xs opacity-75">
{gettext(
"These are activity signals, not an identity, background, qualification, or safety check."
)}
</p>
<div class="mt-5 grid grid-cols-2 gap-4"> <div class="mt-5 grid grid-cols-2 gap-4">
<div> <div>
<div class="text-3xl font-black">{@reputation.completed}</div><div class="text-xs"> <div class="text-3xl font-black">{@reputation.completed}</div><div class="text-xs">
@ -196,7 +201,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
</div> </div>
<div> <div>
<div class="text-3xl font-black">{@reputation.verified_handovers}</div><div class="text-xs"> <div class="text-3xl font-black">{@reputation.verified_handovers}</div><div class="text-xs">
{gettext("verified handovers")} {gettext("code-confirmed handovers")}
</div> </div>
</div> </div>
<div> <div>

View File

@ -122,6 +122,11 @@ defmodule WhoNeedHelpWeb.PublicProfileLive do
<aside class="space-y-6"> <aside class="space-y-6">
<section class="rounded-3xl bg-success p-6 text-success-content"> <section class="rounded-3xl bg-success p-6 text-success-content">
<h2 class="font-bold">{gettext("Public reputation")}</h2> <h2 class="font-bold">{gettext("Public reputation")}</h2>
<p class="mt-1 text-xs opacity-75">
{gettext(
"These are activity signals, not an identity, background, qualification, or safety check."
)}
</p>
<div class="mt-5 grid grid-cols-2 gap-4"> <div class="mt-5 grid grid-cols-2 gap-4">
<div> <div>
<div class="text-3xl font-black">{@reputation.completed}</div> <div class="text-3xl font-black">{@reputation.completed}</div>
@ -133,7 +138,7 @@ defmodule WhoNeedHelpWeb.PublicProfileLive do
</div> </div>
<div> <div>
<div class="text-3xl font-black">{@reputation.verified_handovers}</div> <div class="text-3xl font-black">{@reputation.verified_handovers}</div>
<div class="text-xs">{gettext("verified handovers")}</div> <div class="text-xs">{gettext("code-confirmed handovers")}</div>
</div> </div>
<div> <div>
<div class="text-3xl font-black">{@reputation.rating || "—"}</div> <div class="text-3xl font-black">{@reputation.rating || "—"}</div>

View File

@ -66,11 +66,16 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
case Help.get_request(socket.assigns.current_scope, request.id) do case Help.get_request(socket.assigns.current_scope, request.id) do
{:ok, request} -> {:ok, request} ->
previous_status = socket.assigns.request.status previous_status = socket.assigns.request.status
previous_assignment = socket.assigns.assignment
socket = socket =
socket socket
|> maybe_subscribe_assignment(request) |> 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)} {:noreply, sync_tracking_after_request_update(socket, request)}
@ -144,8 +149,10 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
end end
end end
def handle_event("cancel-request", _, socket) do def handle_event("cancel-request", params, socket) do
case Help.cancel_request(socket.assigns.current_scope, socket.assigns.request.id) do attrs = Map.get(params, "cancellation", %{})
case Help.cancel_request(socket.assigns.current_scope, socket.assigns.request.id, attrs) do
{:ok, _request} -> {:ok, _request} ->
{:noreply, put_flash(socket, :info, gettext("Request cancelled."))} {:noreply, put_flash(socket, :info, gettext("Request cancelled."))}
@ -157,12 +164,37 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
def handle_event("start", _, socket), def handle_event("start", _, socket),
do: transition(socket, &Help.start_assignment/2, gettext("Help is in progress.")) 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), def handle_event("confirm", _, socket),
do: transition(socket, &Help.confirm_completion/2, gettext("Your confirmation was saved.")) do: transition(socket, &Help.confirm_completion/2, gettext("Your confirmation was saved."))
def handle_event("withdraw", _, socket), def handle_event("withdraw", params, socket) do
do: attrs = Map.get(params, "withdrawal", %{})
transition(socket, &Help.withdraw_assignment/2, gettext("You withdrew from this request."))
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 def handle_event("verify-code", %{"handover" => %{"code" => code}}, socket) do
case Help.verify_handover(socket.assigns.current_scope, socket.assigns.assignment.id, code) 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
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( put_flash(
socket, socket,
:info, :info,
@ -455,19 +510,20 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
) )
end 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.")) put_flash(socket, :info, gettext("Help is now in progress."))
end 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.")) put_flash(socket, :info, gettext("Help is complete."))
end 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.")) put_flash(socket, :info, gettext("This request was cancelled."))
end 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 defp restore_private_interaction(socket, true), do: socket
@ -556,6 +612,20 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|> assign(:tracking_active, tracking_active) |> assign(:tracking_active, tracking_active)
|> assign(:message_form, to_form(%{"body" => ""}, as: :message)) |> assign(:message_form, to_form(%{"body" => ""}, as: :message))
|> assign(:handover_form, to_form(%{"code" => ""}, as: :handover)) |> 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(:review_form, to_form(%{"rating" => "5", "comment" => ""}, as: :review))
|> assign(:report_form, report_form()) |> assign(:report_form, report_form())
|> assign( |> assign(
@ -763,6 +833,37 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
else: not is_nil(assignment.helper_confirmed_at) else: not is_nil(assignment.helper_confirmed_at)
end 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(:approximate_public), do: gettext("Approximate publicly")
defp visibility_label(:hidden), do: gettext("Hidden") defp visibility_label(:hidden), do: gettext("Hidden")
defp visibility_label(:exact_for_active_match), do: gettext("Exact only for active match") defp visibility_label(:exact_for_active_match), do: gettext("Exact only for active match")
@ -1063,13 +1164,41 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
<p class="mt-3 text-sm opacity-70"> <p class="mt-3 text-sm opacity-70">
{gettext("Waiting for a nearby volunteer.")} {gettext("Waiting for a nearby volunteer.")}
</p> </p>
<button <details
:if={Help.requester?(@current_scope, @request)} :if={Help.requester?(@current_scope, @request)}
phx-click="cancel-request" class="collapse collapse-arrow mt-4 border border-white/20 bg-white/5"
class="btn btn-error btn-outline mt-4 w-full"
> >
<summary class="collapse-title min-h-0 py-3 text-sm font-semibold">
{gettext("Cancel request")} {gettext("Cancel request")}
</button> </summary>
<div class="collapse-content">
<.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")}
</.button>
</.form>
</div>
</details>
<% @participant -> %> <% @participant -> %>
<div class="mt-4 space-y-3"> <div class="mt-4 space-y-3">
<div class="rounded-xl bg-white/10 p-3 text-sm"> <div class="rounded-xl bg-white/10 p-3 text-sm">
@ -1121,6 +1250,22 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
</p> </p>
</div> </div>
<div class="space-y-2 rounded-xl bg-white/10 p-3 text-xs"> <div class="space-y-2 rounded-xl bg-white/10 p-3 text-xs">
<div class="mb-3 text-sm font-semibold">{gettext("Progress")}</div>
<div
:for={{label, complete?} <- lifecycle_steps(@assignment)}
class="flex items-center gap-2"
>
<.icon
name={if complete?, do: "hero-check-circle", else: "hero-clock"}
class={[
"size-5 shrink-0",
complete? && "text-success",
!complete? && "opacity-45"
]}
/>
<span class={complete? || "opacity-55"}>{label}</span>
</div>
<div class="my-3 border-t border-white/10"></div>
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<span>{gettext("Requester confirmed")}</span> <span>{gettext("Requester confirmed")}</span>
<span class="badge badge-sm"> <span class="badge badge-sm">
@ -1181,6 +1326,16 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
> >
{gettext("Start helping")} {gettext("Start helping")}
</button> </button>
<button
:if={
Help.helper?(@current_scope, @assignment) &&
@assignment.status == :in_progress && is_nil(@assignment.arrived_at)
}
phx-click="arrive"
class="btn btn-success btn-outline w-full"
>
{gettext("I have arrived")}
</button>
<button <button
:if={ :if={
@assignment.status in [:accepted, :in_progress] && @assignment.status in [:accepted, :in_progress] &&
@ -1200,26 +1355,90 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
> >
{gettext("Your completion confirmation is saved.")} {gettext("Your completion confirmation is saved.")}
</p> </p>
<button <details
:if={ :if={
Help.helper?(@current_scope, @assignment) && Help.helper?(@current_scope, @assignment) &&
@assignment.status in [:accepted, :in_progress] @assignment.status in [:accepted, :in_progress]
} }
phx-click="withdraw" class="collapse collapse-arrow border border-white/20 bg-white/5"
class="btn btn-error btn-outline w-full"
> >
<summary class="collapse-title min-h-0 py-3 text-sm font-semibold">
{gettext("Withdraw from this request")} {gettext("Withdraw from this request")}
</button> </summary>
<button <div class="collapse-content">
<.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"
/>
<p class="text-xs opacity-65">
{gettext(
"Leaving ends this private match. If the request has not expired, it becomes available to other helpers."
)}
</p>
<.button
class="btn btn-error btn-outline w-full"
phx-disable-with={gettext("Leaving…")}
>
{gettext("Confirm withdrawal")}
</.button>
</.form>
</div>
</details>
<details
:if={ :if={
Help.requester?(@current_scope, @request) && Help.requester?(@current_scope, @request) &&
@assignment.status in [:accepted, :in_progress] @assignment.status in [:accepted, :in_progress]
} }
phx-click="cancel-request" class="collapse collapse-arrow border border-white/20 bg-white/5"
class="btn btn-error btn-outline w-full"
> >
<summary class="collapse-title min-h-0 py-3 text-sm font-semibold">
{gettext("Cancel request")} {gettext("Cancel request")}
</button> </summary>
<div class="collapse-content">
<.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"
/>
<p class="text-xs opacity-65">
{gettext("Cancelling ends this match and stops live-location sharing.")}
</p>
<.button
class="btn btn-error btn-outline w-full"
phx-disable-with={gettext("Cancelling…")}
>
{gettext("Confirm cancellation")}
</.button>
</.form>
</div>
</details>
<div <div
:if={ :if={

View File

@ -5,7 +5,11 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
{:ok, socket |> assign(:page_title, gettext("Support operations")) |> load()} {:ok,
socket
|> assign(:page_title, gettext("Support operations"))
|> assign(:deletion_assessments, %{})
|> load()}
end end
@impl true @impl true
@ -25,6 +29,16 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
) )
end 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 def handle_event("load-more-support", _params, socket) do
page = page =
Support.paginate_for_staff(socket.assigns.current_scope, 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(: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_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(%Ecto.Changeset{}), do: gettext("Please check the submitted fields.")
defp error_message(_reason), do: gettext("Could not update the request. Please try again.") defp error_message(_reason), do: gettext("Could not update the request. Please try again.")
@ -239,6 +257,36 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
<h3 class="mt-3 font-bold">{request.subject}</h3> <h3 class="mt-3 font-bold">{request.subject}</h3>
<p class="mt-1 text-xs text-base-content/65">{request.contact_email}</p> <p class="mt-1 text-xs text-base-content/65">{request.contact_email}</p>
<p class="mt-3 whitespace-pre-wrap text-sm">{request.details}</p> <p class="mt-3 whitespace-pre-wrap text-sm">{request.details}</p>
<div :if={request.kind == :account_deletion} class="mt-4 rounded-2xl bg-base-200 p-4">
<button
type="button"
phx-click="assess-deletion"
phx-value-id={request.id}
class="btn btn-outline btn-sm"
>
{gettext("Run deletion preflight")}
</button>
<div
:if={assessment = @deletion_assessments[request.id]}
class="mt-3 space-y-2 text-sm"
>
<p class="font-semibold">
{if assessment.technically_idle,
do: gettext("No active product workflow was found."),
else: gettext("Active or unresolved product state must be handled first.")}
</p>
<ul :if={assessment.blockers != []} class="list-disc pl-5">
<li :for={blocker <- assessment.blockers}>
{blocker.kind}: {blocker.count}
</li>
</ul>
<p class="text-warning">
{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."
)}
</p>
</div>
</div>
<.form <.form
for={form} for={form}
phx-submit="moderate-support" phx-submit="moderate-support"

View File

@ -62,6 +62,7 @@ defmodule WhoNeedHelpWeb.Router do
scope "/mobile", WhoNeedHelpWeb do scope "/mobile", WhoNeedHelpWeb do
pipe_through :mobile pipe_through :mobile
post "/push-devices", MobilePushDeviceController, :create
post "/tracking/:assignment_id/position", MobileTrackingController, :update post "/tracking/:assignment_id/position", MobileTrackingController, :update
post "/tracking/:assignment_id/stop", MobileTrackingController, :stop post "/tracking/:assignment_id/stop", MobileTrackingController, :stop
end end
@ -130,6 +131,7 @@ defmodule WhoNeedHelpWeb.Router do
get "/users/settings", UserSettingsController, :edit get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update put "/users/settings", UserSettingsController, :update
get "/users/data-export", UserDataExportController, :show
get "/users/settings/confirm-email", UserSettingsController, :confirm_email_page get "/users/settings/confirm-email", UserSettingsController, :confirm_email_page
post "/users/settings/confirm-email", UserSettingsController, :confirm_email post "/users/settings/confirm-email", UserSettingsController, :confirm_email
post "/auth/google/link", GoogleAuthController, :start_link post "/auth/google/link", GoogleAuthController, :start_link
@ -161,11 +163,13 @@ defmodule WhoNeedHelpWeb.Router do
live "/profile", ProfileLive, :edit live "/profile", ProfileLive, :edit
live "/people/:id", PublicProfileLive, :show live "/people/:id", PublicProfileLive, :show
live "/leaderboard", LeaderboardLive, :index live "/leaderboard", LeaderboardLive, :index
live "/notifications", NotificationLive, :index
end end
live_session :moderation, live_session :moderation,
on_mount: [{WhoNeedHelpWeb.UserAuth, :ensure_moderator}] do on_mount: [{WhoNeedHelpWeb.UserAuth, :ensure_moderator}] do
live "/moderation", ModerationLive, :index live "/moderation", ModerationLive, :index
live "/analytics", ProductAnalyticsLive, :index
live "/support/operations", SupportOperationsLive, :index live "/support/operations", SupportOperationsLive, :index
end end
end end

View File

@ -80,6 +80,8 @@ defmodule WhoNeedHelp.MixProject do
{:gen_smtp, "~> 1.3"}, {:gen_smtp, "~> 1.3"},
{:req, "~> 0.5"}, {:req, "~> 0.5"},
{:assent, "~> 0.3.1"}, {:assent, "~> 0.3.1"},
{:goth, "~> 1.4"},
{:web_push_elixir, "~> 0.8.0"},
{:telemetry_metrics, "~> 1.0"}, {:telemetry_metrics, "~> 1.0"},
{:telemetry_metrics_prometheus_core, "~> 1.2.1"}, {:telemetry_metrics_prometheus_core, "~> 1.2.1"},
{:telemetry_poller, "~> 1.0"}, {:telemetry_poller, "~> 1.0"},

View File

@ -24,10 +24,12 @@
"geo": {:hex, :geo, "4.1.0", "64ba89a64cc400b5b16dd2f5bd644cb141776eb8c2ac5a983332c8d944936c12", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "19edb2b3398ca9f701b573b1fb11bc90951ebd64f18b06bd1bf35abe509a2934"}, "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"}, "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"}, "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]}, "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"}, "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"},
"idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"}, "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"}, "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"}, "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"}, "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"}, "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_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"}, "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"}, "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": {: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"}, "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"},
} }

View File

@ -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

View File

@ -1,5 +1,5 @@
const CACHE_PREFIX = "who-need-help-static-" const CACHE_PREFIX = "who-need-help-static-"
const CACHE = `${CACHE_PREFIX}v4` const CACHE = `${CACHE_PREFIX}v5`
const OFFLINE_URL = "/offline.html" const OFFLINE_URL = "/offline.html"
const SHELL = [ const SHELL = [
OFFLINE_URL, 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) { async function cacheFirst(event) {
const cached = await caches.match(event.request) const cached = await caches.match(event.request)
if (cached) return cached if (cached) return cached

View File

@ -27,6 +27,10 @@ exec docker build \
--build-arg "WNH_DEBUG_BASE_URL=$WNH_DEBUG_BASE_URL" \ --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_MIN_TIME_MS=$WNH_TRACKING_MIN_TIME_MS" \
--build-arg "WNH_TRACKING_HTTP_TIMEOUT_MS=$WNH_TRACKING_HTTP_TIMEOUT_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 \ --target artifact \
--output "type=local,dest=$ROOT/android/dist" \ --output "type=local,dest=$ROOT/android/dist" \
"$ROOT/android" "$ROOT/android"

View File

@ -62,6 +62,10 @@ docker build \
--build-arg "WNH_TRACKING_HTTP_TIMEOUT_MS=$WNH_TRACKING_HTTP_TIMEOUT_MS" \ --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_CODE=$WNH_ANDROID_VERSION_CODE" \
--build-arg "WNH_ANDROID_VERSION_NAME=$WNH_ANDROID_VERSION_NAME" \ --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 \ --target release-artifact \
--output "type=local,dest=$OUTPUT_DIR" \ --output "type=local,dest=$OUTPUT_DIR" \
"$ROOT/android" "$ROOT/android"

View File

@ -19,6 +19,10 @@ exec docker build \
--build-arg "WNH_BASE_URL=$WNH_BASE_URL" \ --build-arg "WNH_BASE_URL=$WNH_BASE_URL" \
--build-arg "WNH_TRACKING_MIN_TIME_MS=$WNH_TRACKING_MIN_TIME_MS" \ --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_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 \ --target staging-artifact \
--output "type=local,dest=$ROOT/android/dist-staging" \ --output "type=local,dest=$ROOT/android/dist-staging" \
"$ROOT/android" "$ROOT/android"

View File

@ -1,9 +1,13 @@
defmodule WhoNeedHelp.MutualAidFlowTest do defmodule WhoNeedHelp.MutualAidFlowTest do
use WhoNeedHelp.DataCase, async: false use WhoNeedHelp.DataCase, async: false
use Oban.Testing, repo: WhoNeedHelp.Repo
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.{Accounts, Catalog, CatalogModeration, Help, Messaging, Tracking, Trust} 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.Help.DiscoveryViewport
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
alias WhoNeedHelp.Tracking.Position alias WhoNeedHelp.Tracking.Position
@ -75,6 +79,103 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
} }
end 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 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.vote(context.requester_scope, "not-a-uuid")
assert {:error, :not_found} = Catalog.unvote(context.requester_scope, "not-a-uuid") assert {:error, :not_found} = Catalog.unvote(context.requester_scope, "not-a-uuid")

View File

@ -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

View File

@ -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

View File

@ -4,8 +4,16 @@ defmodule WhoNeedHelp.PushProductTest do
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.{Catalog, Help, Messaging, Push} alias WhoNeedHelp.{Catalog, Help, Messaging, Notifications, Push, Repo}
alias WhoNeedHelp.Push.DeliveryWorker alias WhoNeedHelp.Notifications.Notification
alias WhoNeedHelp.Notifications.PushDevice
alias WhoNeedHelp.Push.{
DeliveryWorker,
DeviceDeliveryWorker,
FCMAdapter,
NotificationDispatchWorker
}
defmodule RecordingAdapter do defmodule RecordingAdapter do
@behaviour Push @behaviour Push
@ -17,8 +25,25 @@ defmodule WhoNeedHelp.PushProductTest do
end end
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 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)}) previous = Map.new(keys, &{&1, Application.fetch_env(:who_need_help, &1)})
on_exit(fn -> on_exit(fn ->
@ -61,18 +86,39 @@ defmodule WhoNeedHelp.PushProductTest do
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs) {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id) {: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( assert_enqueued(
worker: DeliveryWorker, worker: DeliveryWorker,
queue: :push, queue: :push,
args: %{ args: %{
"idempotency_key" => "request-accepted:#{assignment.id}:#{context.requester.id}", "idempotency_key" => acceptance_key,
"recipient" => "user:#{context.requester.id}", "recipient" => "user:#{context.requester.id}",
"title" => "A helper responded", "title" => "A helper responded",
"body" => "Open Who Need Help to see the request update.", "body" => "Open Who Need Help to see the request update.",
"data" => %{ "data" => %{
"kind" => "request_accepted", "kind" => "request_accepted",
"assignment_id" => assignment.id, "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} = {:ok, message} =
Messaging.send_message(context.requester_scope, assignment, %{"body" => "Thank you"}) 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( assert_enqueued(
worker: DeliveryWorker, worker: DeliveryWorker,
queue: :push, queue: :push,
args: %{ args: %{
"idempotency_key" => "message-created:#{message.id}:#{context.helper.id}", "idempotency_key" => message_key,
"recipient" => "user:#{context.helper.id}", "recipient" => "user:#{context.helper.id}",
"title" => "New message", "title" => "New message",
"body" => "Open Who Need Help to read the conversation.", "body" => "Open Who Need Help to read the conversation.",
@ -92,7 +147,8 @@ defmodule WhoNeedHelp.PushProductTest do
"kind" => "message_created", "kind" => "message_created",
"assignment_id" => assignment.id, "assignment_id" => assignment.id,
"message_id" => message.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} = assert {:ok, replay} =
Push.enqueue_request_accepted(assignment_id, request_id, requester_id) Push.enqueue_request_accepted(assignment_id, request_id, requester_id)
assert replay.conflict?
assert replay.id == first.id 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 end
test "disabled product boundary leaves domain events job-free", context do test "disabled product boundary leaves domain events job-free", context do
@ -129,6 +192,10 @@ defmodule WhoNeedHelp.PushProductTest do
{:ok, _message} = {:ok, _message} =
Messaging.send_message(context.helper_scope, assignment, %{"body" => "On my way"}) 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) assert [] == all_enqueued(worker: DeliveryWorker)
end end
@ -153,4 +220,76 @@ defmodule WhoNeedHelp.PushProductTest do
data: %{"kind" => "message_created"} data: %{"kind" => "message_created"}
}} }}
end 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 end

View File

@ -4,7 +4,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
import Swoosh.TestAssertions import Swoosh.TestAssertions
alias WhoNeedHelp.{ContentRemoval, Repo, Support} alias WhoNeedHelp.{Catalog, ContentRemoval, Help, Repo, Support}
alias WhoNeedHelp.ContentRemoval.Notice alias WhoNeedHelp.ContentRemoval.Notice
alias WhoNeedHelp.Support.SupportRequest alias WhoNeedHelp.Support.SupportRequest
alias WhoNeedHelp.Trust.AuditEvent alias WhoNeedHelp.Trust.AuditEvent
@ -211,6 +211,43 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert request.contact_verified_at assert request.contact_verified_at
end 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 test "content-removal ownership lookup is scoped to the submitting account" do
owner = user_fixture() owner = user_fixture()
outsider = user_fixture() outsider = user_fixture()

View File

@ -24,15 +24,16 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
} = get_session(conn, "google_auth_flow") } = get_session(conn, "google_auth_flow")
end 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 = conn =
post(conn, ~p"/auth/google/register", %{ 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 redirected_to(conn) =~ "https://accounts.google.example/authorize?"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "18 or older" assert %{"flow" => "register", "locale" => "en"} = get_session(conn, "google_auth_flow")
assert is_nil(get_session(conn, "google_auth_flow"))
end end
test "registers, confirms, links, and logs in a new verified Google user", %{conn: conn} do test "registers, confirms, links, and logs in a new verified Google user", %{conn: conn} do
@ -47,6 +48,10 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
|> get( |> get(
~p"/auth/google/callback?code=one-time-code&state=google-test-state&uid=google-123&email=#{email}&name=Google%20Helper" ~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 redirected_to(conn) == ~p"/"
assert get_session(conn, :user_token) assert get_session(conn, :user_token)
@ -83,7 +88,6 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
) )
assert redirected_to(conn) == ~p"/auth/google/complete" 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 get_session(conn, :user_token)
refute Repo.get_by(AuthIdentity, provider: :google, provider_uid: "new-google") refute Repo.get_by(AuthIdentity, provider: :google, provider_uid: "new-google")

View File

@ -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

View File

@ -196,7 +196,7 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
body = response(conn, 200) body = response(conn, 200)
assert body =~ ~s(const CACHE_PREFIX = "who-need-help-static-") 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(const OFFLINE_URL = "/offline.html")
assert body =~ ~s(event.request.mode === "navigate") assert body =~ ~s(event.request.mode === "navigate")
assert body =~ ~S|key.startsWith(CACHE_PREFIX) && key !== CACHE| assert body =~ ~S|key.startsWith(CACHE_PREFIX) && key !== CACHE|

View File

@ -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

View File

@ -4,7 +4,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.{Accounts, Activities, Catalog, Help, Messaging, Trust} alias WhoNeedHelp.{Accounts, Activities, Catalog, Help, Messaging, Notifications, Trust}
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
setup :register_and_log_in_user setup :register_and_log_in_user
@ -15,6 +15,80 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "Repeated help between the same pair" assert html =~ "Repeated help between the same pair"
end 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 test "public profile reveals trust data without exposing private account fields", %{conn: conn} do
category = Catalog.seed_defaults() category = Catalog.seed_defaults()
@ -792,8 +866,13 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert render(helper_view) =~ "Help is now in progress." assert render(helper_view) =~ "Help is now in progress."
requester_view requester_view
|> element("button[phx-click='cancel-request']") |> form("#matched-request-cancellation-form", %{
|> render_click() "cancellation" => %{
"cancellation_reason" => "no_longer_needed",
"cancellation_note" => ""
}
})
|> render_submit()
for view <- [requester_view, helper_view] do for view <- [requester_view, helper_view] do
html = render(view) html = render(view)