feat: complete notifications and request lifecycle
This commit is contained in:
parent
e2bc8acf97
commit
2159e6cda7
22
.env.example
22
.env.example
|
|
@ -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
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
4
android/app/proguard-rules.pro
vendored
4
android/app/proguard-rules.pro
vendored
|
|
@ -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 { *; }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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") ||
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
126
e2e/tests/notifications-data.spec.ts
Normal file
126
e2e/tests/notifications-data.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
|
@ -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 ({
|
||||||
|
|
|
||||||
|
|
@ -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/new");
|
||||||
|
await selectOptionContaining(user.page, "Category", "Medicine pickup");
|
||||||
|
await user.page.getByLabel("Medicine pickup status").selectOption("reserved");
|
||||||
|
await user.page.getByLabel("Short title").fill(title);
|
||||||
|
await user.page
|
||||||
|
.getByLabel("What help do you need?")
|
||||||
|
.fill("A reserved medicine pickup used to verify visible connection recovery.");
|
||||||
|
await user.page.getByLabel("Urgency").selectOption("now");
|
||||||
|
await user.page.getByRole("button", { name: "In 3 hours" }).click();
|
||||||
|
await setRequestLocation(user.page, {
|
||||||
|
label: "Connection recovery area",
|
||||||
|
mode: "hidden",
|
||||||
|
});
|
||||||
|
await user.page.locator("#request-form input[type=checkbox]").last().check();
|
||||||
|
await user.page.getByRole("button", { name: "Publish request" }).click();
|
||||||
|
await expect(user.page.getByRole("heading", { name: title })).toBeVisible();
|
||||||
|
await waitForLiveViewConnected(user.page);
|
||||||
|
|
||||||
|
const runtimeNode = await user.page
|
||||||
|
.locator("#e2e-runtime-node")
|
||||||
|
.getAttribute("data-node");
|
||||||
|
expect(runtimeNode).toBeTruthy();
|
||||||
|
|
||||||
|
const serverError = user.page.locator("#server-error");
|
||||||
|
await expect(serverError).toHaveAttribute("role", "alert");
|
||||||
|
await expect(serverError).toContainText("Attempting to reconnect");
|
||||||
|
await user.page.evaluate(() => {
|
||||||
|
const state = { sawDisconnected: false };
|
||||||
|
(
|
||||||
|
window as typeof window & {
|
||||||
|
__wnhRecoveryState?: { sawDisconnected: boolean };
|
||||||
|
}
|
||||||
|
).__wnhRecoveryState = state;
|
||||||
|
|
||||||
|
window.addEventListener("phx:page-loading-start", (event) => {
|
||||||
|
const detail = (event as CustomEvent<{ kind?: string }>).detail;
|
||||||
|
if (detail?.kind === "error") state.sawDisconnected = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const crash = await request.post("/__e2e__/crash-node", {
|
||||||
|
data: {
|
||||||
|
confirmation: "crash-exact-e2e-node",
|
||||||
|
node: runtimeNode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(crash.status()).toBe(202);
|
||||||
|
|
||||||
await gotoLiveView(user.page, "/requests");
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(
|
||||||
user.page.evaluate(() => {
|
() =>
|
||||||
const liveSocket = (
|
user.page.evaluate(
|
||||||
window as typeof window & {
|
() =>
|
||||||
liveSocket?: { isConnected: () => boolean };
|
(
|
||||||
}
|
window as typeof window & {
|
||||||
).liveSocket;
|
__wnhRecoveryState?: { sawDisconnected: boolean };
|
||||||
return liveSocket?.isConnected() ?? false;
|
}
|
||||||
}),
|
).__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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
454
lib/who_need_help/accounts/data_export.ex
Normal file
454
lib/who_need_help/accounts/data_export.ex
Normal 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
|
||||||
116
lib/who_need_help/accounts/data_lifecycle.ex
Normal file
116
lib/who_need_help/accounts/data_lifecycle.ex
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
[
|
||||||
WhoNeedHelp.Repo,
|
WhoNeedHelpWeb.Telemetry,
|
||||||
{DNSCluster, query: Application.get_env(:who_need_help, :dns_cluster_query) || :ignore},
|
WhoNeedHelp.Repo,
|
||||||
{Phoenix.PubSub, name: WhoNeedHelp.PubSub}
|
{DNSCluster, query: Application.get_env(:who_need_help, :dns_cluster_query) || :ignore},
|
||||||
]
|
{Phoenix.PubSub, name: WhoNeedHelp.PubSub}
|
||||||
|
] ++ WhoNeedHelp.Push.supervisor_children()
|
||||||
|
|
||||||
oban_config = Application.fetch_env!(:who_need_help, Oban)
|
oban_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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 ->
|
||||||
|
|
|
||||||
505
lib/who_need_help/notifications.ex
Normal file
505
lib/who_need_help/notifications.ex
Normal 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
|
||||||
40
lib/who_need_help/notifications/email_notifier.ex
Normal file
40
lib/who_need_help/notifications/email_notifier.ex
Normal 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
|
||||||
123
lib/who_need_help/notifications/nearby_subscription.ex
Normal file
123
lib/who_need_help/notifications/nearby_subscription.ex
Normal 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
|
||||||
49
lib/who_need_help/notifications/notification.ex
Normal file
49
lib/who_need_help/notifications/notification.ex
Normal 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
|
||||||
68
lib/who_need_help/notifications/preference.ex
Normal file
68
lib/who_need_help/notifications/preference.ex
Normal 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
|
||||||
91
lib/who_need_help/notifications/push_device.ex
Normal file
91
lib/who_need_help/notifications/push_device.ex
Normal 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
|
||||||
88
lib/who_need_help/product_analytics.ex
Normal file
88
lib/who_need_help/product_analytics.ex
Normal 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
|
||||||
24
lib/who_need_help/product_analytics/daily_metric.ex
Normal file
24
lib/who_need_help/product_analytics/daily_metric.ex
Normal 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
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
8
lib/who_need_help/push/device_adapter.ex
Normal file
8
lib/who_need_help/push/device_adapter.ex
Normal 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
|
||||||
64
lib/who_need_help/push/device_delivery_worker.ex
Normal file
64
lib/who_need_help/push/device_delivery_worker.ex
Normal 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
|
||||||
111
lib/who_need_help/push/fcm_adapter.ex
Normal file
111
lib/who_need_help/push/fcm_adapter.ex
Normal 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
|
||||||
43
lib/who_need_help/push/nearby_match_worker.ex
Normal file
43
lib/who_need_help/push/nearby_match_worker.ex
Normal 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
|
||||||
102
lib/who_need_help/push/notification_dispatch_worker.ex
Normal file
102
lib/who_need_help/push/notification_dispatch_worker.ex
Normal 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
|
||||||
38
lib/who_need_help/push/notification_email_worker.ex
Normal file
38
lib/who_need_help/push/notification_email_worker.ex
Normal 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
|
||||||
64
lib/who_need_help/push/web_push_adapter.ex
Normal file
64
lib/who_need_help/push/web_push_adapter.ex
Normal 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
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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),
|
case GoogleAuthPending.put(conn, identity_attrs, flow_locale(flow)) do
|
||||||
{:ok, {user, _identity}} <-
|
{:ok, conn} ->
|
||||||
Accounts.register_user_by_google(identity_attrs, %{
|
redirect(conn, to: ~p"/auth/google/complete")
|
||||||
"locale" => flow["locale"],
|
|
||||||
"terms_accepted" => flow["terms_accepted"]
|
|
||||||
}) do
|
|
||||||
conn
|
|
||||||
|> put_flash(:info, gettext("Your account was created with Google."))
|
|
||||||
|> UserAuth.log_in_user(user)
|
|
||||||
else
|
|
||||||
{:error, :email_already_registered} ->
|
|
||||||
case GoogleAuthPending.put(conn, identity_attrs, flow_locale(flow)) do
|
|
||||||
{:ok, conn} ->
|
|
||||||
conn
|
|
||||||
|> put_flash(
|
|
||||||
:info,
|
|
||||||
gettext("This email already has an account. Confirm it once to connect Google.")
|
|
||||||
)
|
|
||||||
|> redirect(to: ~p"/auth/google/complete")
|
|
||||||
|
|
||||||
{:error, _reason} ->
|
|
||||||
google_account_unavailable(conn, ~p"/users/log-in")
|
|
||||||
end
|
|
||||||
|
|
||||||
{:error, :rate_limited} ->
|
|
||||||
conn
|
|
||||||
|> put_flash(
|
|
||||||
:error,
|
|
||||||
gettext("Too many registration attempts in the configured time window.")
|
|
||||||
)
|
|
||||||
|> redirect(to: ~p"/users/register")
|
|
||||||
|
|
||||||
{:error, _reason} ->
|
{:error, _reason} ->
|
||||||
google_account_unavailable(conn, ~p"/users/register")
|
google_account_unavailable(conn, ~p"/users/register")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
<h1 class="mt-1 text-4xl font-black">{gettext("Moderation")}</h1>
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<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>
|
||||||
|
|
|
||||||
737
lib/who_need_help_web/live/notification_live.ex
Normal file
737
lib/who_need_help_web/live/notification_live.ex
Normal 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
|
||||||
91
lib/who_need_help_web/live/product_analytics_live.ex
Normal file
91
lib/who_need_help_web/live/product_analytics_live.ex
Normal 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
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
>
|
>
|
||||||
{gettext("Cancel request")}
|
<summary class="collapse-title min-h-0 py-3 text-sm font-semibold">
|
||||||
</button>
|
{gettext("Cancel request")}
|
||||||
|
</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"
|
|
||||||
>
|
>
|
||||||
{gettext("Withdraw from this request")}
|
<summary class="collapse-title min-h-0 py-3 text-sm font-semibold">
|
||||||
</button>
|
{gettext("Withdraw from this request")}
|
||||||
<button
|
</summary>
|
||||||
|
<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"
|
|
||||||
>
|
>
|
||||||
{gettext("Cancel request")}
|
<summary class="collapse-title min-h-0 py-3 text-sm font-semibold">
|
||||||
</button>
|
{gettext("Cancel request")}
|
||||||
|
</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={
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
2
mix.exs
2
mix.exs
|
|
@ -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"},
|
||||||
|
|
|
||||||
3
mix.lock
3
mix.lock
|
|
@ -24,10 +24,12 @@
|
||||||
"geo": {:hex, :geo, "4.1.0", "64ba89a64cc400b5b16dd2f5bd644cb141776eb8c2ac5a983332c8d944936c12", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "19edb2b3398ca9f701b573b1fb11bc90951ebd64f18b06bd1bf35abe509a2934"},
|
"geo": {: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"},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
362
test/who_need_help/notifications_test.exs
Normal file
362
test/who_need_help/notifications_test.exs
Normal 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
|
||||||
34
test/who_need_help/product_analytics_test.exs
Normal file
34
test/who_need_help/product_analytics_test.exs
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user